restucturization of recipients payment methods
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

This commit is contained in:
Stephan D
2025-12-04 14:40:21 +01:00
parent 3b04753f4e
commit bf85ca062c
120 changed files with 1415 additions and 538 deletions

View File

@@ -0,0 +1,54 @@
package model
import (
"strings"
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
type DepositStatus string
const (
DepositStatusPending DepositStatus = "pending"
DepositStatusConfirmed DepositStatus = "confirmed"
DepositStatusFailed DepositStatus = "failed"
)
// Deposit records an inbound transfer observed on-chain.
type Deposit struct {
storable.Base `bson:",inline" json:",inline"`
DepositRef string `bson:"depositRef" json:"depositRef"`
WalletRef string `bson:"walletRef" json:"walletRef"`
Network string `bson:"network" json:"network"`
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"`
ContractAddress string `bson:"contractAddress" json:"contractAddress"`
Amount *moneyv1.Money `bson:"amount" json:"amount"`
SourceAddress string `bson:"sourceAddress" json:"sourceAddress"`
TxHash string `bson:"txHash" json:"txHash"`
BlockID string `bson:"blockId,omitempty" json:"blockId,omitempty"`
Status DepositStatus `bson:"status" json:"status"`
ObservedAt time.Time `bson:"observedAt" json:"observedAt"`
RecordedAt time.Time `bson:"recordedAt" json:"recordedAt"`
LastStatusAt time.Time `bson:"lastStatusAt" json:"lastStatusAt"`
}
// Collection implements storable.Storable.
func (*Deposit) Collection() string {
return mservice.ChainDeposits
}
// Normalize standardizes case-sensitive fields.
func (d *Deposit) Normalize() {
d.DepositRef = strings.TrimSpace(d.DepositRef)
d.WalletRef = strings.TrimSpace(d.WalletRef)
d.Network = strings.TrimSpace(strings.ToLower(d.Network))
d.TokenSymbol = strings.TrimSpace(strings.ToUpper(d.TokenSymbol))
d.ContractAddress = strings.TrimSpace(strings.ToLower(d.ContractAddress))
d.SourceAddress = strings.TrimSpace(strings.ToLower(d.SourceAddress))
d.TxHash = strings.TrimSpace(strings.ToLower(d.TxHash))
d.BlockID = strings.TrimSpace(d.BlockID)
}

View File

@@ -0,0 +1,91 @@
package model
import (
"strings"
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
type TransferStatus string
const (
TransferStatusPending TransferStatus = "pending"
TransferStatusSigning TransferStatus = "signing"
TransferStatusSubmitted TransferStatus = "submitted"
TransferStatusConfirmed TransferStatus = "confirmed"
TransferStatusFailed TransferStatus = "failed"
TransferStatusCancelled TransferStatus = "cancelled"
)
// ServiceFee represents a fee component applied to a transfer.
type ServiceFee struct {
FeeCode string `bson:"feeCode" json:"feeCode"`
Amount *moneyv1.Money `bson:"amount" json:"amount"`
Description string `bson:"description,omitempty" json:"description,omitempty"`
}
type TransferDestination struct {
ManagedWalletRef string `bson:"managedWalletRef,omitempty" json:"managedWalletRef,omitempty"`
ExternalAddress string `bson:"externalAddress,omitempty" json:"externalAddress,omitempty"`
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
}
// Transfer models an on-chain transfer orchestrated by the gateway.
type Transfer struct {
storable.Base `bson:",inline" json:",inline"`
TransferRef string `bson:"transferRef" json:"transferRef"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
OrganizationRef string `bson:"organizationRef" json:"organizationRef"`
SourceWalletRef string `bson:"sourceWalletRef" json:"sourceWalletRef"`
Destination TransferDestination `bson:"destination" json:"destination"`
Network string `bson:"network" json:"network"`
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"`
ContractAddress string `bson:"contractAddress" json:"contractAddress"`
RequestedAmount *moneyv1.Money `bson:"requestedAmount" json:"requestedAmount"`
NetAmount *moneyv1.Money `bson:"netAmount" json:"netAmount"`
Fees []ServiceFee `bson:"fees,omitempty" json:"fees,omitempty"`
Status TransferStatus `bson:"status" json:"status"`
TxHash string `bson:"txHash,omitempty" json:"txHash,omitempty"`
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
ClientReference string `bson:"clientReference,omitempty" json:"clientReference,omitempty"`
LastStatusAt time.Time `bson:"lastStatusAt" json:"lastStatusAt"`
}
// Collection implements storable.Storable.
func (*Transfer) Collection() string {
return mservice.ChainTransfers
}
// TransferFilter describes the parameters for listing transfers.
type TransferFilter struct {
SourceWalletRef string
DestinationWalletRef string
Status TransferStatus
Cursor string
Limit int32
}
// TransferList contains paginated transfer results.
type TransferList struct {
Items []*Transfer
NextCursor string
}
// Normalize trims strings for consistent indexes.
func (t *Transfer) Normalize() {
t.TransferRef = strings.TrimSpace(t.TransferRef)
t.IdempotencyKey = strings.TrimSpace(t.IdempotencyKey)
t.OrganizationRef = strings.TrimSpace(t.OrganizationRef)
t.SourceWalletRef = strings.TrimSpace(t.SourceWalletRef)
t.Network = strings.TrimSpace(strings.ToLower(t.Network))
t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol))
t.ContractAddress = strings.TrimSpace(strings.ToLower(t.ContractAddress))
t.Destination.ManagedWalletRef = strings.TrimSpace(t.Destination.ManagedWalletRef)
t.Destination.ExternalAddress = strings.TrimSpace(strings.ToLower(t.Destination.ExternalAddress))
t.Destination.Memo = strings.TrimSpace(t.Destination.Memo)
t.ClientReference = strings.TrimSpace(t.ClientReference)
}

View File

@@ -0,0 +1,90 @@
package model
import (
"strings"
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
type ManagedWalletStatus string
const (
ManagedWalletStatusActive ManagedWalletStatus = "active"
ManagedWalletStatusSuspended ManagedWalletStatus = "suspended"
ManagedWalletStatusClosed ManagedWalletStatus = "closed"
)
// ManagedWallet represents a user-controlled on-chain wallet managed by the service.
type ManagedWallet struct {
storable.Base `bson:",inline" json:",inline"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
WalletRef string `bson:"walletRef" json:"walletRef"`
OrganizationRef string `bson:"organizationRef" json:"organizationRef"`
OwnerRef string `bson:"ownerRef" json:"ownerRef"`
Network string `bson:"network" json:"network"`
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"`
ContractAddress string `bson:"contractAddress" json:"contractAddress"`
DepositAddress string `bson:"depositAddress" json:"depositAddress"`
KeyReference string `bson:"keyReference,omitempty" json:"keyReference,omitempty"`
Status ManagedWalletStatus `bson:"status" json:"status"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
}
// Collection implements storable.Storable.
func (*ManagedWallet) Collection() string {
return mservice.ChainWallets
}
// WalletBalance captures computed wallet balances.
type WalletBalance struct {
storable.Base `bson:",inline" json:",inline"`
WalletRef string `bson:"walletRef" json:"walletRef"`
Available *moneyv1.Money `bson:"available" json:"available"`
PendingInbound *moneyv1.Money `bson:"pendingInbound,omitempty" json:"pendingInbound,omitempty"`
PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"`
CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"`
}
// Collection implements storable.Storable.
func (*WalletBalance) Collection() string {
return mservice.ChainWalletBalances
}
// ManagedWalletFilter describes list filters.
type ManagedWalletFilter struct {
OrganizationRef string
OwnerRef string
Network string
TokenSymbol string
Cursor string
Limit int32
}
// ManagedWalletList contains paginated wallet results.
type ManagedWalletList struct {
Items []*ManagedWallet
NextCursor string
}
// Normalize trims string fields for consistent indexing.
func (m *ManagedWallet) Normalize() {
m.IdempotencyKey = strings.TrimSpace(m.IdempotencyKey)
m.WalletRef = strings.TrimSpace(m.WalletRef)
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
m.OwnerRef = strings.TrimSpace(m.OwnerRef)
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
m.DepositAddress = strings.TrimSpace(strings.ToLower(m.DepositAddress))
m.KeyReference = strings.TrimSpace(m.KeyReference)
}
// Normalize trims wallet balance identifiers.
func (b *WalletBalance) Normalize() {
b.WalletRef = strings.TrimSpace(b.WalletRef)
}

View File

@@ -0,0 +1,98 @@
package mongo
import (
"context"
"time"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/storage/mongo/store"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
// Store implements storage.Repository backed by MongoDB.
type Store struct {
logger mlogger.Logger
conn *db.MongoConnection
db *mongo.Database
wallets storage.WalletsStore
transfers storage.TransfersStore
deposits storage.DepositsStore
}
// New creates a new Mongo-backed repository.
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
if conn == nil {
return nil, merrors.InvalidArgument("mongo connection is nil")
}
client := conn.Client()
if client == nil {
return nil, merrors.Internal("mongo client is not initialised")
}
result := &Store{
logger: logger.Named("storage").Named("mongo"),
conn: conn,
db: conn.Database(),
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := result.Ping(ctx); err != nil {
result.logger.Error("mongo ping failed during repository initialisation", zap.Error(err))
return nil, err
}
walletsStore, err := store.NewWallets(result.logger, result.db)
if err != nil {
result.logger.Error("failed to initialise wallets store", zap.Error(err))
return nil, err
}
transfersStore, err := store.NewTransfers(result.logger, result.db)
if err != nil {
result.logger.Error("failed to initialise transfers store", zap.Error(err))
return nil, err
}
depositsStore, err := store.NewDeposits(result.logger, result.db)
if err != nil {
result.logger.Error("failed to initialise deposits store", zap.Error(err))
return nil, err
}
result.wallets = walletsStore
result.transfers = transfersStore
result.deposits = depositsStore
result.logger.Info("Chain gateway MongoDB storage initialised")
return result, nil
}
// Ping verifies the MongoDB connection.
func (s *Store) Ping(ctx context.Context) error {
if s.conn == nil {
return merrors.InvalidArgument("mongo connection is nil")
}
return s.conn.Ping(ctx)
}
// Wallets returns the wallets store.
func (s *Store) Wallets() storage.WalletsStore {
return s.wallets
}
// Transfers returns the transfers store.
func (s *Store) Transfers() storage.TransfersStore {
return s.transfers
}
// Deposits returns the deposits store.
func (s *Store) Deposits() storage.DepositsStore {
return s.deposits
}
var _ storage.Repository = (*Store)(nil)

View File

@@ -0,0 +1,161 @@
package store
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
const (
defaultDepositPageSize int64 = 100
maxDepositPageSize int64 = 500
)
type Deposits struct {
logger mlogger.Logger
repo repository.Repository
}
// NewDeposits constructs a Mongo-backed deposits store.
func NewDeposits(logger mlogger.Logger, db *mongo.Database) (*Deposits, error) {
if db == nil {
return nil, merrors.InvalidArgument("mongo database is nil")
}
repo := repository.CreateMongoRepository(db, mservice.ChainDeposits)
indexes := []*ri.Definition{
{
Keys: []ri.Key{{Field: "depositRef", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "walletRef", Sort: ri.Asc}, {Field: "status", Sort: ri.Asc}},
},
{
Keys: []ri.Key{{Field: "txHash", Sort: ri.Asc}},
Unique: true,
},
}
for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure deposit index", zap.Error(err), zap.String("collection", repo.Collection()))
return nil, err
}
}
childLogger := logger.Named("deposits")
childLogger.Debug("deposits store initialised")
return &Deposits{logger: childLogger, repo: repo}, nil
}
func (d *Deposits) Record(ctx context.Context, deposit *model.Deposit) error {
if deposit == nil {
return merrors.InvalidArgument("depositsStore: nil deposit")
}
deposit.Normalize()
if strings.TrimSpace(deposit.DepositRef) == "" {
return merrors.InvalidArgument("depositsStore: empty depositRef")
}
if deposit.Status == "" {
deposit.Status = model.DepositStatusPending
}
if deposit.ObservedAt.IsZero() {
deposit.ObservedAt = time.Now().UTC()
}
if deposit.RecordedAt.IsZero() {
deposit.RecordedAt = time.Now().UTC()
}
if deposit.LastStatusAt.IsZero() {
deposit.LastStatusAt = time.Now().UTC()
}
existing := &model.Deposit{}
err := d.repo.FindOneByFilter(ctx, repository.Filter("depositRef", deposit.DepositRef), existing)
switch {
case err == nil:
existing.Status = deposit.Status
existing.ObservedAt = deposit.ObservedAt
existing.RecordedAt = deposit.RecordedAt
existing.LastStatusAt = time.Now().UTC()
if deposit.Amount != nil {
existing.Amount = deposit.Amount
}
if deposit.BlockID != "" {
existing.BlockID = deposit.BlockID
}
if deposit.TxHash != "" {
existing.TxHash = deposit.TxHash
}
if deposit.Network != "" {
existing.Network = deposit.Network
}
if deposit.TokenSymbol != "" {
existing.TokenSymbol = deposit.TokenSymbol
}
if deposit.ContractAddress != "" {
existing.ContractAddress = deposit.ContractAddress
}
if deposit.SourceAddress != "" {
existing.SourceAddress = deposit.SourceAddress
}
if err := d.repo.Update(ctx, existing); err != nil {
return err
}
return nil
case errors.Is(err, merrors.ErrNoData):
if err := d.repo.Insert(ctx, deposit, repository.Filter("depositRef", deposit.DepositRef)); err != nil {
return err
}
return nil
default:
return err
}
}
func (d *Deposits) ListPending(ctx context.Context, network string, limit int32) ([]*model.Deposit, error) {
query := repository.Query().Filter(repository.Field("status"), model.DepositStatusPending)
if net := strings.TrimSpace(network); net != "" {
query = query.Filter(repository.Field("network"), strings.ToLower(net))
}
pageSize := sanitizeDepositLimit(limit)
query = query.Sort(repository.Field("observedAt"), true).Limit(&pageSize)
deposits := make([]*model.Deposit, 0, pageSize)
decoder := func(cur *mongo.Cursor) error {
item := &model.Deposit{}
if err := cur.Decode(item); err != nil {
return err
}
deposits = append(deposits, item)
return nil
}
if err := d.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
return deposits, nil
}
func sanitizeDepositLimit(requested int32) int64 {
if requested <= 0 {
return defaultDepositPageSize
}
if requested > int32(maxDepositPageSize) {
return maxDepositPageSize
}
return int64(requested)
}
var _ storage.DepositsStore = (*Deposits)(nil)

View File

@@ -0,0 +1,200 @@
package store
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
const (
defaultTransferPageSize int64 = 50
maxTransferPageSize int64 = 200
)
type Transfers struct {
logger mlogger.Logger
repo repository.Repository
}
// NewTransfers constructs a Mongo-backed transfers store.
func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error) {
if db == nil {
return nil, merrors.InvalidArgument("mongo database is nil")
}
repo := repository.CreateMongoRepository(db, mservice.ChainTransfers)
indexes := []*ri.Definition{
{
Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "sourceWalletRef", Sort: ri.Asc}, {Field: "status", Sort: ri.Asc}},
},
{
Keys: []ri.Key{{Field: "destination.managedWalletRef", Sort: ri.Asc}},
},
}
for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure transfer index", zap.Error(err), zap.String("collection", repo.Collection()))
return nil, err
}
}
childLogger := logger.Named("transfers")
childLogger.Debug("transfers store initialised")
return &Transfers{
logger: childLogger,
repo: repo,
}, nil
}
func (t *Transfers) Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) {
if transfer == nil {
return nil, merrors.InvalidArgument("transfersStore: nil transfer")
}
transfer.Normalize()
if strings.TrimSpace(transfer.TransferRef) == "" {
return nil, merrors.InvalidArgument("transfersStore: empty transferRef")
}
if strings.TrimSpace(transfer.IdempotencyKey) == "" {
return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey")
}
if transfer.Status == "" {
transfer.Status = model.TransferStatusPending
}
if transfer.LastStatusAt.IsZero() {
transfer.LastStatusAt = time.Now().UTC()
}
if strings.TrimSpace(transfer.IdempotencyKey) == "" {
return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey")
}
if err := t.repo.Insert(ctx, transfer, repository.Filter("idempotencyKey", transfer.IdempotencyKey)); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
t.logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", transfer.IdempotencyKey))
return transfer, nil
}
return nil, err
}
t.logger.Debug("transfer created", zap.String("transfer_ref", transfer.TransferRef))
return transfer, nil
}
func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfer, error) {
transferRef = strings.TrimSpace(transferRef)
if transferRef == "" {
return nil, merrors.InvalidArgument("transfersStore: empty transferRef")
}
transfer := &model.Transfer{}
if err := t.repo.FindOneByFilter(ctx, repository.Filter("transferRef", transferRef), transfer); err != nil {
return nil, err
}
return transfer, nil
}
func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) {
query := repository.Query()
if src := strings.TrimSpace(filter.SourceWalletRef); src != "" {
query = query.Filter(repository.Field("sourceWalletRef"), src)
}
if dst := strings.TrimSpace(filter.DestinationWalletRef); dst != "" {
query = query.Filter(repository.Field("destination.managedWalletRef"), dst)
}
if status := strings.TrimSpace(string(filter.Status)); status != "" {
query = query.Filter(repository.Field("status"), status)
}
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
query = query.Comparison(repository.IDField(), builder.Gt, oid)
} else {
t.logger.Warn("ignoring invalid transfer cursor", zap.String("cursor", cursor), zap.Error(err))
}
}
limit := sanitizeTransferLimit(filter.Limit)
fetchLimit := limit + 1
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
transfers := make([]*model.Transfer, 0, fetchLimit)
decoder := func(cur *mongo.Cursor) error {
item := &model.Transfer{}
if err := cur.Decode(item); err != nil {
return err
}
transfers = append(transfers, item)
return nil
}
if err := t.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
nextCursor := ""
if int64(len(transfers)) == fetchLimit {
last := transfers[len(transfers)-1]
nextCursor = last.ID.Hex()
transfers = transfers[:len(transfers)-1]
}
return &model.TransferList{
Items: transfers,
NextCursor: nextCursor,
}, nil
}
func (t *Transfers) UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error) {
transferRef = strings.TrimSpace(transferRef)
if transferRef == "" {
return nil, merrors.InvalidArgument("transfersStore: empty transferRef")
}
transfer := &model.Transfer{}
if err := t.repo.FindOneByFilter(ctx, repository.Filter("transferRef", transferRef), transfer); err != nil {
return nil, err
}
transfer.Status = status
if status == model.TransferStatusFailed {
transfer.FailureReason = strings.TrimSpace(failureReason)
} else {
transfer.FailureReason = ""
}
if hash := strings.TrimSpace(txHash); hash != "" {
transfer.TxHash = strings.ToLower(hash)
}
transfer.LastStatusAt = time.Now().UTC()
if err := t.repo.Update(ctx, transfer); err != nil {
return nil, err
}
return transfer, nil
}
func sanitizeTransferLimit(requested int32) int64 {
if requested <= 0 {
return defaultTransferPageSize
}
if requested > int32(maxTransferPageSize) {
return maxTransferPageSize
}
return int64(requested)
}
var _ storage.TransfersStore = (*Transfers)(nil)

View File

@@ -0,0 +1,236 @@
package store
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/gateway/chain/storage"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
const (
defaultWalletPageSize int64 = 50
maxWalletPageSize int64 = 200
)
type Wallets struct {
logger mlogger.Logger
walletRepo repository.Repository
balanceRepo repository.Repository
}
// NewWallets constructs a Mongo-backed wallets store.
func NewWallets(logger mlogger.Logger, db *mongo.Database) (*Wallets, error) {
if db == nil {
return nil, merrors.InvalidArgument("mongo database is nil")
}
walletRepo := repository.CreateMongoRepository(db, mservice.ChainWallets)
walletIndexes := []*ri.Definition{
{
Keys: []ri.Key{{Field: "walletRef", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "depositAddress", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "ownerRef", Sort: ri.Asc}},
},
}
for _, def := range walletIndexes {
if err := walletRepo.CreateIndex(def); err != nil {
logger.Error("failed to ensure wallet index", zap.String("collection", walletRepo.Collection()), zap.Error(err))
return nil, err
}
}
balanceRepo := repository.CreateMongoRepository(db, mservice.ChainWalletBalances)
balanceIndexes := []*ri.Definition{
{
Keys: []ri.Key{{Field: "walletRef", Sort: ri.Asc}},
Unique: true,
},
}
for _, def := range balanceIndexes {
if err := balanceRepo.CreateIndex(def); err != nil {
logger.Error("failed to ensure wallet balance index", zap.String("collection", balanceRepo.Collection()), zap.Error(err))
return nil, err
}
}
childLogger := logger.Named("wallets")
childLogger.Debug("wallet stores initialised")
return &Wallets{
logger: childLogger,
walletRepo: walletRepo,
balanceRepo: balanceRepo,
}, nil
}
func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*model.ManagedWallet, error) {
if wallet == nil {
return nil, merrors.InvalidArgument("walletsStore: nil wallet")
}
wallet.Normalize()
if strings.TrimSpace(wallet.WalletRef) == "" {
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
}
if wallet.Status == "" {
wallet.Status = model.ManagedWalletStatusActive
}
if strings.TrimSpace(wallet.IdempotencyKey) == "" {
return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey")
}
if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
w.logger.Debug("wallet already exists", zap.String("wallet_ref", wallet.WalletRef), zap.String("idempotency_key", wallet.IdempotencyKey))
return wallet, nil
}
return nil, err
}
w.logger.Debug("wallet created", zap.String("wallet_ref", wallet.WalletRef))
return wallet, nil
}
func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) {
walletRef = strings.TrimSpace(walletRef)
if walletRef == "" {
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
}
wallet := &model.ManagedWallet{}
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), wallet); err != nil {
return nil, err
}
return wallet, nil
}
func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
query := repository.Query()
if org := strings.TrimSpace(filter.OrganizationRef); org != "" {
query = query.Filter(repository.Field("organizationRef"), org)
}
if owner := strings.TrimSpace(filter.OwnerRef); owner != "" {
query = query.Filter(repository.Field("ownerRef"), owner)
}
if network := strings.TrimSpace(filter.Network); network != "" {
query = query.Filter(repository.Field("network"), strings.ToLower(network))
}
if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
query = query.Filter(repository.Field("tokenSymbol"), strings.ToUpper(token))
}
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
query = query.Comparison(repository.IDField(), builder.Gt, oid)
} else {
w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
}
}
limit := sanitizeWalletLimit(filter.Limit)
fetchLimit := limit + 1
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
wallets := make([]*model.ManagedWallet, 0, fetchLimit)
decoder := func(cur *mongo.Cursor) error {
item := &model.ManagedWallet{}
if err := cur.Decode(item); err != nil {
return err
}
wallets = append(wallets, item)
return nil
}
if err := w.walletRepo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
return nil, err
}
nextCursor := ""
if int64(len(wallets)) == fetchLimit {
last := wallets[len(wallets)-1]
nextCursor = last.ID.Hex()
wallets = wallets[:len(wallets)-1]
}
return &model.ManagedWalletList{
Items: wallets,
NextCursor: nextCursor,
}, nil
}
func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error {
if balance == nil {
return merrors.InvalidArgument("walletsStore: nil balance")
}
balance.Normalize()
if strings.TrimSpace(balance.WalletRef) == "" {
return merrors.InvalidArgument("walletsStore: empty walletRef for balance")
}
if balance.CalculatedAt.IsZero() {
balance.CalculatedAt = time.Now().UTC()
}
existing := &model.WalletBalance{}
err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing)
switch {
case err == nil:
existing.Available = balance.Available
existing.PendingInbound = balance.PendingInbound
existing.PendingOutbound = balance.PendingOutbound
existing.CalculatedAt = balance.CalculatedAt
if err := w.balanceRepo.Update(ctx, existing); err != nil {
return err
}
return nil
case errors.Is(err, merrors.ErrNoData):
if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil {
return err
}
return nil
default:
return err
}
}
func (w *Wallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) {
walletRef = strings.TrimSpace(walletRef)
if walletRef == "" {
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
}
balance := &model.WalletBalance{}
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), balance); err != nil {
return nil, err
}
return balance, nil
}
func sanitizeWalletLimit(requested int32) int64 {
if requested <= 0 {
return defaultWalletPageSize
}
if requested > int32(maxWalletPageSize) {
return maxWalletPageSize
}
return int64(requested)
}
var _ storage.WalletsStore = (*Wallets)(nil)

View File

@@ -0,0 +1,53 @@
package storage
import (
"context"
"github.com/tech/sendico/gateway/chain/storage/model"
)
type storageError string
func (e storageError) Error() string {
return string(e)
}
var (
// ErrWalletNotFound indicates that a wallet record was not found.
ErrWalletNotFound = storageError("chain.gateway.storage: wallet not found")
// ErrTransferNotFound indicates that a transfer record was not found.
ErrTransferNotFound = storageError("chain.gateway.storage: transfer not found")
// ErrDepositNotFound indicates that a deposit record was not found.
ErrDepositNotFound = storageError("chain.gateway.storage: deposit not found")
)
// Repository represents the root storage contract for the chain gateway module.
type Repository interface {
Ping(ctx context.Context) error
Wallets() WalletsStore
Transfers() TransfersStore
Deposits() DepositsStore
}
// WalletsStore exposes persistence operations for managed wallets.
type WalletsStore interface {
Create(ctx context.Context, wallet *model.ManagedWallet) (*model.ManagedWallet, error)
Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error)
List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error)
SaveBalance(ctx context.Context, balance *model.WalletBalance) error
GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error)
}
// TransfersStore exposes persistence operations for transfers.
type TransfersStore interface {
Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error)
Get(ctx context.Context, transferRef string) (*model.Transfer, error)
List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error)
UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error)
}
// DepositsStore exposes persistence operations for observed deposits.
type DepositsStore interface {
Record(ctx context.Context, deposit *model.Deposit) error
ListPending(ctx context.Context, network string, limit int32) ([]*model.Deposit, error)
}