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"
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
242 changes: 177 additions & 65 deletions pkg/cantonsdk/token/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const (

spliceTransferModule = "Splice.Api.Token.TransferInstructionV1"
spliceTransferFactory = "TransferFactory"

spliceHoldingModule = "Splice.Api.Token.HoldingV1"
spliceHoldingEntity = "Holding"
)

// Token defines CIP-56 token operations.
Expand All @@ -51,10 +54,17 @@ type Token interface {
// 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 returns holdings for the owner and token symbol.
// Delegates to GetHoldingsByParty using the Splice HoldingV1 interface.
GetHoldings(ctx context.Context, ownerParty string, tokenSymbol string) ([]*Holding, error)

// GetAllHoldings GetHoldings returns all CIP56Holding contracts.
// GetHoldingsByParty queries all Splice HoldingV1 holdings visible to the given party,
// optionally filtered by instrumentID (empty string returns all instruments).
// This is the unified query path for all Splice-compliant tokens (CIP-56 and external).
GetHoldingsByParty(ctx context.Context, ownerParty, instrumentID string) ([]*Holding, error)

// GetAllHoldings returns all CIP56Holding contracts queried as IssuerParty.
// Used by the indexer and totalSupply — does NOT use the unified HoldingV1 path.
GetAllHoldings(ctx context.Context) ([]*Holding, error) // TODO: use pagination

// GetBalanceByFingerprint returns the owner's total balance (sum of holdings) for the token symbol.
Expand Down Expand Up @@ -91,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 @@ -113,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 @@ -259,21 +271,52 @@ func (c *Client) GetHoldings(ctx context.Context, ownerParty string, tokenSymbol
if tokenSymbol == "" {
return nil, fmt.Errorf("token symbol is required")
}
return c.GetHoldingsByParty(ctx, ownerParty, tokenSymbol)
}

allHoldings, err := c.GetAllHoldings(ctx)
// GetHoldingsByParty queries all Splice HoldingV1 holdings visible to the given party.
// This is the unified query path for all Splice-compliant tokens (CIP-56 and external like USDCx).
// If instrumentID is non-empty, results are filtered to that instrument.
// TODO: add unit tests with mocked ledger client (happy path, filtering, errors).
func (c *Client) GetHoldingsByParty(ctx context.Context, ownerParty, instrumentID string) ([]*Holding, error) {
if ownerParty == "" {
return nil, fmt.Errorf("owner party is required")
}

end, err := c.ledger.GetLedgerEnd(ctx)
if err != nil {
return nil, err
}
if end == 0 {
return []*Holding{}, nil
}

iid := &lapiv2.Identifier{
PackageId: c.cfg.SpliceHoldingPackageID,
ModuleName: spliceHoldingModule,
EntityName: spliceHoldingEntity,
}

validHoldings := make([]*Holding, 0)
for _, h := range allHoldings {
if h.Owner != ownerParty || h.Symbol != tokenSymbol {
// GetActiveContractsByInterface returns CreatedEvents with create_arguments populated
// (Required field per Canton Ledger API v2 proto), so decodeHolding works identically
// for both template-based and interface-based queries.
events, err := c.ledger.GetActiveContractsByInterface(ctx, end, []string{ownerParty}, iid)
if err != nil {
return nil, fmt.Errorf("query holdings by party: %w", err)
}

out := make([]*Holding, 0, len(events))
for _, ce := range events {
h := decodeHolding(ce)
Comment thread
salindne marked this conversation as resolved.
if h.Owner != ownerParty {
continue
}
if instrumentID != "" && h.InstrumentID != instrumentID {
continue
}
validHoldings = append(validHoldings, h)
out = append(out, h)
}

return validHoldings, nil
return out, nil
}

func (c *Client) GetAllHoldings(ctx context.Context) ([]*Holding, error) {
Expand Down Expand Up @@ -407,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 @@ -447,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 @@ -486,6 +538,7 @@ func (c *Client) buildTransferCommand(req *transferFactoryRequest) *lapiv2.Comma
now,
now.Add(defaultTransferValidity),
req.InputHoldingCIDs,
req.ChoiceContext,
),
},
},
Expand All @@ -502,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 @@ -587,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 @@ -698,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
Loading
Loading