Files
sendico/api/gateway/chain/internal/service/gateway/service_test.go
2025-12-25 11:25:13 +01:00

614 lines
18 KiB
Go

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
}