diff --git a/config.e2e-local.yaml b/config.e2e-local.yaml index bfbd0d93..db3b5156 100644 --- a/config.e2e-local.yaml +++ b/config.e2e-local.yaml @@ -87,6 +87,7 @@ token: symbol: "USDCx" decimals: 6 instrument_id: "USDCx" + external_transfer: true # Ethereum JSON-RPC facade (MetaMask compatibility) eth_rpc: diff --git a/pkg/app/api/server.go b/pkg/app/api/server.go index a717017e..ae713215 100644 --- a/pkg/app/api/server.go +++ b/pkg/app/api/server.go @@ -297,7 +297,9 @@ 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, cantonClient.Identity, + ) return &services{ evmStore: evmStore, tokenService: tokenService, diff --git a/pkg/cantonsdk/identity/client.go b/pkg/cantonsdk/identity/client.go index 8ee6b22f..b6aaa388 100644 --- a/pkg/cantonsdk/identity/client.go +++ b/pkg/cantonsdk/identity/client.go @@ -34,6 +34,10 @@ type Identity interface { AllocateParty(ctx context.Context, hint string) (*Party, error) AllocateExternalParty(ctx context.Context, hint string, spkiPublicKey []byte, signer ExternalPartyKey) (*Party, error) ListParties(ctx context.Context) ([]*Party, error) // TODO: add iterator + // PartyExists reports whether the party id is known to the participant's + // topology (which includes parties hosted on other participants connected + // to the same synchronizer). + PartyExists(ctx context.Context, partyID string) (bool, error) GetParticipantID(ctx context.Context) (string, error) CreateFingerprintMapping(ctx context.Context, req CreateFingerprintMappingRequest) (*FingerprintMapping, error) @@ -250,6 +254,32 @@ func (c *Client) ListParties(ctx context.Context) ([]*Party, error) { return out, nil } +// PartyExists reports whether the party id is known to the participant's +// topology. GetParties omits unknown parties from the response rather than +// erroring, so existence is a match in the returned details. +func (c *Client) PartyExists(ctx context.Context, partyID string) (bool, error) { + authCtx := c.ledger.AuthContext(ctx) + + resp, err := c.ledger.PartyAdmin().GetParties(authCtx, &adminv2.GetPartiesRequest{ + Parties: []string{partyID}, + }) + if err != nil { + // A malformed party id string is rejected with InvalidArgument; report it + // as non-existent rather than a lookup failure. + if st, ok := status.FromError(err); ok && st.Code() == codes.InvalidArgument { + return false, nil + } + return false, fmt.Errorf("error getting party %s: %w", partyID, err) + } + + for _, p := range resp.PartyDetails { + if p.Party == partyID { + return true, nil + } + } + return false, nil +} + func (c *Client) GetParticipantID(ctx context.Context) (string, error) { authCtx := c.ledger.AuthContext(ctx) diff --git a/pkg/config/defaults/config.api-server.docker.yaml b/pkg/config/defaults/config.api-server.docker.yaml index b5f9dffa..12cfdb72 100644 --- a/pkg/config/defaults/config.api-server.docker.yaml +++ b/pkg/config/defaults/config.api-server.docker.yaml @@ -91,6 +91,7 @@ token: symbol: "USDCx" decimals: 6 instrument_id: "USDCx" + external_transfer: true # Ethereum JSON-RPC facade (MetaMask compatibility) eth_rpc: diff --git a/pkg/config/defaults/config.api-server.local-devnet.yaml b/pkg/config/defaults/config.api-server.local-devnet.yaml index 0512eaed..df011bb5 100644 --- a/pkg/config/defaults/config.api-server.local-devnet.yaml +++ b/pkg/config/defaults/config.api-server.local-devnet.yaml @@ -84,6 +84,7 @@ token: symbol: "USDCx" decimals: 6 instrument_id: "USDCx" + external_transfer: true # Ethereum JSON-RPC facade (MetaMask compatibility) eth_rpc: diff --git a/pkg/config/defaults/config.api-server.mainnet.yaml b/pkg/config/defaults/config.api-server.mainnet.yaml index f8413487..b136b248 100644 --- a/pkg/config/defaults/config.api-server.mainnet.yaml +++ b/pkg/config/defaults/config.api-server.mainnet.yaml @@ -67,6 +67,7 @@ token: symbol: "USDCx" decimals: 6 instrument_id: "USDCx" + external_transfer: true eth_rpc: enabled: true diff --git a/pkg/config/tests/minimal.api.yaml b/pkg/config/tests/minimal.api.yaml index ce877aa3..4b683742 100644 --- a/pkg/config/tests/minimal.api.yaml +++ b/pkg/config/tests/minimal.api.yaml @@ -36,6 +36,7 @@ token: symbol: "USDCx" decimals: 6 instrument_id: "USDCx" + external_transfer: true eth_rpc: chain_id: 31337 diff --git a/pkg/token/config.go b/pkg/token/config.go index 0abf0fc4..bbe3a6c9 100644 --- a/pkg/token/config.go +++ b/pkg/token/config.go @@ -14,6 +14,10 @@ type ERC20Token struct { Symbol string `yaml:"symbol" validate:"required"` Decimals int `yaml:"decimals" validate:"gte=0,lte=18"` InstrumentID string `yaml:"instrument_id" validate:"required"` + // ExternalTransfer marks a token that can be transferred to parties hosted on + // external participant nodes (e.g. USDCx). Tokens without it can only be + // transferred between parties registered with this middleware. + ExternalTransfer bool `yaml:"external_transfer"` } // Config holds token metadata indexed by contract address. diff --git a/pkg/transfer/mocks/mock_party_registry.go b/pkg/transfer/mocks/mock_party_registry.go new file mode 100644 index 00000000..8a59c299 --- /dev/null +++ b/pkg/transfer/mocks/mock_party_registry.go @@ -0,0 +1,93 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// PartyRegistry is an autogenerated mock type for the PartyRegistry type +type PartyRegistry struct { + mock.Mock +} + +type PartyRegistry_Expecter struct { + mock *mock.Mock +} + +func (_m *PartyRegistry) EXPECT() *PartyRegistry_Expecter { + return &PartyRegistry_Expecter{mock: &_m.Mock} +} + +// PartyExists provides a mock function with given fields: ctx, partyID +func (_m *PartyRegistry) PartyExists(ctx context.Context, partyID string) (bool, error) { + ret := _m.Called(ctx, partyID) + + if len(ret) == 0 { + panic("no return value specified for PartyExists") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok { + return rf(ctx, partyID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) bool); ok { + r0 = rf(ctx, partyID) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, partyID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PartyRegistry_PartyExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PartyExists' +type PartyRegistry_PartyExists_Call struct { + *mock.Call +} + +// PartyExists is a helper method to define mock.On call +// - ctx context.Context +// - partyID string +func (_e *PartyRegistry_Expecter) PartyExists(ctx interface{}, partyID interface{}) *PartyRegistry_PartyExists_Call { + return &PartyRegistry_PartyExists_Call{Call: _e.mock.On("PartyExists", ctx, partyID)} +} + +func (_c *PartyRegistry_PartyExists_Call) Run(run func(ctx context.Context, partyID string)) *PartyRegistry_PartyExists_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *PartyRegistry_PartyExists_Call) Return(_a0 bool, _a1 error) *PartyRegistry_PartyExists_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *PartyRegistry_PartyExists_Call) RunAndReturn(run func(context.Context, string) (bool, error)) *PartyRegistry_PartyExists_Call { + _c.Call.Return(run) + return _c +} + +// NewPartyRegistry creates a new instance of PartyRegistry. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPartyRegistry(t interface { + mock.TestingT + Cleanup(func()) +}) *PartyRegistry { + mock := &PartyRegistry{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/transfer/mocks/mock_user_store.go b/pkg/transfer/mocks/mock_user_store.go index 2e24a8c5..33cd184e 100644 --- a/pkg/transfer/mocks/mock_user_store.go +++ b/pkg/transfer/mocks/mock_user_store.go @@ -23,6 +23,65 @@ func (_m *UserStore) EXPECT() *UserStore_Expecter { return &UserStore_Expecter{mock: &_m.Mock} } +// GetUserByCantonPartyID provides a mock function with given fields: ctx, partyID +func (_m *UserStore) GetUserByCantonPartyID(ctx context.Context, partyID string) (*user.User, error) { + ret := _m.Called(ctx, partyID) + + if len(ret) == 0 { + panic("no return value specified for GetUserByCantonPartyID") + } + + var r0 *user.User + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*user.User, error)); ok { + return rf(ctx, partyID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *user.User); ok { + r0 = rf(ctx, partyID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*user.User) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, partyID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// UserStore_GetUserByCantonPartyID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserByCantonPartyID' +type UserStore_GetUserByCantonPartyID_Call struct { + *mock.Call +} + +// GetUserByCantonPartyID is a helper method to define mock.On call +// - ctx context.Context +// - partyID string +func (_e *UserStore_Expecter) GetUserByCantonPartyID(ctx interface{}, partyID interface{}) *UserStore_GetUserByCantonPartyID_Call { + return &UserStore_GetUserByCantonPartyID_Call{Call: _e.mock.On("GetUserByCantonPartyID", ctx, partyID)} +} + +func (_c *UserStore_GetUserByCantonPartyID_Call) Run(run func(ctx context.Context, partyID string)) *UserStore_GetUserByCantonPartyID_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *UserStore_GetUserByCantonPartyID_Call) Return(_a0 *user.User, _a1 error) *UserStore_GetUserByCantonPartyID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *UserStore_GetUserByCantonPartyID_Call) RunAndReturn(run func(context.Context, string) (*user.User, error)) *UserStore_GetUserByCantonPartyID_Call { + _c.Call.Return(run) + return _c +} + // GetUserByEVMAddress provides a mock function with given fields: ctx, evmAddress func (_m *UserStore) GetUserByEVMAddress(ctx context.Context, evmAddress string) (*user.User, error) { ret := _m.Called(ctx, evmAddress) diff --git a/pkg/transfer/service.go b/pkg/transfer/service.go index 9a8a72bc..163a44af 100644 --- a/pkg/transfer/service.go +++ b/pkg/transfer/service.go @@ -25,11 +25,20 @@ import ( //go:generate mockery --name UserStore --output mocks --outpkg mocks --filename mock_user_store.go --with-expecter //go:generate mockery --name TransferCache --output mocks --outpkg mocks --filename mock_transfer_cache.go --with-expecter //go:generate mockery --name IndexerReader --output mocks --outpkg mocks --filename mock_indexer_reader.go --with-expecter +//go:generate mockery --name PartyRegistry --output mocks --outpkg mocks --filename mock_party_registry.go --with-expecter //go:generate mockery --srcpkg github.com/chainsafe/canton-middleware/pkg/cantonsdk/token --name Token --output mocks --outpkg mocks --filename mock_canton_token.go --with-expecter // UserStore is the narrow interface for looking up users. type UserStore interface { GetUserByEVMAddress(ctx context.Context, evmAddress string) (*user.User, error) + GetUserByCantonPartyID(ctx context.Context, partyID string) (*user.User, error) +} + +// PartyRegistry checks whether a party id is known to the participant's +// topology. Implemented by the Canton identity client; used to reject +// external-token transfers to nonexistent parties before submission. +type PartyRegistry interface { + PartyExists(ctx context.Context, partyID string) (bool, error) } // TransferCache is the interface for caching prepared transfers. @@ -93,8 +102,12 @@ type TransferService struct { userStore UserStore cache TransferCache offerLister IndexerReader + partyRegistry PartyRegistry allowedTokenSymbols map[string]bool - tokensByInstrument map[instrumentKey]instrumentMeta + // externalTokenSymbols marks tokens transferable to parties not registered + // with the middleware (hosted on external participant nodes), per token config. + externalTokenSymbols map[string]bool + tokensByInstrument map[instrumentKey]instrumentMeta } // instrumentKey identifies a token by its on-ledger id. Admin is intentionally @@ -121,12 +134,17 @@ func NewTransferService( cache TransferCache, tokenCfg *pkgtoken.Config, offerLister IndexerReader, + partyRegistry PartyRegistry, ) *TransferService { allowed := map[string]bool{} + external := map[string]bool{} byInstrument := map[instrumentKey]instrumentMeta{} if tokenCfg != nil { for addr, tkn := range tokenCfg.SupportedTokens { allowed[tkn.Symbol] = true + if tkn.ExternalTransfer { + external[tkn.Symbol] = true + } // We don't know the InstrumentAdmin from token config alone, so the lookup key // uses only InstrumentID. The Canton ledger may report multiple admins for the // same id; in that case the first one wins, which matches the practical case @@ -140,12 +158,14 @@ func NewTransferService( } } return &TransferService{ - cantonToken: cantonToken, - userStore: userStore, - cache: cache, - offerLister: offerLister, - allowedTokenSymbols: allowed, - tokensByInstrument: byInstrument, + cantonToken: cantonToken, + userStore: userStore, + cache: cache, + offerLister: offerLister, + partyRegistry: partyRegistry, + allowedTokenSymbols: allowed, + externalTokenSymbols: external, + tokensByInstrument: byInstrument, } } @@ -215,6 +235,14 @@ func (s *TransferService) Prepare(ctx context.Context, senderEVMAddr string, req if toPartyID == sender.CantonPartyID { return nil, apperrors.BadRequestError(nil, "cannot transfer to self") } + // A caller-supplied party id may point anywhere, so it gets the full + // registration/topology check; a party resolved from a registered user's + // EVM address is trusted as-is. + if req.ToPartyID != "" { + if vErr := s.checkRecipientParty(ctx, req.Token, toPartyID); vErr != nil { + return nil, vErr + } + } pt, err := s.cantonToken.PrepareTransfer(ctx, &token.PrepareTransferRequest{ FromPartyID: sender.CantonPartyID, @@ -224,10 +252,7 @@ func (s *TransferService) Prepare(ctx context.Context, senderEVMAddr string, req Validity: validity, }) if err != nil { - if errors.Is(err, token.ErrInsufficientBalance) { - return nil, apperrors.BadRequestError(err, "insufficient balance") - } - return nil, fmt.Errorf("prepare transfer: %w", err) + return nil, mapTransferErr(err, "prepare transfer") } if err := s.cache.Put(pt); err != nil { @@ -275,6 +300,9 @@ func (s *TransferService) SendCustodial( if req.ToPartyID == sender.CantonPartyID { return nil, apperrors.BadRequestError(nil, "cannot transfer to self") } + if err = s.checkRecipientParty(ctx, req.Token, req.ToPartyID); err != nil { + return nil, err + } // The middleware signs server-side, so prepare+execute happen in one call. // A fresh idempotency key is used as the Canton command id per request. @@ -282,15 +310,72 @@ func (s *TransferService) SendCustodial( ctx, uuid.NewString(), sender.CantonPartyID, req.ToPartyID, req.Amount, req.Token, validity, ) if err != nil { - if errors.Is(err, token.ErrInsufficientBalance) { - return nil, apperrors.BadRequestError(err, "insufficient balance") - } - return nil, fmt.Errorf("transfer: %w", err) + return nil, mapTransferErr(err, "transfer") } return &ExecuteResponse{Status: "submitted"}, nil } +// checkRecipientParty validates a caller-supplied recipient party id beyond +// syntax (callers run validatePartyID first, before their cheaper checks). +// A party registered with this middleware may receive any supported token. +// An unregistered party may only receive tokens marked external_transfer +// in token config (e.g. USDCx), and must be known to the participant's topology +// so a bad party id fails here instead of surfacing as a ledger submission error. +func (s *TransferService) checkRecipientParty(ctx context.Context, tokenSymbol, toPartyID string) error { + _, err := s.userStore.GetUserByCantonPartyID(ctx, toPartyID) + if err == nil { + return nil + } + if !errors.Is(err, user.ErrUserNotFound) { + return fmt.Errorf("lookup recipient party: %w", err) + } + + if !s.externalTokenSymbols[tokenSymbol] { + return apperrors.BadRequestError(nil, fmt.Sprintf( + "recipient party is not registered with this service and %s does not support transfers to external parties", + tokenSymbol, + )) + } + + if s.partyRegistry == nil { + return apperrors.DependencyError(nil, "could not verify recipient party id") + } + known, err := s.partyRegistry.PartyExists(ctx, toPartyID) + if err != nil { + return apperrors.DependencyError(err, "could not verify recipient party id") + } + if !known { + return apperrors.BadRequestError(nil, "recipient party id is not known on the network") + } + 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 +// 409 rather than opaque 500s — a safety net for anything the pre-submission +// checks miss. +func mapTransferErr(err error, op string) error { + if errors.Is(err, token.ErrInsufficientBalance) { + return apperrors.BadRequestError(err, "insufficient balance") + } + // Wrap first so the op context ("prepare transfer" vs "transfer") survives + // into every branch's logged error; status.FromError unwraps to classify. + err = fmt.Errorf("%s: %w", op, err) + if st, ok := status.FromError(err); ok { + switch st.Code() { + case codes.InvalidArgument, codes.NotFound, codes.FailedPrecondition: + return apperrors.BadRequestError(err, "transfer rejected by the ledger: verify the recipient party id and amount") + case codes.Aborted: + return apperrors.ConflictError(err, "transfer conflicted with a concurrent operation, try again") + case codes.Unavailable, codes.DeadlineExceeded: + return apperrors.DependencyError(err, "ledger temporarily unavailable, try again later") + } + } + return err +} + // 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 ea88ff9d..db658dfa 100644 --- a/pkg/transfer/service_test.go +++ b/pkg/transfer/service_test.go @@ -4,6 +4,7 @@ package transfer import ( "context" + "fmt" "testing" "time" @@ -58,7 +59,11 @@ func newTestServiceWithOffers( userStore: store, cache: cache, offerLister: offers, - allowedTokenSymbols: map[string]bool{"DEMO": true, "PROMPT": true}, + 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 + // external path assign svc.partyRegistry themselves. + externalTokenSymbols: map[string]bool{"USDCx": true}, tokensByInstrument: map[instrumentKey]instrumentMeta{ {id: "DEMO"}: { contractAddress: "0x1111111111111111111111111111111111111111", @@ -203,10 +208,11 @@ func TestTransferService_Prepare_ToPartyID_Success(t *testing.T) { ctx := context.Background() sender := senderUser() - // Only the sender is looked up; the recipient party id is used directly, - // so there is no second GetUserByEVMAddress call. + // The recipient party id resolves to a registered user, so an internal-only + // token like DEMO may be sent to it. store := mocks.NewUserStore(t) store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(recipientUser(), nil).Once() prepared := &token.PreparedTransfer{ TransferID: "txn-ext-1", @@ -275,6 +281,167 @@ func TestTransferService_Prepare_ToPartyID_Self(t *testing.T) { assertServiceErrorCategory(t, err, apperrors.CategoryDataError) } +func TestTransferService_Prepare_InternalToken_UnregisteredParty_Rejected(t *testing.T) { + ctx := context.Background() + sender := senderUser() + + // DEMO is internal-only: a party id that is not a registered user is rejected + // before any Canton call. + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(nil, user.ErrUserNotFound).Once() + + svc := newTestService(t, mocks.NewToken(t), store, mocks.NewTransferCache(t)) + _, err := svc.Prepare(ctx, sender.EVMAddress, &PrepareRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "DEMO", + ValiditySeconds: 3600, + }) + assertServiceErrorCategory(t, err, apperrors.CategoryDataError) + assert.Contains(t, err.Error(), "does not support transfers to external parties") +} + +func TestTransferService_Prepare_ExternalToken_ExternalParty_Success(t *testing.T) { + ctx := context.Background() + sender := senderUser() + + // USDCx allows unregistered recipients as long as the party is known to the + // participant's topology. + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(nil, user.ErrUserNotFound).Once() + + registry := mocks.NewPartyRegistry(t) + registry.EXPECT().PartyExists(ctx, validExternalPartyID).Return(true, nil).Once() + + prepared := &token.PreparedTransfer{ + TransferID: "txn-usdcx-1", + TransactionHash: []byte{0xbe, 0xef}, + PartyID: sender.CantonPartyID, + ExpiresAt: time.Now().Add(2 * time.Minute), + } + tok := mocks.NewToken(t) + tok.EXPECT().PrepareTransfer(ctx, &token.PrepareTransferRequest{ + FromPartyID: sender.CantonPartyID, + ToPartyID: validExternalPartyID, + Amount: "10", + TokenSymbol: "USDCx", + Validity: time.Hour, + }).Return(prepared, nil).Once() + + cache := mocks.NewTransferCache(t) + cache.EXPECT().Put(prepared).Return(nil).Once() + + svc := newTestService(t, tok, store, cache) + svc.partyRegistry = registry + resp, err := svc.Prepare(ctx, sender.EVMAddress, &PrepareRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "USDCx", + ValiditySeconds: 3600, + }) + + require.NoError(t, err) + assert.Equal(t, "txn-usdcx-1", resp.TransferID) +} + +func TestTransferService_Prepare_ExternalToken_UnknownParty_Rejected(t *testing.T) { + ctx := context.Background() + sender := senderUser() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(nil, user.ErrUserNotFound).Once() + + registry := mocks.NewPartyRegistry(t) + registry.EXPECT().PartyExists(ctx, validExternalPartyID).Return(false, nil).Once() + + svc := newTestService(t, mocks.NewToken(t), store, mocks.NewTransferCache(t)) + svc.partyRegistry = registry + _, err := svc.Prepare(ctx, sender.EVMAddress, &PrepareRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "USDCx", + ValiditySeconds: 3600, + }) + assertServiceErrorCategory(t, err, apperrors.CategoryDataError) + assert.Contains(t, err.Error(), "not known on the network") +} + +func TestTransferService_Prepare_ExternalToken_PartyLookupFailure(t *testing.T) { + ctx := context.Background() + sender := senderUser() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(nil, user.ErrUserNotFound).Once() + + registry := mocks.NewPartyRegistry(t) + registry.EXPECT().PartyExists(ctx, validExternalPartyID). + Return(false, grpcstatus.Error(codes.Unavailable, "participant down")).Once() + + svc := newTestService(t, mocks.NewToken(t), store, mocks.NewTransferCache(t)) + svc.partyRegistry = registry + _, err := svc.Prepare(ctx, sender.EVMAddress, &PrepareRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "USDCx", + ValiditySeconds: 3600, + }) + assertServiceErrorCategory(t, err, apperrors.CategoryDependencyFailure) +} + +func TestTransferService_Prepare_LedgerRejection_NotInternalError(t *testing.T) { + ctx := context.Background() + sender := senderUser() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(recipientUser(), nil).Once() + + // A ledger NOT_FOUND (e.g. party disappeared between validation and + // submission) must map to a 400-shaped error, not a 500 — including when + // the SDK has wrapped the gRPC error (status.FromError unwraps via + // errors.As since grpc-go v1.54). + wrapped := fmt.Errorf("submit transfer: %w", grpcstatus.Error(codes.NotFound, "unknown informee party")) + tok := mocks.NewToken(t) + tok.EXPECT().PrepareTransfer(ctx, mock.Anything).Return(nil, wrapped).Once() + + svc := newTestService(t, tok, store, mocks.NewTransferCache(t)) + _, err := svc.Prepare(ctx, sender.EVMAddress, &PrepareRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "DEMO", + ValiditySeconds: 3600, + }) + assertServiceErrorCategory(t, err, apperrors.CategoryDataError) +} + +func TestTransferService_Prepare_LedgerContention_MapsToConflict(t *testing.T) { + ctx := context.Background() + sender := senderUser() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(recipientUser(), nil).Once() + + // Canton contention (e.g. LOCKED_CONTRACTS when two transfers race for the + // same holdings) surfaces as ABORTED and must map to 409, not 500. + tok := mocks.NewToken(t) + tok.EXPECT().PrepareTransfer(ctx, mock.Anything). + Return(nil, grpcstatus.Error(codes.Aborted, "contracts are locked")).Once() + + svc := newTestService(t, tok, store, mocks.NewTransferCache(t)) + _, err := svc.Prepare(ctx, sender.EVMAddress, &PrepareRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "DEMO", + ValiditySeconds: 3600, + }) + assertServiceErrorCategory(t, err, apperrors.CategoryDataConflict) +} + func TestTransferService_Prepare_BothRecipientForms_Rejected(t *testing.T) { ctx := context.Background() sender := senderUser() @@ -325,6 +492,7 @@ func TestTransferService_SendCustodial_Success(t *testing.T) { store := mocks.NewUserStore(t) store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(recipientUser(), nil).Once() tok := mocks.NewToken(t) // idempotencyKey is a freshly generated UUID, so match any string there. @@ -406,6 +574,7 @@ func TestTransferService_SendCustodial_InsufficientBalance(t *testing.T) { store := mocks.NewUserStore(t) store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(recipientUser(), nil).Once() tok := mocks.NewToken(t) tok.EXPECT().TransferByPartyID(ctx, mock.Anything, sender.CantonPartyID, validExternalPartyID, "10", "DEMO", time.Hour). @@ -421,6 +590,53 @@ func TestTransferService_SendCustodial_InsufficientBalance(t *testing.T) { assertServiceErrorCategory(t, err, apperrors.CategoryDataError) } +func TestTransferService_SendCustodial_InternalToken_UnregisteredParty_Rejected(t *testing.T) { + ctx := context.Background() + sender := custodialSender() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(nil, user.ErrUserNotFound).Once() + + svc := newTestService(t, mocks.NewToken(t), store, mocks.NewTransferCache(t)) + _, err := svc.SendCustodial(ctx, sender.EVMAddress, &CustodialTransferRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "PROMPT", + ValiditySeconds: 3600, + }) + assertServiceErrorCategory(t, err, apperrors.CategoryDataError) + assert.Contains(t, err.Error(), "does not support transfers to external parties") +} + +func TestTransferService_SendCustodial_ExternalToken_ExternalParty_Success(t *testing.T) { + ctx := context.Background() + sender := custodialSender() + + store := mocks.NewUserStore(t) + store.EXPECT().GetUserByEVMAddress(ctx, sender.EVMAddress).Return(sender, nil).Once() + store.EXPECT().GetUserByCantonPartyID(ctx, validExternalPartyID).Return(nil, user.ErrUserNotFound).Once() + + registry := mocks.NewPartyRegistry(t) + registry.EXPECT().PartyExists(ctx, validExternalPartyID).Return(true, nil).Once() + + tok := mocks.NewToken(t) + tok.EXPECT().TransferByPartyID(ctx, mock.Anything, sender.CantonPartyID, validExternalPartyID, "10", "USDCx", time.Hour). + Return(nil).Once() + + svc := newTestService(t, tok, store, mocks.NewTransferCache(t)) + svc.partyRegistry = registry + resp, err := svc.SendCustodial(ctx, sender.EVMAddress, &CustodialTransferRequest{ + ToPartyID: validExternalPartyID, + Amount: "10", + Token: "USDCx", + ValiditySeconds: 3600, + }) + + require.NoError(t, err) + assert.Equal(t, "submitted", resp.Status) +} + func TestValidatePartyID(t *testing.T) { cases := []struct { name string