diff --git a/pkg/app/api/server.go b/pkg/app/api/server.go index ae71321..3af79a4 100644 --- a/pkg/app/api/server.go +++ b/pkg/app/api/server.go @@ -297,8 +297,11 @@ func initServices( g.Go(func() error { return sub.Start(gCtx) }) } + // 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, + 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..f59556d 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 @@ -103,6 +104,8 @@ type TransferService struct { 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 +131,8 @@ 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, @@ -135,6 +140,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 +170,8 @@ func NewTransferService( cache: cache, offerLister: offerLister, partyRegistry: partyRegistry, + whitelist: wl, + issuerParty: issuerParty, allowedTokenSymbols: allowed, externalTokenSymbols: external, tokensByInstrument: byInstrument, @@ -229,11 +238,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 } // 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 +292,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 +302,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 } if err = s.checkRecipientParty(ctx, req.Token, req.ToPartyID); err != nil { return nil, err @@ -351,6 +360,19 @@ 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. +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 +398,23 @@ func mapTransferErr(err error, op string) error { return err } +// 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 "::" 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..7b368d9 100644 --- a/pkg/transfer/service_test.go +++ b/pkg/transfer/service_test.go @@ -19,14 +19,18 @@ 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 so they pass +// 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 +39,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 +52,9 @@ func newTestService(t *testing.T, tok *mocks.Token, store *mocks.UserStore, cach return newTestServiceWithOffers(tok, store, cache, mocks.NewIndexerReader(t)) } +// testIssuerParty is the issuer/bridge-operator party wired into the test service. +const testIssuerParty = "issuer::1220aaaa" + func newTestServiceWithOffers( tok *mocks.Token, store *mocks.UserStore, @@ -55,10 +62,13 @@ 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; 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 +539,14 @@ 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{ + 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 +652,238 @@ 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) { + // The Checker mock has no expectations and fails 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