From ccf28e2f3126993b01591caa0969664c108aa974 Mon Sep 17 00:00:00 2001 From: sadiq1971 Date: Wed, 8 Jul 2026 15:56:26 +0600 Subject: [PATCH 1/4] feat: outbound transfer authorization & whitelist policy --- pkg/app/api/server.go | 6 +- pkg/transfer/service.go | 82 +++++++++-- pkg/transfer/service_test.go | 272 +++++++++++++++++++++++++++++++++-- 3 files changed, 340 insertions(+), 20 deletions(-) diff --git a/pkg/app/api/server.go b/pkg/app/api/server.go index ae71321..f68c13a 100644 --- a/pkg/app/api/server.go +++ b/pkg/app/api/server.go @@ -297,8 +297,12 @@ func initServices( g.Go(func() error { return sub.Start(gCtx) }) } + // cantonClient.Identity backs the recipient party-existence check; wl gates + // outbound party-id transfers (#318); cfg.Canton.IssuerParty doubles as the + // bridge-operator party and is rejected as a transfer recipient. transferSvc := transfer.NewTransferService( - cantonClient.Token, userStore, instrumentedCache, cfg.Token, indexerClient, cantonClient.Identity, + cantonClient.Token, userStore, instrumentedCache, cfg.Token, indexerClient, + cantonClient.Identity, wl, cfg.Canton.IssuerParty, ) return &services{ evmStore: evmStore, diff --git a/pkg/transfer/service.go b/pkg/transfer/service.go index 163a44a..c011e8a 100644 --- a/pkg/transfer/service.go +++ b/pkg/transfer/service.go @@ -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 @@ -97,12 +98,26 @@ type Service interface { } // TransferService implements the non-custodial prepare/execute transfer flow. +// +// Outbound authorization policy (#318): transfers addressed by raw party id can +// move value out of the system to arbitrary participant nodes, so they require +// the sender's EVM address to be whitelisted — the same gate that fronts inbound +// eth_sendRawTransaction. The gate applies to both key modes (non-custodial +// Prepare and custodial SendCustodial): custody only changes who signs, not +// whether value may leave. Transfers addressed by recipient EVM address are not +// gated, because the recipient is then a registered local user and value stays +// inside the system. Checking at transfer time (not just registration time, which +// is also whitelist-gated) means removing an address from the whitelist +// immediately revokes its outbound capability. Recipient guards live in +// validateRecipient. type TransferService struct { cantonToken token.Token userStore UserStore cache TransferCache offerLister IndexerReader partyRegistry PartyRegistry + whitelist whitelist.Checker + issuerParty string allowedTokenSymbols map[string]bool // externalTokenSymbols marks tokens transferable to parties not registered // with the middleware (hosted on external participant nodes), per token config. @@ -128,6 +143,11 @@ 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 gates outbound party-id transfers on the sender's EVM address (see the +// TransferService policy comment); pass whitelist.New(store, true) to allow all. +// issuerParty is the local issuer party id, which is also the bridge-operator +// party (client.propagateCommonConfig maps both from canton.issuer_party); it is +// rejected as a transfer recipient. func NewTransferService( cantonToken token.Token, userStore UserStore, @@ -135,6 +155,8 @@ func NewTransferService( tokenCfg *pkgtoken.Config, offerLister IndexerReader, partyRegistry PartyRegistry, + wl whitelist.Checker, + issuerParty string, ) *TransferService { allowed := map[string]bool{} external := map[string]bool{} @@ -163,6 +185,8 @@ func NewTransferService( cache: cache, offerLister: offerLister, partyRegistry: partyRegistry, + whitelist: wl, + issuerParty: issuerParty, allowedTokenSymbols: allowed, externalTokenSymbols: external, tokensByInstrument: byInstrument, @@ -229,11 +253,13 @@ 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 { + // Party-id addressing can reach external participants, so it is gated on + // the sender whitelist; EVM addressing (above) stays inside the system. + 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 } // A caller-supplied party id may point anywhere, so it gets the full // registration/topology check; a party resolved from a registered user's @@ -283,10 +309,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) { @@ -297,8 +319,14 @@ 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") + + // This endpoint is always party-id addressed, so the outbound whitelist gate + // applies unconditionally (see the TransferService policy comment). + if err = s.checkSenderWhitelisted(ctx, senderEVMAddr); err != nil { + return nil, err + } + if err = s.validateRecipient(sender.CantonPartyID, req.ToPartyID); err != nil { + return nil, err } if err = s.checkRecipientParty(ctx, req.Token, req.ToPartyID); err != nil { return nil, err @@ -351,6 +379,20 @@ func (s *TransferService) checkRecipientParty(ctx context.Context, tokenSymbol, return nil } +// checkSenderWhitelisted authorizes an outbound party-id transfer against the +// registration whitelist, mirroring the eth_sendRawTransaction gate. Returns +// 403 when the sender's EVM address is not whitelisted. +func (s *TransferService) checkSenderWhitelisted(ctx context.Context, senderEVMAddr string) error { + whitelisted, err := s.whitelist.IsWhitelisted(ctx, senderEVMAddr) + 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 +} + // mapTransferErr maps a transfer preparation/submission failure to an // HTTP-shaped error. Ledger rejections that indicate a bad request (unknown // party or contract, failed precondition) surface as 400s and contention as @@ -376,6 +418,26 @@ func mapTransferErr(err error, op string) error { return err } +// validateRecipient is the shared recipient guard for both outbound handlers +// (non-custodial Prepare and custodial SendCustodial). On top of the syntactic +// party-id check it rejects self-transfers and transfers to the issuer party — +// which is also the bridge-operator party, as both are configured from +// canton.issuer_party. Sending user funds to the party that operates the bridge +// escrow and mint/burn would corrupt supply accounting, so it is never a valid +// recipient. +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 "::" where the fingerprint is a hex-encoded // multihash. It rejects obvious garbage (e.g. an EVM address pasted by mistake); diff --git a/pkg/transfer/service_test.go b/pkg/transfer/service_test.go index db658df..3772e34 100644 --- a/pkg/transfer/service_test.go +++ b/pkg/transfer/service_test.go @@ -19,14 +19,19 @@ import ( "github.com/chainsafe/canton-middleware/pkg/indexer" "github.com/chainsafe/canton-middleware/pkg/transfer/mocks" "github.com/chainsafe/canton-middleware/pkg/user" + "github.com/chainsafe/canton-middleware/pkg/user/whitelist" + wlmocks "github.com/chainsafe/canton-middleware/pkg/user/whitelist/mocks" ) // --- helpers --- +// Fixture party ids use the real hint:: shape because every +// recipient — including ones resolved from the user store — now passes through +// the syntactic check in validateRecipient. func senderUser() *user.User { return &user.User{ EVMAddress: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - CantonPartyID: "party::sender", + CantonPartyID: "sender::1220aa01", KeyMode: user.KeyModeExternal, CantonPublicKeyFingerprint: "fingerprint-sender", } @@ -35,7 +40,7 @@ func senderUser() *user.User { func recipientUser() *user.User { return &user.User{ EVMAddress: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - CantonPartyID: "party::recipient", + CantonPartyID: "recipient::1220bb02", KeyMode: user.KeyModeExternal, } } @@ -48,6 +53,10 @@ func newTestService(t *testing.T, tok *mocks.Token, store *mocks.UserStore, cach return newTestServiceWithOffers(tok, store, cache, mocks.NewIndexerReader(t)) } +// testIssuerParty is the reserved issuer/bridge-operator party wired into the +// test service; recipient-guard tests transfer to it and expect rejection. +const testIssuerParty = "issuer::1220aaaa" + func newTestServiceWithOffers( tok *mocks.Token, store *mocks.UserStore, @@ -55,10 +64,14 @@ func newTestServiceWithOffers( offers *mocks.IndexerReader, ) *TransferService { return &TransferService{ - cantonToken: tok, - userStore: store, - cache: cache, - offerLister: offers, + cantonToken: tok, + userStore: store, + cache: cache, + offerLister: offers, + // Allow-all gate (skip mode) so tests not about authorization pass + // unchanged; whitelist-gate tests swap in a wlmocks.Checker. + whitelist: whitelist.New(nil, true), + issuerParty: testIssuerParty, allowedTokenSymbols: map[string]bool{"DEMO": true, "PROMPT": true, "USDCx": true}, // USDCx is the externally transferable token, mirroring the deploy // configs; DEMO/PROMPT are internal-only. Tests that exercise the @@ -529,9 +542,16 @@ func TestTransferService_SendCustodial_RequiresCustodial(t *testing.T) { } func TestTransferService_SendCustodial_InvalidPartyID(t *testing.T) { - // Party-id validation happens before any store lookup, so no user mock is needed. - svc := newTestService(t, mocks.NewToken(t), mocks.NewUserStore(t), mocks.NewTransferCache(t)) - _, err := svc.SendCustodial(context.Background(), custodialSender().EVMAddress, &CustodialTransferRequest{ + // Recipient validation is centralized in validateRecipient, which runs after + // the sender is authenticated, so the sender lookup is expected. + ctx := context.Background() + sender := custodialSender() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + + svc := newTestService(t, mocks.NewToken(t), store, mocks.NewTransferCache(t)) + _, err := svc.SendCustodial(ctx, sender.EVMAddress, &CustodialTransferRequest{ ToPartyID: "0xdeadbeef", // missing hint::fingerprint form Amount: "10", Token: "DEMO", @@ -637,6 +657,240 @@ func TestTransferService_SendCustodial_ExternalToken_ExternalParty_Success(t *te assert.Equal(t, "submitted", resp.Status) } +// --- outbound authorization & recipient-guard tests (#318) --- + +func TestTransferService_Prepare_ToPartyID_WhitelistedSenderAllowed(t *testing.T) { + ctx := context.Background() + sender := senderUser() + + // The recipient party resolves to a registered user, so an internal-only + // token (DEMO) is allowed once the sender clears the whitelist gate. + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(recipientUser(), nil).Once() + + wl := wlmocks.NewChecker(t) + wl.EXPECT().IsWhitelisted(ctx, sender.EVMAddress).Return(true, nil).Once() + + prepared := &token.PreparedTransfer{ + TransferID: "txn-wl-1", + TransactionHash: []byte{0x01}, + PartyID: sender.CantonPartyID, + ExpiresAt: time.Now().Add(2 * time.Minute), + } + + tok := mocks.NewToken(t) + tok.EXPECT().PrepareTransfer(ctx, mock.Anything).Return(prepared, nil).Once() + + cache := mocks.NewTransferCache(t) + cache.EXPECT().Put(prepared).Return(nil).Once() + + svc := newTestService(t, tok, store, cache) + svc.whitelist = wl + + resp, err := svc.Prepare(ctx, sender.EVMAddress, &PrepareRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "DEMO", + ValiditySeconds: 3600, + }) + require.NoError(t, err) + assert.Equal(t, "txn-wl-1", resp.TransferID) +} + +func TestTransferService_Prepare_ToPartyID_SenderNotWhitelisted(t *testing.T) { + ctx := context.Background() + sender := senderUser() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + + wl := wlmocks.NewChecker(t) + wl.EXPECT().IsWhitelisted(ctx, sender.EVMAddress).Return(false, nil).Once() + + svc := newTestService(t, mocks.NewToken(t), store, mocks.NewTransferCache(t)) + svc.whitelist = wl + + _, err := svc.Prepare(ctx, sender.EVMAddress, &PrepareRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "DEMO", + ValiditySeconds: 3600, + }) + assertServiceErrorCategory(t, err, apperrors.CategoryForbidden) +} + +func TestTransferService_Prepare_ToPartyID_WhitelistCheckError(t *testing.T) { + ctx := context.Background() + sender := senderUser() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + + wl := wlmocks.NewChecker(t) + wl.EXPECT().IsWhitelisted(ctx, sender.EVMAddress). + Return(false, grpcstatus.Error(codes.Unavailable, "db down")).Once() + + svc := newTestService(t, mocks.NewToken(t), store, mocks.NewTransferCache(t)) + svc.whitelist = wl + + _, err := svc.Prepare(ctx, sender.EVMAddress, &PrepareRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "DEMO", + ValiditySeconds: 3600, + }) + assertServiceErrorCategory(t, err, apperrors.CategoryDependencyFailure) +} + +func TestTransferService_Prepare_EVMRecipient_NotWhitelistGated(t *testing.T) { + // A transfer addressed by recipient EVM address stays between registered + // local users, so the whitelist gate must not fire: the Checker mock has + // no expectations and would fail the test if consulted. + ctx := context.Background() + sender := senderUser() + recipient := recipientUser() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByEVMAddress(ctx, recipient.EVMAddress).Return(recipient, nil).Once() + + prepared := &token.PreparedTransfer{ + TransferID: "txn-local-1", + TransactionHash: []byte{0x02}, + PartyID: sender.CantonPartyID, + ExpiresAt: time.Now().Add(2 * time.Minute), + } + + tok := mocks.NewToken(t) + tok.EXPECT().PrepareTransfer(ctx, mock.Anything).Return(prepared, nil).Once() + + cache := mocks.NewTransferCache(t) + cache.EXPECT().Put(prepared).Return(nil).Once() + + svc := newTestService(t, tok, store, cache) + svc.whitelist = wlmocks.NewChecker(t) + + _, err := svc.Prepare(ctx, sender.EVMAddress, &PrepareRequest{ + To: recipient.EVMAddress, + Amount: "10", + Token: "DEMO", + ValiditySeconds: 3600, + }) + require.NoError(t, err) +} + +func TestTransferService_Prepare_ToPartyID_IssuerRecipientRejected(t *testing.T) { + ctx := context.Background() + sender := senderUser() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + + svc := newTestService(t, mocks.NewToken(t), store, mocks.NewTransferCache(t)) + _, err := svc.Prepare(ctx, sender.EVMAddress, &PrepareRequest{ + ToPartyID: testIssuerParty, + Amount: "10", + Token: "DEMO", + ValiditySeconds: 3600, + }) + assertServiceErrorCategory(t, err, apperrors.CategoryDataError) +} + +func TestTransferService_SendCustodial_SenderNotWhitelisted(t *testing.T) { + ctx := context.Background() + sender := custodialSender() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + + wl := wlmocks.NewChecker(t) + wl.EXPECT().IsWhitelisted(ctx, sender.EVMAddress).Return(false, nil).Once() + + svc := newTestService(t, mocks.NewToken(t), store, mocks.NewTransferCache(t)) + svc.whitelist = wl + + _, err := svc.SendCustodial(ctx, sender.EVMAddress, &CustodialTransferRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "DEMO", + ValiditySeconds: 3600, + }) + assertServiceErrorCategory(t, err, apperrors.CategoryForbidden) +} + +func TestTransferService_SendCustodial_WhitelistedSenderAllowed(t *testing.T) { + ctx := context.Background() + sender := custodialSender() + + // The recipient party resolves to a registered user, so an internal-only + // token (DEMO) is allowed once the sender clears the whitelist gate. + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(recipientUser(), nil).Once() + + wl := wlmocks.NewChecker(t) + wl.EXPECT().IsWhitelisted(ctx, sender.EVMAddress).Return(true, nil).Once() + + tok := mocks.NewToken(t) + tok.EXPECT().TransferByPartyID(ctx, mock.Anything, sender.CantonPartyID, validExternalPartyID, "10", "DEMO", time.Hour). + Return(nil).Once() + + svc := newTestService(t, tok, store, mocks.NewTransferCache(t)) + svc.whitelist = wl + + resp, err := svc.SendCustodial(ctx, sender.EVMAddress, &CustodialTransferRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "DEMO", + ValiditySeconds: 3600, + }) + require.NoError(t, err) + assert.Equal(t, "submitted", resp.Status) +} + +func TestTransferService_SendCustodial_IssuerRecipientRejected(t *testing.T) { + ctx := context.Background() + sender := custodialSender() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + + svc := newTestService(t, mocks.NewToken(t), store, mocks.NewTransferCache(t)) + _, err := svc.SendCustodial(ctx, sender.EVMAddress, &CustodialTransferRequest{ + ToPartyID: testIssuerParty, + Amount: "10", + Token: "DEMO", + ValiditySeconds: 3600, + }) + assertServiceErrorCategory(t, err, apperrors.CategoryDataError) +} + +func TestValidateRecipient(t *testing.T) { + svc := &TransferService{issuerParty: testIssuerParty} + const senderParty = "sender::1220deadbeef" + + cases := []struct { + name string + toPartyID string + wantErr bool + }{ + {"external party allowed", validExternalPartyID, false}, + {"malformed party id", "not-a-party-id", true}, + {"self transfer", senderParty, true}, + {"issuer/bridge-operator party", testIssuerParty, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := svc.validateRecipient(senderParty, tc.toPartyID) + if tc.wantErr { + assertServiceErrorCategory(t, err, apperrors.CategoryDataError) + } else { + require.NoError(t, err) + } + }) + } +} func TestValidatePartyID(t *testing.T) { cases := []struct { name string From fd65b7c4e7173368c691d0a05b0b0829da08562f Mon Sep 17 00:00:00 2001 From: sadiq1971 Date: Wed, 8 Jul 2026 17:05:24 +0600 Subject: [PATCH 2/4] refactor: trim comments, require whitelist checker at construction --- pkg/app/api/server.go | 5 ++--- pkg/transfer/service.go | 41 ++++++++++++------------------------ pkg/transfer/service_test.go | 15 ++++--------- 3 files changed, 19 insertions(+), 42 deletions(-) diff --git a/pkg/app/api/server.go b/pkg/app/api/server.go index f68c13a..3af79a4 100644 --- a/pkg/app/api/server.go +++ b/pkg/app/api/server.go @@ -297,9 +297,8 @@ func initServices( g.Go(func() error { return sub.Start(gCtx) }) } - // cantonClient.Identity backs the recipient party-existence check; wl gates - // outbound party-id transfers (#318); cfg.Canton.IssuerParty doubles as the - // bridge-operator party and is rejected as a transfer recipient. + // cantonClient.Identity backs the recipient party check; wl gates outbound + // party-id transfers; cfg.Canton.IssuerParty is rejected as a recipient. transferSvc := transfer.NewTransferService( cantonClient.Token, userStore, instrumentedCache, cfg.Token, indexerClient, cantonClient.Identity, wl, cfg.Canton.IssuerParty, diff --git a/pkg/transfer/service.go b/pkg/transfer/service.go index c011e8a..14114f2 100644 --- a/pkg/transfer/service.go +++ b/pkg/transfer/service.go @@ -99,17 +99,10 @@ type Service interface { // TransferService implements the non-custodial prepare/execute transfer flow. // -// Outbound authorization policy (#318): transfers addressed by raw party id can -// move value out of the system to arbitrary participant nodes, so they require -// the sender's EVM address to be whitelisted — the same gate that fronts inbound -// eth_sendRawTransaction. The gate applies to both key modes (non-custodial -// Prepare and custodial SendCustodial): custody only changes who signs, not -// whether value may leave. Transfers addressed by recipient EVM address are not -// gated, because the recipient is then a registered local user and value stays -// inside the system. Checking at transfer time (not just registration time, which -// is also whitelist-gated) means removing an address from the whitelist -// immediately revokes its outbound capability. Recipient guards live in -// validateRecipient. +// Outbound policy (#318): party-id-addressed transfers can leave the system, so +// they require a whitelisted sender in both key modes (see checkSenderWhitelisted) +// and pass the recipient guards in validateRecipient. EVM-addressed transfers stay +// between registered users and are not gated. type TransferService struct { cantonToken token.Token userStore UserStore @@ -143,10 +136,7 @@ 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 gates outbound party-id transfers on the sender's EVM address (see the -// TransferService policy comment); pass whitelist.New(store, true) to allow all. -// issuerParty is the local issuer party id, which is also the bridge-operator -// party (client.propagateCommonConfig maps both from canton.issuer_party); it is +// wl is required; pass whitelist.New(store, true) to allow all. issuerParty is // rejected as a transfer recipient. func NewTransferService( cantonToken token.Token, @@ -158,6 +148,9 @@ func NewTransferService( wl whitelist.Checker, issuerParty string, ) *TransferService { + if wl == nil { + panic("transfer: whitelist checker is required (use whitelist.New to construct one)") + } allowed := map[string]bool{} external := map[string]bool{} byInstrument := map[instrumentKey]instrumentMeta{} @@ -254,8 +247,6 @@ func (s *TransferService) Prepare(ctx context.Context, senderEVMAddr string, req } toPartyID = recipient.CantonPartyID } else if wlErr := s.checkSenderWhitelisted(ctx, senderEVMAddr); wlErr != nil { - // Party-id addressing can reach external participants, so it is gated on - // the sender whitelist; EVM addressing (above) stays inside the system. return nil, wlErr } if err = s.validateRecipient(sender.CantonPartyID, toPartyID); err != nil { @@ -320,8 +311,6 @@ func (s *TransferService) SendCustodial( return nil, apperrors.BadRequestError(nil, "this endpoint requires key_mode=custodial") } - // This endpoint is always party-id addressed, so the outbound whitelist gate - // applies unconditionally (see the TransferService policy comment). if err = s.checkSenderWhitelisted(ctx, senderEVMAddr); err != nil { return nil, err } @@ -380,8 +369,7 @@ func (s *TransferService) checkRecipientParty(ctx context.Context, tokenSymbol, } // checkSenderWhitelisted authorizes an outbound party-id transfer against the -// registration whitelist, mirroring the eth_sendRawTransaction gate. Returns -// 403 when the sender's EVM address is not whitelisted. +// registration whitelist, mirroring the eth_sendRawTransaction gate. func (s *TransferService) checkSenderWhitelisted(ctx context.Context, senderEVMAddr string) error { whitelisted, err := s.whitelist.IsWhitelisted(ctx, senderEVMAddr) if err != nil { @@ -418,13 +406,10 @@ func mapTransferErr(err error, op string) error { return err } -// validateRecipient is the shared recipient guard for both outbound handlers -// (non-custodial Prepare and custodial SendCustodial). On top of the syntactic -// party-id check it rejects self-transfers and transfers to the issuer party — -// which is also the bridge-operator party, as both are configured from -// canton.issuer_party. Sending user funds to the party that operates the bridge -// escrow and mint/burn would corrupt supply accounting, so it is never a valid -// recipient. +// 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") diff --git a/pkg/transfer/service_test.go b/pkg/transfer/service_test.go index 3772e34..7b368d9 100644 --- a/pkg/transfer/service_test.go +++ b/pkg/transfer/service_test.go @@ -25,8 +25,7 @@ import ( // --- helpers --- -// Fixture party ids use the real hint:: shape because every -// recipient — including ones resolved from the user store — now passes through +// Fixture party ids use the real hint:: shape so they pass // the syntactic check in validateRecipient. func senderUser() *user.User { return &user.User{ @@ -53,8 +52,7 @@ func newTestService(t *testing.T, tok *mocks.Token, store *mocks.UserStore, cach return newTestServiceWithOffers(tok, store, cache, mocks.NewIndexerReader(t)) } -// testIssuerParty is the reserved issuer/bridge-operator party wired into the -// test service; recipient-guard tests transfer to it and expect rejection. +// testIssuerParty is the issuer/bridge-operator party wired into the test service. const testIssuerParty = "issuer::1220aaaa" func newTestServiceWithOffers( @@ -68,8 +66,7 @@ func newTestServiceWithOffers( userStore: store, cache: cache, offerLister: offers, - // Allow-all gate (skip mode) so tests not about authorization pass - // unchanged; whitelist-gate tests swap in a wlmocks.Checker. + // Allow-all gate; whitelist-gate tests swap in a wlmocks.Checker. whitelist: whitelist.New(nil, true), issuerParty: testIssuerParty, allowedTokenSymbols: map[string]bool{"DEMO": true, "PROMPT": true, "USDCx": true}, @@ -542,8 +539,6 @@ func TestTransferService_SendCustodial_RequiresCustodial(t *testing.T) { } func TestTransferService_SendCustodial_InvalidPartyID(t *testing.T) { - // Recipient validation is centralized in validateRecipient, which runs after - // the sender is authenticated, so the sender lookup is expected. ctx := context.Background() sender := custodialSender() @@ -744,9 +739,7 @@ func TestTransferService_Prepare_ToPartyID_WhitelistCheckError(t *testing.T) { } func TestTransferService_Prepare_EVMRecipient_NotWhitelistGated(t *testing.T) { - // A transfer addressed by recipient EVM address stays between registered - // local users, so the whitelist gate must not fire: the Checker mock has - // no expectations and would fail the test if consulted. + // The Checker mock has no expectations and fails the test if consulted. ctx := context.Background() sender := senderUser() recipient := recipientUser() From 4620c598c9c9d1e42f92fbb307f6625792092e09 Mon Sep 17 00:00:00 2001 From: sadiq1971 Date: Wed, 8 Jul 2026 17:12:39 +0600 Subject: [PATCH 3/4] chore: drop policy comment block --- pkg/transfer/service.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pkg/transfer/service.go b/pkg/transfer/service.go index 14114f2..44ce3b4 100644 --- a/pkg/transfer/service.go +++ b/pkg/transfer/service.go @@ -98,11 +98,6 @@ type Service interface { } // TransferService implements the non-custodial prepare/execute transfer flow. -// -// Outbound policy (#318): party-id-addressed transfers can leave the system, so -// they require a whitelisted sender in both key modes (see checkSenderWhitelisted) -// and pass the recipient guards in validateRecipient. EVM-addressed transfers stay -// between registered users and are not gated. type TransferService struct { cantonToken token.Token userStore UserStore From 144c3dd8fdeba3b75ab7c62ef2ca40bcfed8f6c6 Mon Sep 17 00:00:00 2001 From: sadiq1971 Date: Wed, 8 Jul 2026 17:24:12 +0600 Subject: [PATCH 4/4] fix: removed nil check --- pkg/transfer/service.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/transfer/service.go b/pkg/transfer/service.go index 44ce3b4..f59556d 100644 --- a/pkg/transfer/service.go +++ b/pkg/transfer/service.go @@ -143,9 +143,6 @@ func NewTransferService( wl whitelist.Checker, issuerParty string, ) *TransferService { - if wl == nil { - panic("transfer: whitelist checker is required (use whitelist.New to construct one)") - } allowed := map[string]bool{} external := map[string]bool{} byInstrument := map[instrumentKey]instrumentMeta{}