diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..3bfdb7f --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,193 @@ +# .golangci.yml +version: "2" + +run: + timeout: 5m + tests: true + # Keep it simple: lint the whole module. + # If you need to exclude generated dirs, do it in exclusions.paths. + +linters: + default: none + enable: + # correctness / bugs + - errcheck + - errorlint + - govet + - ineffassign + - staticcheck + - unused + - bodyclose + - copyloopvar + - intrange + - unconvert + - unparam + - noctx + + # security + - gosec + + # maintainability / readability + - revive + - gocritic + - gocyclo + - funlen + - dupl + - goconst + - misspell + - whitespace + - nolintlint + - godox + - lll + - dogsled + - nakedret + - testifylint + + # numbers / magic constants (often noisy; keep, but tuned below) + - mnd + + settings: + dupl: + threshold: 100 + + funlen: + # Lines are a poor signal; statements are a decent one. + lines: -1 + statements: 50 + + goconst: + min-len: 3 + min-occurrences: 3 + + gocritic: + enabled-tags: + - diagnostic + - performance + - style + # "opinionated" and "experimental" tend to create churn in most repos. + disabled-tags: + - opinionated + - experimental + disabled-checks: + - dupImport + - ifElseChain + - octalLiteral + - whyNoLint + + gocyclo: + min-complexity: 15 + + godox: + keywords: + - TODO + - FIXME + + mnd: + checks: + - argument + - case + - condition + - return + ignored-numbers: + - "0" + - "1" + - "2" + - "3" + ignored-functions: + - strings.SplitN + - time.Duration + - time.Sleep + + govet: + enable: + - nilness + - shadow + # Keep govet defaults + these; avoid repo-specific printf funcs unless needed. + + errorlint: + asserts: false + + lll: + line-length: 140 + tab-width: 1 + + misspell: + locale: US + + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + + revive: + # A small, stable ruleset. Add more only when you actually want them enforced. + rules: + - name: indent-error-flow + - name: empty-lines + - name: unused-parameter + - name: unused-receiver + - name: early-return + - name: var-naming + - name: exported + disabled: true # too chatty unless your repo is strict about comments + + exclusions: + presets: + - comments + - std-error-handling + - common-false-positives + - legacy + + # NOTE: exclusions.paths are REGEX (not globs, not plain paths). + # Anchor them so you only exclude those directories. + paths: + - ^pkg/apidb + - ^pkg/auth + - ^pkg/canton + - ^pkg/config + - ^pkg/db + - ^pkg/ethereum + - ^pkg/ethrpc + - ^pkg/keys + - ^pkg/registration + - ^pkg/relayer + - ^pkg/service + - ^cmd + - ^internal + + rules: + - path: (.+)_test\.go$ + linters: [dupl, funlen, gocyclo, mnd, lll] + + - path: \.gen\.go$ + linters: [revive, gocritic, goconst, gocyclo, funlen, lll, dupl, mnd] + +formatters: + enable: + - gofmt + - goimports + settings: + gofmt: + rewrite-rules: + - pattern: "interface{}" + replacement: "any" + goimports: + local-prefixes: + - github.com/chainsafe/canton-middleware + + exclusions: + # NOTE: these are REGEX patterns (not globs) + paths: + - ^pkg/apidb + - ^pkg/auth + - ^pkg/canton + - ^pkg/config + - ^pkg/db + - ^pkg/ethereum + - ^pkg/ethrpc + - ^pkg/keys + - ^pkg/registration + - ^pkg/relayer + - ^pkg/service + - ^cmd + - ^internal diff --git a/Makefile b/Makefile index 9cabd7c..85bff78 100644 --- a/Makefile +++ b/Makefile @@ -27,9 +27,14 @@ deps: go mod download go mod tidy +get_lint: + if [ ! -f ./bin/golangci-lint ]; then \ + curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v2.9.0; \ + fi; + # Run linter -lint: - golangci-lint run +lint: get_lint + ./bin/golangci-lint run # Format code fmt: diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 9f22fe8..fd3870b 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -11,7 +11,7 @@ import ( "time" "github.com/chainsafe/canton-middleware/pkg/apidb" - "github.com/chainsafe/canton-middleware/pkg/canton" + canton "github.com/chainsafe/canton-middleware/pkg/canton-sdk/client" "github.com/chainsafe/canton-middleware/pkg/config" "github.com/chainsafe/canton-middleware/pkg/ethrpc" "github.com/chainsafe/canton-middleware/pkg/keys" @@ -58,7 +58,7 @@ func main() { zap.String("database", cfg.Database.Database)) // Create Canton client - cantonClient, err := canton.NewClient(&cfg.Canton, logger) + cantonClient, err := canton.NewFromAppConfig(context.Background(), &cfg.Canton, canton.WithLogger(logger)) if err != nil { logger.Fatal("Failed to create Canton client", zap.Error(err)) } @@ -67,7 +67,7 @@ func main() { zap.String("rpc_url", cfg.Canton.RPCURL)) // Create and start reconciler for balance cache - reconciler := apidb.NewReconciler(db, cantonClient, logger) + reconciler := apidb.NewReconciler(db, cantonClient.Token, logger) // Run initial reconciliation on startup logger.Info("Running initial balance reconciliation...", @@ -86,7 +86,7 @@ func main() { defer reconciler.Stop() // Create shared token service - tokenService := service.NewTokenService(cfg, db, cantonClient, logger) + tokenService := service.NewTokenService(cfg, db, cantonClient.Token, logger) // Create key store for custodial Canton key management masterKeyStr := os.Getenv(cfg.KeyManagement.MasterKeyEnv) @@ -115,7 +115,7 @@ func main() { })) // Create registration handler with key store - registrationHandler := registration.NewHandler(cfg, db, cantonClient, keyStore, logger) + registrationHandler := registration.NewHandler(cfg, db, cantonClient.Identity, keyStore, logger) mux.Handle("/register", registrationHandler) logger.Info("Registration endpoint enabled at /register") diff --git a/cmd/relayer/main.go b/cmd/relayer/main.go index 7c26cf4..8d01736 100644 --- a/cmd/relayer/main.go +++ b/cmd/relayer/main.go @@ -12,7 +12,7 @@ import ( "time" "github.com/chainsafe/canton-middleware/pkg/apidb" - "github.com/chainsafe/canton-middleware/pkg/canton" + canton "github.com/chainsafe/canton-middleware/pkg/canton-sdk/client" "github.com/chainsafe/canton-middleware/pkg/config" "github.com/chainsafe/canton-middleware/pkg/db" "github.com/chainsafe/canton-middleware/pkg/ethereum" @@ -56,7 +56,7 @@ func main() { logger.Info("Database connection established") // Initialize Canton client - cantonClient, err := canton.NewClient(&cfg.Canton, logger) + cantonClient, err := canton.NewFromAppConfig(context.Background(), &cfg.Canton, canton.WithLogger(logger)) if err != nil { logger.Fatal("Failed to initialize Canton client", zap.Error(err)) } @@ -85,7 +85,7 @@ func main() { // Start relayer engine first so we can reference it in HTTP handlers ctx := context.Background() - engine := relayer.NewEngine(cfg, cantonClient, ethClient, store, logger) + engine := relayer.NewEngine(cfg, cantonClient.Bridge, ethClient, store, logger) if apiStore != nil { engine.SetAPIDB(apiStore) } diff --git a/pkg/apidb/events.go b/pkg/apidb/events.go index 6fddecb..242a83d 100644 --- a/pkg/apidb/events.go +++ b/pkg/apidb/events.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/chainsafe/canton-middleware/pkg/canton" + canton "github.com/chainsafe/canton-middleware/pkg/canton-sdk/token" ) // BridgeEvent represents a stored bridge event for reconciliation diff --git a/pkg/apidb/reconcile.go b/pkg/apidb/reconcile.go index b507b26..4c619a8 100644 --- a/pkg/apidb/reconcile.go +++ b/pkg/apidb/reconcile.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/chainsafe/canton-middleware/pkg/canton" + canton "github.com/chainsafe/canton-middleware/pkg/canton-sdk/token" "github.com/shopspring/decimal" "go.uber.org/zap" ) @@ -14,7 +14,7 @@ import ( // Reconciler handles synchronization between Canton ledger state and DB cache type Reconciler struct { db *Store - cantonClient *canton.Client + cantonClient canton.Token logger *zap.Logger stopCh chan struct{} @@ -22,7 +22,7 @@ type Reconciler struct { } // NewReconciler creates a new reconciler -func NewReconciler(db *Store, cantonClient *canton.Client, logger *zap.Logger) *Reconciler { +func NewReconciler(db *Store, cantonClient canton.Token, logger *zap.Logger) *Reconciler { return &Reconciler{ db: db, cantonClient: cantonClient, @@ -43,7 +43,7 @@ func (r *Reconciler) ReconcileAll(ctx context.Context) error { start := time.Now() // Get all holdings from Canton to calculate total supply - holdings, err := r.cantonClient.GetAllCIP56Holdings(ctx) + holdings, err := r.cantonClient.GetAllHoldings(ctx) if err != nil { return fmt.Errorf("failed to get holdings from Canton: %w", err) } @@ -136,7 +136,7 @@ func (r *Reconciler) ReconcileUserBalancesFromHoldings(ctx context.Context) erro start := time.Now() // Get all holdings from Canton - holdings, err := r.cantonClient.GetAllCIP56Holdings(ctx) + holdings, err := r.cantonClient.GetAllHoldings(ctx) if err != nil { return fmt.Errorf("failed to get holdings from Canton: %w", err) } diff --git a/pkg/canton-sdk/bridge/client.go b/pkg/canton-sdk/bridge/client.go new file mode 100644 index 0000000..19503e5 --- /dev/null +++ b/pkg/canton-sdk/bridge/client.go @@ -0,0 +1,542 @@ +// Package bridge implements optional Wayfinder bridge operations for Canton. +// +// It provides deposit/withdrawal flows and bridge-related event queries. +// The bridge client is optional. +package bridge + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/identity" + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/ledger" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/values" + "github.com/google/uuid" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + streamReconnectDelay = 5 * time.Second + streamMaxReconnectDelay = 60 * time.Second +) + +// Bridge defines bridge operations. +type Bridge interface { + // IsDepositProcessed returns true if a deposit with the given EVM tx hash already exists as an active + // PendingDeposit or DepositReceipt contract. + IsDepositProcessed(ctx context.Context, evmTxHash string) (bool, error) + + // GetWayfinderBridgeConfigCID returns the active WayfinderBridgeConfig contract id. + GetWayfinderBridgeConfigCID(ctx context.Context) (string, error) + + // CreatePendingDeposit creates a PendingDeposit on Canton from an EVM deposit event. + CreatePendingDeposit(ctx context.Context, req CreatePendingDepositRequest) (*PendingDeposit, error) + + // ProcessDepositAndMint processes a PendingDeposit and mints tokens (choice on WayfinderBridgeConfig). + ProcessDepositAndMint(ctx context.Context, req ProcessDepositRequest) (*ProcessedDeposit, error) + + // InitiateWithdrawal creates a WithdrawalRequest for a user (choice on WayfinderBridgeConfig). + InitiateWithdrawal(ctx context.Context, req InitiateWithdrawalRequest) (string, error) + + // CompleteWithdrawal marks a WithdrawalEvent as completed after the EVM release is finalized. + CompleteWithdrawal(ctx context.Context, req CompleteWithdrawalRequest) error + + // StreamWithdrawalEvents streams WithdrawalEvent contracts with automatic reconnection. + StreamWithdrawalEvents(ctx context.Context, offset string) <-chan *WithdrawalEvent + + // GetLatestLedgerOffset returns the ledger end + GetLatestLedgerOffset(ctx context.Context) (int64, error) +} + +// Client implements bridge operations. +type Client struct { + cfg *Config + ledger ledger.Ledger + identity identity.Identity + logger *zap.Logger +} + +// New creates a new bridge client. +func New(cfg Config, l ledger.Ledger, i identity.Identity, opts ...Option) (*Client, error) { + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + if l == nil { + return nil, fmt.Errorf("nil ledger client") + } + s := applyOptions(opts) + + return &Client{ + cfg: &cfg, + ledger: l, + identity: i, + logger: s.logger, + }, nil +} + +func (c *Client) GetWayfinderBridgeConfigCID(ctx context.Context) (string, error) { + end, err := c.ledger.GetLedgerEnd(ctx) + if err != nil { + return "", err + } + if end == 0 { + return "", fmt.Errorf("ledger is empty, no contracts exist") + } + + tid := &lapiv2.Identifier{ + PackageId: c.cfg.BridgePackageID, + ModuleName: c.cfg.BridgeModule, + EntityName: "WayfinderBridgeConfig", + } + + events, err := c.ledger.GetActiveContractsByTemplate(ctx, end, []string{c.cfg.RelayerParty}, tid) + if err != nil { + return "", fmt.Errorf("query WayfinderBridgeConfig: %w", err) + } + if len(events) == 0 { + return "", fmt.Errorf("no active WayfinderBridgeConfig found") + } + + return events[0].ContractId, nil +} + +func (c *Client) IsDepositProcessed(ctx context.Context, evmTxHash string) (bool, error) { + if evmTxHash == "" { + return false, fmt.Errorf("evm tx hash is required") + } + + end, err := c.ledger.GetLedgerEnd(ctx) + if err != nil { + return false, err + } + if end == 0 { + 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. + pendingTID := &lapiv2.Identifier{ + PackageId: c.cfg.BridgePackageID, + ModuleName: "Common.FingerprintAuth", + EntityName: "PendingDeposit", + } + receiptTID := &lapiv2.Identifier{ + PackageId: c.cfg.BridgePackageID, + ModuleName: "Common.FingerprintAuth", + EntityName: "DepositReceipt", + } + + check := func(tid *lapiv2.Identifier) (bool, error) { + events, err := c.ledger.GetActiveContractsByTemplate(ctx, end, []string{c.cfg.RelayerParty}, tid) + if err != nil { + return false, err + } + for _, ce := range events { + fields := values.RecordToMap(ce.CreateArguments) + if values.Text(fields["evmTxHash"]) == evmTxHash { + return true, nil + } + } + return false, nil + } + + ok, err := check(pendingTID) + if err != nil { + return false, fmt.Errorf("query PendingDeposit: %w", err) + } + if ok { + return true, nil + } + + ok, err = check(receiptTID) + if err != nil { + return false, fmt.Errorf("query DepositReceipt: %w", err) + } + return ok, nil +} + +func (c *Client) CreatePendingDeposit(ctx context.Context, req CreatePendingDepositRequest) (*PendingDeposit, error) { + if err := req.validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + configCID, err := c.GetWayfinderBridgeConfigCID(ctx) + if err != nil { + return nil, err + } + + m, err := c.identity.GetFingerprintMapping(ctx, req.Fingerprint) + if err != nil { + return nil, err + } + req.Fingerprint = m.Fingerprint // replace with normalized fingerprint + + cmd := &lapiv2.Command{ + Command: &lapiv2.Command_Exercise{ + Exercise: &lapiv2.ExerciseCommand{ + TemplateId: &lapiv2.Identifier{ + PackageId: c.cfg.BridgePackageID, + ModuleName: c.cfg.BridgeModule, + EntityName: "WayfinderBridgeConfig", + }, + ContractId: configCID, + Choice: "CreatePendingDeposit", + ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: encodeCreatePendingDepositArgs(req)}}, + }, + }, + } + + 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.RelayerParty}, + Commands: []*lapiv2.Command{cmd}, + }, + }, + ) + if err != nil { + return nil, fmt.Errorf("create pending deposit: %w", err) + } + if resp.Transaction == nil { + return nil, fmt.Errorf("create pending deposit: missing transaction in response") + } + + for _, e := range resp.Transaction.Events { + created := e.GetCreated() + if created == nil || created.TemplateId == nil { + continue + } + if created.TemplateId.ModuleName == "Common.FingerprintAuth" && created.TemplateId.EntityName == "PendingDeposit" { + return &PendingDeposit{ + ContractID: created.ContractId, + MappingCID: m.ContractID, + Fingerprint: m.Fingerprint, + CreatedAt: created.CreatedAt.AsTime(), + }, nil + } + } + + return nil, fmt.Errorf("PendingDeposit contract not found in response") +} + +func (c *Client) ProcessDepositAndMint(ctx context.Context, req ProcessDepositRequest) (*ProcessedDeposit, error) { + if err := req.validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + configCID, err := c.GetWayfinderBridgeConfigCID(ctx) + if err != nil { + return nil, err + } + + cmd := &lapiv2.Command{ + Command: &lapiv2.Command_Exercise{ + Exercise: &lapiv2.ExerciseCommand{ + TemplateId: &lapiv2.Identifier{ + PackageId: c.cfg.BridgePackageID, + ModuleName: c.cfg.BridgeModule, + EntityName: "WayfinderBridgeConfig", + }, + ContractId: configCID, + Choice: "ProcessDepositAndMint", + ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: encodeProcessDepositAndMintArgs(req)}}, + }, + }, + } + + 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.RelayerParty}, + Commands: []*lapiv2.Command{cmd}, + }, + }, + ) + if err != nil { + return nil, fmt.Errorf("process deposit and mint: %w", err) + } + if resp.Transaction == nil { + return nil, fmt.Errorf("process deposit and mint: missing transaction in response") + } + + for _, e := range resp.Transaction.Events { + created := e.GetCreated() + if created == nil || created.TemplateId == nil { + continue + } + // Mint results in a holding being created. + if created.TemplateId.ModuleName == "CIP56.Token" && created.TemplateId.EntityName == "CIP56Holding" { + return &ProcessedDeposit{ContractID: created.ContractId}, nil + } + } + + return nil, fmt.Errorf("CIP56Holding contract not found in response") +} + +func (c *Client) InitiateWithdrawal(ctx context.Context, req InitiateWithdrawalRequest) (string, error) { + if err := req.validate(); err != nil { + return "", fmt.Errorf("invalid request: %w", err) + } + + configCID, err := c.GetWayfinderBridgeConfigCID(ctx) + if err != nil { + return "", err + } + + cmd := &lapiv2.Command{ + Command: &lapiv2.Command_Exercise{ + Exercise: &lapiv2.ExerciseCommand{ + TemplateId: &lapiv2.Identifier{ + PackageId: c.cfg.BridgePackageID, + ModuleName: c.cfg.BridgeModule, + EntityName: "WayfinderBridgeConfig", + }, + ContractId: configCID, + Choice: "InitiateWithdrawal", + ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: encodeInitiateWithdrawalArgs(req)}}, + }, + }, + } + + 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.RelayerParty}, + Commands: []*lapiv2.Command{cmd}, + }, + }, + ) + if err != nil { + return "", fmt.Errorf("initiate withdrawal: %w", err) + } + if resp.Transaction == nil { + return "", fmt.Errorf("initiate 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 == "Bridge.Contracts" && created.TemplateId.EntityName == "WithdrawalRequest" { + return created.ContractId, nil + } + } + + return "", fmt.Errorf("WithdrawalRequest 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) + } + + corePkg := c.cfg.effectiveCorePackageID() + + cmd := &lapiv2.Command{ + Command: &lapiv2.Command_Exercise{ + Exercise: &lapiv2.ExerciseCommand{ + TemplateId: &lapiv2.Identifier{ + PackageId: corePkg, + ModuleName: "Bridge.Contracts", + EntityName: "WithdrawalEvent", + }, + ContractId: req.WithdrawalEventCID, + Choice: "CompleteWithdrawal", + ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: encodeCompleteWithdrawalArgs(req.EvmTxHash)}}, + }, + }, + } + + _, err := c.ledger.Command().SubmitAndWait( + c.ledger.AuthContext(ctx), + &lapiv2.SubmitAndWaitRequest{ + Commands: &lapiv2.Commands{ + SynchronizerId: c.cfg.DomainID, + CommandId: uuid.NewString(), + UserId: c.cfg.UserID, + ActAs: []string{c.cfg.RelayerParty}, + Commands: []*lapiv2.Command{cmd}, + }, + }, + ) + if err != nil { + // TODO: Match grpc error code without hard-wiring exact text. + if strings.Contains(strings.ToLower(err.Error()), "already") { + return nil + } + return fmt.Errorf("complete withdrawal: %w", err) + } + + return nil +} + +func (c *Client) StreamWithdrawalEvents(ctx context.Context, offset string) <-chan *WithdrawalEvent { + outCh := make(chan *WithdrawalEvent, 10) + + go func() { + defer close(outCh) + + currentOffset := offset + reconnectDelay := streamReconnectDelay + + for { + select { + case <-ctx.Done(): + return + default: + } + + err := c.streamWithdrawalEventsOnce(ctx, currentOffset, outCh, ¤tOffset) + if err == nil || err == io.EOF || ctx.Err() != nil { + return + } + + if isAuthError(err) { + c.ledger.InvalidateToken() + reconnectDelay = streamReconnectDelay + } + + select { + case <-ctx.Done(): + return + case <-time.After(reconnectDelay): + } + + reconnectDelay = min(reconnectDelay*2, streamMaxReconnectDelay) + } + }() + + return outCh +} + +func (c *Client) streamWithdrawalEventsOnce(ctx context.Context, offset string, outCh chan<- *WithdrawalEvent, lastOffset *string) error { + authCtx := c.ledger.AuthContext(ctx) + + beginExclusive, err := parseOffset(offset) + if err != nil { + return err + } + + corePkg := c.cfg.effectiveCorePackageID() + + updateFormat := &lapiv2.UpdateFormat{ + IncludeTransactions: &lapiv2.TransactionFormat{ + EventFormat: &lapiv2.EventFormat{ + FiltersByParty: map[string]*lapiv2.Filters{ + c.cfg.RelayerParty: { + Cumulative: []*lapiv2.CumulativeFilter{ + { + IdentifierFilter: &lapiv2.CumulativeFilter_TemplateFilter{ + TemplateFilter: &lapiv2.TemplateFilter{ + TemplateId: &lapiv2.Identifier{ + PackageId: corePkg, + ModuleName: "Bridge.Contracts", + EntityName: "WithdrawalEvent", + }, + }, + }, + }, + }, + }, + }, + Verbose: true, + }, + TransactionShape: lapiv2.TransactionShape_TRANSACTION_SHAPE_ACS_DELTA, + }, + } + + stream, err := c.ledger.Update().GetUpdates(authCtx, &lapiv2.GetUpdatesRequest{ + BeginExclusive: beginExclusive, + UpdateFormat: updateFormat, + }) + if err != nil { + return fmt.Errorf("start withdrawal stream: %w", err) + } + + for { + resp, recvErr := stream.Recv() + if recvErr != nil { + if recvErr == io.EOF { + return io.EOF + } + return recvErr + } + + tx := resp.GetTransaction() + if tx == nil { + continue + } + + for _, ev := range tx.Events { + created := ev.GetCreated() + if created == nil || created.TemplateId == nil { + continue + } + + tid := created.TemplateId + if tid.ModuleName != "Bridge.Contracts" || tid.EntityName != "WithdrawalEvent" { + continue + } + + we := decodeWithdrawalEvent(created, tx.UpdateId) + + if we.Status != WithdrawalStatusPending { + continue + } + + select { + case outCh <- we: + *lastOffset = strconv.FormatInt(created.Offset, 10) + case <-ctx.Done(): + return ctx.Err() + } + } + } +} + +func parseOffset(offset string) (int64, error) { + if offset == "" || offset == "BEGIN" { + return 0, nil + } + n, err := strconv.ParseInt(offset, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid offset %q: %w", offset, err) + } + return n, nil +} + +// isAuthError checks if the error is an authentication/authorization error that requires token refresh +func isAuthError(err error) bool { + if err == nil { + return false + } + st, ok := status.FromError(err) + if !ok { + return false + } + return st.Code() == codes.Unauthenticated || st.Code() == codes.PermissionDenied +} + +func (c *Client) GetLatestLedgerOffset(ctx context.Context) (int64, error) { + return c.ledger.GetLedgerEnd(ctx) +} diff --git a/pkg/canton-sdk/bridge/config.go b/pkg/canton-sdk/bridge/config.go new file mode 100644 index 0000000..8ff5c91 --- /dev/null +++ b/pkg/canton-sdk/bridge/config.go @@ -0,0 +1,59 @@ +package bridge + +import "fmt" + +// Config contains bridge-client configuration. +type Config struct { + // DomainID is the Canton synchronizer/domain ID to submit commands against. + DomainID string + + // UserID is the Canton user id (JWT subject) used for command submission. + UserID string + + // RelayerParty is the party that acts as issuer/relayer in bridge flows. + RelayerParty string + + // BridgePackageID is the package id that contains Wayfinder.Bridge templates/choices. + BridgePackageID string + + // BridgeModule is the DAML module name that contains WayfinderBridgeConfig template. + BridgeModule string + + // CorePackageID is the package id that contains bridge-core templates such as WithdrawalEvent. + // If empty, BridgePackageID may be used as a fallback by some deployments. + CorePackageID string + + // CIP56PackageID is the package id containing CIP56.Events templates (MintEvent/BurnEvent). + CIP56PackageID string +} + +// Validate validates config for bridge operations. +func (c Config) validate() error { + if c.DomainID == "" { + return fmt.Errorf("domain id is required") + } + if c.UserID == "" { + return fmt.Errorf("user id is required") + } + if c.RelayerParty == "" { + return fmt.Errorf("relayer party is required") + } + if c.BridgePackageID == "" { + return fmt.Errorf("bridge package id is required") + } + if c.BridgeModule == "" { + return fmt.Errorf("bridge module is required") + } + if c.CIP56PackageID == "" { + return fmt.Errorf("cip56_package id is required") + } + return nil +} + +// effectiveCorePackageID returns the core package id, falling back to BridgePackageID when unset. +func (c Config) effectiveCorePackageID() string { + if c.CorePackageID != "" { + return c.CorePackageID + } + return c.BridgePackageID +} diff --git a/pkg/canton-sdk/bridge/decode.go b/pkg/canton-sdk/bridge/decode.go new file mode 100644 index 0000000..d325089 --- /dev/null +++ b/pkg/canton-sdk/bridge/decode.go @@ -0,0 +1,41 @@ +package bridge + +import ( + "fmt" + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/values" +) + +func decodeWithdrawalEvent(ce *lapiv2.CreatedEvent, txID string) *WithdrawalEvent { + fields := values.RecordToMap(ce.CreateArguments) + + return &WithdrawalEvent{ + ContractID: ce.ContractId, + EventID: fmt.Sprintf("%d-%d", ce.Offset, ce.NodeId), + TransactionID: txID, + Issuer: values.Party(fields["issuer"]), + UserParty: values.Party(fields["userParty"]), + EvmDestination: values.Text(fields["evmDestination"]), + Amount: values.Numeric(fields["amount"]), + Fingerprint: values.Text(fields["fingerprint"]), + Status: decodeWithdrawalStatusV2(fields["status"]), + } +} + +func decodeWithdrawalStatusV2(v *lapiv2.Value) WithdrawalStatus { + if v == nil { + return WithdrawalStatusPending + } + // Status is a variant/enum + if variant, ok := v.Sum.(*lapiv2.Value_Variant); ok { + switch variant.Variant.Constructor { + case "Pending": + return WithdrawalStatusPending + case "Completed": + return WithdrawalStatusCompleted + case "Failed": + return WithdrawalStatusFailed + } + } + return WithdrawalStatusPending +} diff --git a/pkg/canton-sdk/bridge/encode.go b/pkg/canton-sdk/bridge/encode.go new file mode 100644 index 0000000..9ca769f --- /dev/null +++ b/pkg/canton-sdk/bridge/encode.go @@ -0,0 +1,50 @@ +package bridge + +import ( + "time" + + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/values" +) + +func encodeCreatePendingDepositArgs(req CreatePendingDepositRequest) *lapiv2.Record { + return &lapiv2.Record{ + Fields: []*lapiv2.RecordField{ + {Label: "fingerprint", Value: values.TextValue(req.Fingerprint)}, + {Label: "amount", Value: values.NumericValue(req.Amount)}, + {Label: "evmTxHash", Value: values.TextValue(req.EvmTxHash)}, + {Label: "eventTime", Value: values.TimestampValue(time.Now())}, + }, + } +} + +func encodeProcessDepositAndMintArgs(req ProcessDepositRequest) *lapiv2.Record { + return &lapiv2.Record{ + Fields: []*lapiv2.RecordField{ + {Label: "depositCid", Value: values.ContractIDValue(req.DepositCID)}, + {Label: "mappingCid", Value: values.ContractIDValue(req.MappingCID)}, + {Label: "eventTime", Value: values.TimestampValue(time.Now())}, + }, + } +} + +func encodeInitiateWithdrawalArgs(req InitiateWithdrawalRequest) *lapiv2.Record { + return &lapiv2.Record{ + Fields: []*lapiv2.RecordField{ + {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: "eventTime", Value: values.TimestampValue(time.Now())}, + }, + } +} + +func encodeCompleteWithdrawalArgs(evmTxHash string) *lapiv2.Record { + return &lapiv2.Record{ + Fields: []*lapiv2.RecordField{ + {Label: "evmTxHash", Value: values.TextValue(evmTxHash)}, + {Label: "eventTime", Value: values.TimestampValue(time.Now())}, + }, + } +} diff --git a/pkg/canton-sdk/bridge/options.go b/pkg/canton-sdk/bridge/options.go new file mode 100644 index 0000000..ce9422b --- /dev/null +++ b/pkg/canton-sdk/bridge/options.go @@ -0,0 +1,23 @@ +package bridge + +import "go.uber.org/zap" + +type Option func(*settings) + +type settings struct { + logger *zap.Logger +} + +func WithLogger(l *zap.Logger) Option { + return func(s *settings) { s.logger = l } +} + +func applyOptions(opts []Option) settings { + s := settings{logger: zap.NewNop()} + for _, opt := range opts { + if opt != nil { + opt(&s) + } + } + return s +} diff --git a/pkg/canton-sdk/bridge/types.go b/pkg/canton-sdk/bridge/types.go new file mode 100644 index 0000000..693b7ef --- /dev/null +++ b/pkg/canton-sdk/bridge/types.go @@ -0,0 +1,125 @@ +package bridge + +import ( + "errors" + "time" +) + +// PendingDeposit describes a created pending deposit. +type PendingDeposit struct { + ContractID string + MappingCID string + Fingerprint string + CreatedAt time.Time +} + +// CreatePendingDepositRequest contains inputs to create a PendingDeposit from an EVM deposit event. +type CreatePendingDepositRequest struct { + Fingerprint string + Amount string + EvmTxHash string +} + +func (c CreatePendingDepositRequest) validate() error { + if c.Fingerprint == "" { + return errors.New("fingerprint is required") + } + if c.Amount == "" { + return errors.New("amount is required") + } + if c.EvmTxHash == "" { + return errors.New("evm_tx_hash is required") + } + return nil +} + +type ProcessedDeposit struct { + ContractID string +} + +// ProcessDepositRequest contains inputs to process a PendingDeposit and mint tokens. +type ProcessDepositRequest struct { + DepositCID string + MappingCID string +} + +func (p ProcessDepositRequest) validate() error { + if p.DepositCID == "" { + return errors.New("deposit_cid is required") + } + if p.MappingCID == "" { + return errors.New("mapping_cid is required") + } + return nil +} + +// WithdrawalRequest describes a created withdrawal request. +type WithdrawalRequest struct { + ContractID string + Amount string + EvmDestination string + UserFingerprint string + CreatedAt time.Time +} + +// InitiateWithdrawalRequest contains inputs to initiate a withdrawal. +type InitiateWithdrawalRequest struct { + MappingCID string + HoldingCID string + Amount string + EvmDestination string +} + +func (i InitiateWithdrawalRequest) validate() error { + if i.MappingCID == "" { + return errors.New("mapping_cid is required") + } + if i.HoldingCID == "" { + return errors.New("holding_cid is required") + } + if i.Amount == "" { + return errors.New("amount is required") + } + if i.EvmDestination == "" { + return errors.New("evm_destination is required") + } + return nil +} + +// CompleteWithdrawalRequest contains inputs to mark a withdrawal as completed. +type CompleteWithdrawalRequest struct { + WithdrawalEventCID string + EvmTxHash string +} + +func (c CompleteWithdrawalRequest) validate() error { + if c.WithdrawalEventCID == "" { + return errors.New("withdrawal_event_cid is required") + } + if c.EvmTxHash == "" { + return errors.New("evm_tx_hash is required") + } + return nil +} + +// WithdrawalEvent represents a withdrawal ready for EVM processing +type WithdrawalEvent struct { + ContractID string + EventID string + TransactionID string + Issuer string + UserParty string + EvmDestination string + Amount string + Fingerprint string + Status WithdrawalStatus +} + +// WithdrawalStatus represents the state of a withdrawal +type WithdrawalStatus string + +const ( + WithdrawalStatusPending WithdrawalStatus = "Pending" + WithdrawalStatusCompleted WithdrawalStatus = "Completed" + WithdrawalStatusFailed WithdrawalStatus = "Failed" +) diff --git a/pkg/canton-sdk/client/client.go b/pkg/canton-sdk/client/client.go new file mode 100644 index 0000000..9ec77ab --- /dev/null +++ b/pkg/canton-sdk/client/client.go @@ -0,0 +1,149 @@ +// Package client provides the high-level Canton SDK client. +// +// It exposes a unified interface for interacting with a Canton ledger, +// including identity, token, and optional bridge operations. +package client + +import ( + "context" + "fmt" + + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/bridge" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/identity" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/ledger" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/token" + appcfg "github.com/chainsafe/canton-middleware/pkg/config" +) + +// Client is the SDK facade. +type Client struct { + Ledger ledger.Ledger + Identity identity.Identity + Token token.Token + Bridge bridge.Bridge // optional; nil when disabled +} + +// New creates an SDK client from SDK-native config. +func New(ctx context.Context, cfg Config, opts ...Option) (*Client, error) { + _ = ctx // reserved for future (e.g. eager connectivity check) + s := applyOptions(opts) + + l, err := ledger.New(cfg.Ledger, + ledger.WithLogger(s.logger), + ledger.WithHTTPClient(s.httpClient), + ) + if err != nil { + return nil, err + } + + sub := "" + if cfg.Identity.UserID == "" { + sub, err = l.JWTSubject(ctx) + if err != nil { + return nil, err + } + cfg.Identity.UserID = sub + cfg.Token.UserID = sub + } + + id, err := identity.New(cfg.Identity, l, identity.WithLogger(s.logger)) + if err != nil { + _ = l.Close() + return nil, err + } + + tk, err := token.New(cfg.Token, l, id, token.WithLogger(s.logger)) + if err != nil { + _ = l.Close() + return nil, err + } + + var br bridge.Bridge + bridgeCfg := cfg.Bridge + if s.bridgeCfg != nil { + bridgeCfg = s.bridgeCfg + } + if bridgeCfg != nil { + bridgeCfg.UserID = sub + br, err = bridge.New(*bridgeCfg, l, id, bridge.WithLogger(s.logger)) + if err != nil { + _ = l.Close() + return nil, err + } + } + + return &Client{ + Ledger: l, + Identity: id, + Token: tk, + Bridge: br, + }, nil +} + +// Close closes the underlying ledger connection. +func (c *Client) Close() error { + if c == nil || c.Ledger == nil { + return nil + } + return c.Ledger.Close() +} + +// NewFromAppConfig is a convenience adapter for existing config.CantonConfig. +// This keeps SDK clean but makes migration easy. +func NewFromAppConfig(ctx context.Context, cfg *appcfg.CantonConfig, opts ...Option) (*Client, error) { + _ = ctx // reserved for future (e.g. eager connectivity check) + + if cfg == nil { + return nil, fmt.Errorf("nil canton config") + } + s := applyOptions(opts) + + sdkCfg := Config{ + Ledger: ledger.Config{ + RPCURL: cfg.RPCURL, + LedgerID: cfg.LedgerID, + MaxMessageSize: cfg.MaxMessageSize, + TLS: ledger.TLSConfig{ + Enabled: cfg.TLS.Enabled, + CertFile: cfg.TLS.CertFile, + KeyFile: cfg.TLS.KeyFile, + CAFile: cfg.TLS.CAFile, + }, + Auth: ledger.AuthConfig{ + ClientID: cfg.Auth.ClientID, + ClientSecret: cfg.Auth.ClientSecret, + Audience: cfg.Auth.Audience, + TokenURL: cfg.Auth.TokenURL, + }, + }, + Identity: identity.Config{ + DomainID: cfg.DomainID, + RelayerParty: cfg.RelayerParty, + CommonPackageID: cfg.CommonPackageID, + }, + Token: token.Config{ + DomainID: cfg.DomainID, + RelayerParty: cfg.RelayerParty, + CIP56PackageID: cfg.CIP56PackageID, + }, + } + + // Bridge is optional. Enable only when bridge config exists. + if cfg.BridgePackageID != "" && cfg.BridgeModule != "" && cfg.CorePackageID != "" { + sdkCfg.Bridge = &bridge.Config{ + DomainID: cfg.DomainID, + RelayerParty: cfg.RelayerParty, + BridgePackageID: cfg.BridgePackageID, + CorePackageID: cfg.CorePackageID, + BridgeModule: cfg.BridgeModule, + CIP56PackageID: cfg.CIP56PackageID, + // TODO: why bridge config needs the common package id + } + } + + return New(ctx, sdkCfg, + WithLogger(s.logger), + WithHTTPClient(s.httpClient), + WithBridgeConfig(sdkCfg.Bridge), + ) +} diff --git a/pkg/canton-sdk/client/config.go b/pkg/canton-sdk/client/config.go new file mode 100644 index 0000000..ef4b0a5 --- /dev/null +++ b/pkg/canton-sdk/client/config.go @@ -0,0 +1,17 @@ +package client + +import ( + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/bridge" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/identity" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/ledger" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/token" +) + +// Config contains the configuration required to initialize the SDK client. +// It aggregates all sub-component configurations needed by the SDK. +type Config struct { + Ledger ledger.Config + Identity identity.Config + Token token.Config + Bridge *bridge.Config // optional; nil disables bridge client +} diff --git a/pkg/canton-sdk/client/options.go b/pkg/canton-sdk/client/options.go new file mode 100644 index 0000000..9cc9750 --- /dev/null +++ b/pkg/canton-sdk/client/options.go @@ -0,0 +1,46 @@ +package client + +import ( + "net/http" + + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/bridge" + "go.uber.org/zap" +) + +// Option configures client settings using the functional options pattern. +type Option func(*settings) + +type settings struct { + logger *zap.Logger + httpClient *http.Client + bridgeCfg *bridge.Config +} + +// WithLogger sets a custom logger for the SDK client. +func WithLogger(l *zap.Logger) Option { + return func(s *settings) { s.logger = l } +} + +// WithHTTPClient sets a custom HTTP client for the SDK client. +func WithHTTPClient(c *http.Client) Option { + return func(s *settings) { s.httpClient = c } +} + +// WithBridgeConfig enables and configures the optional bridge client. +// If nil, the bridge client is not initialized. +func WithBridgeConfig(cfg *bridge.Config) Option { + return func(s *settings) { s.bridgeCfg = cfg } +} + +func applyOptions(opts []Option) settings { + s := settings{ + logger: zap.NewNop(), + httpClient: http.DefaultClient, + } + for _, opt := range opts { + if opt != nil { + opt(&s) + } + } + return s +} diff --git a/pkg/canton-sdk/identity/client.go b/pkg/canton-sdk/identity/client.go new file mode 100644 index 0000000..26e9f12 --- /dev/null +++ b/pkg/canton-sdk/identity/client.go @@ -0,0 +1,250 @@ +// Package identity implements Canton identity operations such as party management +// and fingerprint-to-party mapping. +package identity + +import ( + "context" + "fmt" + "strings" + + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" + adminv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2/admin" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/ledger" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/values" + "github.com/google/uuid" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const listKnownPartiesPageSize = 1000 + +// Identity defines identity and party management operations. +type Identity interface { + AllocateParty(ctx context.Context, hint string) (*Party, error) + ListParties(ctx context.Context) ([]*Party, error) // TODO: add iterator + GetParticipantID(ctx context.Context) (string, error) + + CreateFingerprintMapping(ctx context.Context, req CreateFingerprintMappingRequest) (*FingerprintMapping, error) + GetFingerprintMapping(ctx context.Context, fingerprint string) (*FingerprintMapping, error) + + GrantActAsParty(ctx context.Context, partyID string) error +} + +// Client implements the Identity interface. +type Client struct { + cfg *Config + ledger ledger.Ledger + logger *zap.Logger +} + +// New creates a new identity client. +func New(cfg Config, l ledger.Ledger, opts ...Option) (*Client, error) { + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + if l == nil { + return nil, fmt.Errorf("nil ledger client") + } + s := applyOptions(opts) + return &Client{cfg: &cfg, ledger: l, logger: s.logger}, nil +} + +func (c *Client) AllocateParty(ctx context.Context, hint string) (*Party, error) { + authCtx := c.ledger.AuthContext(ctx) + + req := &adminv2.AllocatePartyRequest{ + PartyIdHint: hint, + SynchronizerId: c.cfg.DomainID, + } + + resp, err := c.ledger.PartyAdmin().AllocateParty(authCtx, req) + if err != nil { + return nil, fmt.Errorf("error allocating party: %w", err) + } + if resp.PartyDetails == nil { + return nil, fmt.Errorf("allocate party returned nil party details") + } + + return &Party{ + PartyID: resp.PartyDetails.Party, + IsLocal: resp.PartyDetails.IsLocal, + }, nil +} + +func (c *Client) ListParties(ctx context.Context) ([]*Party, error) { + authCtx := c.ledger.AuthContext(ctx) + + var out []*Party + pageToken := "" + + for { + resp, err := c.ledger.PartyAdmin().ListKnownParties(authCtx, &adminv2.ListKnownPartiesRequest{ + PageSize: listKnownPartiesPageSize, + PageToken: pageToken, + }) + if err != nil { + return nil, fmt.Errorf("error listing parties: %w", err) + } + + for _, p := range resp.PartyDetails { + out = append(out, &Party{PartyID: p.Party, IsLocal: p.IsLocal}) + } + + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + return out, nil +} + +func (c *Client) GetParticipantID(ctx context.Context) (string, error) { + authCtx := c.ledger.AuthContext(ctx) + + resp, err := c.ledger.PartyAdmin().GetParticipantId(authCtx, &adminv2.GetParticipantIdRequest{}) + if err != nil { + return "", fmt.Errorf("error getting participant id: %w", err) + } + + return resp.ParticipantId, nil +} + +func (c *Client) CreateFingerprintMapping(ctx context.Context, req CreateFingerprintMappingRequest) (*FingerprintMapping, error) { + if err := req.validate(); err != nil { + return nil, fmt.Errorf("invalid request: %w", err) + } + + authCtx := c.ledger.AuthContext(ctx) + packageID := c.cfg.GetPackageID() + module := "Common.FingerprintAuth" + entity := "FingerprintMapping" + + cmd := &lapiv2.Command{ + Command: &lapiv2.Command_Create{ + Create: &lapiv2.CreateCommand{ + TemplateId: &lapiv2.Identifier{ + PackageId: packageID, + ModuleName: module, + EntityName: entity, + }, + CreateArguments: encodeFingerprintMappingCreate( + c.cfg.RelayerParty, + req.UserParty, + req.Fingerprint, + req.EvmAddress, + ), + }, + }, + } + + resp, err := c.ledger.Command().SubmitAndWaitForTransaction(authCtx, &lapiv2.SubmitAndWaitForTransactionRequest{ + Commands: &lapiv2.Commands{ + SynchronizerId: c.cfg.DomainID, + CommandId: uuid.NewString(), + UserId: c.cfg.UserID, + ActAs: []string{c.cfg.RelayerParty}, + ReadAs: []string{req.UserParty}, + Commands: []*lapiv2.Command{cmd}, + }, + }) + if err != nil { + return nil, fmt.Errorf("error creating fingerprint mapping: %w", err) + } + + if resp.Transaction != nil { + for _, e := range resp.Transaction.Events { + if created := e.GetCreated(); created != nil { + if created.TemplateId.ModuleName == module && created.TemplateId.EntityName == entity { + return fingerprintMappingFromCreateEvent(created), nil + } + } + } + } + + return nil, fmt.Errorf("fingerprint mapping contract not found in response") +} + +func (c *Client) GetFingerprintMapping(ctx context.Context, fingerprint string) (*FingerprintMapping, error) { + fp := normalizeFingerprint(fingerprint) + + end, err := c.ledger.GetLedgerEnd(ctx) + if err != nil { + return nil, err + } + if end == 0 { + return nil, fmt.Errorf("ledger is empty, no contracts exist") + } + + tid := &lapiv2.Identifier{ + PackageId: c.cfg.GetPackageID(), + ModuleName: "Common.FingerprintAuth", + EntityName: "FingerprintMapping", + } + + events, err := c.ledger.GetActiveContractsByTemplate(ctx, end, []string{c.cfg.RelayerParty}, tid) + if err != nil { + return nil, err + } + + for _, ce := range events { + m := fingerprintMappingFromCreateEvent(ce) + if m != nil && m.Fingerprint == fp { + return m, nil + } + } + + return nil, fmt.Errorf("no FingerprintMapping found for fingerprint: %s", fp) +} + +func fingerprintMappingFromCreateEvent(event *lapiv2.CreatedEvent) *FingerprintMapping { + fields := values.RecordToMap(event.CreateArguments) + mfp := normalizeFingerprint(values.Text(fields["fingerprint"])) + + return &FingerprintMapping{ + ContractID: event.ContractId, + Issuer: values.Party(fields["issuer"]), + UserParty: values.Party(fields["userParty"]), + Fingerprint: mfp, + EvmAddress: values.Text(fields["evmAddress"]), + } +} + +func (c *Client) GrantActAsParty(ctx context.Context, partyID string) error { + authCtx := c.ledger.AuthContext(ctx) + + right := &adminv2.Right{ + Kind: &adminv2.Right_CanActAs_{ + CanActAs: &adminv2.Right_CanActAs{Party: partyID}, + }, + } + + _, err := c.ledger.UserAdmin().GrantUserRights(authCtx, &adminv2.GrantUserRightsRequest{ + UserId: c.cfg.UserID, + Rights: []*adminv2.Right{right}, + }) + if err != nil { + if isAlreadyExistsError(err) { // TODO: need to verify this works + return nil + } + return fmt.Errorf("grant can act as: %w", err) + } + + return nil +} + +func isAlreadyExistsError(err error) bool { + if s, ok := status.FromError(err); ok { + return s.Code() == codes.AlreadyExists + } + + return false +} + +func normalizeFingerprint(fingerprint string) string { + if !strings.HasPrefix(fingerprint, "0x") { + fingerprint = "0x" + fingerprint + } + return fingerprint +} diff --git a/pkg/canton-sdk/identity/config.go b/pkg/canton-sdk/identity/config.go new file mode 100644 index 0000000..ac9734f --- /dev/null +++ b/pkg/canton-sdk/identity/config.go @@ -0,0 +1,41 @@ +package identity + +import "errors" + +// Config contains the configuration required to initialize the identity client. +type Config struct { + DomainID string + RelayerParty string + UserID string + + // CommonPackageID is the preferred package ID for FingerprintMapping. + CommonPackageID string + + // BridgePackageID is a fallback package ID when CommonPackageID is not configured. + BridgePackageID string +} + +func (c Config) validate() error { + if c.DomainID == "" { + return errors.New("domain_id is required") + } + if c.RelayerParty == "" { + return errors.New("relayer_party is required") + } + if c.UserID == "" { + return errors.New("user_id is required") + } + if c.GetPackageID() == "" { + return errors.New("one of common_package_id or bridge_package_id is required") + } + return nil +} + +// GetPackageID return the CommonPackageID or BridgePackageID based on the preference. +func (c Config) GetPackageID() string { + if c.CommonPackageID != "" { + return c.CommonPackageID + } + // Fallback BridgePackageID if common package id is missing + return c.BridgePackageID +} diff --git a/pkg/canton-sdk/identity/encode.go b/pkg/canton-sdk/identity/encode.go new file mode 100644 index 0000000..3247a56 --- /dev/null +++ b/pkg/canton-sdk/identity/encode.go @@ -0,0 +1,17 @@ +package identity + +import ( + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/values" +) + +func encodeFingerprintMappingCreate(issuer, userParty, fingerprint, evmAddress string) *lapiv2.Record { + return &lapiv2.Record{ + Fields: []*lapiv2.RecordField{ + {Label: "issuer", Value: values.PartyValue(issuer)}, + {Label: "userParty", Value: values.PartyValue(userParty)}, + {Label: "fingerprint", Value: values.TextValue(fingerprint)}, + {Label: "evmAddress", Value: values.TextValue(evmAddress)}, + }, + } +} diff --git a/pkg/canton-sdk/identity/options.go b/pkg/canton-sdk/identity/options.go new file mode 100644 index 0000000..5fbc9dc --- /dev/null +++ b/pkg/canton-sdk/identity/options.go @@ -0,0 +1,25 @@ +package identity + +import "go.uber.org/zap" + +type settings struct { + logger *zap.Logger +} + +// Option configures the identity client. +type Option func(*settings) + +// WithLogger sets a custom logger for the identity client. +func WithLogger(l *zap.Logger) Option { + return func(s *settings) { s.logger = l } +} + +func applyOptions(opts []Option) settings { + s := settings{logger: zap.NewNop()} + for _, opt := range opts { + if opt != nil { + opt(&s) + } + } + return s +} diff --git a/pkg/canton-sdk/identity/types.go b/pkg/canton-sdk/identity/types.go new file mode 100644 index 0000000..2d4ed87 --- /dev/null +++ b/pkg/canton-sdk/identity/types.go @@ -0,0 +1,35 @@ +package identity + +import "errors" + +// Party contains the result of allocating a new Canton party. +type Party struct { + PartyID string + IsLocal bool +} + +// FingerprintMapping represents a FingerprintMapping contract. +type FingerprintMapping struct { + ContractID string + Issuer string + UserParty string + Fingerprint string + EvmAddress string +} + +// CreateFingerprintMappingRequest contains inputs for creating a FingerprintMapping. +type CreateFingerprintMappingRequest struct { + UserParty string + Fingerprint string + EvmAddress string +} + +func (c CreateFingerprintMappingRequest) validate() error { + if c.UserParty == "" { + return errors.New("user_party ID is required") + } + if c.Fingerprint == "" { + return errors.New("fingerprint ID is required") + } + return nil +} diff --git a/pkg/canton/lapi/v2/admin/command_inspection_service.pb.go b/pkg/canton-sdk/lapi/v2/admin/command_inspection_service.pb.go similarity index 99% rename from pkg/canton/lapi/v2/admin/command_inspection_service.pb.go rename to pkg/canton-sdk/lapi/v2/admin/command_inspection_service.pb.go index 7a76ec3..1791612 100644 --- a/pkg/canton/lapi/v2/admin/command_inspection_service.pb.go +++ b/pkg/canton-sdk/lapi/v2/admin/command_inspection_service.pb.go @@ -10,7 +10,7 @@ package admin import ( - v2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2" + v2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" diff --git a/pkg/canton/lapi/v2/admin/command_inspection_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/admin/command_inspection_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/admin/command_inspection_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/admin/command_inspection_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/admin/identity_provider_config_service.pb.go b/pkg/canton-sdk/lapi/v2/admin/identity_provider_config_service.pb.go similarity index 100% rename from pkg/canton/lapi/v2/admin/identity_provider_config_service.pb.go rename to pkg/canton-sdk/lapi/v2/admin/identity_provider_config_service.pb.go diff --git a/pkg/canton/lapi/v2/admin/identity_provider_config_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/admin/identity_provider_config_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/admin/identity_provider_config_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/admin/identity_provider_config_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/admin/object_meta.pb.go b/pkg/canton-sdk/lapi/v2/admin/object_meta.pb.go similarity index 100% rename from pkg/canton/lapi/v2/admin/object_meta.pb.go rename to pkg/canton-sdk/lapi/v2/admin/object_meta.pb.go diff --git a/pkg/canton/lapi/v2/admin/package_management_service.pb.go b/pkg/canton-sdk/lapi/v2/admin/package_management_service.pb.go similarity index 99% rename from pkg/canton/lapi/v2/admin/package_management_service.pb.go rename to pkg/canton-sdk/lapi/v2/admin/package_management_service.pb.go index f154065..1f9a045 100644 --- a/pkg/canton/lapi/v2/admin/package_management_service.pb.go +++ b/pkg/canton-sdk/lapi/v2/admin/package_management_service.pb.go @@ -10,7 +10,7 @@ package admin import ( - v2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2" + v2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" diff --git a/pkg/canton/lapi/v2/admin/package_management_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/admin/package_management_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/admin/package_management_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/admin/package_management_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/admin/participant_pruning_service.pb.go b/pkg/canton-sdk/lapi/v2/admin/participant_pruning_service.pb.go similarity index 100% rename from pkg/canton/lapi/v2/admin/participant_pruning_service.pb.go rename to pkg/canton-sdk/lapi/v2/admin/participant_pruning_service.pb.go diff --git a/pkg/canton/lapi/v2/admin/participant_pruning_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/admin/participant_pruning_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/admin/participant_pruning_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/admin/participant_pruning_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/admin/party_management_service.pb.go b/pkg/canton-sdk/lapi/v2/admin/party_management_service.pb.go similarity index 99% rename from pkg/canton/lapi/v2/admin/party_management_service.pb.go rename to pkg/canton-sdk/lapi/v2/admin/party_management_service.pb.go index dd45e20..857f5db 100644 --- a/pkg/canton/lapi/v2/admin/party_management_service.pb.go +++ b/pkg/canton-sdk/lapi/v2/admin/party_management_service.pb.go @@ -10,7 +10,7 @@ package admin import ( - v2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2" + v2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" fieldmaskpb "google.golang.org/protobuf/types/known/fieldmaskpb" diff --git a/pkg/canton/lapi/v2/admin/party_management_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/admin/party_management_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/admin/party_management_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/admin/party_management_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/admin/user_management_service.pb.go b/pkg/canton-sdk/lapi/v2/admin/user_management_service.pb.go similarity index 100% rename from pkg/canton/lapi/v2/admin/user_management_service.pb.go rename to pkg/canton-sdk/lapi/v2/admin/user_management_service.pb.go diff --git a/pkg/canton/lapi/v2/admin/user_management_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/admin/user_management_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/admin/user_management_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/admin/user_management_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/command_completion_service.pb.go b/pkg/canton-sdk/lapi/v2/command_completion_service.pb.go similarity index 100% rename from pkg/canton/lapi/v2/command_completion_service.pb.go rename to pkg/canton-sdk/lapi/v2/command_completion_service.pb.go diff --git a/pkg/canton/lapi/v2/command_completion_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/command_completion_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/command_completion_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/command_completion_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/command_service.pb.go b/pkg/canton-sdk/lapi/v2/command_service.pb.go similarity index 100% rename from pkg/canton/lapi/v2/command_service.pb.go rename to pkg/canton-sdk/lapi/v2/command_service.pb.go diff --git a/pkg/canton/lapi/v2/command_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/command_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/command_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/command_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/command_submission_service.pb.go b/pkg/canton-sdk/lapi/v2/command_submission_service.pb.go similarity index 100% rename from pkg/canton/lapi/v2/command_submission_service.pb.go rename to pkg/canton-sdk/lapi/v2/command_submission_service.pb.go diff --git a/pkg/canton/lapi/v2/command_submission_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/command_submission_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/command_submission_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/command_submission_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/commands.pb.go b/pkg/canton-sdk/lapi/v2/commands.pb.go similarity index 100% rename from pkg/canton/lapi/v2/commands.pb.go rename to pkg/canton-sdk/lapi/v2/commands.pb.go diff --git a/pkg/canton/lapi/v2/completion.pb.go b/pkg/canton-sdk/lapi/v2/completion.pb.go similarity index 100% rename from pkg/canton/lapi/v2/completion.pb.go rename to pkg/canton-sdk/lapi/v2/completion.pb.go diff --git a/pkg/canton/lapi/v2/crypto.pb.go b/pkg/canton-sdk/lapi/v2/crypto.pb.go similarity index 100% rename from pkg/canton/lapi/v2/crypto.pb.go rename to pkg/canton-sdk/lapi/v2/crypto.pb.go diff --git a/pkg/canton/lapi/v2/event.pb.go b/pkg/canton-sdk/lapi/v2/event.pb.go similarity index 100% rename from pkg/canton/lapi/v2/event.pb.go rename to pkg/canton-sdk/lapi/v2/event.pb.go diff --git a/pkg/canton/lapi/v2/event_query_service.pb.go b/pkg/canton-sdk/lapi/v2/event_query_service.pb.go similarity index 100% rename from pkg/canton/lapi/v2/event_query_service.pb.go rename to pkg/canton-sdk/lapi/v2/event_query_service.pb.go diff --git a/pkg/canton/lapi/v2/event_query_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/event_query_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/event_query_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/event_query_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/experimental_features.pb.go b/pkg/canton-sdk/lapi/v2/experimental_features.pb.go similarity index 100% rename from pkg/canton/lapi/v2/experimental_features.pb.go rename to pkg/canton-sdk/lapi/v2/experimental_features.pb.go diff --git a/pkg/canton/lapi/v2/offset_checkpoint.pb.go b/pkg/canton-sdk/lapi/v2/offset_checkpoint.pb.go similarity index 100% rename from pkg/canton/lapi/v2/offset_checkpoint.pb.go rename to pkg/canton-sdk/lapi/v2/offset_checkpoint.pb.go diff --git a/pkg/canton/lapi/v2/package_reference.pb.go b/pkg/canton-sdk/lapi/v2/package_reference.pb.go similarity index 100% rename from pkg/canton/lapi/v2/package_reference.pb.go rename to pkg/canton-sdk/lapi/v2/package_reference.pb.go diff --git a/pkg/canton/lapi/v2/package_service.pb.go b/pkg/canton-sdk/lapi/v2/package_service.pb.go similarity index 100% rename from pkg/canton/lapi/v2/package_service.pb.go rename to pkg/canton-sdk/lapi/v2/package_service.pb.go diff --git a/pkg/canton/lapi/v2/package_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/package_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/package_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/package_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/reassignment.pb.go b/pkg/canton-sdk/lapi/v2/reassignment.pb.go similarity index 100% rename from pkg/canton/lapi/v2/reassignment.pb.go rename to pkg/canton-sdk/lapi/v2/reassignment.pb.go diff --git a/pkg/canton/lapi/v2/reassignment_commands.pb.go b/pkg/canton-sdk/lapi/v2/reassignment_commands.pb.go similarity index 100% rename from pkg/canton/lapi/v2/reassignment_commands.pb.go rename to pkg/canton-sdk/lapi/v2/reassignment_commands.pb.go diff --git a/pkg/canton/lapi/v2/state_service.pb.go b/pkg/canton-sdk/lapi/v2/state_service.pb.go similarity index 100% rename from pkg/canton/lapi/v2/state_service.pb.go rename to pkg/canton-sdk/lapi/v2/state_service.pb.go diff --git a/pkg/canton/lapi/v2/state_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/state_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/state_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/state_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/topology_transaction.pb.go b/pkg/canton-sdk/lapi/v2/topology_transaction.pb.go similarity index 100% rename from pkg/canton/lapi/v2/topology_transaction.pb.go rename to pkg/canton-sdk/lapi/v2/topology_transaction.pb.go diff --git a/pkg/canton/lapi/v2/trace_context.pb.go b/pkg/canton-sdk/lapi/v2/trace_context.pb.go similarity index 100% rename from pkg/canton/lapi/v2/trace_context.pb.go rename to pkg/canton-sdk/lapi/v2/trace_context.pb.go diff --git a/pkg/canton/lapi/v2/transaction.pb.go b/pkg/canton-sdk/lapi/v2/transaction.pb.go similarity index 100% rename from pkg/canton/lapi/v2/transaction.pb.go rename to pkg/canton-sdk/lapi/v2/transaction.pb.go diff --git a/pkg/canton/lapi/v2/transaction_filter.pb.go b/pkg/canton-sdk/lapi/v2/transaction_filter.pb.go similarity index 100% rename from pkg/canton/lapi/v2/transaction_filter.pb.go rename to pkg/canton-sdk/lapi/v2/transaction_filter.pb.go diff --git a/pkg/canton/lapi/v2/update_service.pb.go b/pkg/canton-sdk/lapi/v2/update_service.pb.go similarity index 100% rename from pkg/canton/lapi/v2/update_service.pb.go rename to pkg/canton-sdk/lapi/v2/update_service.pb.go diff --git a/pkg/canton/lapi/v2/update_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/update_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/update_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/update_service_grpc.pb.go diff --git a/pkg/canton/lapi/v2/value.pb.go b/pkg/canton-sdk/lapi/v2/value.pb.go similarity index 100% rename from pkg/canton/lapi/v2/value.pb.go rename to pkg/canton-sdk/lapi/v2/value.pb.go diff --git a/pkg/canton/lapi/v2/version_service.pb.go b/pkg/canton-sdk/lapi/v2/version_service.pb.go similarity index 100% rename from pkg/canton/lapi/v2/version_service.pb.go rename to pkg/canton-sdk/lapi/v2/version_service.pb.go diff --git a/pkg/canton/lapi/v2/version_service_grpc.pb.go b/pkg/canton-sdk/lapi/v2/version_service_grpc.pb.go similarity index 100% rename from pkg/canton/lapi/v2/version_service_grpc.pb.go rename to pkg/canton-sdk/lapi/v2/version_service_grpc.pb.go diff --git a/pkg/canton-sdk/ledger/auth.go b/pkg/canton-sdk/ledger/auth.go new file mode 100644 index 0000000..18303d2 --- /dev/null +++ b/pkg/canton-sdk/ledger/auth.go @@ -0,0 +1,115 @@ +package ledger + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" +) + +// AuthProvider defines how the ledger client obtains +// and refreshes authentication tokens. +type AuthProvider interface { + // Token returns a valid access token and its expiry time. + // Implementations must cache and refresh tokens as needed. + Token(ctx context.Context) (token string, expiry time.Time, err error) +} + +// OAuthClientCredentialsProvider implements AuthProvider +// using the OAuth2 client credentials flow. +type OAuthClientCredentialsProvider struct { + cfg AuthConfig + httpClient *http.Client + leeway time.Duration + + mu sync.Mutex + token string + expiry time.Time +} + +// NewOAuthClientCredentialsProvider creates a new OAuthClientCredentialsProvider instance. +func NewOAuthClientCredentialsProvider(cfg AuthConfig, httpClient *http.Client) *OAuthClientCredentialsProvider { + leeway := cfg.ExpiryLeeway + if leeway == 0 { + leeway = 60 * time.Second + } + if httpClient == nil { + httpClient = http.DefaultClient + } + return &OAuthClientCredentialsProvider{ + cfg: cfg, + httpClient: httpClient, + leeway: leeway, + } +} + +func (p *OAuthClientCredentialsProvider) Token(ctx context.Context) (string, time.Time, error) { + if p.cfg.ClientID == "" || p.cfg.ClientSecret == "" || p.cfg.Audience == "" || p.cfg.TokenURL == "" { + return "", time.Time{}, fmt.Errorf("no auth configured: OAuth2 client credentials are required") + } + + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + if p.token != "" && now.Before(p.expiry) { + return p.token, p.expiry, nil + } + + payload := map[string]string{ + "client_id": p.cfg.ClientID, + "client_secret": p.cfg.ClientSecret, + "audience": p.cfg.Audience, + "grant_type": "client_credentials", + } + body, err := json.Marshal(payload) + if err != nil { + return "", time.Time{}, fmt.Errorf("marshal token request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.cfg.TokenURL, bytes.NewReader(body)) + if err != nil { + return "", time.Time{}, fmt.Errorf("create token request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.httpClient.Do(req) + if err != nil { + return "", time.Time{}, fmt.Errorf("call token endpoint: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", time.Time{}, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(b)) + } + + var tr struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + } + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + return "", time.Time{}, fmt.Errorf("decode token response: %w", err) + } + if tr.AccessToken == "" { + return "", time.Time{}, fmt.Errorf("token response missing access_token") + } + + expiry := now.Add(5 * time.Minute) + if tr.ExpiresIn > 0 { + exp := now.Add(time.Duration(tr.ExpiresIn) * time.Second) + expiry = exp.Add(-p.leeway) + if expiry.Before(now) { + expiry = now.Add(time.Duration(tr.ExpiresIn/2) * time.Second) + } + } + + p.token = tr.AccessToken + p.expiry = expiry + return p.token, p.expiry, nil +} diff --git a/pkg/canton-sdk/ledger/client.go b/pkg/canton-sdk/ledger/client.go new file mode 100644 index 0000000..100c70d --- /dev/null +++ b/pkg/canton-sdk/ledger/client.go @@ -0,0 +1,289 @@ +// Package ledger implements the low-level Canton Ledger API client. +// +// It manages gRPC connectivity, TLS configuration, OAuth2 authentication, +// and exposes typed access to Ledger API v2 services including State, +// Command, Update, and Admin services. +package ledger + +import ( + "context" + "fmt" + "io" + "sync" + "time" + + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" + adminv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2/admin" + + "github.com/golang-jwt/jwt/v5" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// Ledger defines the public Canton ledger client interface. +// +// It provides authenticated access to Ledger API services and common +// helper operations such as querying the ledger end and retrieving +// active contracts by template. +type Ledger interface { + // AuthContext attaches authorization metadata to the given context. + AuthContext(ctx context.Context) context.Context + + // InvalidateToken invalidates the token. + InvalidateToken() + + // JWTSubject returns the JWT subject ("sub") from the current access token. + JWTSubject(ctx context.Context) (string, error) + + // State returns the Ledger API StateService client. + State() lapiv2.StateServiceClient + + // Command returns the Ledger API CommandService client. + Command() lapiv2.CommandServiceClient + + // Update returns the Ledger API UpdateService client. + Update() lapiv2.UpdateServiceClient + + // PartyAdmin returns the PartyManagementService client. + PartyAdmin() adminv2.PartyManagementServiceClient + + // UserAdmin returns the UserManagementService client. + UserAdmin() adminv2.UserManagementServiceClient + + // GetLedgerEnd retrieves the current absolute ledger offset. + GetLedgerEnd(ctx context.Context) (int64, error) + + // GetActiveContractsByTemplate retrieves active contracts filtered + // by template identifier and visible parties at the given offset. + GetActiveContractsByTemplate( + ctx context.Context, + activeAtOffset int64, + parties []string, + templateID *lapiv2.Identifier, + ) ([]*lapiv2.CreatedEvent, error) + + // Conn returns the underlying gRPC client connection. + Conn() *grpc.ClientConn + + // Close closes the underlying gRPC connection. + Close() error +} + +// Client is the concrete implementation of the Ledger interface. +// +// It encapsulates the gRPC connection, Ledger API service clients, +// and authentication handling. +type Client struct { + cfg Config + logger *zap.Logger + + conn *grpc.ClientConn + + state lapiv2.StateServiceClient + command lapiv2.CommandServiceClient + update lapiv2.UpdateServiceClient + + partyAdmin adminv2.PartyManagementServiceClient + userAdmin adminv2.UserManagementServiceClient + + auth AuthProvider + + tokenMu sync.Mutex + cachedToken string + tokenExpiry time.Time +} + +// New creates a new Ledger client using the provided configuration. +func New(cfg Config, opts ...Option) (*Client, error) { + s := applyOptions(opts) + + dopts, err := dialOptions(cfg, s.dialOpts) + if err != nil { + return nil, err + } + + conn, err := grpc.NewClient(cfg.RPCURL, dopts...) + if err != nil { + return nil, fmt.Errorf("failed to create Canton client: %w", err) + } + + var ap AuthProvider = s.authProvider + if ap == nil { + ap = NewOAuthClientCredentialsProvider(cfg.Auth, s.httpClient) + } + + s.logger.Info("Connected to Canton Network (canton-sdk)", + zap.String("rpc_url", cfg.RPCURL), + zap.String("ledger_id", cfg.LedgerID), + ) + + return &Client{ + cfg: cfg, + logger: s.logger, + conn: conn, + state: lapiv2.NewStateServiceClient(conn), + command: lapiv2.NewCommandServiceClient(conn), + update: lapiv2.NewUpdateServiceClient(conn), + partyAdmin: adminv2.NewPartyManagementServiceClient(conn), + userAdmin: adminv2.NewUserManagementServiceClient(conn), + auth: ap, + }, nil +} + +func (c *Client) Conn() *grpc.ClientConn { return c.conn } + +func (c *Client) Close() error { return c.conn.Close() } + +func (c *Client) State() lapiv2.StateServiceClient { return c.state } + +func (c *Client) Command() lapiv2.CommandServiceClient { return c.command } + +func (c *Client) Update() lapiv2.UpdateServiceClient { return c.update } + +func (c *Client) PartyAdmin() adminv2.PartyManagementServiceClient { + return c.partyAdmin +} + +func (c *Client) UserAdmin() adminv2.UserManagementServiceClient { + return c.userAdmin +} + +func (c *Client) AuthContext(ctx context.Context) context.Context { + token, err := c.loadToken(ctx) + if err != nil { + c.logger.Debug("No JWT token configured (OK with wildcard auth)", zap.Error(err)) + return ctx + } + if token == "" { + return ctx + } + return metadata.NewOutgoingContext(ctx, metadata.Pairs("authorization", "Bearer "+token)) +} + +func (c *Client) InvalidateToken() { + c.tokenMu.Lock() + defer c.tokenMu.Unlock() + c.cachedToken = "" + c.tokenExpiry = time.Time{} +} + +func (c *Client) JWTSubject(ctx context.Context) (string, error) { + // Extract JWT subject if token is configured + token, err := c.loadToken(ctx) + if err != nil || token == "" { + return "", fmt.Errorf("error loading JWT token: %w", err) + } + subject, err := extractJWTSubject(token) + if err != nil { + return "", fmt.Errorf("error extracting JWT subject: %w", err) + } + return subject, nil +} + +func (c *Client) loadToken(ctx context.Context) (string, error) { + c.tokenMu.Lock() + defer c.tokenMu.Unlock() + + now := time.Now() + if c.cachedToken != "" && now.Before(c.tokenExpiry) { + return c.cachedToken, nil + } + + tok, exp, err := c.auth.Token(ctx) + if err != nil { + return "", err + } + c.cachedToken = tok + c.tokenExpiry = exp + return tok, nil +} + +// extractJWTSubject parses the JWT token and extracts the 'sub' claim +func extractJWTSubject(tokenString string) (string, error) { + // Parse without validating signature (Canton handles verification) + token, _, err := jwt.NewParser().ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + return "", fmt.Errorf("failed to parse JWT: %w", err) + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", fmt.Errorf("invalid JWT claims") + } + sub, ok := claims["sub"].(string) + if !ok { + return "", fmt.Errorf("JWT missing 'sub' claim") + } + return sub, nil +} + +func (c *Client) GetLedgerEnd(ctx context.Context) (int64, error) { + authCtx := c.AuthContext(ctx) + + resp, err := c.state.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) + if err != nil { + return 0, fmt.Errorf("failed to get ledger end: %w", err) + } + return resp.Offset, nil +} + +func (c *Client) GetActiveContractsByTemplate( + ctx context.Context, + activeAtOffset int64, + parties []string, + templateID *lapiv2.Identifier, +) ([]*lapiv2.CreatedEvent, error) { + if activeAtOffset == 0 { + return nil, fmt.Errorf("ledger is empty, no contracts exist") + } + if len(parties) == 0 { + return nil, fmt.Errorf("at least one party is required") + } + if templateID == nil { + return nil, fmt.Errorf("templateID is required") + } + + authCtx := c.AuthContext(ctx) + + filtersByParty := make(map[string]*lapiv2.Filters, len(parties)) + for _, p := range parties { + filtersByParty[p] = &lapiv2.Filters{ + Cumulative: []*lapiv2.CumulativeFilter{ + { + IdentifierFilter: &lapiv2.CumulativeFilter_TemplateFilter{ + TemplateFilter: &lapiv2.TemplateFilter{ + TemplateId: templateID, + }, + }, + }, + }, + } + } + + stream, err := c.state.GetActiveContracts(authCtx, &lapiv2.GetActiveContractsRequest{ + ActiveAtOffset: activeAtOffset, + EventFormat: &lapiv2.EventFormat{ + FiltersByParty: filtersByParty, + Verbose: true, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to get active contracts: %w", err) + } + + var out []*lapiv2.CreatedEvent + for { + msg, err := stream.Recv() + if err != nil { + if err == io.EOF { + break + } + return nil, fmt.Errorf("failed to receive active contract: %w", err) + } + if ac := msg.GetActiveContract(); ac != nil && ac.CreatedEvent != nil { + out = append(out, ac.CreatedEvent) + } + } + + return out, nil +} diff --git a/pkg/canton-sdk/ledger/config.go b/pkg/canton-sdk/ledger/config.go new file mode 100644 index 0000000..2ad12f9 --- /dev/null +++ b/pkg/canton-sdk/ledger/config.go @@ -0,0 +1,36 @@ +package ledger + +import "time" + +// Config contains the configuration required to establish +// a connection to a Canton participant. +type Config struct { + RPCURL string + LedgerID string + MaxMessageSize int + + TLS TLSConfig + Auth AuthConfig +} + +// TLSConfig defines transport security settings for the gRPC connection. +type TLSConfig struct { + Enabled bool + CertFile string + KeyFile string + CAFile string + InsecureSkipVerify bool +} + +// AuthConfig defines OAuth2 client credentials settings +// used for authenticating against the Canton participant. +type AuthConfig struct { + ClientID string + ClientSecret string + Audience string + TokenURL string + + // ExpiryLeeway specifies how long before actual token expiry + // the token should be considered expired. If zero, a default is applied. + ExpiryLeeway time.Duration +} diff --git a/pkg/canton-sdk/ledger/dial.go b/pkg/canton-sdk/ledger/dial.go new file mode 100644 index 0000000..0ffe331 --- /dev/null +++ b/pkg/canton-sdk/ledger/dial.go @@ -0,0 +1,62 @@ +package ledger + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" +) + +func dialOptions(cfg Config, extra []grpc.DialOption) ([]grpc.DialOption, error) { + var opts []grpc.DialOption + + if cfg.TLS.Enabled { + tlsCfg, err := loadTLSConfig(cfg.TLS) + if err != nil { + return nil, fmt.Errorf("load TLS config: %w", err) + } + opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg))) + } else { + opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + + if cfg.MaxMessageSize > 0 { + opts = append(opts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(cfg.MaxMessageSize))) + } + + opts = append(opts, extra...) + return opts, nil +} + +func loadTLSConfig(c TLSConfig) (*tls.Config, error) { + tlsCfg := &tls.Config{ + InsecureSkipVerify: c.InsecureSkipVerify, + NextProtos: []string{"h2"}, + } + + if c.CertFile != "" && c.KeyFile != "" { + cert, err := tls.LoadX509KeyPair(c.CertFile, c.KeyFile) + if err != nil { + return nil, fmt.Errorf("load client cert/key: %w", err) + } + tlsCfg.Certificates = []tls.Certificate{cert} + } + + if c.CAFile != "" { + b, err := os.ReadFile(c.CAFile) + if err != nil { + return nil, fmt.Errorf("read CA file: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(b) { + return nil, fmt.Errorf("append CA certs from PEM failed") + } + tlsCfg.RootCAs = pool + } + + return tlsCfg, nil +} diff --git a/pkg/canton-sdk/ledger/options.go b/pkg/canton-sdk/ledger/options.go new file mode 100644 index 0000000..32380bd --- /dev/null +++ b/pkg/canton-sdk/ledger/options.go @@ -0,0 +1,57 @@ +package ledger + +import ( + "net/http" + + "go.uber.org/zap" + "google.golang.org/grpc" +) + +// Option configures ledger client settings using +// the functional options pattern. +type Option func(*settings) + +// settings holds internal configurable dependencies +// used during ledger client initialization. +type settings struct { + logger *zap.Logger + httpClient *http.Client + dialOpts []grpc.DialOption + + authProvider AuthProvider // optional override, primarily for tests +} + +// WithLogger sets a custom logger for the ledger client. +func WithLogger(l *zap.Logger) Option { + return func(s *settings) { s.logger = l } +} + +// WithHTTPClient sets a custom HTTP client for authentication requests. +func WithHTTPClient(c *http.Client) Option { + return func(s *settings) { s.httpClient = c } +} + +// WithGRPCDialOptions appends additional gRPC dial options. +func WithGRPCDialOptions(opts ...grpc.DialOption) Option { + return func(s *settings) { s.dialOpts = append(s.dialOpts, opts...) } +} + +// WithAuthProvider overrides the default authentication provider. +func WithAuthProvider(p AuthProvider) Option { + return func(s *settings) { s.authProvider = p } +} + +// applyOptions applies the provided options and returns the resulting settings. +// Defaults are applied before user-defined options. +func applyOptions(opts []Option) settings { + s := settings{ + logger: zap.NewNop(), + httpClient: http.DefaultClient, + } + for _, opt := range opts { + if opt != nil { + opt(&s) + } + } + return s +} diff --git a/pkg/canton-sdk/token/client.go b/pkg/canton-sdk/token/client.go new file mode 100644 index 0000000..760a70c --- /dev/null +++ b/pkg/canton-sdk/token/client.go @@ -0,0 +1,544 @@ +// Package token implements CIP-56 token operations such as mint, burn, transfer, +// and balance queries. +package token + +import ( + "context" + "errors" + "fmt" + + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/identity" + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/ledger" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/values" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// Sentinel errors for balance-related operations. +var ( + // ErrInsufficientBalance indicates the owner's total balance is less than the required amount. + ErrInsufficientBalance = errors.New("insufficient balance") + + // ErrBalanceFragmented indicates the owner has sufficient total balance but it's split across + // multiple holdings such that no single holding has enough for the transfer. + ErrBalanceFragmented = errors.New("balance fragmented across multiple holdings: consolidation required") +) + +// Token defines CIP-56 token operations. +type Token interface { + // GetTokenConfigCID returns the active TokenConfig contract ID for the given token symbol. + GetTokenConfigCID(ctx context.Context, tokenSymbol string) (string, error) + + // Mint mints tokens using TokenConfig.IssuerMint and returns the created holding contract ID. + Mint(ctx context.Context, req MintRequest) (string, error) + + // Burn burns tokens using TokenConfig.IssuerBurn. + Burn(ctx context.Context, req BurnRequest) error + + // GetHoldings returns all CIP56Holding contracts for the owner and token symbol. + GetHoldings(ctx context.Context, ownerParty string, tokenSymbol string) ([]*Holding, error) + + // GetAllHoldings GetHoldings returns all CIP56Holding contracts. + GetAllHoldings(ctx context.Context) ([]*Holding, error) // TODO: use pagination + + // GetBalanceByFingerprint returns the owner's total balance (sum of holdings) for the token symbol. + GetBalanceByFingerprint(ctx context.Context, fingerprint string, tokenSymbol string) (string, error) + + // GetTotalSupply returns the total supply (sum across all holdings) for the token symbol. + GetTotalSupply(ctx context.Context, tokenSymbol string) (string, error) + + // TransferByFingerprint transfers tokens by resolving fingerprints to parties. + TransferByFingerprint(ctx context.Context, fromFingerprint, toFingerprint, amount, tokenSymbol string) error + + // TransferByPartyID transfers tokens by party IDs. + TransferByPartyID(ctx context.Context, fromParty, toParty, amount, tokenSymbol string) error + + // GetMintEvents returns all active CIP56.Events.MintEvent contracts visible to relayerParty. + GetMintEvents(ctx context.Context) ([]*MintEvent, error) + + // GetBurnEvents returns all active CIP56.Events.BurnEvent contracts visible to relayerParty. + GetBurnEvents(ctx context.Context) ([]*BurnEvent, error) +} + +// Client implements CIP-56 token operations. +type Client struct { + cfg *Config + ledger ledger.Ledger + identity identity.Identity + logger *zap.Logger +} + +// New creates a new token client. +func New(cfg Config, l ledger.Ledger, id identity.Identity, opts ...Option) (*Client, error) { + err := cfg.Validate() + if err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + if l == nil { + return nil, fmt.Errorf("nil ledger client") + } + if id == nil { + return nil, fmt.Errorf("nil identity client") + } + + s := applyOptions(opts) + return &Client{ + cfg: &cfg, + ledger: l, + identity: id, + logger: s.logger, + }, nil +} + +func (c *Client) GetTokenConfigCID(ctx context.Context, tokenSymbol string) (string, error) { + end, err := c.ledger.GetLedgerEnd(ctx) + if err != nil { + return "", err + } + if end == 0 { + return "", fmt.Errorf("ledger is empty, no contracts exist") + } + + tid := &lapiv2.Identifier{ + PackageId: c.cfg.CIP56PackageID, + ModuleName: "CIP56.Config", + EntityName: "TokenConfig", + } + + events, err := c.ledger.GetActiveContractsByTemplate(ctx, end, []string{c.cfg.RelayerParty}, tid) + if err != nil { + return "", fmt.Errorf("error getting contracts: %w", err) + } + + for _, ce := range events { + fields := values.RecordToMap(ce.CreateArguments) + if values.MetaSymbol(fields["meta"]) == tokenSymbol { + return ce.ContractId, nil + } + } + + return "", fmt.Errorf("no active TokenConfig found for symbol %s", tokenSymbol) +} + +func (c *Client) Mint(ctx context.Context, req MintRequest) (string, error) { + err := req.validate() + if err != nil { + return "", fmt.Errorf("invalid request: %w", err) + } + + cid := req.ConfigCID + if cid == "" { + cid, err = c.GetTokenConfigCID(ctx, req.TokenSymbol) + if err != nil { + return "", err + } + } + + cmd := &lapiv2.Command{ + Command: &lapiv2.Command_Exercise{ + Exercise: &lapiv2.ExerciseCommand{ + TemplateId: &lapiv2.Identifier{ + PackageId: c.cfg.CIP56PackageID, + ModuleName: "CIP56.Config", + EntityName: "TokenConfig", + }, + ContractId: cid, + Choice: "IssuerMint", + ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: encodeIssuerMintArgs(req)}}, + }, + }, + } + + 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.RelayerParty}, + Commands: []*lapiv2.Command{cmd}, + }, + }) + if err != nil { + return "", fmt.Errorf("mint tokens: %w", err) + } + + if resp.Transaction == nil { + return "", fmt.Errorf("mint tokens: missing transaction in response") + } + + for _, e := range resp.Transaction.Events { + created := e.GetCreated() + if created == nil || created.TemplateId == nil { + continue + } + if created.TemplateId.ModuleName == "CIP56.Token" && created.TemplateId.EntityName == "CIP56Holding" { + return created.ContractId, nil + } + } + + return "", fmt.Errorf("CIP56Holding contract not found in mint response") +} + +func (c *Client) Burn(ctx context.Context, req BurnRequest) error { + err := req.validate() + if err != nil { + return fmt.Errorf("invalid request: %w", err) + } + + configCID, err := c.GetTokenConfigCID(ctx, req.TokenSymbol) + if err != nil { + return err + } + + cmd := &lapiv2.Command{ + Command: &lapiv2.Command_Exercise{ + Exercise: &lapiv2.ExerciseCommand{ + TemplateId: &lapiv2.Identifier{ + PackageId: c.cfg.CIP56PackageID, + ModuleName: "CIP56.Config", + EntityName: "TokenConfig", + }, + ContractId: configCID, + Choice: "IssuerBurn", + ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: encodeIssuerBurnArgs(req)}}, + }, + }, + } + + _, err = c.ledger.Command().SubmitAndWait(c.ledger.AuthContext(ctx), &lapiv2.SubmitAndWaitRequest{ + Commands: &lapiv2.Commands{ + SynchronizerId: c.cfg.DomainID, + CommandId: uuid.NewString(), + UserId: c.cfg.UserID, + ActAs: []string{c.cfg.RelayerParty}, + Commands: []*lapiv2.Command{cmd}, + }, + }) + if err != nil { + return fmt.Errorf("burn tokens: %w", err) + } + + return nil +} + +func (c *Client) GetHoldings(ctx context.Context, ownerParty string, tokenSymbol string) ([]*Holding, error) { + if ownerParty == "" { + return nil, fmt.Errorf("owner party is required") + } + if tokenSymbol == "" { + return nil, fmt.Errorf("token symbol is required") + } + // TODO: check if it supports filtering on request + + allHoldings, err := c.GetAllHoldings(ctx) + if err != nil { + return nil, err + } + + validHoldings := make([]*Holding, 0) + for _, h := range allHoldings { + if h.Owner != ownerParty || h.Symbol != tokenSymbol { + continue + } + validHoldings = append(validHoldings, h) + } + + return validHoldings, nil +} + +func (c *Client) GetAllHoldings(ctx context.Context) ([]*Holding, error) { + end, err := c.ledger.GetLedgerEnd(ctx) + if err != nil { + return nil, err + } + if end == 0 { + return []*Holding{}, nil + } + + tid := &lapiv2.Identifier{ + PackageId: c.cfg.CIP56PackageID, + ModuleName: "CIP56.Token", + EntityName: "CIP56Holding", + } + + events, err := c.ledger.GetActiveContractsByTemplate(ctx, end, []string{c.cfg.RelayerParty}, tid) + if err != nil { + return nil, fmt.Errorf("query holdings: %w", err) + } + + out := make([]*Holding, 0) + for _, ce := range events { + fields := values.RecordToMap(ce.CreateArguments) + out = append(out, &Holding{ + ContractID: ce.ContractId, + Issuer: values.Party(fields["issuer"]), + Owner: values.Party(fields["owner"]), + Amount: values.Numeric(fields["amount"]), + Symbol: values.MetaSymbol(fields["meta"]), + }) + } + return out, nil +} + +func (c *Client) GetBalanceByFingerprint(ctx context.Context, fingerprint string, tokenSymbol string) (string, error) { + m, err := c.identity.GetFingerprintMapping(ctx, fingerprint) + if err != nil { + return "0", err + } + return c.getBalanceByPartyID(ctx, m.UserParty, tokenSymbol) +} + +func (c *Client) getBalanceByPartyID(ctx context.Context, partyID string, tokenSymbol string) (string, error) { + holdings, err := c.GetHoldings(ctx, partyID, tokenSymbol) + if err != nil { + return "0", err + } + + total := "0" + for _, h := range holdings { + next, err := addDecimalStrings(total, h.Amount) + if err != nil { + return "0", err + } + total = next + } + + return total, nil +} + +func (c *Client) GetTotalSupply(ctx context.Context, tokenSymbol string) (string, error) { + if tokenSymbol == "" { + return "0", fmt.Errorf("token symbol is required") + } + + end, err := c.ledger.GetLedgerEnd(ctx) + if err != nil { + return "0", err + } + if end == 0 { + return "0", nil + } + + tid := &lapiv2.Identifier{ + PackageId: c.cfg.CIP56PackageID, + ModuleName: "CIP56.Token", + EntityName: "CIP56Holding", + } + + events, err := c.ledger.GetActiveContractsByTemplate(ctx, end, []string{c.cfg.RelayerParty}, tid) + if err != nil { + return "0", fmt.Errorf("query holdings: %w", err) + } + + total := "0" + for _, ce := range events { + fields := values.RecordToMap(ce.CreateArguments) + if values.MetaSymbol(fields["meta"]) != tokenSymbol { + continue + } + + next, err := addDecimalStrings(total, values.Numeric(fields["amount"])) + if err != nil { + return "0", err + } + total = next + } + + return total, nil +} + +func (c *Client) TransferByFingerprint(ctx context.Context, fromFingerprint, toFingerprint, amount, tokenSymbol string) error { + fromMap, err := c.identity.GetFingerprintMapping(ctx, fromFingerprint) + if err != nil { + return fmt.Errorf("sender not found: %w", err) + } + toMap, err := c.identity.GetFingerprintMapping(ctx, toFingerprint) + if err != nil { + return fmt.Errorf("recipient not found: %w", err) + } + + return c.TransferByPartyID(ctx, fromMap.UserParty, toMap.UserParty, amount, tokenSymbol) +} + +func (c *Client) TransferByPartyID(ctx context.Context, fromParty, toParty, amount, tokenSymbol string) error { + if fromParty == "" || toParty == "" { + return fmt.Errorf("from/to party is required") + } + if amount == "" { + return fmt.Errorf("amount is required") + } + if tokenSymbol == "" { + return fmt.Errorf("token symbol is required") + } + + holdingCID, err := c.findHoldingForTransfer(ctx, fromParty, amount, tokenSymbol) + if err != nil { + return err + } + + recipientHolding, err := c.findRecipientHolding(ctx, toParty, tokenSymbol) + if err != nil { + return err + } + + return c.transferHolding(ctx, transferAsUserRequest{ + FromPartyID: fromParty, + ToPartyID: toParty, + HoldingCID: holdingCID, + Amount: amount, + TokenSymbol: tokenSymbol, + ExistingRecipientHolding: recipientHolding, + }) +} + +type transferAsUserRequest struct { + FromPartyID string + ToPartyID string + HoldingCID string + Amount string + TokenSymbol string + // Existing recipient CIP56Holding CID (for merge), empty if none + ExistingRecipientHolding string +} + +func (c *Client) transferHolding(ctx context.Context, req transferAsUserRequest) error { + cmd := &lapiv2.Command{ + Command: &lapiv2.Command_Exercise{ + Exercise: &lapiv2.ExerciseCommand{ + TemplateId: &lapiv2.Identifier{ + PackageId: c.cfg.CIP56PackageID, + ModuleName: "CIP56.Token", + EntityName: "CIP56Holding", + }, + ContractId: req.HoldingCID, + Choice: "Transfer", + ChoiceArgument: &lapiv2.Value{ + Sum: &lapiv2.Value_Record{ + Record: encodeHoldingTransferArgs(req.ToPartyID, req.Amount, req.ExistingRecipientHolding), + }, + }, + }, + }, + } + + _, err := c.ledger.Command().SubmitAndWait(c.ledger.AuthContext(ctx), &lapiv2.SubmitAndWaitRequest{ + Commands: &lapiv2.Commands{ + SynchronizerId: c.cfg.DomainID, + CommandId: uuid.NewString(), + UserId: c.cfg.UserID, + ActAs: []string{req.FromPartyID}, + ReadAs: []string{c.cfg.RelayerParty}, + Commands: []*lapiv2.Command{cmd}, + }, + }) + if err != nil { + return fmt.Errorf("transfer failed: %w", err) + } + + return nil +} + +func (c *Client) findHoldingForTransfer(ctx context.Context, ownerParty, requiredAmount, tokenSymbol string) (string, error) { + holdings, err := c.GetHoldings(ctx, ownerParty, tokenSymbol) + if err != nil { + return "", err + } + if len(holdings) == 0 { + return "", fmt.Errorf("%w: no %s holdings found", ErrInsufficientBalance, tokenSymbol) + } + + total := "0" + for _, h := range holdings { + next, err := addDecimalStrings(total, h.Amount) + if err != nil { + return "", err + } + total = next + + cmp, err := compareDecimalStrings(h.Amount, requiredAmount) + if err != nil { + return "", err + } + if cmp >= 0 { + return h.ContractID, nil + } + } + + cmpTotal, err := compareDecimalStrings(total, requiredAmount) + if err != nil { + return "", err + } + if cmpTotal >= 0 { + return "", fmt.Errorf("%w: total %s balance %s across %d holdings, need %s in single holding", + ErrBalanceFragmented, tokenSymbol, total, len(holdings), requiredAmount) + } + + return "", fmt.Errorf("%w: total %s balance %s, need %s", + ErrInsufficientBalance, tokenSymbol, total, requiredAmount) +} + +func (c *Client) findRecipientHolding(ctx context.Context, recipientParty, tokenSymbol string) (string, error) { + holdings, err := c.GetHoldings(ctx, recipientParty, tokenSymbol) + if err != nil { + return "", err + } + if len(holdings) == 0 { + return "", nil + } + return holdings[0].ContractID, nil +} + +func (c *Client) GetMintEvents(ctx context.Context) ([]*MintEvent, error) { + end, err := c.ledger.GetLedgerEnd(ctx) + if err != nil { + return nil, err + } + if end == 0 { + return []*MintEvent{}, nil + } + + tid := &lapiv2.Identifier{ + PackageId: c.cfg.CIP56PackageID, + ModuleName: "CIP56.Events", + EntityName: "MintEvent", + } + + events, err := c.ledger.GetActiveContractsByTemplate(ctx, end, []string{c.cfg.RelayerParty}, tid) + if err != nil { + return nil, fmt.Errorf("query MintEvent: %w", err) + } + + out := make([]*MintEvent, 0, len(events)) + for _, ce := range events { + out = append(out, decodeMintEvent(ce)) + } + return out, nil +} + +func (c *Client) GetBurnEvents(ctx context.Context) ([]*BurnEvent, error) { + end, err := c.ledger.GetLedgerEnd(ctx) + if err != nil { + return nil, err + } + if end == 0 { + return []*BurnEvent{}, nil + } + + tid := &lapiv2.Identifier{ + PackageId: c.cfg.CIP56PackageID, + ModuleName: "CIP56.Events", + EntityName: "BurnEvent", + } + + events, err := c.ledger.GetActiveContractsByTemplate(ctx, end, []string{c.cfg.RelayerParty}, tid) + if err != nil { + return nil, fmt.Errorf("query BurnEvent: %w", err) + } + + out := make([]*BurnEvent, 0, len(events)) + for _, ce := range events { + out = append(out, decodeBurnEvent(ce)) + } + return out, nil +} diff --git a/pkg/canton-sdk/token/config.go b/pkg/canton-sdk/token/config.go new file mode 100644 index 0000000..1e6fb24 --- /dev/null +++ b/pkg/canton-sdk/token/config.go @@ -0,0 +1,29 @@ +package token + +import "errors" + +// Config contains the configuration required to initialize the token client. +type Config struct { + DomainID string + RelayerParty string + UserID string + + // CIP56PackageID is the package ID containing CIP-56 templates. + CIP56PackageID string +} + +func (c Config) Validate() error { + if c.DomainID == "" { + return errors.New("domain_id is required") + } + if c.RelayerParty == "" { + return errors.New("relayer_party is required") + } + if c.UserID == "" { + return errors.New("user_id is required") + } + if c.CIP56PackageID == "" { + return errors.New("cip56_package_id is required") + } + return nil +} diff --git a/pkg/canton-sdk/token/decimal.go b/pkg/canton-sdk/token/decimal.go new file mode 100644 index 0000000..d9b8774 --- /dev/null +++ b/pkg/canton-sdk/token/decimal.go @@ -0,0 +1,31 @@ +package token + +import ( + "fmt" + + "github.com/shopspring/decimal" +) + +func addDecimalStrings(a, b string) (string, error) { + da, err := decimal.NewFromString(a) + if err != nil { + return "", fmt.Errorf("parse decimal: %w", err) + } + db, err := decimal.NewFromString(b) + if err != nil { + return "", fmt.Errorf("parse decimal: %w", err) + } + return da.Add(db).String(), nil +} + +func compareDecimalStrings(a, b string) (int, error) { + da, err := decimal.NewFromString(a) + if err != nil { + return 0, fmt.Errorf("parse decimal: %w", err) + } + db, err := decimal.NewFromString(b) + if err != nil { + return 0, fmt.Errorf("parse decimal: %w", err) + } + return da.Cmp(db), nil +} diff --git a/pkg/canton-sdk/token/decode.go b/pkg/canton-sdk/token/decode.go new file mode 100644 index 0000000..f9d0559 --- /dev/null +++ b/pkg/canton-sdk/token/decode.go @@ -0,0 +1,39 @@ +package token + +import ( + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/values" +) + +func decodeMintEvent(ce *lapiv2.CreatedEvent) *MintEvent { + fields := values.RecordToMap(ce.CreateArguments) + + return &MintEvent{ + ContractID: ce.ContractId, + Issuer: values.Party(fields["issuer"]), + Recipient: values.Party(fields["recipient"]), + Amount: values.Numeric(fields["amount"]), + HoldingCid: values.ContractID(fields["holdingCid"]), + TokenSymbol: values.Text(fields["tokenSymbol"]), + EvmTxHash: values.Text(fields["evmTxHash"]), + UserFingerprint: values.Text(fields["userFingerprint"]), + Timestamp: values.Timestamp(fields["timestamp"]), + AuditObservers: values.PartyList(fields["auditObservers"]), + } +} + +func decodeBurnEvent(ce *lapiv2.CreatedEvent) *BurnEvent { + fields := values.RecordToMap(ce.CreateArguments) + + return &BurnEvent{ + ContractID: ce.ContractId, + Issuer: values.Party(fields["issuer"]), + BurnedFrom: values.Party(fields["burnedFrom"]), + Amount: values.Numeric(fields["amount"]), + EvmDestination: values.Text(fields["evmDestination"]), + TokenSymbol: values.Text(fields["tokenSymbol"]), + UserFingerprint: values.Text(fields["userFingerprint"]), + Timestamp: values.Timestamp(fields["timestamp"]), + AuditObservers: values.PartyList(fields["auditObservers"]), + } +} diff --git a/pkg/canton-sdk/token/encode.go b/pkg/canton-sdk/token/encode.go new file mode 100644 index 0000000..3bcd631 --- /dev/null +++ b/pkg/canton-sdk/token/encode.go @@ -0,0 +1,59 @@ +package token + +import ( + "time" + + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" + "github.com/chainsafe/canton-middleware/pkg/canton-sdk/values" +) + +func encodeIssuerMintArgs(req MintRequest) *lapiv2.Record { + evmTx := values.None() + if req.EvmTxHash != "" { + evmTx = values.Optional(values.TextValue(req.EvmTxHash)) + } + + return &lapiv2.Record{ + Fields: []*lapiv2.RecordField{ + {Label: "recipient", Value: values.PartyValue(req.RecipientParty)}, + {Label: "amount", Value: values.NumericValue(req.Amount)}, + {Label: "eventTime", Value: values.TimestampValue(time.Now())}, + {Label: "userFingerprint", Value: values.TextValue(req.UserFingerprint)}, + {Label: "evmTxHash", Value: evmTx}, + }, + } +} + +func encodeIssuerBurnArgs(req BurnRequest) *lapiv2.Record { + evmDest := values.None() + if req.EvmDestination != "" { + evmDest = values.Optional(values.TextValue(req.EvmDestination)) + } + + return &lapiv2.Record{ + Fields: []*lapiv2.RecordField{ + {Label: "holdingCid", Value: values.ContractIDValue(req.HoldingCID)}, + {Label: "amount", Value: values.NumericValue(req.Amount)}, + {Label: "eventTime", Value: values.TimestampValue(time.Now())}, + {Label: "userFingerprint", Value: values.TextValue(req.UserFingerprint)}, + {Label: "evmDestination", Value: evmDest}, + }, + } +} + +func encodeHoldingTransferArgs(toParty, amount, existingRecipientHolding string) *lapiv2.Record { + existing := values.None() + if existingRecipientHolding != "" { + existing = values.Optional(values.ContractIDValue(existingRecipientHolding)) + } + + return &lapiv2.Record{ + Fields: []*lapiv2.RecordField{ + {Label: "to", Value: values.PartyValue(toParty)}, + {Label: "value", Value: values.NumericValue(amount)}, + {Label: "existingRecipientHolding", Value: existing}, + {Label: "complianceRulesCid", Value: values.None()}, + {Label: "complianceProofCid", Value: values.None()}, + }, + } +} diff --git a/pkg/canton-sdk/token/options.go b/pkg/canton-sdk/token/options.go new file mode 100644 index 0000000..85c326f --- /dev/null +++ b/pkg/canton-sdk/token/options.go @@ -0,0 +1,25 @@ +package token + +import "go.uber.org/zap" + +type settings struct { + logger *zap.Logger +} + +// Option configures the token client. +type Option func(*settings) + +// WithLogger sets a custom logger for the token client. +func WithLogger(l *zap.Logger) Option { + return func(s *settings) { s.logger = l } +} + +func applyOptions(opts []Option) settings { + s := settings{logger: zap.NewNop()} + for _, opt := range opts { + if opt != nil { + opt(&s) + } + } + return s +} diff --git a/pkg/canton-sdk/token/types.go b/pkg/canton-sdk/token/types.go new file mode 100644 index 0000000..da30b6e --- /dev/null +++ b/pkg/canton-sdk/token/types.go @@ -0,0 +1,95 @@ +package token + +import ( + "fmt" + "time" +) + +// Holding represents a CIP56Holding contract. +type Holding struct { + ContractID string + Issuer string + Owner string + Amount string + Symbol string +} + +// MintRequest represents an issuer mint request via TokenConfig. +type MintRequest struct { + RecipientParty string + Amount string + UserFingerprint string + TokenSymbol string + ConfigCID string + EvmTxHash string +} + +func (m MintRequest) validate() error { + if m.RecipientParty == "" { + return fmt.Errorf("recipient_party is required") + } + if m.Amount == "" { + return fmt.Errorf("amount is required") + } + if m.TokenSymbol == "" { + return fmt.Errorf("token_symbol is required") + } + if m.UserFingerprint == "" { + return fmt.Errorf("user_fingerprint is required") + } + return nil +} + +// BurnRequest represents an issuer burn request via TokenConfig. +type BurnRequest struct { + HoldingCID string + Amount string + UserFingerprint string + TokenSymbol string + EvmDestination string +} + +func (b BurnRequest) validate() error { + if b.HoldingCID == "" { + return fmt.Errorf("holding_cid is required") + } + if b.Amount == "" { + return fmt.Errorf("amount is required") + } + if b.TokenSymbol == "" { + return fmt.Errorf("token_symbol is required") + } + if b.UserFingerprint == "" { + return fmt.Errorf("user_fingerprint is required") + } + return nil +} + +// MintEvent is a decoded representation of a CIP56.Events.MintEvent. +// Ref: https://github.com/ChainSafe/canton-erc20/blob/53065ebcffa047e07cd7dc472ba9a9eed9895340/daml/cip56-token/src/CIP56/Events.daml#L21C10-L21C19 +type MintEvent struct { + ContractID string + Issuer string + Recipient string + Amount string + HoldingCid string + TokenSymbol string + EvmTxHash string + UserFingerprint string + Timestamp time.Time + AuditObservers []string +} + +// BurnEvent is a decoded representation of a CIP56.Events.BurnEvent. +// Ref: https://github.com/ChainSafe/canton-erc20/blob/53065ebcffa047e07cd7dc472ba9a9eed9895340/daml/cip56-token/src/CIP56/Events.daml#L45 +type BurnEvent struct { + ContractID string + Issuer string + BurnedFrom string + Amount string + EvmDestination string + TokenSymbol string + UserFingerprint string + Timestamp time.Time + AuditObservers []string +} diff --git a/pkg/canton-sdk/values/decode.go b/pkg/canton-sdk/values/decode.go new file mode 100644 index 0000000..b39e8ec --- /dev/null +++ b/pkg/canton-sdk/values/decode.go @@ -0,0 +1,78 @@ +package values + +import ( + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" + "time" +) + +// Text extracts a text value. +func Text(v *lapiv2.Value) string { + if v == nil { + return "" + } + if t, ok := v.Sum.(*lapiv2.Value_Text); ok { + return t.Text + } + return "" +} + +// Party extracts a party value. +func Party(v *lapiv2.Value) string { + if v == nil { + return "" + } + if p, ok := v.Sum.(*lapiv2.Value_Party); ok { + return p.Party + } + return "" +} + +// PartyList extracts list of parties. +func PartyList(v *lapiv2.Value) []string { + if v == nil { + return []string{} + } + pl := make([]string, 0) + if list, ok := v.Sum.(*lapiv2.Value_List); ok { + for _, element := range list.List.Elements { + party := Party(element) + if party != "" { + pl = append(pl, party) + } + } + } + return pl +} + +// Numeric extracts a numeric value as string. +func Numeric(v *lapiv2.Value) string { + if v == nil { + return "0" + } + if n, ok := v.Sum.(*lapiv2.Value_Numeric); ok { + return n.Numeric + } + return "0" +} + +// ContractID extracts a contract ID value. +func ContractID(v *lapiv2.Value) string { + if v == nil { + return "" + } + if c, ok := v.Sum.(*lapiv2.Value_ContractId); ok { + return c.ContractId + } + return "" +} + +// Timestamp extracts timestamp. +func Timestamp(v *lapiv2.Value) time.Time { + if v == nil { + return time.Time{} + } + if t, ok := v.Sum.(*lapiv2.Value_Timestamp); ok { + return time.UnixMicro(t.Timestamp) + } + return time.Time{} +} diff --git a/pkg/canton-sdk/values/encode.go b/pkg/canton-sdk/values/encode.go new file mode 100644 index 0000000..d24fac0 --- /dev/null +++ b/pkg/canton-sdk/values/encode.go @@ -0,0 +1,70 @@ +package values + +import ( + "time" + + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" +) + +// TextValue returns a text ledger value. +func TextValue(v string) *lapiv2.Value { + return &lapiv2.Value{ + Sum: &lapiv2.Value_Text{ + Text: v, + }, + } +} + +// PartyValue returns a party ledger value. +func PartyValue(v string) *lapiv2.Value { + return &lapiv2.Value{ + Sum: &lapiv2.Value_Party{ + Party: v, + }, + } +} + +// NumericValue returns a numeric ledger value. +func NumericValue(v string) *lapiv2.Value { + return &lapiv2.Value{ + Sum: &lapiv2.Value_Numeric{ + Numeric: v, + }, + } +} + +// ContractIDValue returns a contract ID ledger value. +func ContractIDValue(v string) *lapiv2.Value { + return &lapiv2.Value{ + Sum: &lapiv2.Value_ContractId{ + ContractId: v, + }, + } +} + +// TimestampValue returns a timestamp ledger value. +func TimestampValue(t time.Time) *lapiv2.Value { + return &lapiv2.Value{ + Sum: &lapiv2.Value_Timestamp{ + Timestamp: t.UnixMicro(), + }, + } +} + +// None returns an empty optional value. +func None() *lapiv2.Value { + return &lapiv2.Value{ + Sum: &lapiv2.Value_Optional{ + Optional: nil, + }, + } +} + +// Optional wraps an optional value. +func Optional(v *lapiv2.Value) *lapiv2.Value { + return &lapiv2.Value{ + Sum: &lapiv2.Value_Optional{ + Optional: &lapiv2.Optional{Value: v}, + }, + } +} diff --git a/pkg/canton-sdk/values/meta.go b/pkg/canton-sdk/values/meta.go new file mode 100644 index 0000000..a42388c --- /dev/null +++ b/pkg/canton-sdk/values/meta.go @@ -0,0 +1,19 @@ +package values + +import ( + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" +) + +// MetaSymbol extracts token symbol from a CIP-56 meta record. +func MetaSymbol(v *lapiv2.Value) string { + if v == nil { + return "" + } + rec, ok := v.Sum.(*lapiv2.Value_Record) + if !ok || rec.Record == nil { + return "" + } + + fields := RecordToMap(rec.Record) + return Text(fields["symbol"]) +} diff --git a/pkg/canton-sdk/values/values.go b/pkg/canton-sdk/values/values.go new file mode 100644 index 0000000..1d93c82 --- /dev/null +++ b/pkg/canton-sdk/values/values.go @@ -0,0 +1,23 @@ +// Package values provides helper utilities for working with +// Canton Ledger API value types. +package values + +import ( + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" +) + +// RecordToMap converts a Ledger API record into a map keyed by field label. +// Fields without labels are ignored. +func RecordToMap(r *lapiv2.Record) map[string]*lapiv2.Value { + out := make(map[string]*lapiv2.Value) + if r == nil { + return out + } + for _, f := range r.Fields { + if f.Label == "" { + continue + } + out[f.Label] = f.Value + } + return out +} diff --git a/pkg/canton/client.go b/pkg/canton/client.go deleted file mode 100644 index 52defc6..0000000 --- a/pkg/canton/client.go +++ /dev/null @@ -1,1839 +0,0 @@ -package canton - -import ( - "bytes" - "context" - "crypto/tls" - "crypto/x509" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "strconv" - "strings" - "sync" - "time" - - "github.com/golang-jwt/jwt/v5" - - lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2" - adminv2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2/admin" - "github.com/chainsafe/canton-middleware/pkg/config" - "go.uber.org/zap" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/metadata" -) - -// Sentinel errors for balance-related operations -var ( - // ErrInsufficientBalance indicates the user's total balance is less than the required amount - ErrInsufficientBalance = errors.New("insufficient balance") - // ErrBalanceFragmented indicates the user has sufficient total balance but it's spread across - // multiple holdings, none of which individually has enough for the transfer - ErrBalanceFragmented = errors.New("balance fragmented across multiple holdings: consolidation required") -) - -// Client is a wrapper around the Canton gRPC client -type Client struct { - config *config.CantonConfig - conn *grpc.ClientConn - logger *zap.Logger - - stateService lapiv2.StateServiceClient - commandService lapiv2.CommandServiceClient - updateService lapiv2.UpdateServiceClient - partyManagementService adminv2.PartyManagementServiceClient - userManagementService adminv2.UserManagementServiceClient - - // OAuth token cache - tokenMu sync.Mutex - cachedToken string - tokenExpiry time.Time - - jwtSubject string // Extracted from JWT token -} - -// NewClient creates a new Canton gRPC client -func NewClient(config *config.CantonConfig, logger *zap.Logger) (*Client, error) { - var opts []grpc.DialOption - - // Setup TLS if enabled - if config.TLS.Enabled { - tlsConfig, err := loadTLSConfig(&config.TLS) - if err != nil { - return nil, fmt.Errorf("failed to load TLS config: %w", err) - } - creds := credentials.NewTLS(tlsConfig) - opts = append(opts, grpc.WithTransportCredentials(creds)) - } else { - opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) - } - - // Set max message size - if config.MaxMessageSize > 0 { - opts = append(opts, grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(config.MaxMessageSize))) - } - - // Connect to Canton participant node - conn, err := grpc.NewClient(config.RPCURL, opts...) - if err != nil { - return nil, fmt.Errorf("failed to create Canton client: %w", err) - } - - logger.Info("Connected to Canton Network", - zap.String("rpc_url", config.RPCURL), - zap.String("ledger_id", config.LedgerID)) - - // Validate required package IDs for server-side TemplateFilter queries -requiredPackages := map[string]string{ - "canton.cip56_package_id": config.CIP56PackageID, - "canton.common_package_id": config.CommonPackageID, - "canton.bridge_package_id": config.BridgePackageID, -} -for name, id := range requiredPackages { - if id == "" { - return nil, fmt.Errorf("%s is required", name) - } -} - - c := &Client{ - config: config, - conn: conn, - logger: logger, - stateService: lapiv2.NewStateServiceClient(conn), - commandService: lapiv2.NewCommandServiceClient(conn), - updateService: lapiv2.NewUpdateServiceClient(conn), - partyManagementService: adminv2.NewPartyManagementServiceClient(conn), - userManagementService: adminv2.NewUserManagementServiceClient(conn), - } - - // Extract JWT subject if token is configured - if token, err := c.loadToken(); err == nil && token != "" { - if subject, err := extractJWTSubject(token); err == nil { - c.jwtSubject = subject - logger.Info("Extracted JWT subject", zap.String("subject", subject)) - } else { - logger.Warn("Failed to extract JWT subject", zap.Error(err)) - } - } - - return c, nil -} - -// Close closes the connection to the Canton node -func (c *Client) Close() error { - return c.conn.Close() -} - -// GetAuthContext returns a context with the JWT token if configured -// When using wildcard auth, no token is needed and this returns the original context -func (c *Client) GetAuthContext(ctx context.Context) context.Context { - token, err := c.loadToken() - if err != nil { - // Not an error with wildcard auth - just means no token configured - c.logger.Debug("No JWT token configured (OK with wildcard auth)", zap.Error(err)) - return ctx - } - - if token != "" { - md := metadata.Pairs("authorization", "Bearer "+token) - return metadata.NewOutgoingContext(ctx, md) - } - return ctx -} - -// invalidateToken clears the cached token to force a refresh on next GetAuthContext call -func (c *Client) invalidateToken() { - c.tokenMu.Lock() - defer c.tokenMu.Unlock() - c.cachedToken = "" - c.tokenExpiry = time.Time{} -} - -func (c *Client) loadToken() (string, error) { - auth := c.config.Auth - if auth.ClientID == "" || auth.ClientSecret == "" || auth.Audience == "" || auth.TokenURL == "" { - return "", fmt.Errorf("no auth configured: OAuth2 client credentials are required") - } - - c.tokenMu.Lock() - defer c.tokenMu.Unlock() - - now := time.Now() - if c.cachedToken != "" && now.Before(c.tokenExpiry) { - return c.cachedToken, nil - } - - payload := map[string]string{ - "client_id": auth.ClientID, - "client_secret": auth.ClientSecret, - "audience": auth.Audience, - "grant_type": "client_credentials", - } - bodyBytes, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("failed to marshal OAuth token request: %w", err) - } - - c.logger.Info("Fetching new OAuth2 access token", zap.String("token_url", auth.TokenURL)) - - req, err := http.NewRequest("POST", auth.TokenURL, bytes.NewReader(bodyBytes)) - if err != nil { - return "", fmt.Errorf("failed to create OAuth token request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to call OAuth token endpoint: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return "", fmt.Errorf("OAuth token endpoint returned %d: %s", resp.StatusCode, string(b)) - } - - var tokenResp struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - } - if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { - return "", fmt.Errorf("failed to decode OAuth token response: %w", err) - } - if tokenResp.AccessToken == "" { - return "", fmt.Errorf("OAuth token response missing access_token") - } - - expiry := now.Add(5 * time.Minute) - if tokenResp.ExpiresIn > 0 { - leeway := 60 - if tokenResp.ExpiresIn <= leeway { - leeway = tokenResp.ExpiresIn / 2 - } - expiry = now.Add(time.Duration(tokenResp.ExpiresIn-leeway) * time.Second) - } - - c.cachedToken = tokenResp.AccessToken - c.tokenExpiry = expiry - - c.logger.Info("Fetched new OAuth2 access token", - zap.String("token_url", auth.TokenURL), - zap.Int("expires_in", tokenResp.ExpiresIn), - ) - - return tokenResp.AccessToken, nil -} - -// extractJWTSubject parses the JWT token and extracts the 'sub' claim -func extractJWTSubject(tokenString string) (string, error) { - // Parse without validating signature (Canton handles verification) - token, _, err := jwt.NewParser().ParseUnverified(tokenString, jwt.MapClaims{}) - if err != nil { - return "", fmt.Errorf("failed to parse JWT: %w", err) - } - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return "", fmt.Errorf("invalid JWT claims") - } - sub, ok := claims["sub"].(string) - if !ok { - return "", fmt.Errorf("JWT missing 'sub' claim") - } - return sub, nil -} - -// loadTLSConfig loads TLS configuration from files -// If no cert files are provided, uses system CA pool (standard TLS) -// If cert files are provided, uses mTLS (mutual TLS with client certs) -func loadTLSConfig(tlsCfg *config.TLSConfig) (*tls.Config, error) { - tlsConfig := &tls.Config{ - InsecureSkipVerify: true, // Skip cert verification for dev - TODO: make configurable - NextProtos: []string{"h2"}, // Force HTTP/2 ALPN for grpc-go 1.67+ compatibility - } - - // If client cert/key provided, load them (mTLS) - if tlsCfg.CertFile != "" && tlsCfg.KeyFile != "" { - cert, err := tls.LoadX509KeyPair(tlsCfg.CertFile, tlsCfg.KeyFile) - if err != nil { - return nil, fmt.Errorf("failed to load client cert/key: %w", err) - } - tlsConfig.Certificates = []tls.Certificate{cert} - } - - // If CA file provided, use it; otherwise use system CA pool - if tlsCfg.CAFile != "" { - caCert, err := os.ReadFile(tlsCfg.CAFile) - if err != nil { - return nil, fmt.Errorf("failed to read CA cert: %w", err) - } - - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("failed to append CA cert") - } - tlsConfig.RootCAs = caCertPool - } - // If no CA file, tlsConfig.RootCAs = nil uses system CA pool - - return tlsConfig, nil -} - -// StreamTransactions streams transactions from the Canton ledger -// V2 API: offset is int64 and uses UpdateFormat instead of TransactionFilter -func (c *Client) StreamTransactions(ctx context.Context, offset string, updateFormat *lapiv2.UpdateFormat) (grpc.ServerStreamingClient[lapiv2.GetUpdatesResponse], error) { - authCtx := c.GetAuthContext(ctx) - - // Parse offset - V2 API uses int64 - var beginOffset int64 - if offset == "BEGIN" || offset == "" { - beginOffset = 0 // 0 means start from beginning - } else { - var err error - beginOffset, err = strconv.ParseInt(offset, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid offset %s: %w", offset, err) - } - } - - req := &lapiv2.GetUpdatesRequest{ - BeginExclusive: beginOffset, - UpdateFormat: updateFormat, - } - - return c.updateService.GetUpdates(authCtx, req) -} - -// GetWayfinderBridgeConfig finds the active WayfinderBridgeConfig contract -func (c *Client) GetWayfinderBridgeConfig(ctx context.Context) (string, error) { - authCtx := c.GetAuthContext(ctx) - - // V2 API requires ActiveAtOffset - get current ledger end - ledgerEndResp, err := c.stateService.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) - if err != nil { - return "", fmt.Errorf("failed to get ledger end: %w", err) - } - activeAtOffset := ledgerEndResp.Offset - if activeAtOffset == 0 { - return "", fmt.Errorf("ledger is empty, no contracts exist") - } - - // V2 API: GetActiveContracts uses EventFormat with FiltersByParty and Cumulative filters - resp, err := c.stateService.GetActiveContracts(authCtx, &lapiv2.GetActiveContractsRequest{ - ActiveAtOffset: activeAtOffset, - EventFormat: &lapiv2.EventFormat{ - FiltersByParty: map[string]*lapiv2.Filters{ - c.config.RelayerParty: { - Cumulative: []*lapiv2.CumulativeFilter{ - { - IdentifierFilter: &lapiv2.CumulativeFilter_TemplateFilter{ - TemplateFilter: &lapiv2.TemplateFilter{ - TemplateId: &lapiv2.Identifier{ - PackageId: c.config.BridgePackageID, - ModuleName: c.config.BridgeModule, - EntityName: "WayfinderBridgeConfig", - }, - }, - }, - }, - }, - }, - }, - Verbose: true, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to search for config: %w", err) - } - - // Read the stream - for { - msg, err := resp.Recv() - if err != nil { - break // EOF or error - } - // In V2, ActiveContracts are delivered via ContractEntry oneof - if contract := msg.GetActiveContract(); contract != nil { - return contract.CreatedEvent.ContractId, nil - } - } - - return "", fmt.Errorf("no active WayfinderBridgeConfig found") -} - -// GetLedgerEnd gets the current ledger end offset -func (c *Client) GetLedgerEnd(ctx context.Context) (string, error) { - authCtx := c.GetAuthContext(ctx) - - resp, err := c.stateService.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) - if err != nil { - return "", fmt.Errorf("failed to get ledger end: %w", err) - } - - // V2 API: Offset is int64 directly - // 0 = empty ledger, positive = absolute offset - if resp.Offset == 0 { - return "BEGIN", nil - } - - return strconv.FormatInt(resp.Offset, 10), nil -} - -// ============================================================================= -// PARTY MANAGEMENT METHODS (for custodial key model) -// ============================================================================= - -// AllocatePartyResult contains the result of allocating a new Canton party -type AllocatePartyResult struct { - PartyID string // The full party ID (e.g., "user_0x1234::participant123") - IsLocal bool // Whether the party is local to this participant -} - -// AllocateParty allocates a new Canton party for a user. -// The hint is used to create a human-readable party ID prefix. -// Returns the allocated party details. -func (c *Client) AllocateParty(ctx context.Context, hint string) (*AllocatePartyResult, error) { - c.logger.Info("Allocating new Canton party", - zap.String("hint", hint), - zap.String("synchronizer_id", c.config.DomainID)) - - authCtx := c.GetAuthContext(ctx) - - req := &adminv2.AllocatePartyRequest{ - PartyIdHint: hint, - SynchronizerId: c.config.DomainID, - } - - resp, err := c.partyManagementService.AllocateParty(authCtx, req) - if err != nil { - return nil, fmt.Errorf("failed to allocate party: %w", err) - } - - if resp.PartyDetails == nil { - return nil, fmt.Errorf("AllocateParty returned nil party details") - } - - c.logger.Info("Allocated new Canton party", - zap.String("party_id", resp.PartyDetails.Party), - zap.Bool("is_local", resp.PartyDetails.IsLocal)) - - return &AllocatePartyResult{ - PartyID: resp.PartyDetails.Party, - IsLocal: resp.PartyDetails.IsLocal, - }, nil -} - -// ListParties returns all parties known to this participant (paginates through all results) -func (c *Client) ListParties(ctx context.Context) ([]*AllocatePartyResult, error) { - authCtx := c.GetAuthContext(ctx) - - var results []*AllocatePartyResult - pageToken := "" - - for { - resp, err := c.partyManagementService.ListKnownParties(authCtx, &adminv2.ListKnownPartiesRequest{ - PageSize: 1000, - PageToken: pageToken, - }) - if err != nil { - return nil, fmt.Errorf("failed to list parties: %w", err) - } - - for _, p := range resp.PartyDetails { - results = append(results, &AllocatePartyResult{ - PartyID: p.Party, - IsLocal: p.IsLocal, - }) - } - - if resp.NextPageToken == "" { - break - } - pageToken = resp.NextPageToken - } - - return results, nil -} - -// GetParticipantID returns the participant ID of the connected Canton node -func (c *Client) GetParticipantID(ctx context.Context) (string, error) { - authCtx := c.GetAuthContext(ctx) - - resp, err := c.partyManagementService.GetParticipantId(authCtx, &adminv2.GetParticipantIdRequest{}) - if err != nil { - return "", fmt.Errorf("failed to get participant ID: %w", err) - } - - return resp.ParticipantId, nil -} - -// ============================================================================= -// ISSUER-CENTRIC MODEL METHODS -// ============================================================================= - -// CreateFingerprintMappingDirect creates a FingerprintMapping using direct Create command -// The issuer has signatory rights on FingerprintMapping, so no bridge config is needed. -func (c *Client) CreateFingerprintMappingDirect(ctx context.Context, req *RegisterUserRequest) (string, error) { - c.logger.Info("Creating FingerprintMapping directly", - zap.String("user_party", req.UserParty), - zap.String("fingerprint", req.Fingerprint)) - - authCtx := c.GetAuthContext(ctx) - - // Determine the package ID for Common.FingerprintAuth - // Try CommonPackageID first, fall back to BridgePackageID - packageID := c.config.CommonPackageID - if packageID == "" { - packageID = c.config.BridgePackageID - } - if packageID == "" { - return "", fmt.Errorf("no package ID configured for FingerprintMapping (set common_package_id or bridge_package_id)") - } - - cmd := &lapiv2.Command{ - Command: &lapiv2.Command_Create{ - Create: &lapiv2.CreateCommand{ - TemplateId: &lapiv2.Identifier{ - PackageId: packageID, - ModuleName: "Common.FingerprintAuth", - EntityName: "FingerprintMapping", - }, - CreateArguments: EncodeFingerprintMappingCreate( - c.config.RelayerParty, - req.UserParty, - req.Fingerprint, - req.EvmAddress, - ), - }, - }, - } - - resp, err := c.commandService.SubmitAndWaitForTransaction(authCtx, &lapiv2.SubmitAndWaitForTransactionRequest{ - Commands: &lapiv2.Commands{ - SynchronizerId: c.config.DomainID, - CommandId: generateUUID(), - UserId: c.jwtSubject, - ActAs: []string{c.config.RelayerParty}, - ReadAs: []string{req.UserParty}, // userParty is observer - Commands: []*lapiv2.Command{cmd}, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to create FingerprintMapping: %w", err) - } - - // Extract the created FingerprintMapping contract ID from response - if resp.Transaction != nil { - for _, event := range resp.Transaction.Events { - if created := event.GetCreated(); created != nil { - templateId := created.TemplateId - if templateId.ModuleName == "Common.FingerprintAuth" && templateId.EntityName == "FingerprintMapping" { - c.logger.Info("FingerprintMapping created successfully", - zap.String("contract_id", created.ContractId), - zap.String("user_party", req.UserParty)) - return created.ContractId, nil - } - } - } - } - - return "", fmt.Errorf("FingerprintMapping contract not found in response") -} - -// GetFingerprintMapping finds a FingerprintMapping by fingerprint -func (c *Client) GetFingerprintMapping(ctx context.Context, fingerprint string) (*FingerprintMapping, error) { - // Normalize fingerprint to have 0x prefix for comparison - // since API server stores with 0x prefix but Ethereum events may not include it - normalizedFingerprint := fingerprint - if !strings.HasPrefix(normalizedFingerprint, "0x") { - normalizedFingerprint = "0x" + normalizedFingerprint - } - - authCtx := c.GetAuthContext(ctx) - - // V2 API requires ActiveAtOffset - get current ledger end - ledgerEndResp, err := c.stateService.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) - if err != nil { - return nil, fmt.Errorf("failed to get ledger end: %w", err) - } - activeAtOffset := ledgerEndResp.Offset - if activeAtOffset == 0 { - return nil, fmt.Errorf("ledger is empty, no contracts exist") - } - - // Build filter for FingerprintMapping contracts using TemplateFilter - resp, err := c.stateService.GetActiveContracts(authCtx, &lapiv2.GetActiveContractsRequest{ - ActiveAtOffset: activeAtOffset, - EventFormat: &lapiv2.EventFormat{ - FiltersByParty: map[string]*lapiv2.Filters{ - c.config.RelayerParty: { - Cumulative: []*lapiv2.CumulativeFilter{ - templateFilter(c.config.CommonPackageID, "Common.FingerprintAuth", "FingerprintMapping"), - }, - }, - }, - Verbose: true, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to search for FingerprintMapping: %w", err) - } - - // Read through the stream to find the matching FingerprintMapping - for { - msg, err := resp.Recv() - if err != nil { - break // EOF or error - } - if contract := msg.GetActiveContract(); contract != nil { - mapping, err := DecodeFingerprintMapping( - contract.CreatedEvent.ContractId, - contract.CreatedEvent.CreateArguments, - ) - if err != nil { - c.logger.Warn("Failed to decode FingerprintMapping", zap.Error(err)) - continue - } - // Compare with normalized fingerprint (both should have 0x prefix) - mappingFingerprint := mapping.Fingerprint - if !strings.HasPrefix(mappingFingerprint, "0x") { - mappingFingerprint = "0x" + mappingFingerprint - } - if mappingFingerprint == normalizedFingerprint { - return mapping, nil - } - } - } - - return nil, fmt.Errorf("no FingerprintMapping found for fingerprint: %s", normalizedFingerprint) -} - -// IsDepositProcessed checks if a deposit with the given EVM tx hash has already been processed -// It looks for existing PendingDeposit or DepositReceipt contracts with matching evmTxHash -func (c *Client) IsDepositProcessed(ctx context.Context, evmTxHash string) (bool, error) { - authCtx := c.GetAuthContext(ctx) - - // Get current ledger end for active contracts query - ledgerEndResp, err := c.stateService.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) - if err != nil { - return false, fmt.Errorf("failed to get ledger end: %w", err) - } - activeAtOffset := ledgerEndResp.Offset - if activeAtOffset == 0 { - return false, nil // Empty ledger, no deposits exist - } - - // Query for PendingDeposit and DepositReceipt contracts using TemplateFilter - resp, err := c.stateService.GetActiveContracts(authCtx, &lapiv2.GetActiveContractsRequest{ - ActiveAtOffset: activeAtOffset, - EventFormat: &lapiv2.EventFormat{ - FiltersByParty: map[string]*lapiv2.Filters{ - c.config.RelayerParty: { - Cumulative: []*lapiv2.CumulativeFilter{ - templateFilter(c.config.CommonPackageID, "Common.FingerprintAuth", "PendingDeposit"), - templateFilter(c.config.CommonPackageID, "Common.FingerprintAuth", "DepositReceipt"), - }, - }, - }, - Verbose: true, - }, - }) - if err != nil { - return false, fmt.Errorf("failed to query active contracts: %w", err) - } - - // Search for PendingDeposit or DepositReceipt with matching evmTxHash - for { - msg, err := resp.Recv() - if err != nil { - break // EOF or error - } - if contract := msg.GetActiveContract(); contract != nil { - // Extract evmTxHash from contract arguments - if contract.CreatedEvent.CreateArguments != nil { - for _, field := range contract.CreatedEvent.CreateArguments.Fields { - if field.Label == "evmTxHash" { - if textVal, ok := field.Value.Sum.(*lapiv2.Value_Text); ok { - if textVal.Text == evmTxHash { - c.logger.Debug("Found existing deposit contract", - zap.String("evm_tx_hash", evmTxHash), - zap.String("contract_type", contract.CreatedEvent.TemplateId.EntityName), - zap.String("contract_id", contract.CreatedEvent.ContractId)) - return true, nil - } - } - } - } - } - } - } - - return false, nil -} - -// CreatePendingDeposit creates a PendingDeposit from an EVM deposit event -func (c *Client) CreatePendingDeposit(ctx context.Context, req *CreatePendingDepositRequest) (string, error) { - c.logger.Info("Creating pending deposit", - zap.String("fingerprint", req.Fingerprint), - zap.String("amount", req.Amount), - zap.String("evm_tx_hash", req.EvmTxHash)) - - configCid, err := c.GetWayfinderBridgeConfig(ctx) - if err != nil { - return "", fmt.Errorf("failed to get WayfinderBridgeConfig: %w", err) - } - - authCtx := c.GetAuthContext(ctx) - - cmd := &lapiv2.Command{ - Command: &lapiv2.Command_Exercise{ - Exercise: &lapiv2.ExerciseCommand{ - TemplateId: &lapiv2.Identifier{ - PackageId: c.config.BridgePackageID, - ModuleName: "Wayfinder.Bridge", - EntityName: "WayfinderBridgeConfig", - }, - ContractId: configCid, - Choice: "CreatePendingDeposit", - ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: EncodeCreatePendingDepositArgs(req)}}, - }, - }, - } - - resp, err := c.commandService.SubmitAndWaitForTransaction(authCtx, &lapiv2.SubmitAndWaitForTransactionRequest{ - Commands: &lapiv2.Commands{ - SynchronizerId: c.config.DomainID, - CommandId: generateUUID(), - UserId: c.jwtSubject, - ActAs: []string{c.config.RelayerParty}, - Commands: []*lapiv2.Command{cmd}, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to create pending deposit: %w", err) - } - - // Extract the created PendingDeposit contract ID - if resp.Transaction != nil { - for _, event := range resp.Transaction.Events { - if created := event.GetCreated(); created != nil { - templateId := created.TemplateId - if templateId.ModuleName == "Common.FingerprintAuth" && templateId.EntityName == "PendingDeposit" { - return created.ContractId, nil - } - } - } - } - - return "", fmt.Errorf("PendingDeposit contract not found in response") -} - -// ProcessDeposit processes a pending deposit and mints tokens -func (c *Client) ProcessDeposit(ctx context.Context, req *ProcessDepositRequest) (string, error) { - c.logger.Info("Processing deposit and minting tokens", - zap.String("deposit_cid", req.DepositCid), - zap.String("mapping_cid", req.MappingCid)) - - configCid, err := c.GetWayfinderBridgeConfig(ctx) - if err != nil { - return "", fmt.Errorf("failed to get WayfinderBridgeConfig: %w", err) - } - - authCtx := c.GetAuthContext(ctx) - - cmd := &lapiv2.Command{ - Command: &lapiv2.Command_Exercise{ - Exercise: &lapiv2.ExerciseCommand{ - TemplateId: &lapiv2.Identifier{ - PackageId: c.config.BridgePackageID, - ModuleName: "Wayfinder.Bridge", - EntityName: "WayfinderBridgeConfig", - }, - ContractId: configCid, - Choice: "ProcessDepositAndMint", - ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: EncodeProcessDepositAndMintArgs(req)}}, - }, - }, - } - - resp, err := c.commandService.SubmitAndWaitForTransaction(authCtx, &lapiv2.SubmitAndWaitForTransactionRequest{ - Commands: &lapiv2.Commands{ - SynchronizerId: c.config.DomainID, - CommandId: generateUUID(), - UserId: c.jwtSubject, - ActAs: []string{c.config.RelayerParty}, - Commands: []*lapiv2.Command{cmd}, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to process deposit: %w", err) - } - - // Extract the created CIP56Holding contract ID - if resp.Transaction != nil { - for _, event := range resp.Transaction.Events { - if created := event.GetCreated(); created != nil { - templateId := created.TemplateId - if templateId.ModuleName == "CIP56.Token" && templateId.EntityName == "CIP56Holding" { - return created.ContractId, nil - } - } - } - } - - return "", fmt.Errorf("CIP56Holding contract not found in response") -} - -// InitiateWithdrawal starts a withdrawal on behalf of a user -func (c *Client) InitiateWithdrawal(ctx context.Context, req *InitiateWithdrawalRequest) (string, error) { - c.logger.Info("Initiating withdrawal", - zap.String("mapping_cid", req.MappingCid), - zap.String("holding_cid", req.HoldingCid), - zap.String("amount", req.Amount), - zap.String("evm_destination", req.EvmDestination)) - - configCid, err := c.GetWayfinderBridgeConfig(ctx) - if err != nil { - return "", fmt.Errorf("failed to get WayfinderBridgeConfig: %w", err) - } - - authCtx := c.GetAuthContext(ctx) - - cmd := &lapiv2.Command{ - Command: &lapiv2.Command_Exercise{ - Exercise: &lapiv2.ExerciseCommand{ - TemplateId: &lapiv2.Identifier{ - PackageId: c.config.BridgePackageID, - ModuleName: "Wayfinder.Bridge", - EntityName: "WayfinderBridgeConfig", - }, - ContractId: configCid, - Choice: "InitiateWithdrawal", - ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: EncodeInitiateWithdrawalArgs(req)}}, - }, - }, - } - - resp, err := c.commandService.SubmitAndWaitForTransaction(authCtx, &lapiv2.SubmitAndWaitForTransactionRequest{ - Commands: &lapiv2.Commands{ - SynchronizerId: c.config.DomainID, - CommandId: generateUUID(), - UserId: c.jwtSubject, - ActAs: []string{c.config.RelayerParty}, - Commands: []*lapiv2.Command{cmd}, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to initiate withdrawal: %w", err) - } - - // Extract the created WithdrawalRequest contract ID - if resp.Transaction != nil { - for _, event := range resp.Transaction.Events { - if created := event.GetCreated(); created != nil { - templateId := created.TemplateId - if templateId.ModuleName == "Bridge.Contracts" && templateId.EntityName == "WithdrawalRequest" { - return created.ContractId, nil - } - } - } - } - - return "", fmt.Errorf("WithdrawalRequest contract not found in response") -} - -// CompleteWithdrawal marks a withdrawal as complete after EVM release -func (c *Client) CompleteWithdrawal(ctx context.Context, req *CompleteWithdrawalRequest) error { - c.logger.Info("Completing withdrawal", - zap.String("withdrawal_event_cid", req.WithdrawalEventCid), - zap.String("evm_tx_hash", req.EvmTxHash)) - - authCtx := c.GetAuthContext(ctx) - - // WithdrawalEvent is in bridge-core package (CorePackageID), not bridge-wayfinder - corePackageID := c.config.CorePackageID - if corePackageID == "" { - corePackageID = c.config.BridgePackageID // fallback for backwards compatibility - } - - cmd := &lapiv2.Command{ - Command: &lapiv2.Command_Exercise{ - Exercise: &lapiv2.ExerciseCommand{ - TemplateId: &lapiv2.Identifier{ - PackageId: corePackageID, - ModuleName: "Bridge.Contracts", - EntityName: "WithdrawalEvent", - }, - ContractId: req.WithdrawalEventCid, - Choice: "CompleteWithdrawal", - ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: EncodeCompleteWithdrawalArgs(req.EvmTxHash)}}, - }, - }, - } - - _, err := c.commandService.SubmitAndWait(authCtx, &lapiv2.SubmitAndWaitRequest{ - Commands: &lapiv2.Commands{ - SynchronizerId: c.config.DomainID, - CommandId: generateUUID(), - UserId: c.jwtSubject, - ActAs: []string{c.config.RelayerParty}, - Commands: []*lapiv2.Command{cmd}, - }, - }) - if err != nil { - return fmt.Errorf("failed to complete withdrawal: %w", err) - } - - return nil -} - -// ============================================================================= -// ERC-20 API SERVER METHODS -// ============================================================================= - -// cip56HoldingFilter returns a TemplateFilter for CIP56.Token.CIP56Holding contracts. -func (c *Client) cip56HoldingFilter() []*lapiv2.CumulativeFilter { - return []*lapiv2.CumulativeFilter{templateFilter(c.config.CIP56PackageID, "CIP56.Token", "CIP56Holding")} -} - -// tokenConfigFilter returns a TemplateFilter for CIP56.Config.TokenConfig contracts. -func (c *Client) tokenConfigFilter() []*lapiv2.CumulativeFilter { - return []*lapiv2.CumulativeFilter{templateFilter(c.config.CIP56PackageID, "CIP56.Config", "TokenConfig")} -} - -// templateFilter builds a CumulativeFilter with a TemplateFilter for server-side contract filtering. -func templateFilter(packageID, moduleName, entityName string) *lapiv2.CumulativeFilter { - return &lapiv2.CumulativeFilter{ - IdentifierFilter: &lapiv2.CumulativeFilter_TemplateFilter{ - TemplateFilter: &lapiv2.TemplateFilter{ - TemplateId: &lapiv2.Identifier{ - PackageId: packageID, - ModuleName: moduleName, - EntityName: entityName, - }, - }, - }, - } -} - - -// CIP56Holding represents a token holding contract -type CIP56Holding struct { - ContractID string - Issuer string - Owner string - Amount string - TokenID string - Symbol string // Token symbol from metadata ("DEMO" or "PROMPT") -} - -// GetUserBalance gets the total CIP56Holding balance for a user by fingerprint -func (c *Client) GetUserBalance(ctx context.Context, fingerprint string) (string, error) { - c.logger.Debug("Getting user balance", zap.String("fingerprint", fingerprint)) - - // First, find the FingerprintMapping to get the user's party - mapping, err := c.GetFingerprintMapping(ctx, fingerprint) - if err != nil { - return "0", fmt.Errorf("failed to find user: %w", err) - } - - // Now find all CIP56Holding contracts owned by this party - authCtx := c.GetAuthContext(ctx) - - ledgerEndResp, err := c.stateService.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) - if err != nil { - return "0", fmt.Errorf("failed to get ledger end: %w", err) - } - activeAtOffset := ledgerEndResp.Offset - if activeAtOffset == 0 { - return "0", nil - } - - // Query for CIP56Holding contracts using TemplateFilter - resp, err := c.stateService.GetActiveContracts(authCtx, &lapiv2.GetActiveContractsRequest{ - ActiveAtOffset: activeAtOffset, - EventFormat: &lapiv2.EventFormat{ - FiltersByParty: map[string]*lapiv2.Filters{ - c.config.RelayerParty: { - Cumulative: c.cip56HoldingFilter(), - }, - }, - Verbose: true, - }, - }) - if err != nil { - return "0", fmt.Errorf("failed to query holdings: %w", err) - } - - // Sum up all holdings for this user - totalBalance := "0" - for { - msg, err := resp.Recv() - if err != nil { - break - } - if contract := msg.GetActiveContract(); contract != nil { - // Check if this holding belongs to our user - fields := recordToMapV2(contract.CreatedEvent.CreateArguments) - owner, _ := extractPartyV2(fields["owner"]) - if owner != mapping.UserParty { - continue - } - - amount, _ := extractNumericV2(fields["amount"]) - if amount != "" { - // Add to total (simple string addition via decimal library) - total, _ := addDecimalStrings(totalBalance, amount) - totalBalance = total - } - } - } - - return totalBalance, nil -} - -// GetTotalSupply gets the total supply of CIP56 tokens -func (c *Client) GetTotalSupply(ctx context.Context) (string, error) { - c.logger.Debug("Getting total token supply") - - authCtx := c.GetAuthContext(ctx) - - ledgerEndResp, err := c.stateService.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) - if err != nil { - return "0", fmt.Errorf("failed to get ledger end: %w", err) - } - activeAtOffset := ledgerEndResp.Offset - if activeAtOffset == 0 { - return "0", nil - } - - // Query for all CIP56Holding contracts using TemplateFilter - resp, err := c.stateService.GetActiveContracts(authCtx, &lapiv2.GetActiveContractsRequest{ - ActiveAtOffset: activeAtOffset, - EventFormat: &lapiv2.EventFormat{ - FiltersByParty: map[string]*lapiv2.Filters{ - c.config.RelayerParty: { - Cumulative: c.cip56HoldingFilter(), - }, - }, - Verbose: true, - }, - }) - if err != nil { - return "0", fmt.Errorf("failed to query holdings: %w", err) - } - - // Sum up all holdings - totalSupply := "0" - for { - msg, err := resp.Recv() - if err != nil { - break - } - if contract := msg.GetActiveContract(); contract != nil { - fields := recordToMapV2(contract.CreatedEvent.CreateArguments) - amount, _ := extractNumericV2(fields["amount"]) - if amount != "" { - total, _ := addDecimalStrings(totalSupply, amount) - totalSupply = total - } - } - } - - return totalSupply, nil -} - -// getTokenConfigCidFromBridge retrieves the TokenConfig contract ID from WayfinderBridgeConfig -func (c *Client) getTokenConfigCidFromBridge(ctx context.Context) (string, error) { - authCtx := c.GetAuthContext(ctx) - - ledgerEndResp, err := c.stateService.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) - if err != nil { - return "", fmt.Errorf("failed to get ledger end: %w", err) - } - - resp, err := c.stateService.GetActiveContracts(authCtx, &lapiv2.GetActiveContractsRequest{ - ActiveAtOffset: ledgerEndResp.Offset, - EventFormat: &lapiv2.EventFormat{ - FiltersByParty: map[string]*lapiv2.Filters{ - c.config.RelayerParty: { - Cumulative: []*lapiv2.CumulativeFilter{ - templateFilter(c.config.BridgePackageID, "Wayfinder.Bridge", "WayfinderBridgeConfig"), - }, - }, - }, - Verbose: true, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to query contracts: %w", err) - } - - for { - msg, err := resp.Recv() - if err != nil { - break - } - if contract := msg.GetActiveContract(); contract != nil { - fields := recordToMapV2(contract.CreatedEvent.CreateArguments) - if tokenConfigCid, ok := extractContractIdV2(fields["tokenConfigCid"]); ok { - return tokenConfigCid, nil - } - } - } - - return "", fmt.Errorf("no WayfinderBridgeConfig found") -} - -// findHoldingForTransfer finds a CIP56Holding contract with sufficient balance for a given token. -// Filters by owner party and token symbol from metadata. -// Returns structured errors to distinguish between insufficient total balance -// and fragmented balance across multiple holdings. -func (c *Client) findHoldingForTransfer(ctx context.Context, ownerParty, requiredAmount, tokenSymbol string) (string, error) { - authCtx := c.GetAuthContext(ctx) - - ledgerEndResp, err := c.stateService.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) - if err != nil { - return "", fmt.Errorf("failed to get ledger end: %w", err) - } - activeAtOffset := ledgerEndResp.Offset - if activeAtOffset == 0 { - return "", fmt.Errorf("%w: no holdings exist", ErrInsufficientBalance) - } - - resp, err := c.stateService.GetActiveContracts(authCtx, &lapiv2.GetActiveContractsRequest{ - ActiveAtOffset: activeAtOffset, - EventFormat: &lapiv2.EventFormat{ - FiltersByParty: map[string]*lapiv2.Filters{ - c.config.RelayerParty: { - Cumulative: c.cip56HoldingFilter(), - }, - }, - Verbose: true, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to query holdings: %w", err) - } - - var totalBalance string = "0" - var holdingCount int - - for { - msg, err := resp.Recv() - if err != nil { - break - } - if contract := msg.GetActiveContract(); contract != nil { - fields := recordToMapV2(contract.CreatedEvent.CreateArguments) - owner, _ := extractPartyV2(fields["owner"]) - if owner != ownerParty { - continue - } - - // Filter by token symbol from metadata - symbol := extractHoldingSymbol(fields) - if symbol != tokenSymbol { - continue - } - - amount, _ := extractNumericV2(fields["amount"]) - holdingCount++ - totalBalance, _ = addDecimalStrings(totalBalance, amount) - - if compareDecimalStrings(amount, requiredAmount) >= 0 { - return contract.CreatedEvent.ContractId, nil - } - } - } - - if holdingCount == 0 { - return "", fmt.Errorf("%w: no %s holdings found for owner", ErrInsufficientBalance, tokenSymbol) - } - - if compareDecimalStrings(totalBalance, requiredAmount) >= 0 { - return "", fmt.Errorf("%w: total %s balance %s across %d holdings, need %s in single holding", - ErrBalanceFragmented, tokenSymbol, totalBalance, holdingCount, requiredAmount) - } - - return "", fmt.Errorf("%w: total %s balance %s, need %s", - ErrInsufficientBalance, tokenSymbol, totalBalance, requiredAmount) -} - -// findRecipientHolding finds an existing CIP56Holding for the recipient party and token symbol. -// Returns the contract ID if found, empty string if none exists. -func (c *Client) findRecipientHolding(ctx context.Context, recipientParty, tokenSymbol string) (string, error) { - authCtx := c.GetAuthContext(ctx) - - ledgerEndResp, err := c.stateService.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) - if err != nil { - return "", fmt.Errorf("failed to get ledger end: %w", err) - } - if ledgerEndResp.Offset == 0 { - return "", nil - } - - resp, err := c.stateService.GetActiveContracts(authCtx, &lapiv2.GetActiveContractsRequest{ - ActiveAtOffset: ledgerEndResp.Offset, - EventFormat: &lapiv2.EventFormat{ - FiltersByParty: map[string]*lapiv2.Filters{ - c.config.RelayerParty: { - Cumulative: c.cip56HoldingFilter(), - }, - }, - Verbose: true, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to query holdings: %w", err) - } - - for { - msg, err := resp.Recv() - if err != nil { - break - } - if contract := msg.GetActiveContract(); contract != nil { - fields := recordToMapV2(contract.CreatedEvent.CreateArguments) - owner, _ := extractPartyV2(fields["owner"]) - if owner != recipientParty { - continue - } - symbol := extractHoldingSymbol(fields) - if symbol == tokenSymbol { - return contract.CreatedEvent.ContractId, nil - } - } - } - - return "", nil // No existing holding found -} - -// extractHoldingSymbol extracts the token symbol from a CIP56Holding's metadata -func extractHoldingSymbol(fields map[string]*lapiv2.Value) string { - if metaVal, ok := fields["meta"]; ok { - if metaRecord := metaVal.GetRecord(); metaRecord != nil { - for _, metaField := range metaRecord.Fields { - if metaField.Label == "symbol" { - if s := metaField.Value.GetText(); s != "" { - return s - } - } - } - } - } - return "" -} - -// GetAllCIP56Holdings retrieves all CIP56Holding contracts from Canton -func (c *Client) GetAllCIP56Holdings(ctx context.Context) ([]*CIP56Holding, error) { - authCtx := c.GetAuthContext(ctx) - - ledgerEndResp, err := c.stateService.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) - if err != nil { - return nil, fmt.Errorf("failed to get ledger end: %w", err) - } - activeAtOffset := ledgerEndResp.Offset - if activeAtOffset == 0 { - return []*CIP56Holding{}, nil - } - - resp, err := c.stateService.GetActiveContracts(authCtx, &lapiv2.GetActiveContractsRequest{ - ActiveAtOffset: activeAtOffset, - EventFormat: &lapiv2.EventFormat{ - FiltersByParty: map[string]*lapiv2.Filters{ - c.config.RelayerParty: { - Cumulative: c.cip56HoldingFilter(), - }, - }, - Verbose: true, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to query holdings: %w", err) - } - - var holdings []*CIP56Holding - for { - msg, err := resp.Recv() - if err != nil { - break // EOF or error - } - if contract := msg.GetActiveContract(); contract != nil { - fields := recordToMapV2(contract.CreatedEvent.CreateArguments) - issuer, _ := extractPartyV2(fields["issuer"]) - owner, _ := extractPartyV2(fields["owner"]) - amount, _ := extractNumericV2(fields["amount"]) - - // Extract symbol from metadata - var symbol string - if metaVal, ok := fields["meta"]; ok { - if metaRecord := metaVal.GetRecord(); metaRecord != nil { - for _, metaField := range metaRecord.Fields { - if metaField.Label == "symbol" { - if s := metaField.Value.GetText(); s != "" { - symbol = s - } - } - } - } - } - - holdings = append(holdings, &CIP56Holding{ - ContractID: contract.CreatedEvent.ContractId, - Issuer: issuer, - Owner: owner, - Amount: amount, - Symbol: symbol, - }) - } - } - - return holdings, nil -} - -// ============================================================================= -// BRIDGE EVENT QUERY METHODS (for reconciliation) -// ============================================================================= - -// contractDecoder is a function type for decoding contract records into typed events -type contractDecoder[T any] func(contractID string, record *lapiv2.Record) (*T, error) - -// getActiveContractsByTemplate is a generic helper that queries active contracts -// filtered by module and entity name, then decodes them using the provided decoder. -func getActiveContractsByTemplate[T any]( - c *Client, - ctx context.Context, - moduleName string, - entityName string, - decoder contractDecoder[T], -) ([]*T, error) { - authCtx := c.GetAuthContext(ctx) - - ledgerEndResp, err := c.stateService.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) - if err != nil { - return nil, fmt.Errorf("failed to get ledger end: %w", err) - } - activeAtOffset := ledgerEndResp.Offset - if activeAtOffset == 0 { - return []*T{}, nil - } - - // NOTE: This generic helper uses WildcardFilter because it doesn't know - // which package the module/entity belongs to. Client-side filtering is - // applied below. Consider adding a packageID parameter if performance matters. - resp, err := c.stateService.GetActiveContracts(authCtx, &lapiv2.GetActiveContractsRequest{ - ActiveAtOffset: activeAtOffset, - EventFormat: &lapiv2.EventFormat{ - FiltersByParty: map[string]*lapiv2.Filters{ - c.config.RelayerParty: { - Cumulative: []*lapiv2.CumulativeFilter{ - { - IdentifierFilter: &lapiv2.CumulativeFilter_WildcardFilter{ - WildcardFilter: &lapiv2.WildcardFilter{}, - }, - }, - }, - }, - }, - Verbose: true, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to query %s.%s contracts: %w", moduleName, entityName, err) - } - - var results []*T - contractCount := 0 - for { - msg, err := resp.Recv() - if err != nil { - break - } - if contract := msg.GetActiveContract(); contract != nil { - contractCount++ - templateId := contract.CreatedEvent.TemplateId - c.logger.Debug("Found active contract", - zap.String("module", templateId.ModuleName), - zap.String("entity", templateId.EntityName), - zap.String("package_id", templateId.PackageId)) - - if templateId.ModuleName != moduleName || templateId.EntityName != entityName { - continue - } - - decoded, err := decoder( - contract.CreatedEvent.ContractId, - contract.CreatedEvent.CreateArguments, - ) - if err != nil { - c.logger.Warn("Failed to decode contract", - zap.String("module", moduleName), - zap.String("entity", entityName), - zap.Error(err)) - continue - } - results = append(results, decoded) - } - } - - c.logger.Debug("getActiveContractsByTemplate completed", - zap.String("module", moduleName), - zap.String("entity", entityName), - zap.Int("total_contracts_scanned", contractCount), - zap.Int("matching_contracts_found", len(results))) - - return results, nil -} - -// GetMintEvents retrieves all MintEvent contracts from Canton (CIP56.Events.MintEvent) -// These are created when tokens are minted (native or bridge deposits) -func (c *Client) GetMintEvents(ctx context.Context) ([]*MintEvent, error) { - return getActiveContractsByTemplate(c, ctx, "CIP56.Events", "MintEvent", DecodeMintEvent) -} - -// GetBurnEvents retrieves all BurnEvent contracts from Canton (CIP56.Events.BurnEvent) -// These are created when tokens are burned (native or bridge withdrawals) -func (c *Client) GetBurnEvents(ctx context.Context) ([]*BurnEvent, error) { - return getActiveContractsByTemplate(c, ctx, "CIP56.Events", "BurnEvent", DecodeBurnEvent) -} - -// ============================================================================= -// UNIFIED TOKEN METHODS (via CIP56.Config.TokenConfig) -// ============================================================================= - -// GetTokenConfig finds an active TokenConfig contract by matching token symbol in metadata. -// Returns the contract ID of the matching TokenConfig. -func (c *Client) GetTokenConfig(ctx context.Context, tokenSymbol string) (string, error) { - authCtx := c.GetAuthContext(ctx) - - ledgerEndResp, err := c.stateService.GetLedgerEnd(authCtx, &lapiv2.GetLedgerEndRequest{}) - if err != nil { - return "", fmt.Errorf("failed to get ledger end: %w", err) - } - if ledgerEndResp.Offset == 0 { - return "", fmt.Errorf("ledger is empty, no contracts exist") - } - - resp, err := c.stateService.GetActiveContracts(authCtx, &lapiv2.GetActiveContractsRequest{ - ActiveAtOffset: ledgerEndResp.Offset, - EventFormat: &lapiv2.EventFormat{ - FiltersByParty: map[string]*lapiv2.Filters{ - c.config.RelayerParty: { - Cumulative: c.tokenConfigFilter(), - }, - }, - Verbose: true, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to search for TokenConfig: %w", err) - } - - for { - msg, err := resp.Recv() - if err != nil { - break - } - if contract := msg.GetActiveContract(); contract != nil { - // Check if meta.symbol matches - fields := recordToMapV2(contract.CreatedEvent.CreateArguments) - if metaVal, ok := fields["meta"]; ok { - if metaRecord := metaVal.GetRecord(); metaRecord != nil { - for _, metaField := range metaRecord.Fields { - if metaField.Label == "symbol" { - if s := metaField.Value.GetText(); s == tokenSymbol { - return contract.CreatedEvent.ContractId, nil - } - } - } - } - } - } - } - - return "", fmt.Errorf("no active TokenConfig found for symbol %s", tokenSymbol) -} - -// TokenMintRequest represents a request to mint tokens via TokenConfig.IssuerMint -type TokenMintRequest struct { - RecipientParty string // Canton party to mint to - Amount string // Amount to mint (decimal string) - UserFingerprint string // EVM fingerprint of the recipient - TokenSymbol string // Token symbol (e.g., "DEMO", "PROMPT") - ConfigCid string // TokenConfig contract ID (optional, will be fetched if empty) - EvmTxHash string // Optional: set for bridge deposits, empty for native mints -} - -// TokenMint mints tokens to a user via TokenConfig.IssuerMint -func (c *Client) TokenMint(ctx context.Context, req *TokenMintRequest) (string, error) { - c.logger.Info("Minting tokens via TokenConfig", - zap.String("recipient", req.RecipientParty), - zap.String("amount", req.Amount), - zap.String("symbol", req.TokenSymbol), - zap.String("fingerprint", req.UserFingerprint)) - - configCid := req.ConfigCid - if configCid == "" { - var err error - configCid, err = c.GetTokenConfig(ctx, req.TokenSymbol) - if err != nil { - return "", fmt.Errorf("failed to get TokenConfig for %s: %w", req.TokenSymbol, err) - } - } - - authCtx := c.GetAuthContext(ctx) - - // Build IssuerMint choice arguments (includes optional evmTxHash) - evmTxHashValue := NoneValue() - if req.EvmTxHash != "" { - evmTxHashValue = OptionalValue(TextValue(req.EvmTxHash)) - } - - mintArgs := &lapiv2.Record{ - Fields: []*lapiv2.RecordField{ - {Label: "recipient", Value: PartyValue(req.RecipientParty)}, - {Label: "amount", Value: NumericValue(req.Amount)}, - {Label: "eventTime", Value: TimestampValue(time.Now())}, - {Label: "userFingerprint", Value: TextValue(req.UserFingerprint)}, - {Label: "evmTxHash", Value: evmTxHashValue}, - }, - } - - cmd := &lapiv2.Command{ - Command: &lapiv2.Command_Exercise{ - Exercise: &lapiv2.ExerciseCommand{ - TemplateId: &lapiv2.Identifier{ - PackageId: c.config.CIP56PackageID, - ModuleName: "CIP56.Config", - EntityName: "TokenConfig", - }, - ContractId: configCid, - Choice: "IssuerMint", - ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: mintArgs}}, - }, - }, - } - - resp, err := c.commandService.SubmitAndWaitForTransaction(authCtx, &lapiv2.SubmitAndWaitForTransactionRequest{ - Commands: &lapiv2.Commands{ - SynchronizerId: c.config.DomainID, - CommandId: generateUUID(), - UserId: c.jwtSubject, - ActAs: []string{c.config.RelayerParty}, - Commands: []*lapiv2.Command{cmd}, - }, - }) - if err != nil { - return "", fmt.Errorf("failed to mint %s tokens: %w", req.TokenSymbol, err) - } - - if resp.Transaction != nil { - for _, event := range resp.Transaction.Events { - if created := event.GetCreated(); created != nil { - templateId := created.TemplateId - if templateId.ModuleName == "CIP56.Token" && templateId.EntityName == "CIP56Holding" { - c.logger.Info("Tokens minted successfully", - zap.String("holding_cid", created.ContractId), - zap.String("recipient", req.RecipientParty), - zap.String("amount", req.Amount), - zap.String("symbol", req.TokenSymbol)) - return created.ContractId, nil - } - } - } - } - - return "", fmt.Errorf("CIP56Holding contract not found in mint response") -} - -// TokenBurn burns tokens via TokenConfig.IssuerBurn -// Used for cleanup/reset operations and bridge withdrawals -func (c *Client) TokenBurn(ctx context.Context, holdingCid, amount, tokenSymbol, userFingerprint, evmDestination string) error { - c.logger.Info("Burning tokens via TokenConfig", - zap.String("holding_cid", holdingCid), - zap.String("amount", amount), - zap.String("symbol", tokenSymbol)) - - configCid, err := c.GetTokenConfig(ctx, tokenSymbol) - if err != nil { - return fmt.Errorf("failed to get TokenConfig for %s: %w", tokenSymbol, err) - } - - authCtx := c.GetAuthContext(ctx) - - evmDestValue := NoneValue() - if evmDestination != "" { - evmDestValue = OptionalValue(TextValue(evmDestination)) - } - - burnArgs := &lapiv2.Record{ - Fields: []*lapiv2.RecordField{ - {Label: "holdingCid", Value: ContractIdValue(holdingCid)}, - {Label: "amount", Value: NumericValue(amount)}, - {Label: "eventTime", Value: TimestampValue(time.Now())}, - {Label: "userFingerprint", Value: TextValue(userFingerprint)}, - {Label: "evmDestination", Value: evmDestValue}, - }, - } - - cmd := &lapiv2.Command{ - Command: &lapiv2.Command_Exercise{ - Exercise: &lapiv2.ExerciseCommand{ - TemplateId: &lapiv2.Identifier{ - PackageId: c.config.CIP56PackageID, - ModuleName: "CIP56.Config", - EntityName: "TokenConfig", - }, - ContractId: configCid, - Choice: "IssuerBurn", - ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: burnArgs}}, - }, - }, - } - - _, err = c.commandService.SubmitAndWait(authCtx, &lapiv2.SubmitAndWaitRequest{ - Commands: &lapiv2.Commands{ - SynchronizerId: c.config.DomainID, - CommandId: generateUUID(), - UserId: c.jwtSubject, - ActAs: []string{c.config.RelayerParty}, - Commands: []*lapiv2.Command{cmd}, - }, - }) - if err != nil { - return fmt.Errorf("failed to burn %s holding: %w", tokenSymbol, err) - } - - c.logger.Info("Tokens burned successfully", - zap.String("holding_cid", holdingCid), - zap.String("amount", amount), - zap.String("symbol", tokenSymbol)) - - return nil -} - -// ============================================================================= -// CUSTODIAL TRANSFER METHODS (for user-owned holdings) -// ============================================================================= - -// TransferAsUserRequest represents a request to transfer tokens using the owner-controlled Transfer choice -type TransferAsUserRequest struct { - FromPartyID string // User's Canton party ID (must be the holding owner) - ToPartyID string // Recipient's Canton party ID - HoldingCID string // The CIP56Holding contract ID to transfer from - Amount string // Amount to transfer - TokenSymbol string // "PROMPT" or "DEMO" - FromFingerprint string // For logging/audit - ToFingerprint string // For logging/audit - ExistingRecipientHolding string // Existing recipient CIP56Holding CID (for merge), empty if none -} - -// TransferAsUser performs a token transfer using the CIP56Holding.Transfer choice. -// This is the owner-controlled transfer for user-owned holdings (custodial mode). -// Passes existingRecipientHolding to prevent fragmentation. -func (c *Client) TransferAsUser(ctx context.Context, req *TransferAsUserRequest) error { - c.logger.Info("Executing transfer as user (owner-controlled)", - zap.String("from_party", req.FromPartyID), - zap.String("to_party", req.ToPartyID), - zap.String("holding_cid", req.HoldingCID), - zap.String("amount", req.Amount), - zap.String("token", req.TokenSymbol), - zap.String("existing_recipient_holding", req.ExistingRecipientHolding)) - - authCtx := c.GetAuthContext(ctx) - - // Build existingRecipientHolding as Optional (ContractId CIP56Holding) - existingHoldingValue := NoneValue() - if req.ExistingRecipientHolding != "" { - existingHoldingValue = OptionalValue(ContractIdValue(req.ExistingRecipientHolding)) - } - - // CIP56Holding.Transfer takes: to, value, existingRecipientHolding, complianceRulesCid, complianceProofCid - transferArgs := &lapiv2.Record{ - Fields: []*lapiv2.RecordField{ - {Label: "to", Value: PartyValue(req.ToPartyID)}, - {Label: "value", Value: NumericValue(req.Amount)}, - {Label: "existingRecipientHolding", Value: existingHoldingValue}, - {Label: "complianceRulesCid", Value: NoneValue()}, - {Label: "complianceProofCid", Value: NoneValue()}, - }, - } - - cmd := &lapiv2.Command{ - Command: &lapiv2.Command_Exercise{ - Exercise: &lapiv2.ExerciseCommand{ - TemplateId: &lapiv2.Identifier{ - PackageId: c.config.CIP56PackageID, - ModuleName: "CIP56.Token", - EntityName: "CIP56Holding", - }, - ContractId: req.HoldingCID, - Choice: "Transfer", - ChoiceArgument: &lapiv2.Value{Sum: &lapiv2.Value_Record{Record: transferArgs}}, - }, - }, - } - - // Act as the user's party (custodial) with readAs relayer (to see recipient holdings) - _, err := c.commandService.SubmitAndWait(authCtx, &lapiv2.SubmitAndWaitRequest{ - Commands: &lapiv2.Commands{ - SynchronizerId: c.config.DomainID, - CommandId: generateUUID(), - UserId: c.jwtSubject, - ActAs: []string{req.FromPartyID}, - ReadAs: []string{c.config.RelayerParty}, - Commands: []*lapiv2.Command{cmd}, - }, - }) - if err != nil { - return fmt.Errorf("transfer failed: %w", err) - } - - c.logger.Info("Transfer completed (user-owned)", - zap.String("from", req.FromFingerprint), - zap.String("to", req.ToFingerprint), - zap.String("amount", req.Amount), - zap.String("token", req.TokenSymbol)) - - return nil -} - -// TransferAsUserByFingerprint performs a token transfer looking up users by fingerprint. -// Resolves fingerprints to party IDs, finds the holding, looks up recipient holding for merge. -func (c *Client) TransferAsUserByFingerprint(ctx context.Context, fromFingerprint, toFingerprint, amount, tokenSymbol string) error { - c.logger.Info("Executing transfer by fingerprint", - zap.String("from_fingerprint", fromFingerprint), - zap.String("to_fingerprint", toFingerprint), - zap.String("amount", amount), - zap.String("token", tokenSymbol)) - - fromMapping, err := c.GetFingerprintMapping(ctx, fromFingerprint) - if err != nil { - return fmt.Errorf("sender not found: %w", err) - } - - toMapping, err := c.GetFingerprintMapping(ctx, toFingerprint) - if err != nil { - return fmt.Errorf("recipient not found: %w", err) - } - - // Find sender's holding with sufficient balance (unified, symbol-aware) - holdingCID, err := c.findHoldingForTransfer(ctx, fromMapping.UserParty, amount, tokenSymbol) - if err != nil { - return fmt.Errorf("insufficient balance: %w", err) - } - - // Find recipient's existing holding for merge (prevents fragmentation) - recipientHolding, err := c.findRecipientHolding(ctx, toMapping.UserParty, tokenSymbol) - if err != nil { - c.logger.Warn("Failed to find recipient holding for merge, proceeding without", zap.Error(err)) - } - - return c.TransferAsUser(ctx, &TransferAsUserRequest{ - FromPartyID: fromMapping.UserParty, - ToPartyID: toMapping.UserParty, - HoldingCID: holdingCID, - Amount: amount, - TokenSymbol: tokenSymbol, - FromFingerprint: fromFingerprint, - ToFingerprint: toFingerprint, - ExistingRecipientHolding: recipientHolding, - }) -} - -// TransferByPartyID performs a token transfer using party IDs directly. -// Finds the sender's holding and recipient's existing holding for merge. -func (c *Client) TransferByPartyID(ctx context.Context, fromParty, toParty, amount, tokenSymbol string) error { - c.logger.Info("Executing transfer by party ID", - zap.String("from_party", fromParty), - zap.String("to_party", toParty), - zap.String("amount", amount), - zap.String("token", tokenSymbol)) - - holdingCID, err := c.findHoldingForTransfer(ctx, fromParty, amount, tokenSymbol) - if err != nil { - return fmt.Errorf("insufficient balance or holding not found: %w", err) - } - - recipientHolding, err := c.findRecipientHolding(ctx, toParty, tokenSymbol) - if err != nil { - c.logger.Warn("Failed to find recipient holding for merge, proceeding without", zap.Error(err)) - } - - return c.TransferAsUser(ctx, &TransferAsUserRequest{ - FromPartyID: fromParty, - ToPartyID: toParty, - HoldingCID: holdingCID, - Amount: amount, - TokenSymbol: tokenSymbol, - FromFingerprint: "", - ToFingerprint: "", - ExistingRecipientHolding: recipientHolding, - }) -} - -// GrantCanActAs grants the OAuth client (this API server) CanActAs rights for the given party. -// This is called during user registration to enable the custodial model where the API server -// can submit transactions on behalf of the user's party. -// This maintains user ownership (the user's party owns the holdings) while allowing the -// API server to act as a trusted intermediary after the user authorizes via MetaMask signature. -func (c *Client) GrantCanActAs(ctx context.Context, partyID string) error { - if c.jwtSubject == "" { - return fmt.Errorf("JWT subject not available - cannot determine user ID for rights grant") - } - - c.logger.Info("Granting CanActAs rights to OAuth client", - zap.String("party_id", partyID), - zap.String("oauth_user", c.jwtSubject)) - - authCtx := c.GetAuthContext(ctx) - - // Create the CanActAs right for this party - right := &adminv2.Right{ - Kind: &adminv2.Right_CanActAs_{ - CanActAs: &adminv2.Right_CanActAs{ - Party: partyID, - }, - }, - } - - // Grant the right to the OAuth client user - resp, err := c.userManagementService.GrantUserRights(authCtx, &adminv2.GrantUserRightsRequest{ - UserId: c.jwtSubject, - Rights: []*adminv2.Right{right}, - }) - if err != nil { - // Check if it's because the right already exists (not an error) - if strings.Contains(err.Error(), "already") { - c.logger.Info("CanActAs right already exists", - zap.String("party_id", partyID), - zap.String("oauth_user", c.jwtSubject)) - return nil - } - return fmt.Errorf("failed to grant CanActAs rights: %w", err) - } - - if len(resp.NewlyGrantedRights) > 0 { - c.logger.Info("CanActAs rights granted successfully", - zap.String("party_id", partyID), - zap.String("oauth_user", c.jwtSubject), - zap.Int("newly_granted", len(resp.NewlyGrantedRights))) - } else { - c.logger.Info("CanActAs right already existed", - zap.String("party_id", partyID), - zap.String("oauth_user", c.jwtSubject)) - } - - return nil -} - -// GetJWTSubject returns the OAuth client's user ID (JWT subject claim) -func (c *Client) GetJWTSubject() string { - return c.jwtSubject -} diff --git a/pkg/canton/client_test.go b/pkg/canton/client_test.go deleted file mode 100644 index 8fdb71f..0000000 --- a/pkg/canton/client_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package canton - -import ( - "context" - "io" - "testing" - "time" - - lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2" - "github.com/chainsafe/canton-middleware/pkg/config" - "go.uber.org/zap" - "google.golang.org/grpc" -) - -func TestClient_StreamWithdrawalEvents(t *testing.T) { - // Setup mock stream - mockStream := &MockGetUpdatesClient{ - RecvFunc: func() (*lapiv2.GetUpdatesResponse, error) { - return nil, io.EOF // End of stream immediately for this test - }, - } - - // Setup mock update service - mockUpdateService := &MockUpdateService{ - GetUpdatesFunc: func(ctx context.Context, in *lapiv2.GetUpdatesRequest, opts ...grpc.CallOption) (lapiv2.UpdateService_GetUpdatesClient, error) { - return mockStream, nil - }, - } - - client := &Client{ - config: &config.CantonConfig{ - RelayerParty: "Alice", - BridgePackageID: "pkg-id", - BridgeModule: "Module", - BridgeContract: "contract-id", - LedgerID: "ledger-id", - ApplicationID: "app-id", - DomainID: "domain-id", - }, - logger: zap.NewNop(), - updateService: mockUpdateService, - } - - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - withdrawalCh := client.StreamWithdrawalEvents(ctx, "BEGIN") - - // Wait for completion - select { - case <-withdrawalCh: - // Channel closed - case <-ctx.Done(): - t.Errorf("Test timed out") - } -} - -func TestClient_StreamWithdrawalEvents_WithData(t *testing.T) { - // Create a fake withdrawal event - withdrawalRecord := &lapiv2.Record{ - Fields: []*lapiv2.RecordField{ - {Label: "issuer", Value: PartyValue("Issuer")}, - {Label: "userParty", Value: PartyValue("Alice")}, - {Label: "evmDestination", Value: &lapiv2.Value{ - Sum: &lapiv2.Value_Record{ - Record: &lapiv2.Record{ - Fields: []*lapiv2.RecordField{ - {Label: "value", Value: TextValue("0xRecipient")}, - }, - }, - }, - }}, - {Label: "amount", Value: NumericValue("50.00")}, - {Label: "fingerprint", Value: TextValue("fp-123")}, - {Label: "status", Value: &lapiv2.Value{ - Sum: &lapiv2.Value_Variant{ - Variant: &lapiv2.Variant{ - Constructor: "Pending", - }, - }, - }}, - }, - } - - event := &lapiv2.Event{ - Event: &lapiv2.Event_Created{ - Created: &lapiv2.CreatedEvent{ - TemplateId: &lapiv2.Identifier{ - ModuleName: "Bridge.Contracts", - EntityName: "WithdrawalEvent", - }, - ContractId: "cid-123", - CreateArguments: withdrawalRecord, - Offset: 100, - NodeId: 1, - }, - }, - } - - tx := &lapiv2.Transaction{ - UpdateId: "tx-1", - Events: []*lapiv2.Event{event}, - } - - // Setup mock stream - sent := false - mockStream := &MockGetUpdatesClient{ - RecvFunc: func() (*lapiv2.GetUpdatesResponse, error) { - if !sent { - sent = true - return &lapiv2.GetUpdatesResponse{ - Update: &lapiv2.GetUpdatesResponse_Transaction{ - Transaction: tx, - }, - }, nil - } - return nil, io.EOF - }, - } - - mockUpdateService := &MockUpdateService{ - GetUpdatesFunc: func(ctx context.Context, in *lapiv2.GetUpdatesRequest, opts ...grpc.CallOption) (lapiv2.UpdateService_GetUpdatesClient, error) { - return mockStream, nil - }, - } - - client := &Client{ - config: &config.CantonConfig{ - RelayerParty: "Issuer", - CorePackageID: "core-pkg-id", - BridgePackageID: "pkg-id", - BridgeModule: "Module", - BridgeContract: "contract-id", - LedgerID: "ledger-id", - ApplicationID: "app-id", - DomainID: "domain-id", - }, - logger: zap.NewNop(), - updateService: mockUpdateService, - } - - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - withdrawalCh := client.StreamWithdrawalEvents(ctx, "BEGIN") - - select { - case withdrawal := <-withdrawalCh: - if withdrawal == nil { - t.Errorf("Expected withdrawal event, got nil") - return - } - if withdrawal.Amount != "50.00" { - t.Errorf("Expected Amount 50.00, got %s", withdrawal.Amount) - } - if withdrawal.EvmDestination != "0xRecipient" { - t.Errorf("Expected destination 0xRecipient, got %s", withdrawal.EvmDestination) - } - case <-ctx.Done(): - t.Errorf("Test timed out waiting for withdrawal event") - } -} diff --git a/pkg/canton/encoding.go b/pkg/canton/encoding.go deleted file mode 100644 index f01163a..0000000 --- a/pkg/canton/encoding.go +++ /dev/null @@ -1,707 +0,0 @@ -package canton - -import ( - "fmt" - "math/big" - "time" - - lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2" - "github.com/shopspring/decimal" -) - -// ============================================================================= -// ISSUER-CENTRIC MODEL ENCODING -// ============================================================================= - -// EncodeFingerprintMappingCreate encodes arguments for creating a FingerprintMapping directly -// The issuer has signatory rights on FingerprintMapping, so no bridge config is needed. -func EncodeFingerprintMappingCreate(issuer, userParty, fingerprint, evmAddress string) *lapiv2.Record { - fields := []*lapiv2.RecordField{ - {Label: "issuer", Value: PartyValue(issuer)}, - {Label: "userParty", Value: PartyValue(userParty)}, - {Label: "fingerprint", Value: TextValue(fingerprint)}, - } - - // Optional EVM address - if evmAddress != "" { - fields = append(fields, &lapiv2.RecordField{ - Label: "evmAddress", - Value: OptionalValue(RecordValue("EvmAddress", TextValue(evmAddress))), - }) - } else { - fields = append(fields, &lapiv2.RecordField{ - Label: "evmAddress", - Value: NoneValue(), - }) - } - - return &lapiv2.Record{Fields: fields} -} - -// EncodeCreatePendingDepositArgs encodes arguments for WayfinderBridgeConfig.CreatePendingDeposit -func EncodeCreatePendingDepositArgs(req *CreatePendingDepositRequest) *lapiv2.Record { - return &lapiv2.Record{ - Fields: []*lapiv2.RecordField{ - {Label: "fingerprint", Value: TextValue(req.Fingerprint)}, - {Label: "amount", Value: NumericValue(req.Amount)}, - {Label: "evmTxHash", Value: TextValue(req.EvmTxHash)}, - {Label: "timestamp", Value: TimestampValue(req.Timestamp)}, - }, - } -} - -// EncodeProcessDepositAndMintArgs encodes arguments for WayfinderBridgeConfig.ProcessDepositAndMint -func EncodeProcessDepositAndMintArgs(req *ProcessDepositRequest) *lapiv2.Record { - return &lapiv2.Record{ - Fields: []*lapiv2.RecordField{ - {Label: "depositCid", Value: ContractIdValue(req.DepositCid)}, - {Label: "mappingCid", Value: ContractIdValue(req.MappingCid)}, - {Label: "timestamp", Value: TimestampValue(req.Timestamp)}, - }, - } -} - -// EncodeInitiateWithdrawalArgs encodes arguments for WayfinderBridgeConfig.InitiateWithdrawal -func EncodeInitiateWithdrawalArgs(req *InitiateWithdrawalRequest) *lapiv2.Record { - return &lapiv2.Record{ - Fields: []*lapiv2.RecordField{ - {Label: "mappingCid", Value: ContractIdValue(req.MappingCid)}, - {Label: "holdingCid", Value: ContractIdValue(req.HoldingCid)}, - {Label: "amount", Value: NumericValue(req.Amount)}, - {Label: "evmDestination", Value: RecordValue("EvmAddress", TextValue(req.EvmDestination))}, - }, - } -} - -// EncodeCompleteWithdrawalArgs encodes arguments for WithdrawalEvent.CompleteWithdrawal -func EncodeCompleteWithdrawalArgs(evmTxHash string) *lapiv2.Record { - return &lapiv2.Record{ - Fields: []*lapiv2.RecordField{ - {Label: "evmTxHash", Value: TextValue(evmTxHash)}, - }, - } -} - -// ============================================================================= -// ISSUER-CENTRIC MODEL DECODING -// ============================================================================= - -// DecodeFingerprintMapping decodes a FingerprintMapping contract -func DecodeFingerprintMapping(contractID string, record *lapiv2.Record) (*FingerprintMapping, error) { - fields := recordToMap(record) - - issuer, err := extractParty(fields["issuer"]) - if err != nil { - return nil, fmt.Errorf("failed to extract issuer: %w", err) - } - - userParty, err := extractParty(fields["userParty"]) - if err != nil { - return nil, fmt.Errorf("failed to extract userParty: %w", err) - } - - fingerprint, err := extractText(fields["fingerprint"]) - if err != nil { - return nil, fmt.Errorf("failed to extract fingerprint: %w", err) - } - - evmAddress, _ := extractOptionalEvmAddress(fields["evmAddress"]) - - return &FingerprintMapping{ - ContractID: contractID, - Issuer: issuer, - UserParty: userParty, - Fingerprint: fingerprint, - EvmAddress: evmAddress, - }, nil -} - -// DecodePendingDeposit decodes a PendingDeposit contract -func DecodePendingDeposit(contractID string, record *lapiv2.Record) (*PendingDeposit, error) { - fields := recordToMap(record) - - issuer, err := extractParty(fields["issuer"]) - if err != nil { - return nil, fmt.Errorf("failed to extract issuer: %w", err) - } - - fingerprint, err := extractText(fields["fingerprint"]) - if err != nil { - return nil, fmt.Errorf("failed to extract fingerprint: %w", err) - } - - amount, err := extractNumeric(fields["amount"]) - if err != nil { - return nil, fmt.Errorf("failed to extract amount: %w", err) - } - - evmTxHash, err := extractText(fields["evmTxHash"]) - if err != nil { - return nil, fmt.Errorf("failed to extract evmTxHash: %w", err) - } - - tokenID, err := extractText(fields["tokenId"]) - if err != nil { - return nil, fmt.Errorf("failed to extract tokenId: %w", err) - } - - return &PendingDeposit{ - ContractID: contractID, - Issuer: issuer, - Fingerprint: fingerprint, - Amount: amount, - EvmTxHash: evmTxHash, - TokenID: tokenID, - }, nil -} - -// DecodeWithdrawalEvent decodes a WithdrawalEvent contract (V1 record) -func DecodeWithdrawalEvent(eventID, txID, contractID string, record *lapiv2.Record) (*WithdrawalEvent, error) { - fields := recordToMap(record) - - issuer, err := extractParty(fields["issuer"]) - if err != nil { - return nil, fmt.Errorf("failed to extract issuer: %w", err) - } - - userParty, err := extractParty(fields["userParty"]) - if err != nil { - return nil, fmt.Errorf("failed to extract userParty: %w", err) - } - - evmDestination, err := extractEvmAddress(fields["evmDestination"]) - if err != nil { - return nil, fmt.Errorf("failed to extract evmDestination: %w", err) - } - - amount, err := extractNumeric(fields["amount"]) - if err != nil { - return nil, fmt.Errorf("failed to extract amount: %w", err) - } - - fingerprint, err := extractText(fields["fingerprint"]) - if err != nil { - return nil, fmt.Errorf("failed to extract fingerprint: %w", err) - } - - status := extractWithdrawalStatus(fields["status"]) - - return &WithdrawalEvent{ - ContractID: contractID, - EventID: eventID, - TransactionID: txID, - Issuer: issuer, - UserParty: userParty, - EvmDestination: evmDestination, - Amount: amount, - Fingerprint: fingerprint, - Status: status, - }, nil -} - -// DecodeWithdrawalEventV2 decodes a WithdrawalEvent contract from V2 API record -func DecodeWithdrawalEventV2(eventID, txID, contractID string, record *lapiv2.Record) (*WithdrawalEvent, error) { - fields := recordToMapV2(record) - - issuer, err := extractPartyV2(fields["issuer"]) - if err != nil { - return nil, fmt.Errorf("failed to extract issuer: %w", err) - } - - userParty, err := extractPartyV2(fields["userParty"]) - if err != nil { - return nil, fmt.Errorf("failed to extract userParty: %w", err) - } - - evmDestination, err := extractEvmAddressV2(fields["evmDestination"]) - if err != nil { - return nil, fmt.Errorf("failed to extract evmDestination: %w", err) - } - - amount, err := extractNumericV2(fields["amount"]) - if err != nil { - return nil, fmt.Errorf("failed to extract amount: %w", err) - } - - fingerprint, err := extractTextV2(fields["fingerprint"]) - if err != nil { - return nil, fmt.Errorf("failed to extract fingerprint: %w", err) - } - - status := extractWithdrawalStatusV2(fields["status"]) - - return &WithdrawalEvent{ - ContractID: contractID, - EventID: eventID, - TransactionID: txID, - Issuer: issuer, - UserParty: userParty, - EvmDestination: evmDestination, - Amount: amount, - Fingerprint: fingerprint, - Status: status, - }, nil -} - -// ============================================================================= -// HELPER ENCODING FUNCTIONS -// ============================================================================= - -func TextValue(s string) *lapiv2.Value { - return &lapiv2.Value{Sum: &lapiv2.Value_Text{Text: s}} -} - -func PartyValue(s string) *lapiv2.Value { - return &lapiv2.Value{Sum: &lapiv2.Value_Party{Party: s}} -} - -func Int64Value(n int64) *lapiv2.Value { - return &lapiv2.Value{Sum: &lapiv2.Value_Int64{Int64: n}} -} - -func NumericValue(s string) *lapiv2.Value { - return &lapiv2.Value{Sum: &lapiv2.Value_Numeric{Numeric: s}} -} - -func ContractIdValue(cid string) *lapiv2.Value { - return &lapiv2.Value{Sum: &lapiv2.Value_ContractId{ContractId: cid}} -} - -func TimestampValue(t time.Time) *lapiv2.Value { - // DAML timestamps are microseconds since Unix epoch - return &lapiv2.Value{Sum: &lapiv2.Value_Timestamp{Timestamp: t.UnixMicro()}} -} - -func RecordValue(typeName string, fields ...*lapiv2.Value) *lapiv2.Value { - recordFields := make([]*lapiv2.RecordField, len(fields)) - for i, f := range fields { - recordFields[i] = &lapiv2.RecordField{Value: f} - } - return &lapiv2.Value{ - Sum: &lapiv2.Value_Record{ - Record: &lapiv2.Record{Fields: recordFields}, - }, - } -} - -func OptionalValue(v *lapiv2.Value) *lapiv2.Value { - return &lapiv2.Value{ - Sum: &lapiv2.Value_Optional{ - Optional: &lapiv2.Optional{Value: v}, - }, - } -} - -func NoneValue() *lapiv2.Value { - return &lapiv2.Value{ - Sum: &lapiv2.Value_Optional{ - Optional: &lapiv2.Optional{Value: nil}, - }, - } -} - -// ============================================================================= -// HELPER EXTRACTION FUNCTIONS -// ============================================================================= - -func recordToMap(record *lapiv2.Record) map[string]*lapiv2.Value { - fields := make(map[string]*lapiv2.Value) - for _, field := range record.Fields { - fields[field.Label] = field.Value - } - return fields -} - -func extractText(v *lapiv2.Value) (string, error) { - if v == nil { - return "", fmt.Errorf("nil value") - } - if t, ok := v.Sum.(*lapiv2.Value_Text); ok { - return t.Text, nil - } - return "", fmt.Errorf("not a text value") -} - -func extractParty(v *lapiv2.Value) (string, error) { - if v == nil { - return "", fmt.Errorf("nil value") - } - if p, ok := v.Sum.(*lapiv2.Value_Party); ok { - return p.Party, nil - } - return "", fmt.Errorf("not a party value") -} - -func extractInt64(v *lapiv2.Value) (int64, error) { - if v == nil { - return 0, fmt.Errorf("nil value") - } - if i, ok := v.Sum.(*lapiv2.Value_Int64); ok { - return i.Int64, nil - } - return 0, fmt.Errorf("not an int64 value") -} - -func extractNumeric(v *lapiv2.Value) (string, error) { - if v == nil { - return "", fmt.Errorf("nil value") - } - if n, ok := v.Sum.(*lapiv2.Value_Numeric); ok { - return n.Numeric, nil - } - return "", fmt.Errorf("not a numeric value") -} - -func extractEvmAddress(v *lapiv2.Value) (string, error) { - if v == nil { - return "", fmt.Errorf("nil value") - } - // EvmAddress is a record with a single "value" field - if r, ok := v.Sum.(*lapiv2.Value_Record); ok { - for _, field := range r.Record.Fields { - if field.Label == "value" { - return extractText(field.Value) - } - } - } - return "", fmt.Errorf("not an EvmAddress record") -} - -func extractOptionalEvmAddress(v *lapiv2.Value) (string, error) { - if v == nil { - return "", nil - } - if opt, ok := v.Sum.(*lapiv2.Value_Optional); ok { - if opt.Optional.Value == nil { - return "", nil - } - return extractEvmAddress(opt.Optional.Value) - } - return "", fmt.Errorf("not an optional value") -} - -func extractWithdrawalStatus(v *lapiv2.Value) WithdrawalStatus { - if v == nil { - return WithdrawalStatusPending - } - // Status is a variant/enum - if variant, ok := v.Sum.(*lapiv2.Value_Variant); ok { - switch variant.Variant.Constructor { - case "Pending": - return WithdrawalStatusPending - case "Completed": - return WithdrawalStatusCompleted - case "Failed": - return WithdrawalStatusFailed - } - } - return WithdrawalStatusPending -} - -// ============================================================================= -// CONVERSION FUNCTIONS -// ============================================================================= - -// BigIntToDecimal converts big.Int to Daml decimal string -func BigIntToDecimal(amount *big.Int, decimals int) string { - d := decimal.NewFromBigInt(amount, int32(-decimals)) - return d.String() -} - -// DecimalToBigInt converts Daml decimal string to big.Int -func DecimalToBigInt(s string, decimals int) (*big.Int, error) { - d, err := decimal.NewFromString(s) - if err != nil { - return nil, fmt.Errorf("invalid decimal format: %w", err) - } - d = d.Mul(decimal.New(1, int32(decimals))) - return d.BigInt(), nil -} - -// addDecimalStrings adds two decimal strings and returns the result -func addDecimalStrings(a, b string) (string, error) { - da, err := decimal.NewFromString(a) - if err != nil { - da = decimal.Zero - } - db, err := decimal.NewFromString(b) - if err != nil { - db = decimal.Zero - } - return da.Add(db).String(), nil -} - -// compareDecimalStrings compares two decimal strings -// Returns: -1 if a < b, 0 if a == b, 1 if a > b -func compareDecimalStrings(a, b string) int { - da, err := decimal.NewFromString(a) - if err != nil { - da = decimal.Zero - } - db, err := decimal.NewFromString(b) - if err != nil { - db = decimal.Zero - } - return da.Cmp(db) -} - -// ============================================================================= -// V2 API HELPER EXTRACTION FUNCTIONS -// ============================================================================= - -func recordToMapV2(record *lapiv2.Record) map[string]*lapiv2.Value { - fields := make(map[string]*lapiv2.Value) - for _, field := range record.Fields { - fields[field.Label] = field.Value - } - return fields -} - -func extractTextV2(v *lapiv2.Value) (string, error) { - if v == nil { - return "", fmt.Errorf("nil value") - } - if t, ok := v.Sum.(*lapiv2.Value_Text); ok { - return t.Text, nil - } - return "", fmt.Errorf("not a text value") -} - -func extractPartyV2(v *lapiv2.Value) (string, error) { - if v == nil { - return "", fmt.Errorf("nil value") - } - if p, ok := v.Sum.(*lapiv2.Value_Party); ok { - return p.Party, nil - } - return "", fmt.Errorf("not a party value") -} - -func extractNumericV2(v *lapiv2.Value) (string, error) { - if v == nil { - return "", fmt.Errorf("nil value") - } - if n, ok := v.Sum.(*lapiv2.Value_Numeric); ok { - return n.Numeric, nil - } - return "", fmt.Errorf("not a numeric value") -} - -func extractEvmAddressV2(v *lapiv2.Value) (string, error) { - if v == nil { - return "", fmt.Errorf("nil value") - } - // EvmAddress is a record with a single "value" field - if r, ok := v.Sum.(*lapiv2.Value_Record); ok { - for _, field := range r.Record.Fields { - if field.Label == "value" { - return extractTextV2(field.Value) - } - } - } - return "", fmt.Errorf("not an EvmAddress record") -} - -func extractWithdrawalStatusV2(v *lapiv2.Value) WithdrawalStatus { - if v == nil { - return WithdrawalStatusPending - } - // Status is a variant/enum - if variant, ok := v.Sum.(*lapiv2.Value_Variant); ok { - switch variant.Variant.Constructor { - case "Pending": - return WithdrawalStatusPending - case "Completed": - return WithdrawalStatusCompleted - case "Failed": - return WithdrawalStatusFailed - } - } - return WithdrawalStatusPending -} - -func extractOptionalTextV2(v *lapiv2.Value) (string, error) { - if v == nil { - return "", nil - } - if opt, ok := v.Sum.(*lapiv2.Value_Optional); ok { - if opt.Optional.Value == nil { - return "", nil // None - } - return extractTextV2(opt.Optional.Value) - } - // Backward compat: try plain text - return extractTextV2(v) -} - -func extractContractIdV2(v *lapiv2.Value) (string, bool) { - if v == nil { - return "", false - } - if c, ok := v.Sum.(*lapiv2.Value_ContractId); ok { - return c.ContractId, true - } - return "", false -} - -func extractTimestampV2(v *lapiv2.Value) (time.Time, error) { - if v == nil { - return time.Time{}, fmt.Errorf("nil value") - } - if t, ok := v.Sum.(*lapiv2.Value_Timestamp); ok { - // DAML timestamps are microseconds since Unix epoch - return time.UnixMicro(t.Timestamp), nil - } - return time.Time{}, fmt.Errorf("not a timestamp value") -} - -func extractPartyListV2(v *lapiv2.Value) ([]string, error) { - if v == nil { - return nil, fmt.Errorf("nil value") - } - if l, ok := v.Sum.(*lapiv2.Value_List); ok { - var parties []string - for _, elem := range l.List.Elements { - if p, ok := elem.Sum.(*lapiv2.Value_Party); ok { - parties = append(parties, p.Party) - } - } - return parties, nil - } - return nil, fmt.Errorf("not a list value") -} - -func extractOptionalContractIdV2(v *lapiv2.Value) (string, error) { - if v == nil { - return "", nil - } - if opt, ok := v.Sum.(*lapiv2.Value_Optional); ok { - if opt.Optional.Value == nil { - return "", nil - } - if c, ok := opt.Optional.Value.Sum.(*lapiv2.Value_ContractId); ok { - return c.ContractId, nil - } - } - return "", nil -} - -// ============================================================================= -// BRIDGE EVENT DECODING -// ============================================================================= - -// DecodeMintEvent decodes a CIP56.Events.MintEvent contract -func DecodeMintEvent(contractID string, record *lapiv2.Record) (*MintEvent, error) { - fields := recordToMapV2(record) - - issuer, err := extractPartyV2(fields["issuer"]) - if err != nil { - return nil, fmt.Errorf("failed to extract issuer: %w", err) - } - - recipient, err := extractPartyV2(fields["recipient"]) - if err != nil { - return nil, fmt.Errorf("failed to extract recipient: %w", err) - } - - amount, err := extractNumericV2(fields["amount"]) - if err != nil { - return nil, fmt.Errorf("failed to extract amount: %w", err) - } - - tokenSymbol, err := extractTextV2(fields["tokenSymbol"]) - if err != nil { - return nil, fmt.Errorf("failed to extract tokenSymbol: %w", err) - } - - // evmTxHash is Optional Text in unified events - evmTxHash, _ := extractOptionalTextV2(fields["evmTxHash"]) - - userFingerprint, err := extractTextV2(fields["userFingerprint"]) - if err != nil { - return nil, fmt.Errorf("failed to extract userFingerprint: %w", err) - } - - holdingCid, ok := extractContractIdV2(fields["holdingCid"]) - if !ok { - return nil, fmt.Errorf("failed to extract holdingCid: missing or invalid contract ID") - } - - timestamp, err := extractTimestampV2(fields["timestamp"]) - if err != nil { - return nil, fmt.Errorf("failed to extract timestamp: %w", err) - } - - auditObservers, err := extractPartyListV2(fields["auditObservers"]) - if err != nil { - return nil, fmt.Errorf("failed to extract auditObservers: %w", err) - } - - return &MintEvent{ - ContractID: contractID, - Issuer: issuer, - Recipient: recipient, - Amount: amount, - HoldingCid: holdingCid, - TokenSymbol: tokenSymbol, - EvmTxHash: evmTxHash, - UserFingerprint: userFingerprint, - Timestamp: timestamp, - AuditObservers: auditObservers, - }, nil -} - -// DecodeBurnEvent decodes a CIP56.Events.BurnEvent contract -func DecodeBurnEvent(contractID string, record *lapiv2.Record) (*BurnEvent, error) { - fields := recordToMapV2(record) - - issuer, err := extractPartyV2(fields["issuer"]) - if err != nil { - return nil, fmt.Errorf("failed to extract issuer: %w", err) - } - - burnedFrom, err := extractPartyV2(fields["burnedFrom"]) - if err != nil { - return nil, fmt.Errorf("failed to extract burnedFrom: %w", err) - } - - amount, err := extractNumericV2(fields["amount"]) - if err != nil { - return nil, fmt.Errorf("failed to extract amount: %w", err) - } - - // evmDestination is Optional Text in unified events (not EvmAddress record) - evmDestination, _ := extractOptionalTextV2(fields["evmDestination"]) - - tokenSymbol, err := extractTextV2(fields["tokenSymbol"]) - if err != nil { - return nil, fmt.Errorf("failed to extract tokenSymbol: %w", err) - } - - userFingerprint, err := extractTextV2(fields["userFingerprint"]) - if err != nil { - return nil, fmt.Errorf("failed to extract userFingerprint: %w", err) - } - - timestamp, err := extractTimestampV2(fields["timestamp"]) - if err != nil { - return nil, fmt.Errorf("failed to extract timestamp: %w", err) - } - - auditObservers, err := extractPartyListV2(fields["auditObservers"]) - if err != nil { - return nil, fmt.Errorf("failed to extract auditObservers: %w", err) - } - - return &BurnEvent{ - ContractID: contractID, - Issuer: issuer, - BurnedFrom: burnedFrom, - Amount: amount, - EvmDestination: evmDestination, - TokenSymbol: tokenSymbol, - UserFingerprint: userFingerprint, - Timestamp: timestamp, - AuditObservers: auditObservers, - }, nil -} diff --git a/pkg/canton/encoding_test.go b/pkg/canton/encoding_test.go deleted file mode 100644 index 9281ff3..0000000 --- a/pkg/canton/encoding_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package canton - -import ( - "testing" - - lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2" -) - -func TestDecodeWithdrawalEvent(t *testing.T) { - // EvmAddress is encoded as a record with a "value" field - evmAddressRecord := &lapiv2.Value{ - Sum: &lapiv2.Value_Record{ - Record: &lapiv2.Record{ - Fields: []*lapiv2.RecordField{ - {Label: "value", Value: TextValue("0xRecipient")}, - }, - }, - }, - } - - // Status is encoded as a variant - statusVariant := &lapiv2.Value{ - Sum: &lapiv2.Value_Variant{ - Variant: &lapiv2.Variant{ - Constructor: "Pending", - }, - }, - } - - record := &lapiv2.Record{ - Fields: []*lapiv2.RecordField{ - {Label: "issuer", Value: PartyValue("Issuer")}, - {Label: "userParty", Value: PartyValue("Alice")}, - {Label: "evmDestination", Value: evmAddressRecord}, - {Label: "amount", Value: NumericValue("100.00")}, - {Label: "fingerprint", Value: TextValue("fp-123")}, - {Label: "status", Value: statusVariant}, - }, - } - - withdrawal, err := DecodeWithdrawalEvent("event-1", "tx-1", "cid-1", record) - if err != nil { - t.Fatalf("DecodeWithdrawalEvent failed: %v", err) - } - - if withdrawal.EventID != "event-1" { - t.Errorf("Expected EventID event-1, got %s", withdrawal.EventID) - } - if withdrawal.TransactionID != "tx-1" { - t.Errorf("Expected TransactionID tx-1, got %s", withdrawal.TransactionID) - } - if withdrawal.ContractID != "cid-1" { - t.Errorf("Expected ContractID cid-1, got %s", withdrawal.ContractID) - } - if withdrawal.Issuer != "Issuer" { - t.Errorf("Expected Issuer Issuer, got %s", withdrawal.Issuer) - } - if withdrawal.UserParty != "Alice" { - t.Errorf("Expected UserParty Alice, got %s", withdrawal.UserParty) - } - if withdrawal.Amount != "100.00" { - t.Errorf("Expected Amount 100.00, got %s", withdrawal.Amount) - } - if withdrawal.EvmDestination != "0xRecipient" { - t.Errorf("Expected EvmDestination 0xRecipient, got %s", withdrawal.EvmDestination) - } - if withdrawal.Fingerprint != "fp-123" { - t.Errorf("Expected Fingerprint fp-123, got %s", withdrawal.Fingerprint) - } - if withdrawal.Status != "Pending" { - t.Errorf("Expected Status Pending, got %s", withdrawal.Status) - } -} - -func TestHelperFunctions(t *testing.T) { - // TextValue - tv := TextValue("hello") - if tv.GetText() != "hello" { - t.Errorf("TextValue failed") - } - - // PartyValue - pv := PartyValue("Alice") - if pv.GetParty() != "Alice" { - t.Errorf("PartyValue failed") - } - - // Int64Value - iv := Int64Value(123) - if iv.GetInt64() != 123 { - t.Errorf("Int64Value failed") - } - - // NumericValue - nv := NumericValue("10.5") - if nv.GetNumeric() != "10.5" { - t.Errorf("NumericValue failed") - } -} diff --git a/pkg/canton/mocks_test.go b/pkg/canton/mocks_test.go deleted file mode 100644 index de911fc..0000000 --- a/pkg/canton/mocks_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package canton - -import ( - "context" - "io" - - "google.golang.org/grpc" - - lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2" -) - -// MockStateService is a mock implementation of lapi.StateServiceClient -type MockStateService struct { - GetActiveContractsFunc func(ctx context.Context, in *lapiv2.GetActiveContractsRequest, opts ...grpc.CallOption) (lapiv2.StateService_GetActiveContractsClient, error) - GetLedgerEndFunc func(ctx context.Context, in *lapiv2.GetLedgerEndRequest, opts ...grpc.CallOption) (*lapiv2.GetLedgerEndResponse, error) - GetConnectedSynchronizersFunc func(ctx context.Context, in *lapiv2.GetConnectedSynchronizersRequest, opts ...grpc.CallOption) (*lapiv2.GetConnectedSynchronizersResponse, error) - GetLatestPrunedOffsetsFunc func(ctx context.Context, in *lapiv2.GetLatestPrunedOffsetsRequest, opts ...grpc.CallOption) (*lapiv2.GetLatestPrunedOffsetsResponse, error) -} - -func (m *MockStateService) GetActiveContracts(ctx context.Context, in *lapiv2.GetActiveContractsRequest, opts ...grpc.CallOption) (lapiv2.StateService_GetActiveContractsClient, error) { - if m.GetActiveContractsFunc != nil { - return m.GetActiveContractsFunc(ctx, in, opts...) - } - return nil, nil -} - -func (m *MockStateService) GetLedgerEnd(ctx context.Context, in *lapiv2.GetLedgerEndRequest, opts ...grpc.CallOption) (*lapiv2.GetLedgerEndResponse, error) { - if m.GetLedgerEndFunc != nil { - return m.GetLedgerEndFunc(ctx, in, opts...) - } - return &lapiv2.GetLedgerEndResponse{}, nil -} - -func (m *MockStateService) GetConnectedSynchronizers(ctx context.Context, in *lapiv2.GetConnectedSynchronizersRequest, opts ...grpc.CallOption) (*lapiv2.GetConnectedSynchronizersResponse, error) { - if m.GetConnectedSynchronizersFunc != nil { - return m.GetConnectedSynchronizersFunc(ctx, in, opts...) - } - return &lapiv2.GetConnectedSynchronizersResponse{}, nil -} - -func (m *MockStateService) GetLatestPrunedOffsets(ctx context.Context, in *lapiv2.GetLatestPrunedOffsetsRequest, opts ...grpc.CallOption) (*lapiv2.GetLatestPrunedOffsetsResponse, error) { - if m.GetLatestPrunedOffsetsFunc != nil { - return m.GetLatestPrunedOffsetsFunc(ctx, in, opts...) - } - return &lapiv2.GetLatestPrunedOffsetsResponse{}, nil -} - -// MockUpdateService is a mock implementation of lapi.UpdateServiceClient -type MockUpdateService struct { - GetUpdatesFunc func(ctx context.Context, in *lapiv2.GetUpdatesRequest, opts ...grpc.CallOption) (lapiv2.UpdateService_GetUpdatesClient, error) - GetUpdateByOffsetFunc func(ctx context.Context, in *lapiv2.GetUpdateByOffsetRequest, opts ...grpc.CallOption) (*lapiv2.GetUpdateResponse, error) - GetUpdateByIdFunc func(ctx context.Context, in *lapiv2.GetUpdateByIdRequest, opts ...grpc.CallOption) (*lapiv2.GetUpdateResponse, error) -} - -func (m *MockUpdateService) GetUpdates(ctx context.Context, in *lapiv2.GetUpdatesRequest, opts ...grpc.CallOption) (lapiv2.UpdateService_GetUpdatesClient, error) { - if m.GetUpdatesFunc != nil { - return m.GetUpdatesFunc(ctx, in, opts...) - } - return nil, nil -} - -func (m *MockUpdateService) GetUpdateByOffset(ctx context.Context, in *lapiv2.GetUpdateByOffsetRequest, opts ...grpc.CallOption) (*lapiv2.GetUpdateResponse, error) { - if m.GetUpdateByOffsetFunc != nil { - return m.GetUpdateByOffsetFunc(ctx, in, opts...) - } - return nil, nil -} - -func (m *MockUpdateService) GetUpdateById(ctx context.Context, in *lapiv2.GetUpdateByIdRequest, opts ...grpc.CallOption) (*lapiv2.GetUpdateResponse, error) { - if m.GetUpdateByIdFunc != nil { - return m.GetUpdateByIdFunc(ctx, in, opts...) - } - return nil, nil -} - -// MockCommandService is a mock implementation of lapi.CommandServiceClient -type MockCommandService struct { - SubmitAndWaitFunc func(ctx context.Context, in *lapiv2.SubmitAndWaitRequest, opts ...grpc.CallOption) (*lapiv2.SubmitAndWaitResponse, error) - SubmitAndWaitForTransactionFunc func(ctx context.Context, in *lapiv2.SubmitAndWaitForTransactionRequest, opts ...grpc.CallOption) (*lapiv2.SubmitAndWaitForTransactionResponse, error) - SubmitAndWaitForReassignmentFunc func(ctx context.Context, in *lapiv2.SubmitAndWaitForReassignmentRequest, opts ...grpc.CallOption) (*lapiv2.SubmitAndWaitForReassignmentResponse, error) -} - -func (m *MockCommandService) SubmitAndWait(ctx context.Context, in *lapiv2.SubmitAndWaitRequest, opts ...grpc.CallOption) (*lapiv2.SubmitAndWaitResponse, error) { - if m.SubmitAndWaitFunc != nil { - return m.SubmitAndWaitFunc(ctx, in, opts...) - } - return &lapiv2.SubmitAndWaitResponse{}, nil -} - -func (m *MockCommandService) SubmitAndWaitForTransaction(ctx context.Context, in *lapiv2.SubmitAndWaitForTransactionRequest, opts ...grpc.CallOption) (*lapiv2.SubmitAndWaitForTransactionResponse, error) { - if m.SubmitAndWaitForTransactionFunc != nil { - return m.SubmitAndWaitForTransactionFunc(ctx, in, opts...) - } - return nil, nil -} - -func (m *MockCommandService) SubmitAndWaitForReassignment(ctx context.Context, in *lapiv2.SubmitAndWaitForReassignmentRequest, opts ...grpc.CallOption) (*lapiv2.SubmitAndWaitForReassignmentResponse, error) { - if m.SubmitAndWaitForReassignmentFunc != nil { - return m.SubmitAndWaitForReassignmentFunc(ctx, in, opts...) - } - return nil, nil -} - -// MockGetActiveContractsClient is a mock implementation of grpc.ServerStreamingClient[lapiv2.GetActiveContractsResponse] -type MockGetActiveContractsClient struct { - grpc.ClientStream - RecvFunc func() (*lapiv2.GetActiveContractsResponse, error) -} - -func (m *MockGetActiveContractsClient) Recv() (*lapiv2.GetActiveContractsResponse, error) { - if m.RecvFunc != nil { - return m.RecvFunc() - } - return nil, io.EOF -} - -// MockGetUpdatesClient is a mock implementation of grpc.ServerStreamingClient[lapiv2.GetUpdatesResponse] -type MockGetUpdatesClient struct { - grpc.ClientStream - RecvFunc func() (*lapiv2.GetUpdatesResponse, error) -} - -func (m *MockGetUpdatesClient) Recv() (*lapiv2.GetUpdatesResponse, error) { - if m.RecvFunc != nil { - return m.RecvFunc() - } - return nil, io.EOF -} diff --git a/pkg/canton/stream.go b/pkg/canton/stream.go deleted file mode 100644 index a56d98e..0000000 --- a/pkg/canton/stream.go +++ /dev/null @@ -1,179 +0,0 @@ -package canton - -import ( - "context" - "fmt" - "io" - "strconv" - "time" - - lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2" - "github.com/google/uuid" - "go.uber.org/zap" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -const ( - streamReconnectDelay = 5 * time.Second - streamMaxReconnectDelay = 60 * time.Second -) - -// isAuthError checks if the error is an authentication/authorization error that requires token refresh -func isAuthError(err error) bool { - if err == nil { - return false - } - st, ok := status.FromError(err) - if !ok { - return false - } - return st.Code() == codes.Unauthenticated || st.Code() == codes.PermissionDenied -} - -// StreamWithdrawalEvents streams WithdrawalEvent contracts from Canton with automatic reconnection on token expiry -func (c *Client) StreamWithdrawalEvents(ctx context.Context, offset string) <-chan *WithdrawalEvent { - outCh := make(chan *WithdrawalEvent, 10) - - go func() { - defer close(outCh) - - currentOffset := offset - reconnectDelay := streamReconnectDelay - - for { - select { - case <-ctx.Done(): - return - default: - } - - c.logger.Info("Starting Canton withdrawal event stream", zap.String("offset", currentOffset)) - - err := c.streamWithdrawalEventsOnce(ctx, currentOffset, outCh, ¤tOffset) - if err == nil || err == io.EOF { - return - } - - if isAuthError(err) { - c.logger.Warn("Withdrawal stream auth error, refreshing token and reconnecting", - zap.Error(err), - zap.String("resume_offset", currentOffset)) - c.invalidateToken() - reconnectDelay = streamReconnectDelay - } else { - c.logger.Error("Withdrawal stream error, reconnecting", - zap.Error(err), - zap.String("resume_offset", currentOffset), - zap.Duration("delay", reconnectDelay)) - } - - select { - case <-ctx.Done(): - return - case <-time.After(reconnectDelay): - } - - reconnectDelay = min(reconnectDelay*2, streamMaxReconnectDelay) - } - }() - - return outCh -} - -func (c *Client) streamWithdrawalEventsOnce(ctx context.Context, offset string, outCh chan<- *WithdrawalEvent, lastOffset *string) error { - authCtx := c.GetAuthContext(ctx) - - var beginOffset int64 - if offset == "BEGIN" || offset == "" { - beginOffset = 0 - } else { - var err error - beginOffset, err = strconv.ParseInt(offset, 10, 64) - if err != nil { - return fmt.Errorf("invalid offset %s: %w", offset, err) - } - } - - corePackageID := c.config.CorePackageID - if corePackageID == "" { - corePackageID = c.config.BridgePackageID - } - - updateFormat := &lapiv2.UpdateFormat{ - IncludeTransactions: &lapiv2.TransactionFormat{ - EventFormat: &lapiv2.EventFormat{ - FiltersByParty: map[string]*lapiv2.Filters{ - c.config.RelayerParty: { - Cumulative: []*lapiv2.CumulativeFilter{ - { - IdentifierFilter: &lapiv2.CumulativeFilter_TemplateFilter{ - TemplateFilter: &lapiv2.TemplateFilter{ - TemplateId: &lapiv2.Identifier{ - PackageId: corePackageID, - ModuleName: "Bridge.Contracts", - EntityName: "WithdrawalEvent", - }, - }, - }, - }, - }, - }, - }, - Verbose: true, - }, - TransactionShape: lapiv2.TransactionShape_TRANSACTION_SHAPE_ACS_DELTA, - }, - } - - stream, err := c.updateService.GetUpdates(authCtx, &lapiv2.GetUpdatesRequest{ - BeginExclusive: beginOffset, - UpdateFormat: updateFormat, - }) - if err != nil { - return fmt.Errorf("failed to start withdrawal stream: %w", err) - } - - for { - resp, err := stream.Recv() - if err == io.EOF { - return nil - } - if err != nil { - return err - } - - if tx := resp.GetTransaction(); tx != nil { - for _, event := range tx.Events { - if createdEvent := event.GetCreated(); createdEvent != nil { - templateId := createdEvent.TemplateId - if templateId.ModuleName == "Bridge.Contracts" && templateId.EntityName == "WithdrawalEvent" { - withdrawalEvent, err := DecodeWithdrawalEvent( - fmt.Sprintf("%d-%d", createdEvent.Offset, createdEvent.NodeId), - tx.UpdateId, - createdEvent.ContractId, - createdEvent.CreateArguments, - ) - if err != nil { - c.logger.Error("Failed to decode withdrawal event", zap.Error(err)) - continue - } - - if withdrawalEvent.Status == WithdrawalStatusPending { - select { - case outCh <- withdrawalEvent: - *lastOffset = strconv.FormatInt(createdEvent.Offset, 10) - case <-ctx.Done(): - return ctx.Err() - } - } - } - } - } - } - } -} - -func generateUUID() string { - return uuid.New().String() -} diff --git a/pkg/canton/types.go b/pkg/canton/types.go deleted file mode 100644 index c3949f3..0000000 --- a/pkg/canton/types.go +++ /dev/null @@ -1,197 +0,0 @@ -package canton - -import ( - "time" -) - -// Config is imported from config package to avoid duplication -// See: pkg/config/config.go for CantonConfig definition - -// ============================================================================= -// ISSUER-CENTRIC MODEL TYPES -// ============================================================================= - -// FingerprintMapping represents a registered user's fingerprint → Party mapping -// This is created by the issuer when onboarding a user -type FingerprintMapping struct { - ContractID string - Issuer string - UserParty string - Fingerprint string // 32-byte fingerprint as hex (64-68 chars) - EvmAddress string // Optional EVM address for withdrawals -} - -// PendingDeposit represents a deposit waiting to be processed -// Created by middleware when EVM deposit event is detected -type PendingDeposit struct { - ContractID string - Issuer string - Fingerprint string - Amount string - EvmTxHash string - TokenID string - CreatedAt time.Time -} - -// DepositReceipt represents a successfully processed deposit -type DepositReceipt struct { - ContractID string - Issuer string - Recipient string // Resolved Party - Fingerprint string - Amount string - EvmTxHash string - TokenID string -} - -// WithdrawalEvent represents a withdrawal ready for EVM processing -type WithdrawalEvent struct { - ContractID string - EventID string - TransactionID string - Issuer string - UserParty string - EvmDestination string - Amount string - Fingerprint string - Status WithdrawalStatus -} - -// WithdrawalStatus represents the state of a withdrawal -type WithdrawalStatus string - -const ( - WithdrawalStatusPending WithdrawalStatus = "Pending" - WithdrawalStatusCompleted WithdrawalStatus = "Completed" - WithdrawalStatusFailed WithdrawalStatus = "Failed" -) - -// ============================================================================= -// REQUEST TYPES (for submitting commands) -// ============================================================================= - -// RegisterUserRequest represents a request to register a user's fingerprint -type RegisterUserRequest struct { - UserParty string - Fingerprint string - EvmAddress string // Optional -} - -// CreatePendingDepositRequest represents a request to create a pending deposit -type CreatePendingDepositRequest struct { - Fingerprint string - Amount string - EvmTxHash string - Timestamp time.Time -} - -// ProcessDepositRequest represents a request to process a deposit -type ProcessDepositRequest struct { - DepositCid string - MappingCid string - Timestamp time.Time // Required for audit event -} - -// InitiateWithdrawalRequest represents a request to start a withdrawal -type InitiateWithdrawalRequest struct { - MappingCid string - HoldingCid string - Amount string - EvmDestination string -} - -// CompleteWithdrawalRequest represents a request to mark withdrawal complete -type CompleteWithdrawalRequest struct { - WithdrawalEventCid string - EvmTxHash string -} - -// ============================================================================= -// OTHER TYPES -// ============================================================================= - -// DepositRequest represents a Canton deposit request event -type DepositRequest struct { - EventID string - TransactionID string - BridgeID string - Depositor string - TokenSymbol string - Amount string - EthChainID int64 - EthRecipient string - Mode AssetMode - ClientNonce string - CreatedAt time.Time -} - -// WithdrawalRequest represents a withdrawal to be confirmed on Canton -// Note: Different from InitiateWithdrawalRequest which is the new flow -type WithdrawalRequest struct { - EthTxHash string - EthSender string - Recipient string - TokenSymbol string - Amount string - Nonce int64 - EthChainID int64 -} - -// AssetMode represents the bridge mode -type AssetMode string - -const ( - AssetModeLockUnlock AssetMode = "LockUnlock" - AssetModeMintBurn AssetMode = "MintBurn" -) - -// TokenRef represents a Canton token reference -type TokenRef struct { - Issuer string - Symbol string -} - -// ProcessedEvent tracks processed Ethereum events -type ProcessedEvent struct { - ChainID int64 - TxHash string -} - -// ============================================================================= -// AUDIT EVENTS (for reconciliation) -// ============================================================================= - -// MintEvent represents a mint operation on Canton (native or bridge deposit) -// Maps to CIP56.Events.MintEvent in DAML -type MintEvent struct { - ContractID string - EventID string - TransactionID string - Offset int64 - Issuer string - Recipient string - Amount string - HoldingCid string - TokenSymbol string - EvmTxHash string // Optional: set for bridge deposits, empty for native mints - UserFingerprint string - Timestamp time.Time - AuditObservers []string -} - -// BurnEvent represents a burn operation on Canton (native or bridge withdrawal) -// Maps to CIP56.Events.BurnEvent in DAML -type BurnEvent struct { - ContractID string - EventID string - TransactionID string - Offset int64 - Issuer string - BurnedFrom string - Amount string - EvmDestination string // Optional: set for bridge withdrawals, empty for native burns - TokenSymbol string - UserFingerprint string - Timestamp time.Time - AuditObservers []string -} diff --git a/pkg/ethrpc/eth_api.go b/pkg/ethrpc/eth_api.go index 6c4c4cc..7113469 100644 --- a/pkg/ethrpc/eth_api.go +++ b/pkg/ethrpc/eth_api.go @@ -3,12 +3,12 @@ package ethrpc import ( "context" "fmt" + "github.com/shopspring/decimal" "math/big" "time" "github.com/chainsafe/canton-middleware/pkg/apidb" "github.com/chainsafe/canton-middleware/pkg/auth" - "github.com/chainsafe/canton-middleware/pkg/canton" "github.com/chainsafe/canton-middleware/pkg/ethereum" "github.com/chainsafe/canton-middleware/pkg/service" "github.com/ethereum/go-ethereum/accounts/abi" @@ -46,7 +46,7 @@ func (api *EthAPI) BlockNumber() (hexutil.Uint64, error) { // Simulates ~1 block per second since we don't have real block production timeSinceStart := time.Since(api.server.startTime).Seconds() timeBasedBlocks := uint64(timeSinceStart) - + // Return max of: (latest tx block + 12) or (time-based blocks) // This ensures both old transactions and new ones appear confirmed baseBlock := n + 12 @@ -151,7 +151,12 @@ func (api *EthAPI) SendRawTransaction(ctx context.Context, data hexutil.Bytes) ( if tx.To() == nil || (!isPromptToken && !isDemoToken) { api.server.logger.Warn("Transaction rejected: unsupported contract", - zap.String("tx_to", func() string { if tx.To() == nil { return "" }; return tx.To().Hex() }()), + zap.String("tx_to", func() string { + if tx.To() == nil { + return "" + } + return tx.To().Hex() + }()), zap.String("prompt_token", api.server.tokenAddress.Hex()), zap.String("demo_token", api.server.demoTokenAddress.Hex())) return common.Hash{}, fmt.Errorf("unsupported contract: only PROMPT and DEMO token transfers allowed") @@ -201,7 +206,7 @@ func (api *EthAPI) SendRawTransaction(ctx context.Context, data hexutil.Bytes) ( } // Convert Wei amount to human-readable decimal format - humanReadableAmount := canton.BigIntToDecimal(amount, decimals) + humanReadableAmount := bigIntToDecimal(amount, decimals) timeoutCtx, cancel := context.WithTimeout(ctx, api.server.cfg.EthRPC.RequestTimeout) defer cancel() @@ -216,7 +221,12 @@ func (api *EthAPI) SendRawTransaction(ctx context.Context, data hexutil.Bytes) ( if err != nil { api.server.logger.Error("Transfer failed", - zap.String("token", func() string { if isDemoToken { return "DEMO" }; return "PROMPT" }()), + zap.String("token", func() string { + if isDemoToken { + return "DEMO" + } + return "PROMPT" + }()), zap.String("from", from.Hex()), zap.String("to", toAddr.Hex()), zap.String("amount_wei", amount.String()), @@ -273,7 +283,12 @@ func (api *EthAPI) SendRawTransaction(ctx context.Context, data hexutil.Bytes) ( } api.server.logger.Info("Transaction submitted", - zap.String("token", func() string { if isDemoToken { return "DEMO" }; return "PROMPT" }()), + zap.String("token", func() string { + if isDemoToken { + return "DEMO" + } + return "PROMPT" + }()), zap.String("hash", txHash.Hex()), zap.String("from", from.Hex()), zap.String("to", toAddr.Hex()), @@ -460,7 +475,7 @@ func (api *EthAPI) callBalanceOf(ctx context.Context, data []byte, tokenSymbol s if decimals == 0 { decimals = 18 } - bal, err := canton.DecimalToBigInt(balStr, decimals) + bal, err := decimalToBigInt(balStr, decimals) if err != nil { api.server.logger.Warn("Failed to convert balance", zap.String("token", tokenSymbol), @@ -508,7 +523,7 @@ func (api *EthAPI) callTotalSupply(ctx context.Context, tokenSymbol string) (hex if decimals == 0 { decimals = 18 } - supply, err := canton.DecimalToBigInt(supplyStr, decimals) + supply, err := decimalToBigInt(supplyStr, decimals) if err != nil { return nil, fmt.Errorf("failed to convert total supply: %w", err) } @@ -646,11 +661,11 @@ func (api *EthAPI) GetBlockByNumber(ctx context.Context, blockNr BlockNumberOrHa if err != nil { return nil, err } - + // Add time-based progression (same logic as BlockNumber()) timeSinceStart := time.Since(api.server.startTime).Seconds() timeBasedBlocks := uint64(timeSinceStart) - + baseBlock := latestTxBlock + 12 if timeBasedBlocks > baseBlock { blockNum = timeBasedBlocks @@ -698,22 +713,38 @@ func (api *EthAPI) GetBlockByNumber(ctx context.Context, blockNr BlockNumberOrHa func (api *EthAPI) GetBlockByHash(ctx context.Context, hash common.Hash, fullTx bool) (*RPCBlock, error) { // Try to find a transaction with this block hash // If not found, we can still generate a synthetic block if it matches our hash pattern - + // Check if any stored transaction uses this block hash blockNum, err := api.server.db.GetBlockNumberByHash(hash.Bytes()) if err != nil { api.server.logger.Debug("GetBlockByHash: not found in DB, generating synthetic", zap.String("hash", hash.Hex())) } - + if blockNum > 0 { // Found in DB - generate block for this number return api.GetBlockByNumber(ctx, BlockNumberOrHash{BlockNumber: (*hexutil.Uint64)(&blockNum)}, fullTx) } - + // For any hash query, try to reverse-engineer the block number from our hash scheme // Our hashes are computed as: keccak256(chainID || blockNumber) // We can't easily reverse this, so just return the latest block for unknown hashes // This is a workaround - MetaMask will at least get a valid block response - + return api.GetBlockByNumber(ctx, BlockNumberOrHash{}, fullTx) } + +// decimalToBigInt converts Daml decimal string to big.Int +func decimalToBigInt(s string, decimals int) (*big.Int, error) { + d, err := decimal.NewFromString(s) + if err != nil { + return nil, fmt.Errorf("invalid decimal format: %w", err) + } + d = d.Mul(decimal.New(1, int32(decimals))) + return d.BigInt(), nil +} + +// bigIntToDecimal converts big.Int to Daml decimal string +func bigIntToDecimal(amount *big.Int, decimals int) string { + d := decimal.NewFromBigInt(amount, int32(-decimals)) + return d.String() +} diff --git a/pkg/registration/handler.go b/pkg/registration/handler.go index 2068c76..12e0d65 100644 --- a/pkg/registration/handler.go +++ b/pkg/registration/handler.go @@ -11,7 +11,7 @@ import ( "github.com/chainsafe/canton-middleware/pkg/apidb" "github.com/chainsafe/canton-middleware/pkg/auth" - "github.com/chainsafe/canton-middleware/pkg/canton" + canton "github.com/chainsafe/canton-middleware/pkg/canton-sdk/identity" "github.com/chainsafe/canton-middleware/pkg/config" "github.com/chainsafe/canton-middleware/pkg/keys" "github.com/ethereum/go-ethereum/crypto" @@ -22,7 +22,7 @@ import ( type Handler struct { config *config.APIServerConfig db *apidb.Store - cantonClient *canton.Client + cantonClient canton.Identity keyStore keys.KeyStore logger *zap.Logger } @@ -31,7 +31,7 @@ type Handler struct { func NewHandler( cfg *config.APIServerConfig, db *apidb.Store, - cantonClient *canton.Client, + cantonClient canton.Identity, keyStore keys.KeyStore, logger *zap.Logger, ) *Handler { @@ -223,7 +223,7 @@ func (h *Handler) handleWeb3Registration(w http.ResponseWriter, r *http.Request, // Grant CanActAs rights to the OAuth client for this party // This enables the custodial model: users own their holdings, API server acts on their behalf - if err := h.cantonClient.GrantCanActAs(ctx, cantonPartyID); err != nil { + if err = h.cantonClient.GrantActAsParty(ctx, cantonPartyID); err != nil { h.logger.Warn("Failed to grant CanActAs rights (transfers may fail)", zap.String("party_id", cantonPartyID), zap.Error(err)) @@ -231,8 +231,8 @@ func (h *Handler) handleWeb3Registration(w http.ResponseWriter, r *http.Request, } // Create fingerprint mapping on Canton (direct creation by issuer) - var mappingCID string - mappingCID, err = h.cantonClient.CreateFingerprintMappingDirect(ctx, &canton.RegisterUserRequest{ + var mapping *canton.FingerprintMapping + mapping, err = h.cantonClient.CreateFingerprintMapping(ctx, canton.CreateFingerprintMappingRequest{ UserParty: cantonPartyID, Fingerprint: fingerprint, EvmAddress: evmAddress, @@ -251,7 +251,7 @@ func (h *Handler) handleWeb3Registration(w http.ResponseWriter, r *http.Request, EVMAddress: evmAddress, CantonParty: cantonPartyID, Fingerprint: fingerprint, - MappingCID: mappingCID, + MappingCID: mapping.ContractID, CantonPartyID: cantonPartyID, CantonKeyCreatedAt: &now, } @@ -288,7 +288,7 @@ func (h *Handler) handleWeb3Registration(w http.ResponseWriter, r *http.Request, h.writeJSON(w, http.StatusOK, RegisterResponse{ Party: cantonPartyID, Fingerprint: fingerprint, - MappingCID: mappingCID, + MappingCID: mapping.ContractID, EVMAddress: evmAddress, }) } @@ -364,7 +364,7 @@ func (h *Handler) handleCantonNativeRegistration(w http.ResponseWriter, r *http. // Grant CanActAs rights to the OAuth client for this party // This enables the custodial model: native users can also use MetaMask via the API server - if err := h.cantonClient.GrantCanActAs(ctx, req.CantonPartyID); err != nil { + if err = h.cantonClient.GrantActAsParty(ctx, req.CantonPartyID); err != nil { h.logger.Warn("Failed to grant CanActAs rights (transfers may fail)", zap.String("party_id", req.CantonPartyID), zap.Error(err)) @@ -373,8 +373,8 @@ func (h *Handler) handleCantonNativeRegistration(w http.ResponseWriter, r *http. // Create fingerprint mapping on Canton (direct creation by issuer) // For Canton native users, the party already exists, we just create the mapping - var mappingCID string - mappingCID, err = h.cantonClient.CreateFingerprintMappingDirect(ctx, &canton.RegisterUserRequest{ + var mapping *canton.FingerprintMapping + mapping, err = h.cantonClient.CreateFingerprintMapping(ctx, canton.CreateFingerprintMappingRequest{ UserParty: req.CantonPartyID, Fingerprint: fingerprint, EvmAddress: evmAddress, @@ -393,7 +393,7 @@ func (h *Handler) handleCantonNativeRegistration(w http.ResponseWriter, r *http. EVMAddress: evmAddress, CantonParty: req.CantonPartyID, Fingerprint: fingerprint, - MappingCID: mappingCID, + MappingCID: mapping.ContractID, CantonPartyID: req.CantonPartyID, CantonKeyCreatedAt: &now, } @@ -430,7 +430,7 @@ func (h *Handler) handleCantonNativeRegistration(w http.ResponseWriter, r *http. h.writeJSON(w, http.StatusOK, RegisterResponse{ Party: req.CantonPartyID, Fingerprint: fingerprint, - MappingCID: mappingCID, + MappingCID: mapping.ContractID, EVMAddress: evmAddress, PrivateKey: evmKeyPair.PrivateKeyHex(), // For MetaMask import }) diff --git a/pkg/relayer/engine.go b/pkg/relayer/engine.go index 389709e..777ec71 100644 --- a/pkg/relayer/engine.go +++ b/pkg/relayer/engine.go @@ -10,45 +10,15 @@ import ( "github.com/chainsafe/canton-middleware/internal/metrics" "github.com/chainsafe/canton-middleware/pkg/apidb" - "github.com/chainsafe/canton-middleware/pkg/canton" + canton "github.com/chainsafe/canton-middleware/pkg/canton-sdk/bridge" "github.com/chainsafe/canton-middleware/pkg/config" "github.com/chainsafe/canton-middleware/pkg/db" "github.com/chainsafe/canton-middleware/pkg/ethereum" "github.com/ethereum/go-ethereum/common" + "go.uber.org/zap" ) -// cantonChainKey returns the canonical chain key for Canton state storage -func (e *Engine) cantonChainKey() string { - if e.config.Canton.ChainID != "" { - return e.config.Canton.ChainID - } - return "canton" -} - -// ethereumChainKey returns the canonical chain key for Ethereum state storage -func (e *Engine) ethereumChainKey() string { - if e.config.Ethereum.ChainID != 0 { - return strconv.FormatInt(e.config.Ethereum.ChainID, 10) - } - return "ethereum" -} - -// CantonBridgeClient defines the interface for Canton interactions -type CantonBridgeClient interface { - // Issuer-centric model methods - StreamWithdrawalEvents(ctx context.Context, offset string) <-chan *canton.WithdrawalEvent - GetFingerprintMapping(ctx context.Context, fingerprint string) (*canton.FingerprintMapping, error) - CreatePendingDeposit(ctx context.Context, req *canton.CreatePendingDepositRequest) (string, error) - ProcessDeposit(ctx context.Context, req *canton.ProcessDepositRequest) (string, error) - IsDepositProcessed(ctx context.Context, evmTxHash string) (bool, error) - InitiateWithdrawal(ctx context.Context, req *canton.InitiateWithdrawalRequest) (string, error) - CompleteWithdrawal(ctx context.Context, req *canton.CompleteWithdrawalRequest) error - - // Ledger state - GetLedgerEnd(ctx context.Context) (string, error) -} - // EthereumBridgeClient defines the interface for Ethereum interactions type EthereumBridgeClient interface { GetLatestBlockNumber(ctx context.Context) (uint64, error) @@ -73,7 +43,7 @@ type BridgeStore interface { // Engine orchestrates the bridge relayer operations type Engine struct { config *config.Config - cantonClient CantonBridgeClient + cantonClient canton.Bridge ethClient EthereumBridgeClient store BridgeStore apiDB *apidb.Store // Optional: API server database for balance cache @@ -95,7 +65,7 @@ type Engine struct { // NewEngine creates a new relayer engine func NewEngine( cfg *config.Config, - cantonClient CantonBridgeClient, + cantonClient canton.Bridge, ethClient EthereumBridgeClient, store BridgeStore, logger *zap.Logger, @@ -111,6 +81,7 @@ func NewEngine( } // SetAPIDB sets the API database store for balance cache updates +// TODO: make it as option of NewEngine func (e *Engine) SetAPIDB(apiDB *apidb.Store) { e.apiDB = apiDB } @@ -200,6 +171,7 @@ func (e *Engine) Stop() { } // loadOffsets loads last processed offsets from database +// TODO: refactor the method and simplify func (e *Engine) loadOffsets(ctx context.Context) error { // Load Canton offset cantonState, err := e.store.GetChainState(e.cantonChainKey()) @@ -209,7 +181,7 @@ func (e *Engine) loadOffsets(ctx context.Context) error { if cantonState != nil { storedOffset := cantonState.LastBlockHash // Validate stored offset is not ahead of ledger end - ledgerEnd, err := e.cantonClient.GetLedgerEnd(ctx) + ledgerEnd, err := e.cantonClient.GetLatestLedgerOffset(ctx) if err != nil { e.logger.Warn("Failed to validate Canton offset against ledger end, using stored offset", zap.String("stored_offset", storedOffset), @@ -217,8 +189,8 @@ func (e *Engine) loadOffsets(ctx context.Context) error { e.cantonOffset = storedOffset } else { storedNum, err1 := strconv.ParseInt(storedOffset, 10, 64) - endNum, err2 := strconv.ParseInt(ledgerEnd, 10, 64) - if err1 == nil && err2 == nil && storedNum > endNum { + endNum := ledgerEnd + if err1 == nil && storedNum > endNum { lookback := e.config.Canton.LookbackBlocks if lookback <= 0 { lookback = 1 @@ -231,10 +203,10 @@ func (e *Engine) loadOffsets(ctx context.Context) error { } e.logger.Warn("Stored Canton offset is ahead of ledger end, resetting to safe offset", zap.String("stored_offset", storedOffset), - zap.String("ledger_end", ledgerEnd), + zap.Int64("ledger_end", ledgerEnd), zap.String("safe_offset", safeOffset)) e.cantonOffset = safeOffset - if err := e.store.SetChainState(e.cantonChainKey(), 0, safeOffset); err != nil { + if err = e.store.SetChainState(e.cantonChainKey(), 0, safeOffset); err != nil { return fmt.Errorf("failed to persist corrected Canton offset: %w", err) } } else { @@ -251,8 +223,8 @@ func (e *Engine) loadOffsets(ctx context.Context) error { zap.String("offset", e.cantonOffset)) } else { // 2) Use ledger end minus lookback_blocks - ledgerEnd, err := e.cantonClient.GetLedgerEnd(ctx) - if err != nil { + ledgerEnd, err := e.cantonClient.GetLatestLedgerOffset(ctx) + if err != nil || ledgerEnd == 0 { e.logger.Warn("Failed to get Canton ledger end, falling back to BEGIN", zap.Error(err)) e.cantonOffset = "BEGIN" @@ -260,28 +232,22 @@ func (e *Engine) loadOffsets(ctx context.Context) error { lookback := e.config.Canton.LookbackBlocks if lookback <= 0 { // lookback <= 0: preserve old behavior (start at tip, no replay) - e.cantonOffset = ledgerEnd + e.cantonOffset = strconv.FormatInt(ledgerEnd, 10) e.logger.Info("Starting Canton stream from current ledger end (lookback disabled)", - zap.String("ledger_end", ledgerEnd)) + zap.String("ledger_end", e.cantonOffset)) } else { - endOffset, err := strconv.ParseInt(ledgerEnd, 10, 64) - if err != nil { - e.logger.Warn("Invalid Canton ledger end value, falling back to BEGIN", - zap.String("ledger_end", ledgerEnd), - zap.Error(err)) + endOffset := ledgerEnd + if endOffset <= lookback { + // Not enough history to look back fully; start from BEGIN e.cantonOffset = "BEGIN" } else { - if endOffset <= lookback { - // Not enough history to look back fully; start from BEGIN - e.cantonOffset = "BEGIN" - } else { - e.cantonOffset = strconv.FormatInt(endOffset-lookback, 10) - } - e.logger.Info("Starting Canton stream from ledger end with lookback", - zap.String("ledger_end", ledgerEnd), - zap.Int64("lookback_blocks", lookback), - zap.String("start_offset", e.cantonOffset)) + e.cantonOffset = strconv.FormatInt(endOffset-lookback, 10) } + e.logger.Info("Starting Canton stream from ledger end with lookback", + zap.Int64("ledger_end", ledgerEnd), + zap.Int64("lookback_blocks", lookback), + zap.String("start_offset", e.cantonOffset)) + } } } @@ -476,14 +442,15 @@ func (e *Engine) checkReadiness(ctx context.Context) { } // Check Canton readiness - ledgerEnd, err := e.cantonClient.GetLedgerEnd(ctx) + ledgerEnd, err := e.cantonClient.GetLatestLedgerOffset(ctx) if err != nil { e.logger.Warn("Failed to get Canton ledger end for readiness check", zap.Error(err)) } else { e.mu.Lock() if !e.cantonSynced { + ledgerEndString := strconv.FormatInt(ledgerEnd, 10) // If we started from ledger end or have no offset, consider synced - if e.cantonOffset == "" || e.cantonOffset == ledgerEnd { + if e.cantonOffset == "" || e.cantonOffset == ledgerEndString { e.cantonSynced = true e.logger.Info("Canton processor initial sync complete (at ledger end)", zap.String("offset", e.cantonOffset)) @@ -492,17 +459,17 @@ func (e *Engine) checkReadiness(ctx context.Context) { // (Canton streaming may fail due to protobuf issues, but we can still be ready) e.cantonSynced = true e.logger.Info("Canton processor initial sync complete (BEGIN offset, ledger reachable)", - zap.String("ledger_end", ledgerEnd)) + zap.String("ledger_end", ledgerEndString)) } else { // Try numeric comparison for Canton offsets currentOffset, err1 := strconv.ParseInt(e.cantonOffset, 10, 64) - endOffset, err2 := strconv.ParseInt(ledgerEnd, 10, 64) + endOffset, err2 := strconv.ParseInt(ledgerEndString, 10, 64) if err1 == nil && err2 == nil { if currentOffset >= endOffset { e.cantonSynced = true e.logger.Info("Canton processor initial sync complete", zap.String("offset", e.cantonOffset), - zap.String("ledger_end", ledgerEnd)) + zap.String("ledger_end", ledgerEndString)) } else { // Canton offset only advances when matching events are processed. // If the stream has been running for 10+ seconds without errors, @@ -512,11 +479,11 @@ func (e *Engine) checkReadiness(ctx context.Context) { e.cantonSynced = true e.logger.Info("Canton processor synced (stream healthy, no pending withdrawals)", zap.String("offset", e.cantonOffset), - zap.String("ledger_end", ledgerEnd)) + zap.String("ledger_end", ledgerEndString)) } else { e.logger.Debug("Canton processor catching up", zap.String("offset", e.cantonOffset), - zap.String("ledger_end", ledgerEnd), + zap.String("ledger_end", ledgerEndString), zap.Int64("behind", endOffset-currentOffset)) } } @@ -526,3 +493,19 @@ func (e *Engine) checkReadiness(ctx context.Context) { e.mu.Unlock() } } + +// cantonChainKey returns the canonical chain key for Canton state storage +func (e *Engine) cantonChainKey() string { + if e.config.Canton.ChainID != "" { + return e.config.Canton.ChainID + } + return "canton" +} + +// ethereumChainKey returns the canonical chain key for Ethereum state storage +func (e *Engine) ethereumChainKey() string { + if e.config.Ethereum.ChainID != 0 { + return strconv.FormatInt(e.config.Ethereum.ChainID, 10) + } + return "ethereum" +} diff --git a/pkg/relayer/engine_test.go b/pkg/relayer/engine_test.go index 82a3566..c7fb90a 100644 --- a/pkg/relayer/engine_test.go +++ b/pkg/relayer/engine_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/chainsafe/canton-middleware/pkg/canton" + canton "github.com/chainsafe/canton-middleware/pkg/canton-sdk/bridge" "github.com/chainsafe/canton-middleware/pkg/config" "github.com/chainsafe/canton-middleware/pkg/db" "github.com/ethereum/go-ethereum/common" @@ -227,8 +227,8 @@ func TestEngine_LoadOffsets_NoState_WithLookback(t *testing.T) { } mockCantonClient := &MockCantonClient{ - GetLedgerEndFunc: func(ctx context.Context) (string, error) { - return "10000", nil + GetLatestLedgerOffsetFunc: func(ctx context.Context) (int64, error) { + return 10000, nil }, } @@ -292,8 +292,8 @@ func TestEngine_LoadOffsets_NoState_LookbackLargerThanChain(t *testing.T) { } mockCantonClient := &MockCantonClient{ - GetLedgerEndFunc: func(ctx context.Context) (string, error) { - return "5000", nil // Less than lookback + GetLatestLedgerOffsetFunc: func(ctx context.Context) (int64, error) { + return 5000, nil // Less than lookback }, } @@ -329,8 +329,8 @@ func TestEngine_LoadOffsets_NoState_LookbackDisabled(t *testing.T) { } mockCantonClient := &MockCantonClient{ - GetLedgerEndFunc: func(ctx context.Context) (string, error) { - return "10000", nil + GetLatestLedgerOffsetFunc: func(ctx context.Context) (int64, error) { + return 10000, nil }, } @@ -410,8 +410,8 @@ func TestEngine_CheckReadiness_EthereumCaughtUp(t *testing.T) { } mockCantonClient := &MockCantonClient{ - GetLedgerEndFunc: func(ctx context.Context) (string, error) { - return "1000", nil + GetLatestLedgerOffsetFunc: func(ctx context.Context) (int64, error) { + return 1000, nil }, } @@ -439,8 +439,8 @@ func TestEngine_CheckReadiness_EthereumBehind(t *testing.T) { } mockCantonClient := &MockCantonClient{ - GetLedgerEndFunc: func(ctx context.Context) (string, error) { - return "1000", nil + GetLatestLedgerOffsetFunc: func(ctx context.Context) (int64, error) { + return 1000, nil }, } @@ -468,8 +468,8 @@ func TestEngine_CheckReadiness_CantonCaughtUp(t *testing.T) { } mockCantonClient := &MockCantonClient{ - GetLedgerEndFunc: func(ctx context.Context) (string, error) { - return "1000", nil + GetLatestLedgerOffsetFunc: func(ctx context.Context) (int64, error) { + return 1000, nil }, } @@ -497,8 +497,8 @@ func TestEngine_CheckReadiness_CantonBehind(t *testing.T) { } mockCantonClient := &MockCantonClient{ - GetLedgerEndFunc: func(ctx context.Context) (string, error) { - return "1000", nil + GetLatestLedgerOffsetFunc: func(ctx context.Context) (int64, error) { + return 1000, nil }, } @@ -526,8 +526,8 @@ func TestEngine_CheckReadiness_SyncedStaysTrue(t *testing.T) { } mockCantonClient := &MockCantonClient{ - GetLedgerEndFunc: func(ctx context.Context) (string, error) { - return "2000", nil // Head moved forward + GetLatestLedgerOffsetFunc: func(ctx context.Context) (int64, error) { + return 2000, nil // Head moved forward }, } diff --git a/pkg/relayer/handlers.go b/pkg/relayer/handlers.go index 507b4a2..20f746d 100644 --- a/pkg/relayer/handlers.go +++ b/pkg/relayer/handlers.go @@ -1,28 +1,31 @@ package relayer +// Todo: split this into multiple files (source & destination) that will implement source and destination respectively. +// Current name 'handlers' doesn't represent clear context + import ( "context" "encoding/hex" "fmt" "math/big" "strconv" - "time" "github.com/chainsafe/canton-middleware/pkg/apidb" - "github.com/chainsafe/canton-middleware/pkg/canton" + canton "github.com/chainsafe/canton-middleware/pkg/canton-sdk/bridge" "github.com/chainsafe/canton-middleware/pkg/config" "github.com/chainsafe/canton-middleware/pkg/ethereum" "github.com/ethereum/go-ethereum/common" + "github.com/shopspring/decimal" ) // CantonSource implements Source for Canton type CantonSource struct { - client CantonBridgeClient + client canton.Bridge tokenContract string chainID string } -func NewCantonSource(client CantonBridgeClient, tokenContract string, chainID string) *CantonSource { +func NewCantonSource(client canton.Bridge, tokenContract string, chainID string) *CantonSource { return &CantonSource{ client: client, tokenContract: tokenContract, @@ -56,8 +59,8 @@ func (s *CantonSource) StreamEvents(ctx context.Context, offset string) (<-chan outCh <- &Event{ ID: withdrawal.EventID, TransactionID: withdrawal.TransactionID, - SourceChain: "canton", - DestinationChain: "ethereum", + SourceChain: "canton", // todo: use constant + DestinationChain: "ethereum", // todo: use constant SourceTxHash: withdrawal.ContractID, TokenAddress: s.tokenContract, Amount: withdrawal.Amount, @@ -114,8 +117,8 @@ func (s *EthereumSource) StreamEvents(ctx context.Context, offset string) (<-cha relayerEvent := &Event{ ID: fmt.Sprintf("%s-%d", event.TxHash.Hex(), event.LogIndex), TransactionID: event.TxHash.Hex(), - SourceChain: "ethereum", - DestinationChain: "canton", + SourceChain: "ethereum", // todo: use constant + DestinationChain: "canton", // todo: use constant SourceTxHash: event.TxHash.Hex(), TokenAddress: event.Token.Hex(), Amount: event.Amount.String(), @@ -148,14 +151,14 @@ func (s *EthereumSource) StreamEvents(ctx context.Context, offset string) (<-cha // CantonDestination implements Destination for Canton type CantonDestination struct { - client CantonBridgeClient + client canton.Bridge config *config.EthereumConfig relayerParty string chainID string apiDB *apidb.Store // Optional: for updating balance cache } -func NewCantonDestination(client CantonBridgeClient, cfg *config.EthereumConfig, relayerParty string, chainID string) *CantonDestination { +func NewCantonDestination(client canton.Bridge, cfg *config.EthereumConfig, relayerParty string, chainID string) *CantonDestination { return &CantonDestination{client: client, config: cfg, relayerParty: relayerParty, chainID: chainID} } @@ -169,13 +172,10 @@ func (d *CantonDestination) GetChainID() string { } func (d *CantonDestination) SubmitTransfer(ctx context.Context, event *Event) (string, error) { - // The recipient from EVM is a fingerprint (bytes32 as hex) - fingerprintFromEvent := event.Recipient - // Parse amount amount := new(big.Int) amount.SetString(event.Amount, 10) - amountStr := canton.BigIntToDecimal(amount, 18) + amountStr := bigIntToDecimal(amount, 18) // Defense in depth: Check if this deposit was already processed on Canton // This prevents duplicate deposits if multiple relayer instances are running @@ -189,68 +189,60 @@ func (d *CantonDestination) SubmitTransfer(ctx context.Context, event *Event) (s return fmt.Sprintf("already-processed:%s", event.SourceTxHash), nil } - // Step 1: Look up FingerprintMapping FIRST to get the exact stored fingerprint format - // GetFingerprintMapping normalizes the input for comparison, but we need the exact - // stored format to use in PendingDeposit so it matches during ProcessDeposit - mapping, err := d.client.GetFingerprintMapping(ctx, fingerprintFromEvent) - if err != nil { - return "", fmt.Errorf("failed to get fingerprint mapping for %s: %w", fingerprintFromEvent, err) - } - - // Use the EXACT fingerprint from the mapping - this ensures PendingDeposit.fingerprint - // will match FingerprintMapping.fingerprint exactly, regardless of whether it has 0x prefix - fingerprint := mapping.Fingerprint - - // Step 2: Create PendingDeposit from EVM event using exact fingerprint from mapping - depositReq := &canton.CreatePendingDepositRequest{ - Fingerprint: fingerprint, + // Step 1: Create Pending Deposit on canton Bridge + depositReq := canton.CreatePendingDepositRequest{ + Fingerprint: event.Recipient, Amount: amountStr, EvmTxHash: event.SourceTxHash, - Timestamp: time.Now(), } - depositCid, err := d.client.CreatePendingDeposit(ctx, depositReq) + pendingDeposit, err := d.client.CreatePendingDeposit(ctx, depositReq) if err != nil { return "", fmt.Errorf("failed to create pending deposit: %w", err) } - // Step 3: Process deposit (unlock tokens on Canton side) - processReq := &canton.ProcessDepositRequest{ - DepositCid: depositCid, - MappingCid: mapping.ContractID, - Timestamp: time.Now(), + // Step 2: Process deposit and mint(unlock tokens on Canton side) + processReq := canton.ProcessDepositRequest{ + DepositCID: pendingDeposit.ContractID, + MappingCID: pendingDeposit.MappingCID, } - holdingCid, err := d.client.ProcessDeposit(ctx, processReq) + deposit, err := d.client.ProcessDepositAndMint(ctx, processReq) if err != nil { - return "", fmt.Errorf("failed to process deposit: %w", err) + return "", fmt.Errorf("failed to process deposit and mint: %w", err) } - // Step 4: Update balance cache if API DB is configured + // Step 3: Update balance cache if API DB is configured if d.apiDB != nil { // Increment user PROMPT balance - if err := d.apiDB.IncrementBalanceByFingerprint(fingerprint, amountStr, apidb.TokenPrompt); err != nil { + if err = d.apiDB.IncrementBalanceByFingerprint(pendingDeposit.Fingerprint, amountStr, apidb.TokenPrompt); err != nil { // Log but don't fail - the deposit succeeded on Canton - fmt.Printf("WARN: Failed to update prompt balance cache for %s: %v\n", fingerprint, err) + fmt.Printf("WARN: Failed to update prompt balance cache for %s: %v\n", pendingDeposit.Fingerprint, err) } // Increment total supply for PROMPT - if err := d.apiDB.IncrementTotalSupply("PROMPT", amountStr); err != nil { + if err = d.apiDB.IncrementTotalSupply("PROMPT", amountStr); err != nil { fmt.Printf("WARN: Failed to update total supply cache: %v\n", err) } } - return holdingCid, nil + return deposit.ContractID, nil +} + +// bigIntToDecimal converts big.Int to Daml decimal string +func bigIntToDecimal(amount *big.Int, decimals int) string { + d := decimal.NewFromBigInt(amount, int32(-decimals)) + return d.String() } // EthereumDestination implements Destination for Ethereum type EthereumDestination struct { client EthereumBridgeClient - cantonClient CantonBridgeClient + cantonClient canton.Bridge chainID string apiDB *apidb.Store // Optional: for updating balance cache } -func NewEthereumDestination(client EthereumBridgeClient, cantonClient CantonBridgeClient, chainID string) *EthereumDestination { +func NewEthereumDestination(client EthereumBridgeClient, cantonClient canton.Bridge, chainID string) *EthereumDestination { return &EthereumDestination{client: client, cantonClient: cantonClient, chainID: chainID} } @@ -271,7 +263,7 @@ func (d *EthereumDestination) SubmitTransfer(ctx context.Context, event *Event) tokenAddress := common.HexToAddress(event.TokenAddress) // Convert amount - amount, err := canton.DecimalToBigInt(event.Amount, 18) + amount, err := decimalToBigInt(event.Amount, 18) if err != nil { return "", fmt.Errorf("failed to parse amount: %w", err) } @@ -312,11 +304,11 @@ func (d *EthereumDestination) SubmitTransfer(ctx context.Context, event *Event) // Mark withdrawal as complete on Canton (if we have the withdrawal event) if withdrawal, ok := event.Raw.(*canton.WithdrawalEvent); ok && d.cantonClient != nil { - completeReq := &canton.CompleteWithdrawalRequest{ - WithdrawalEventCid: withdrawal.ContractID, + completeReq := canton.CompleteWithdrawalRequest{ + WithdrawalEventCID: withdrawal.ContractID, EvmTxHash: txHash.Hex(), } - if err := d.cantonClient.CompleteWithdrawal(ctx, completeReq); err != nil { + if err = d.cantonClient.CompleteWithdrawal(ctx, completeReq); err != nil { // Log but don't fail - the EVM transfer succeeded // This can be reconciled later via cleanup script fmt.Printf("WARN: Failed to mark withdrawal complete on Canton (EVM succeeded): %v\n", err) @@ -339,3 +331,13 @@ func (d *EthereumDestination) SubmitTransfer(ctx context.Context, event *Event) return txHash.Hex(), nil } + +// decimalToBigInt converts Daml decimal string to big.Int +func decimalToBigInt(s string, decimals int) (*big.Int, error) { + d, err := decimal.NewFromString(s) + if err != nil { + return nil, fmt.Errorf("invalid decimal format: %w", err) + } + d = d.Mul(decimal.New(1, int32(decimals))) + return d.BigInt(), nil +} diff --git a/pkg/relayer/handlers_test.go b/pkg/relayer/handlers_test.go index 88df955..2b09640 100644 --- a/pkg/relayer/handlers_test.go +++ b/pkg/relayer/handlers_test.go @@ -2,43 +2,33 @@ package relayer import ( "context" - "testing" - - "github.com/chainsafe/canton-middleware/pkg/canton" + canton "github.com/chainsafe/canton-middleware/pkg/canton-sdk/bridge" "github.com/chainsafe/canton-middleware/pkg/config" + "testing" ) func TestCantonDestination_SubmitTransfer(t *testing.T) { // Setup mock client with issuer-centric flow methods mockClient := &MockCantonClient{ - CreatePendingDepositFunc: func(ctx context.Context, req *canton.CreatePendingDepositRequest) (string, error) { + CreatePendingDepositFunc: func(ctx context.Context, req canton.CreatePendingDepositRequest) (*canton.PendingDeposit, error) { if req.Fingerprint != "BobFingerprint" { t.Errorf("Expected Fingerprint BobFingerprint, got %s", req.Fingerprint) } if req.EvmTxHash != "0xsrc-tx-hash" { t.Errorf("Expected EvmTxHash 0xsrc-tx-hash, got %s", req.EvmTxHash) } - return "deposit-cid-123", nil - }, - GetFingerprintMappingFunc: func(ctx context.Context, fingerprint string) (*canton.FingerprintMapping, error) { - if fingerprint != "BobFingerprint" { - t.Errorf("Expected fingerprint BobFingerprint, got %s", fingerprint) - } - return &canton.FingerprintMapping{ - ContractID: "mapping-cid-123", - Issuer: "Issuer", - UserParty: "Bob", - Fingerprint: fingerprint, + return &canton.PendingDeposit{ + ContractID: "deposit-cid-123", }, nil }, - ProcessDepositFunc: func(ctx context.Context, req *canton.ProcessDepositRequest) (string, error) { - if req.DepositCid != "deposit-cid-123" { - t.Errorf("Expected DepositCid deposit-cid-123, got %s", req.DepositCid) + ProcessDepositAndMintFunc: func(ctx context.Context, req canton.ProcessDepositRequest) (*canton.ProcessedDeposit, error) { + if req.DepositCID != "deposit-cid-123" { + t.Errorf("Expected DepositCid deposit-cid-123, got %s", req.DepositCID) } - if req.MappingCid != "mapping-cid-123" { - t.Errorf("Expected MappingCid mapping-cid-123, got %s", req.MappingCid) + if req.MappingCID != "mapping-cid-123" { + t.Errorf("Expected MappingCid mapping-cid-123, got %s", req.MappingCID) } - return "holding-cid-123", nil + return &canton.ProcessedDeposit{ContractID: "holding-cid-123"}, nil }, } diff --git a/pkg/relayer/mocks_test.go b/pkg/relayer/mocks_test.go index d57d88c..dceed09 100644 --- a/pkg/relayer/mocks_test.go +++ b/pkg/relayer/mocks_test.go @@ -1,10 +1,12 @@ package relayer +// TODO: remove the mock impl and use mockery to generate mock + import ( "context" "math/big" - "github.com/chainsafe/canton-middleware/pkg/canton" + canton "github.com/chainsafe/canton-middleware/pkg/canton-sdk/bridge" "github.com/chainsafe/canton-middleware/pkg/db" "github.com/chainsafe/canton-middleware/pkg/ethereum" "github.com/ethereum/go-ethereum/common" @@ -14,15 +16,12 @@ import ( type MockCantonClient struct { // Issuer-centric model methods StreamWithdrawalEventsFunc func(ctx context.Context, offset string) <-chan *canton.WithdrawalEvent - GetFingerprintMappingFunc func(ctx context.Context, fingerprint string) (*canton.FingerprintMapping, error) - CreatePendingDepositFunc func(ctx context.Context, req *canton.CreatePendingDepositRequest) (string, error) - ProcessDepositFunc func(ctx context.Context, req *canton.ProcessDepositRequest) (string, error) + CreatePendingDepositFunc func(ctx context.Context, req canton.CreatePendingDepositRequest) (*canton.PendingDeposit, error) + ProcessDepositAndMintFunc func(ctx context.Context, req canton.ProcessDepositRequest) (*canton.ProcessedDeposit, error) IsDepositProcessedFunc func(ctx context.Context, evmTxHash string) (bool, error) - InitiateWithdrawalFunc func(ctx context.Context, req *canton.InitiateWithdrawalRequest) (string, error) - CompleteWithdrawalFunc func(ctx context.Context, req *canton.CompleteWithdrawalRequest) error - - // Ledger state - GetLedgerEndFunc func(ctx context.Context) (string, error) + InitiateWithdrawalFunc func(ctx context.Context, req canton.InitiateWithdrawalRequest) (string, error) + CompleteWithdrawalFunc func(ctx context.Context, req canton.CompleteWithdrawalRequest) error + GetLatestLedgerOffsetFunc func(ctx context.Context) (int64, error) } func (m *MockCantonClient) StreamWithdrawalEvents(ctx context.Context, offset string) <-chan *canton.WithdrawalEvent { @@ -32,25 +31,22 @@ func (m *MockCantonClient) StreamWithdrawalEvents(ctx context.Context, offset st return nil } -func (m *MockCantonClient) GetFingerprintMapping(ctx context.Context, fingerprint string) (*canton.FingerprintMapping, error) { - if m.GetFingerprintMappingFunc != nil { - return m.GetFingerprintMappingFunc(ctx, fingerprint) - } - return nil, nil -} - -func (m *MockCantonClient) CreatePendingDeposit(ctx context.Context, req *canton.CreatePendingDepositRequest) (string, error) { +func (m *MockCantonClient) CreatePendingDeposit( + ctx context.Context, + req canton.CreatePendingDepositRequest) (*canton.PendingDeposit, error) { if m.CreatePendingDepositFunc != nil { return m.CreatePendingDepositFunc(ctx, req) } - return "", nil + return nil, nil } -func (m *MockCantonClient) ProcessDeposit(ctx context.Context, req *canton.ProcessDepositRequest) (string, error) { - if m.ProcessDepositFunc != nil { - return m.ProcessDepositFunc(ctx, req) +func (m *MockCantonClient) ProcessDepositAndMint( + ctx context.Context, + req canton.ProcessDepositRequest) (*canton.ProcessedDeposit, error) { + if m.ProcessDepositAndMintFunc != nil { + return m.ProcessDepositAndMintFunc(ctx, req) } - return "", nil + return nil, nil } func (m *MockCantonClient) IsDepositProcessed(ctx context.Context, evmTxHash string) (bool, error) { @@ -60,25 +56,29 @@ func (m *MockCantonClient) IsDepositProcessed(ctx context.Context, evmTxHash str return false, nil } -func (m *MockCantonClient) InitiateWithdrawal(ctx context.Context, req *canton.InitiateWithdrawalRequest) (string, error) { +func (m *MockCantonClient) InitiateWithdrawal(ctx context.Context, req canton.InitiateWithdrawalRequest) (string, error) { if m.InitiateWithdrawalFunc != nil { return m.InitiateWithdrawalFunc(ctx, req) } return "", nil } -func (m *MockCantonClient) CompleteWithdrawal(ctx context.Context, req *canton.CompleteWithdrawalRequest) error { +func (m *MockCantonClient) CompleteWithdrawal(ctx context.Context, req canton.CompleteWithdrawalRequest) error { if m.CompleteWithdrawalFunc != nil { return m.CompleteWithdrawalFunc(ctx, req) } return nil } -func (m *MockCantonClient) GetLedgerEnd(ctx context.Context) (string, error) { - if m.GetLedgerEndFunc != nil { - return m.GetLedgerEndFunc(ctx) +func (m *MockCantonClient) GetLatestLedgerOffset(ctx context.Context) (int64, error) { + if m.GetLatestLedgerOffsetFunc != nil { + return m.GetLatestLedgerOffsetFunc(ctx) } - return "BEGIN", nil + return 0, nil +} + +func (m *MockCantonClient) GetWayfinderBridgeConfigCID(ctx context.Context) (string, error) { + return m.GetWayfinderBridgeConfigCID(ctx) } // MockEthereumClient is a mock implementation of EthereumBridgeClient diff --git a/pkg/relayer/processor.go b/pkg/relayer/processor.go index 2156856..0f63757 100644 --- a/pkg/relayer/processor.go +++ b/pkg/relayer/processor.go @@ -149,11 +149,13 @@ func (p *Processor) processEvent(ctx context.Context, event *Event) error { zap.Error(err)) // Update status to failed + // todo: log error if update fails on db. p.store.UpdateTransferStatus(event.ID, db.TransferStatusFailed, nil) return fmt.Errorf("submission failed: %w", err) } // Update status to completed + // todo: handle error p.store.UpdateTransferStatus(event.ID, db.TransferStatusCompleted, &destTxHash) // Persist offset after successful processing diff --git a/pkg/service/token.go b/pkg/service/token.go index 387d05e..341a3f0 100644 --- a/pkg/service/token.go +++ b/pkg/service/token.go @@ -7,7 +7,7 @@ import ( "github.com/chainsafe/canton-middleware/pkg/apidb" "github.com/chainsafe/canton-middleware/pkg/auth" - "github.com/chainsafe/canton-middleware/pkg/canton" + canton "github.com/chainsafe/canton-middleware/pkg/canton-sdk/token" "github.com/chainsafe/canton-middleware/pkg/config" "go.uber.org/zap" ) @@ -23,7 +23,7 @@ var ( type TokenService struct { config *config.APIServerConfig db *apidb.Store - cantonClient *canton.Client + cantonClient canton.Token logger *zap.Logger } @@ -31,7 +31,7 @@ type TokenService struct { func NewTokenService( cfg *config.APIServerConfig, db *apidb.Store, - cantonClient *canton.Client, + cantonClient canton.Token, logger *zap.Logger, ) *TokenService { return &TokenService{ @@ -90,7 +90,7 @@ func (s *TokenService) Transfer(ctx context.Context, req *TransferRequest) (*Tra return nil, ErrRecipientNotFound } - err = s.cantonClient.TransferAsUserByFingerprint(ctx, + err = s.cantonClient.TransferByFingerprint(ctx, fromUser.Fingerprint, toUser.Fingerprint, req.Amount, @@ -211,11 +211,6 @@ func isInsufficientFunds(err error) bool { return false } -// GetCantonClient returns the underlying Canton client for direct access -func (s *TokenService) GetCantonClient() *canton.Client { - return s.cantonClient -} - // CantonTransferRequest represents a Canton-native token transfer request using party IDs type CantonTransferRequest struct { FromPartyID string // Sender's Canton party ID @@ -265,7 +260,7 @@ func (s *TokenService) TransferByPartyID(ctx context.Context, req *CantonTransfe dbTokenType = apidb.TokenPrompt } - err = s.cantonClient.TransferAsUserByFingerprint(ctx, + err = s.cantonClient.TransferByFingerprint(ctx, fromUser.Fingerprint, toUser.Fingerprint, req.Amount, diff --git a/scripts/setup/bootstrap-bridge.go b/scripts/setup/bootstrap-bridge.go index d119611..2fa7d6d 100644 --- a/scripts/setup/bootstrap-bridge.go +++ b/scripts/setup/bootstrap-bridge.go @@ -35,7 +35,7 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" - lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2" + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" ) var ( diff --git a/scripts/testing/demo-activity.go b/scripts/testing/demo-activity.go index 9f49906..31434b1 100644 --- a/scripts/testing/demo-activity.go +++ b/scripts/testing/demo-activity.go @@ -31,7 +31,7 @@ import ( "sync" "time" - lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2" + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" "github.com/chainsafe/canton-middleware/pkg/config" "github.com/golang-jwt/jwt/v5" _ "github.com/lib/pq" diff --git a/scripts/testing/register-user.go b/scripts/testing/register-user.go index fe8a1bc..c3d91bc 100644 --- a/scripts/testing/register-user.go +++ b/scripts/testing/register-user.go @@ -27,7 +27,7 @@ import ( "sync" "time" - lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton/lapi/v2" + lapiv2 "github.com/chainsafe/canton-middleware/pkg/canton-sdk/lapi/v2" "github.com/chainsafe/canton-middleware/pkg/config" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid"