Skip to content
Open
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: 4 additions & 1 deletion pkg/app/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,10 @@ func initServices(
g.Go(func() error { return sub.Start(gCtx) })
}

transferSvc := transfer.NewTransferService(cantonClient.Token, userStore, instrumentedCache, cfg.Token, indexerClient)
transferSvc := transfer.NewTransferService(
cantonClient.Token, userStore, instrumentedCache, cfg.Token, indexerClient,
wl, cfg.Canton.IssuerParty,
)
return &services{
evmStore: evmStore,
tokenService: tokenService,
Expand Down
59 changes: 49 additions & 10 deletions pkg/transfer/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/chainsafe/canton-middleware/pkg/indexer"
pkgtoken "github.com/chainsafe/canton-middleware/pkg/token"
"github.com/chainsafe/canton-middleware/pkg/user"
"github.com/chainsafe/canton-middleware/pkg/user/whitelist"
)

//go:generate mockery --name UserStore --output mocks --outpkg mocks --filename mock_user_store.go --with-expecter
Expand Down Expand Up @@ -93,6 +94,8 @@ type TransferService struct {
userStore UserStore
cache TransferCache
offerLister IndexerReader
whitelist whitelist.Checker
issuerParty string
allowedTokenSymbols map[string]bool
tokensByInstrument map[instrumentKey]instrumentMeta
}
Expand All @@ -115,12 +118,16 @@ type instrumentMeta struct {
// instrument→EVM-contract mapping (used to enrich ListIncoming responses).
// offerLister is required — the api-server now wires every service to the same
// indexer client at startup, so ListIncoming relies on it being non-nil.
// wl is required; pass whitelist.New(store, true) to allow all. issuerParty is
// rejected as a transfer recipient.
func NewTransferService(
cantonToken token.Token,
userStore UserStore,
cache TransferCache,
tokenCfg *pkgtoken.Config,
offerLister IndexerReader,
wl whitelist.Checker,
issuerParty string,
) *TransferService {
allowed := map[string]bool{}
byInstrument := map[instrumentKey]instrumentMeta{}
Expand All @@ -144,6 +151,8 @@ func NewTransferService(
userStore: userStore,
cache: cache,
offerLister: offerLister,
whitelist: wl,
issuerParty: issuerParty,
allowedTokenSymbols: allowed,
tokensByInstrument: byInstrument,
}
Expand Down Expand Up @@ -209,11 +218,11 @@ func (s *TransferService) Prepare(ctx context.Context, senderEVMAddr string, req
return nil, fmt.Errorf("lookup recipient: %w", lookupErr)
}
toPartyID = recipient.CantonPartyID
} else if vErr := validatePartyID(toPartyID); vErr != nil {
return nil, apperrors.BadRequestError(vErr, "invalid recipient party id")
} else if wlErr := s.checkSenderWhitelisted(ctx, senderEVMAddr); wlErr != nil {
return nil, wlErr
}
if toPartyID == sender.CantonPartyID {
return nil, apperrors.BadRequestError(nil, "cannot transfer to self")
if err = s.validateRecipient(sender.CantonPartyID, toPartyID); err != nil {
return nil, err
}

pt, err := s.cantonToken.PrepareTransfer(ctx, &token.PrepareTransferRequest{
Expand Down Expand Up @@ -258,10 +267,6 @@ func (s *TransferService) SendCustodial(
return nil, err
}

if err = validatePartyID(req.ToPartyID); err != nil {
return nil, apperrors.BadRequestError(err, "invalid recipient party id")
}

sender, err := s.userStore.GetUserByEVMAddress(ctx, senderEVMAddr)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
Expand All @@ -272,8 +277,12 @@ func (s *TransferService) SendCustodial(
if sender.KeyMode != user.KeyModeCustodial {
return nil, apperrors.BadRequestError(nil, "this endpoint requires key_mode=custodial")
}
if req.ToPartyID == sender.CantonPartyID {
return nil, apperrors.BadRequestError(nil, "cannot transfer to self")

if err = s.checkSenderWhitelisted(ctx, senderEVMAddr); err != nil {
return nil, err
}
if err = s.validateRecipient(sender.CantonPartyID, req.ToPartyID); err != nil {
return nil, err
}

// The middleware signs server-side, so prepare+execute happen in one call.
Expand All @@ -291,6 +300,36 @@ func (s *TransferService) SendCustodial(
return &ExecuteResponse{Status: "submitted"}, nil
}

// checkSenderWhitelisted authorizes an outbound party-id transfer against the
// registration whitelist, mirroring the eth_sendRawTransaction gate.
func (s *TransferService) checkSenderWhitelisted(ctx context.Context, senderEVMAddr string) error {
whitelisted, err := s.whitelist.IsWhitelisted(ctx, senderEVMAddr)
Comment thread
sadiq1971 marked this conversation as resolved.
if err != nil {
return apperrors.DependencyError(err, "whitelist check")
}
if !whitelisted {
return apperrors.ForbiddenError(nil, "sender not whitelisted for transfers to a party id")
}
return nil
}

// validateRecipient is the shared recipient guard for both outbound handlers:
// syntactic party-id check, no self-transfers, and no transfers to the issuer
// party (also the bridge-operator party — both configured from
// canton.issuer_party), which would corrupt supply accounting.
func (s *TransferService) validateRecipient(senderPartyID, toPartyID string) error {
if err := validatePartyID(toPartyID); err != nil {
return apperrors.BadRequestError(err, "invalid recipient party id")
}
if toPartyID == senderPartyID {
return apperrors.BadRequestError(nil, "cannot transfer to self")
}
if toPartyID == s.issuerParty {
return apperrors.BadRequestError(nil, "cannot transfer to the issuer/bridge-operator party")
}
return nil
}

// validatePartyID does a lightweight syntactic check of a Canton party id, which
// has the form "<hint>::<fingerprint>" where the fingerprint is a hex-encoded
// multihash. It rejects obvious garbage (e.g. an EVM address pasted by mistake);
Expand Down
Loading
Loading