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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config.e2e-local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ token:
symbol: "DEMO"
decimals: 18
instrument_id: "DEMO"
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48":
name: "USD Coin"
symbol: "USDCx"
decimals: 6
instrument_id: "USDCx"
Comment thread
salindne marked this conversation as resolved.
native_balance_wei: "1000000000000000000000"

# Ethereum JSON-RPC facade (MetaMask compatibility)
Expand Down
7 changes: 7 additions & 0 deletions pkg/cantonsdk/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ package client
import (
"context"
"fmt"
"net/http"
"time"

"github.com/chainsafe/canton-middleware/pkg/cantonsdk/bridge"
"github.com/chainsafe/canton-middleware/pkg/cantonsdk/identity"
Expand Down Expand Up @@ -60,6 +62,11 @@ func New(ctx context.Context, cfg *Config, opts ...Option) (*Client, error) {
if s.keyResolver != nil {
tokenOpts = append(tokenOpts, token.WithKeyResolver(s.keyResolver))
}
if len(cfg.Token.ExternalTokens) > 0 {
tokenOpts = append(tokenOpts, token.WithRegistryClient(
token.NewRegistryClient(&http.Client{Timeout: 10 * time.Second}),
))
}
tk, err := token.New(cfg.Token, l, id, tokenOpts...)
if err != nil {
_ = l.Close()
Expand Down
183 changes: 127 additions & 56 deletions pkg/cantonsdk/token/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,12 @@ type Token interface {

// Client implements CIP-56 token operations.
type Client struct {
cfg *Config
ledger ledger.Ledger
identity identity.Identity
keyResolver KeyResolver
logger *zap.Logger
cfg *Config
ledger ledger.Ledger
identity identity.Identity
keyResolver KeyResolver
registryClient *RegistryClient
logger *zap.Logger
}

// New creates a new token client.
Expand All @@ -123,11 +124,12 @@ func New(cfg *Config, l ledger.Ledger, id identity.Identity, opts ...Option) (*C

s := applyOptions(opts)
return &Client{
cfg: cfg,
ledger: l,
identity: id,
keyResolver: s.keyResolver,
logger: s.logger,
cfg: cfg,
ledger: l,
identity: id,
keyResolver: s.keyResolver,
registryClient: s.registryClient,
logger: s.logger,
}, nil
}

Expand Down Expand Up @@ -448,32 +450,35 @@ func (c *Client) TransferByPartyID(ctx context.Context, idempotencyKey, fromPart
return fmt.Errorf("select holdings for transfer: %w", err)
}

factoryCID, err := c.getTransferFactoryCID(ctx)
if err != nil {
return err
}

return c.transferViaFactory(ctx, &transferFactoryRequest{
req := &transferFactoryRequest{
CommandID: idempotencyKey,
FromPartyID: fromParty,
ToPartyID: toParty,
Amount: amount,
InstrumentAdmin: selected.InstrumentAdmin,
InstrumentID: selected.InstrumentID,
InputHoldingCIDs: selected.CIDs,
FactoryCID: factoryCID,
})
}

if err := c.resolveTransferFactory(ctx, req); err != nil {
return err
}

return c.transferViaFactory(ctx, req)
}

type transferFactoryRequest struct {
CommandID string
FromPartyID string
ToPartyID string
Amount string
InstrumentAdmin string
InstrumentID string
InputHoldingCIDs []string
FactoryCID string
CommandID string
FromPartyID string
ToPartyID string
Amount string
InstrumentAdmin string
InstrumentID string
InputHoldingCIDs []string
FactoryCID string
ChoiceContext map[string]string
DisclosedContracts []*lapiv2.DisclosedContract
IsExternal bool
}

func (c *Client) transferViaFactory(ctx context.Context, req *transferFactoryRequest) error {
Expand All @@ -488,13 +493,19 @@ func (c *Client) transferViaFactory(ctx context.Context, req *transferFactoryReq

cmd := c.buildTransferCommand(req)

readAs := []string{c.cfg.IssuerParty}
if req.IsExternal {
readAs = nil
}

commands := &lapiv2.Commands{
SynchronizerId: c.cfg.DomainID,
CommandId: req.CommandID,
UserId: c.cfg.UserID,
ActAs: []string{req.FromPartyID},
ReadAs: []string{c.cfg.IssuerParty},
Commands: []*lapiv2.Command{cmd},
SynchronizerId: c.cfg.DomainID,
CommandId: req.CommandID,
UserId: c.cfg.UserID,
ActAs: []string{req.FromPartyID},
ReadAs: readAs,
Commands: []*lapiv2.Command{cmd},
DisclosedContracts: req.DisclosedContracts,
}

return c.prepareAndExecuteAsUser(ctx, commands, signerKey, req.FromPartyID)
Expand Down Expand Up @@ -527,6 +538,7 @@ func (c *Client) buildTransferCommand(req *transferFactoryRequest) *lapiv2.Comma
now,
now.Add(defaultTransferValidity),
req.InputHoldingCIDs,
req.ChoiceContext,
),
},
},
Expand All @@ -543,6 +555,58 @@ func (c *Client) getTransferFactoryCID(ctx context.Context) (string, error) {
return info.ContractID, nil
}

// resolveTransferFactory fills in factory info on the request by routing based on InstrumentAdmin.
// For our tokens (InstrumentAdmin == IssuerParty): uses local ACS query.
// For external tokens: calls the Transfer Factory Registry API.
func (c *Client) resolveTransferFactory(ctx context.Context, req *transferFactoryRequest) error {
if req.InstrumentAdmin == c.cfg.IssuerParty {
cid, err := c.getTransferFactoryCID(ctx)
if err != nil {
return err
}
req.FactoryCID = cid
return nil
}

// External token — use registry
if c.registryClient == nil {
return fmt.Errorf("no registry client configured for external token transfers")
}
extCfg, ok := c.cfg.ExternalTokens[req.InstrumentAdmin]
if !ok {
return fmt.Errorf("unsupported external token issuer: %s", req.InstrumentAdmin)
}

regResp, err := c.registryClient.GetTransferFactory(ctx, extCfg.RegistryURL, &RegistryRequest{
ExpectedAdmin: req.InstrumentAdmin,
Transfer: RegistryTransferDetail{
Sender: req.FromPartyID,
Receiver: req.ToPartyID,
Amount: req.Amount,
InstrumentID: req.InstrumentID,
InputHoldingCIDs: req.InputHoldingCIDs,
},
})
if err != nil {
return fmt.Errorf("registry lookup for %s: %w", req.InstrumentAdmin, err)
}

req.FactoryCID = regResp.FactoryID
req.IsExternal = true

req.ChoiceContext, err = ConvertChoiceContext(regResp.ChoiceContext)
if err != nil {
return fmt.Errorf("convert choice context: %w", err)
}

req.DisclosedContracts, err = ConvertDisclosedContracts(regResp.DisclosedContracts, c.cfg.DomainID)
if err != nil {
return fmt.Errorf("convert disclosed contracts: %w", err)
}

return nil
}

func (c *Client) GetTransferFactory(ctx context.Context) (*TransferFactoryInfo, error) {
end, err := c.ledger.GetLedgerEnd(ctx)
if err != nil {
Expand Down Expand Up @@ -628,12 +692,13 @@ func (c *Client) prepareAndExecuteAsUser(ctx context.Context, commands *lapiv2.C
authCtx := c.ledger.AuthContext(ctx)

prepResp, err := c.ledger.Interactive().PrepareSubmission(authCtx, &interactivev2.PrepareSubmissionRequest{
UserId: commands.UserId,
CommandId: commands.CommandId,
Commands: commands.Commands,
ActAs: commands.ActAs,
ReadAs: commands.ReadAs,
SynchronizerId: commands.SynchronizerId,
UserId: commands.UserId,
CommandId: commands.CommandId,
Commands: commands.Commands,
ActAs: commands.ActAs,
ReadAs: commands.ReadAs,
SynchronizerId: commands.SynchronizerId,
DisclosedContracts: commands.DisclosedContracts,
})
if err != nil {
return fmt.Errorf("prepare submission: %w", err)
Expand Down Expand Up @@ -739,39 +804,45 @@ func (c *Client) PrepareTransfer(ctx context.Context, req *PrepareTransferReques
return nil, fmt.Errorf("select holdings for transfer: %w", err)
}

factoryCID, err := c.getTransferFactoryCID(ctx)
if err != nil {
return nil, err
}

factoryReq := &transferFactoryRequest{
FromPartyID: req.FromPartyID,
ToPartyID: req.ToPartyID,
Amount: req.Amount,
InstrumentAdmin: selected.InstrumentAdmin,
InstrumentID: selected.InstrumentID,
InputHoldingCIDs: selected.CIDs,
FactoryCID: factoryCID,
}

if resolveErr := c.resolveTransferFactory(ctx, factoryReq); resolveErr != nil {
return nil, resolveErr
}

cmd := c.buildTransferCommand(factoryReq)

readAs := []string{c.cfg.IssuerParty}
if factoryReq.IsExternal {
readAs = nil
}

commands := &lapiv2.Commands{
SynchronizerId: c.cfg.DomainID,
CommandId: uuid.NewString(),
UserId: c.cfg.UserID,
ActAs: []string{req.FromPartyID},
ReadAs: []string{c.cfg.IssuerParty},
Commands: []*lapiv2.Command{cmd},
SynchronizerId: c.cfg.DomainID,
CommandId: uuid.NewString(),
UserId: c.cfg.UserID,
ActAs: []string{req.FromPartyID},
ReadAs: readAs,
Commands: []*lapiv2.Command{cmd},
DisclosedContracts: factoryReq.DisclosedContracts,
}

authCtx := c.ledger.AuthContext(ctx)
prepResp, err := c.ledger.Interactive().PrepareSubmission(authCtx, &interactivev2.PrepareSubmissionRequest{
UserId: commands.UserId,
CommandId: commands.CommandId,
Commands: commands.Commands,
ActAs: commands.ActAs,
ReadAs: commands.ReadAs,
SynchronizerId: commands.SynchronizerId,
UserId: commands.UserId,
CommandId: commands.CommandId,
Commands: commands.Commands,
ActAs: commands.ActAs,
ReadAs: commands.ReadAs,
SynchronizerId: commands.SynchronizerId,
DisclosedContracts: commands.DisclosedContracts,
})
if err != nil {
return nil, fmt.Errorf("prepare submission: %w", err)
Expand Down
11 changes: 11 additions & 0 deletions pkg/cantonsdk/token/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ package token

import "errors"

// ExternalTokenConfig holds the registry endpoint for an external token issuer.
// Key in the map is the InstrumentAdmin party ID (e.g., Circle's Bridge-Operator).
type ExternalTokenConfig struct {
RegistryURL string `yaml:"registry_url" validate:"required"`
}

// Config contains the configuration required to initialize the token client.
type Config struct {
DomainID string `yaml:"domain_id"`
Expand All @@ -11,6 +17,11 @@ type Config struct {
CIP56PackageID string `yaml:"cip56_package_id" validate:"required"`
SpliceTransferPackageID string `yaml:"splice_transfer_package_id" validate:"required"`
SpliceHoldingPackageID string `yaml:"splice_holding_package_id" validate:"required"`

// ExternalTokens maps InstrumentAdmin party IDs to their registry configuration.
// Tokens whose InstrumentAdmin matches IssuerParty use local ACS-based factory discovery.
// Tokens whose InstrumentAdmin is in this map use the external registry API.
ExternalTokens map[string]ExternalTokenConfig `yaml:"external_tokens"`
}

func (c *Config) validate() error {
Expand Down
3 changes: 2 additions & 1 deletion pkg/cantonsdk/token/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func encodeTransferFactoryTransferArgs(
requestedAt time.Time,
executeBefore time.Time,
inputHoldingCIDs []string,
choiceContext map[string]string,
) *lapiv2.Record {
holdingCidValues := make([]*lapiv2.Value, len(inputHoldingCIDs))
for i, cid := range inputHoldingCIDs {
Expand Down Expand Up @@ -68,7 +69,7 @@ func encodeTransferFactoryTransferArgs(
Fields: []*lapiv2.RecordField{
{Label: "expectedAdmin", Value: values.PartyValue(expectedAdmin)},
{Label: "transfer", Value: transfer},
{Label: "extraArgs", Value: values.EncodeExtraArgs()},
{Label: "extraArgs", Value: values.EncodeExtraArgs(choiceContext)},
},
}
}
11 changes: 9 additions & 2 deletions pkg/cantonsdk/token/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ package token
import "go.uber.org/zap"

type settings struct {
logger *zap.Logger
keyResolver KeyResolver
logger *zap.Logger
keyResolver KeyResolver
registryClient *RegistryClient
}

// Option configures the token client.
Expand All @@ -21,6 +22,12 @@ func WithKeyResolver(kr KeyResolver) Option {
return func(s *settings) { s.keyResolver = kr }
}

// WithRegistryClient sets the HTTP client for external Transfer Factory Registry API calls.
// Required for transferring tokens issued by external parties (e.g., USDCx).
func WithRegistryClient(rc *RegistryClient) Option {
return func(s *settings) { s.registryClient = rc }
}

func applyOptions(opts []Option) settings {
s := settings{logger: zap.NewNop()}
for _, opt := range opts {
Expand Down
Loading
Loading