package gateway import ( "context" "fmt" "math/big" "sort" "strings" "sync" "testing" "time" "github.com/stretchr/testify/require" ichainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "github.com/tech/sendico/gateway/chain/internal/keymanager" "github.com/tech/sendico/gateway/chain/internal/service/gateway/drivers" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/storage" "github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/pkg/merrors" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" "github.com/ethereum/go-ethereum/core/types" ) const ( walletDefaultLimit int64 = 50 walletMaxLimit int64 = 200 transferDefaultLimit int64 = 50 transferMaxLimit int64 = 200 depositDefaultLimit int64 = 100 depositMaxLimit int64 = 500 ) func TestCreateManagedWallet_Idempotent(t *testing.T) { svc, repo := newTestService(t) ctx := context.Background() req := &ichainv1.CreateManagedWalletRequest{ IdempotencyKey: "idem-1", OrganizationRef: "org-1", OwnerRef: "owner-1", Asset: &ichainv1.Asset{ Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, TokenSymbol: "USDC", }, } resp, err := svc.CreateManagedWallet(ctx, req) require.NoError(t, err) require.NotNil(t, resp.GetWallet()) firstRef := resp.GetWallet().GetWalletRef() require.NotEmpty(t, firstRef) resp2, err := svc.CreateManagedWallet(ctx, req) require.NoError(t, err) require.Equal(t, firstRef, resp2.GetWallet().GetWalletRef()) // ensure stored only once require.Equal(t, 1, repo.wallets.count()) } func TestCreateManagedWallet_NativeTokenWithoutContract(t *testing.T) { svc, _ := newTestService(t) ctx := context.Background() resp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{ IdempotencyKey: "idem-native", OrganizationRef: "org-1", OwnerRef: "owner-1", Asset: &ichainv1.Asset{ Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, TokenSymbol: "ETH", }, }) require.NoError(t, err) require.NotNil(t, resp.GetWallet()) require.Equal(t, "ETH", resp.GetWallet().GetAsset().GetTokenSymbol()) require.Empty(t, resp.GetWallet().GetAsset().GetContractAddress()) } func TestSubmitTransfer_ManagedDestination(t *testing.T) { svc, repo := newTestService(t) ctx := context.Background() // create source wallet srcResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{ IdempotencyKey: "idem-src", OrganizationRef: "org-1", OwnerRef: "owner-1", Asset: &ichainv1.Asset{ Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, TokenSymbol: "USDC", }, }) require.NoError(t, err) srcRef := srcResp.GetWallet().GetWalletRef() // destination wallet dstResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{ IdempotencyKey: "idem-dst", OrganizationRef: "org-1", OwnerRef: "owner-2", Asset: &ichainv1.Asset{ Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, TokenSymbol: "USDC", }, }) require.NoError(t, err) dstRef := dstResp.GetWallet().GetWalletRef() transferResp, err := svc.SubmitTransfer(ctx, &ichainv1.SubmitTransferRequest{ IdempotencyKey: "transfer-1", OrganizationRef: "org-1", SourceWalletRef: srcRef, Destination: &ichainv1.TransferDestination{ Destination: &ichainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: dstRef}, }, Amount: &moneyv1.Money{Currency: "USDC", Amount: "100"}, Fees: []*ichainv1.ServiceFeeBreakdown{ { FeeCode: "service", Amount: &moneyv1.Money{Currency: "USDC", Amount: "5"}, }, }, }) require.NoError(t, err) require.NotNil(t, transferResp.GetTransfer()) require.Equal(t, "95", transferResp.GetTransfer().GetNetAmount().GetAmount()) stored := repo.transfers.get(transferResp.GetTransfer().GetTransferRef()) require.NotNil(t, stored) require.Equal(t, model.TransferStatusPending, stored.Status) // GetTransfer getResp, err := svc.GetTransfer(ctx, &ichainv1.GetTransferRequest{TransferRef: stored.TransferRef}) require.NoError(t, err) require.Equal(t, stored.TransferRef, getResp.GetTransfer().GetTransferRef()) // ListTransfers listResp, err := svc.ListTransfers(ctx, &ichainv1.ListTransfersRequest{ SourceWalletRef: srcRef, Page: &paginationv1.CursorPageRequest{Limit: 10}, }) require.NoError(t, err) require.Len(t, listResp.GetTransfers(), 1) require.Equal(t, stored.TransferRef, listResp.GetTransfers()[0].GetTransferRef()) } func TestGetWalletBalance_NotFound(t *testing.T) { svc, _ := newTestService(t) ctx := context.Background() _, err := svc.GetWalletBalance(ctx, &ichainv1.GetWalletBalanceRequest{WalletRef: "missing"}) require.Error(t, err) st, _ := status.FromError(err) require.Equal(t, codes.NotFound, st.Code()) } func TestGetWalletBalance_ReturnsCachedNativeAvailable(t *testing.T) { svc, repo := newTestService(t) ctx := context.Background() createResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{ IdempotencyKey: "idem-balance", OrganizationRef: "org-1", OwnerRef: "owner-1", Asset: &ichainv1.Asset{ Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, TokenSymbol: "USDC", }, }) require.NoError(t, err) walletRef := createResp.GetWallet().GetWalletRef() err = repo.wallets.SaveBalance(ctx, &model.WalletBalance{ WalletRef: walletRef, Available: &moneyv1.Money{Currency: "USDC", Amount: "25"}, NativeAvailable: &moneyv1.Money{Currency: "ETH", Amount: "0.5"}, CalculatedAt: time.Now().UTC(), }) require.NoError(t, err) resp, err := svc.GetWalletBalance(ctx, &ichainv1.GetWalletBalanceRequest{WalletRef: walletRef}) require.NoError(t, err) require.NotNil(t, resp.GetBalance()) require.Equal(t, "0.5", resp.GetBalance().GetNativeAvailable().GetAmount()) require.Equal(t, "ETH", resp.GetBalance().GetNativeAvailable().GetCurrency()) } // ---- in-memory storage implementation ---- type inMemoryRepository struct { wallets *inMemoryWallets transfers *inMemoryTransfers deposits *inMemoryDeposits } func newInMemoryRepository() *inMemoryRepository { return &inMemoryRepository{ wallets: newInMemoryWallets(), transfers: newInMemoryTransfers(), deposits: newInMemoryDeposits(), } } func (r *inMemoryRepository) Ping(context.Context) error { return nil } func (r *inMemoryRepository) Wallets() storage.WalletsStore { return r.wallets } func (r *inMemoryRepository) Transfers() storage.TransfersStore { return r.transfers } func (r *inMemoryRepository) Deposits() storage.DepositsStore { return r.deposits } // Wallets store type inMemoryWallets struct { mu sync.Mutex wallets map[string]*model.ManagedWallet byIdemp map[string]string balances map[string]*model.WalletBalance } func newInMemoryWallets() *inMemoryWallets { return &inMemoryWallets{ wallets: make(map[string]*model.ManagedWallet), byIdemp: make(map[string]string), balances: make(map[string]*model.WalletBalance), } } func (w *inMemoryWallets) count() int { w.mu.Lock() defer w.mu.Unlock() return len(w.wallets) } func (w *inMemoryWallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*model.ManagedWallet, error) { w.mu.Lock() defer w.mu.Unlock() if wallet == nil { return nil, merrors.InvalidArgument("walletsStore: nil wallet") } wallet.Normalize() if wallet.IdempotencyKey == "" { return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey") } if existingRef, ok := w.byIdemp[wallet.IdempotencyKey]; ok { existing := w.wallets[existingRef] return existing, merrors.ErrDataConflict } if wallet.WalletRef == "" { wallet.WalletRef = primitive.NewObjectID().Hex() } if wallet.GetID() == nil || wallet.GetID().IsZero() { wallet.SetID(primitive.NewObjectID()) } else { wallet.Update() } w.wallets[wallet.WalletRef] = wallet w.byIdemp[wallet.IdempotencyKey] = wallet.WalletRef return wallet, nil } func (w *inMemoryWallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) { w.mu.Lock() defer w.mu.Unlock() wallet, ok := w.wallets[strings.TrimSpace(walletRef)] if !ok { return nil, merrors.NoData("wallet not found") } return wallet, nil } func (w *inMemoryWallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) { w.mu.Lock() defer w.mu.Unlock() items := make([]*model.ManagedWallet, 0, len(w.wallets)) for _, wallet := range w.wallets { if filter.OrganizationRef != "" && !strings.EqualFold(wallet.OrganizationRef, filter.OrganizationRef) { continue } if filter.OwnerRef != "" && !strings.EqualFold(wallet.OwnerRef, filter.OwnerRef) { continue } if filter.Network != "" && !strings.EqualFold(wallet.Network, filter.Network) { continue } if filter.TokenSymbol != "" && !strings.EqualFold(wallet.TokenSymbol, filter.TokenSymbol) { continue } items = append(items, wallet) } sort.Slice(items, func(i, j int) bool { return items[i].ID.Timestamp().Before(items[j].ID.Timestamp()) }) startIndex := 0 if cursor := strings.TrimSpace(filter.Cursor); cursor != "" { if oid, err := primitive.ObjectIDFromHex(cursor); err == nil { for idx, item := range items { if item.ID.Timestamp().After(oid.Timestamp()) { startIndex = idx break } } } } limit := int(sanitizeLimit(filter.Limit, walletDefaultLimit, walletMaxLimit)) end := startIndex + limit hasMore := false if end < len(items) { hasMore = true items = items[startIndex:end] } else { items = items[startIndex:] } nextCursor := "" if hasMore && len(items) > 0 { nextCursor = items[len(items)-1].ID.Hex() } return &model.ManagedWalletList{Items: items, NextCursor: nextCursor}, nil } func (w *inMemoryWallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error { w.mu.Lock() defer w.mu.Unlock() if balance == nil { return merrors.InvalidArgument("walletsStore: nil balance") } balance.Normalize() if balance.WalletRef == "" { return merrors.InvalidArgument("walletsStore: empty walletRef for balance") } if balance.CalculatedAt.IsZero() { balance.CalculatedAt = time.Now().UTC() } existing, ok := w.balances[balance.WalletRef] if !ok { if balance.GetID() == nil || balance.GetID().IsZero() { balance.SetID(primitive.NewObjectID()) } w.balances[balance.WalletRef] = balance return nil } existing.Available = balance.Available existing.PendingInbound = balance.PendingInbound existing.PendingOutbound = balance.PendingOutbound existing.CalculatedAt = balance.CalculatedAt existing.Update() return nil } func (w *inMemoryWallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) { w.mu.Lock() defer w.mu.Unlock() balance, ok := w.balances[strings.TrimSpace(walletRef)] if !ok { return nil, merrors.NoData("wallet balance not found") } return balance, nil } // Transfers store type inMemoryTransfers struct { mu sync.Mutex items map[string]*model.Transfer byIdemp map[string]string } func newInMemoryTransfers() *inMemoryTransfers { return &inMemoryTransfers{ items: make(map[string]*model.Transfer), byIdemp: make(map[string]string), } } func (t *inMemoryTransfers) Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) { t.mu.Lock() defer t.mu.Unlock() if transfer == nil { return nil, merrors.InvalidArgument("transfersStore: nil transfer") } transfer.Normalize() if transfer.IdempotencyKey == "" { return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey") } if ref, ok := t.byIdemp[transfer.IdempotencyKey]; ok { return t.items[ref], merrors.ErrDataConflict } if transfer.TransferRef == "" { transfer.TransferRef = primitive.NewObjectID().Hex() } if transfer.GetID() == nil || transfer.GetID().IsZero() { transfer.SetID(primitive.NewObjectID()) } else { transfer.Update() } t.items[transfer.TransferRef] = transfer t.byIdemp[transfer.IdempotencyKey] = transfer.TransferRef return transfer, nil } func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model.Transfer, error) { t.mu.Lock() defer t.mu.Unlock() transfer, ok := t.items[strings.TrimSpace(transferRef)] if !ok { return nil, merrors.NoData("transfer not found") } return transfer, nil } func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) { t.mu.Lock() defer t.mu.Unlock() items := make([]*model.Transfer, 0, len(t.items)) for _, transfer := range t.items { if filter.SourceWalletRef != "" && !strings.EqualFold(transfer.SourceWalletRef, filter.SourceWalletRef) { continue } if filter.DestinationWalletRef != "" && !strings.EqualFold(transfer.Destination.ManagedWalletRef, filter.DestinationWalletRef) { continue } if filter.Status != "" && transfer.Status != filter.Status { continue } items = append(items, transfer) } sort.Slice(items, func(i, j int) bool { return items[i].ID.Timestamp().Before(items[j].ID.Timestamp()) }) start := 0 if cursor := strings.TrimSpace(filter.Cursor); cursor != "" { if oid, err := primitive.ObjectIDFromHex(cursor); err == nil { for idx, item := range items { if item.ID.Timestamp().After(oid.Timestamp()) { start = idx break } } } } limit := int(sanitizeLimit(filter.Limit, transferDefaultLimit, transferMaxLimit)) end := start + limit hasMore := false if end < len(items) { hasMore = true items = items[start:end] } else { items = items[start:] } nextCursor := "" if hasMore && len(items) > 0 { nextCursor = items[len(items)-1].ID.Hex() } return &model.TransferList{Items: items, NextCursor: nextCursor}, nil } func (t *inMemoryTransfers) UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error) { t.mu.Lock() defer t.mu.Unlock() transfer, ok := t.items[strings.TrimSpace(transferRef)] if !ok { return nil, merrors.NoData("transfer not found") } transfer.Status = status if status == model.TransferStatusFailed { transfer.FailureReason = strings.TrimSpace(failureReason) } else { transfer.FailureReason = "" } transfer.TxHash = strings.TrimSpace(txHash) transfer.LastStatusAt = time.Now().UTC() transfer.Update() return transfer, nil } // helper for tests func (t *inMemoryTransfers) get(ref string) *model.Transfer { t.mu.Lock() defer t.mu.Unlock() return t.items[ref] } // Deposits store (minimal for tests) type inMemoryDeposits struct { mu sync.Mutex items map[string]*model.Deposit } func newInMemoryDeposits() *inMemoryDeposits { return &inMemoryDeposits{items: make(map[string]*model.Deposit)} } func (d *inMemoryDeposits) Record(ctx context.Context, deposit *model.Deposit) error { d.mu.Lock() defer d.mu.Unlock() if deposit == nil { return merrors.InvalidArgument("depositsStore: nil deposit") } deposit.Normalize() if deposit.DepositRef == "" { return merrors.InvalidArgument("depositsStore: empty depositRef") } if existing, ok := d.items[deposit.DepositRef]; ok { existing.Status = deposit.Status existing.LastStatusAt = time.Now().UTC() existing.Update() return nil } if deposit.GetID() == nil || deposit.GetID().IsZero() { deposit.SetID(primitive.NewObjectID()) } if deposit.ObservedAt.IsZero() { deposit.ObservedAt = time.Now().UTC() } if deposit.RecordedAt.IsZero() { deposit.RecordedAt = time.Now().UTC() } deposit.LastStatusAt = time.Now().UTC() d.items[deposit.DepositRef] = deposit return nil } func (d *inMemoryDeposits) ListPending(ctx context.Context, network string, limit int32) ([]*model.Deposit, error) { d.mu.Lock() defer d.mu.Unlock() results := make([]*model.Deposit, 0) for _, deposit := range d.items { if deposit.Status != model.DepositStatusPending { continue } if network != "" && !strings.EqualFold(deposit.Network, network) { continue } results = append(results, deposit) } sort.Slice(results, func(i, j int) bool { return results[i].ObservedAt.Before(results[j].ObservedAt) }) limitVal := int(sanitizeLimit(limit, depositDefaultLimit, depositMaxLimit)) if len(results) > limitVal { results = results[:limitVal] } return results, nil } // shared helpers func sanitizeLimit(requested int32, def, max int64) int64 { if requested <= 0 { return def } if requested > int32(max) { return max } return int64(requested) } func newTestService(t *testing.T) (*Service, *inMemoryRepository) { repo := newInMemoryRepository() logger := zap.NewNop() networks := []shared.Network{{ Name: "ethereum_mainnet", NativeToken: "ETH", TokenConfigs: []shared.TokenContract{ {Symbol: "USDC", ContractAddress: "0xusdc"}, }, }} driverRegistry, err := drivers.NewRegistry(logger.Named("drivers"), networks) require.NoError(t, err) svc := NewService(logger, repo, nil, WithKeyManager(&fakeKeyManager{}), WithNetworks(networks), WithServiceWallet(shared.ServiceWallet{Network: "ethereum_mainnet", Address: "0xservice"}), WithDriverRegistry(driverRegistry), ) return svc, repo } type fakeKeyManager struct{} func (f *fakeKeyManager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) { return &keymanager.ManagedWalletKey{ KeyID: fmt.Sprintf("%s/%s", strings.ToLower(network), walletRef), Address: "0x" + strings.Repeat("a", 40), PublicKey: strings.Repeat("b", 128), }, nil } func (f *fakeKeyManager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { return tx, nil }