Compare commits
57 Commits
4251dfb2c6
...
SEND021
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9a605ce21 | ||
|
|
c3ec50c8e4 | ||
|
|
f3ad4c2d4f | ||
| 4aeb06fd31 | |||
|
|
d1786dc5d9 | ||
| f5bf8cf6d0 | |||
| 7daa4ab027 | |||
|
|
6f2309669b | ||
|
|
e4847cd137 | ||
| dbd06a4162 | |||
|
|
1ec6cd8386 | ||
| 6daf567baf | |||
| 23a57e543d | |||
|
|
8adfab94b5 | ||
|
|
db488a31e8 | ||
| 3836ff5ef3 | |||
| aef5c99a22 | |||
|
|
be7c965234 | ||
|
|
63448ab267 | ||
| 34a565d86d | |||
|
|
171d90b3f7 | ||
| 5191336a49 | |||
|
|
48f64a722d | ||
| bde453d106 | |||
|
|
3bb33b8895 | ||
| 8ee092089f | |||
|
|
eca3d0d62e | ||
| aba743406a | |||
|
|
deb29efde3 | ||
| 6995afc47d | |||
|
|
7b645a3bbe | ||
| 0ddd92b88b | |||
|
|
6151e3d3a5 | ||
| af7abbb095 | |||
|
|
71be1ef9f0 | ||
| 3df358d865 | |||
|
|
c6b2ba486b | ||
| d324e455cc | |||
|
|
8c87e5534e | ||
| bcb3e9e647 | |||
|
|
43f26143df | ||
| ed6e6bf1ba | |||
|
|
2d38b974ba | ||
|
|
610296b301 | ||
|
|
fcc68c8380 | ||
| b96babdfd4 | |||
| 69fdbf4e95 | |||
|
|
d32b2aa959 | ||
|
|
be10839e3a | ||
| d530af43a1 | |||
|
|
aa673fb26d | ||
| d978e24a9d | |||
|
|
31d93e5113 | ||
| f02f3449f3 | |||
|
|
d46822b9bb | ||
| 0505b2314e | |||
|
|
407e704352 |
@@ -130,7 +130,7 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("pair not supported", zap.String("pair", pairKey.Base+"/"+pairKey.Quote))
|
||||
logger.Warn("Pair not supported", zap.String("pair", pairKey.Base+"/"+pairKey.Quote))
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
|
||||
default:
|
||||
logger.Warn("GetQuote failed to load pair", zap.Error(err))
|
||||
@@ -150,7 +150,7 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("rate not found", zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||
logger.Warn("Rate not found", zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
|
||||
default:
|
||||
logger.Warn("GetQuote failed to load rate", zap.Error(err), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||
|
||||
@@ -50,28 +50,28 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
defer cancel()
|
||||
|
||||
if err := s.Ping(ctx); err != nil {
|
||||
s.logger.Error("mongo ping failed during store init", zap.Error(err))
|
||||
s.logger.Error("Mongo ping failed during store init", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ratesStore, err := store.NewRates(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize rates store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize rates store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
quotesStore, err := store.NewQuotes(s.logger, db, txFactory)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize quotes store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize quotes store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
pairsStore, err := store.NewPair(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize pair store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize pair store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
currencyStore, err := store.NewCurrency(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize currency store", zap.Error(err))
|
||||
s.logger.Error("Failed to initialize currency store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
s.pairs = pairsStore
|
||||
s.currencies = currencyStore
|
||||
|
||||
s.logger.Info("mongo storage ready")
|
||||
s.logger.Info("Mongo storage ready")
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -29,11 +29,11 @@ func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencySto
|
||||
Unique: true,
|
||||
}
|
||||
if err := repo.CreateIndex(index); err != nil {
|
||||
logger.Error("failed to ensure currencies index", zap.Error(err))
|
||||
logger.Error("Failed to ensure currencies index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
childLogger := logger.Named(model.CurrenciesCollection)
|
||||
childLogger.Debug("currency store initialised", zap.String("collection", model.CurrenciesCollection))
|
||||
childLogger.Debug("Currency store initialised", zap.String("collection", model.CurrenciesCollection))
|
||||
|
||||
return ¤cyStore{
|
||||
logger: childLogger,
|
||||
@@ -43,17 +43,17 @@ func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencySto
|
||||
|
||||
func (c *currencyStore) Get(ctx context.Context, code string) (*model.Currency, error) {
|
||||
if code == "" {
|
||||
c.logger.Warn("attempt to fetch currency with empty code")
|
||||
c.logger.Warn("Attempt to fetch currency with empty code")
|
||||
return nil, merrors.InvalidArgument("currencyStore: empty code")
|
||||
}
|
||||
result := &model.Currency{}
|
||||
if err := c.repo.FindOneByFilter(ctx, repository.Filter("code", code), result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.logger.Debug("currency not found", zap.String("code", code))
|
||||
c.logger.Debug("Currency not found", zap.String("code", code))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
c.logger.Debug("currency loaded", zap.String("code", code))
|
||||
c.logger.Debug("Currency loaded", zap.String("code", code))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -77,20 +77,20 @@ func (c *currencyStore) List(ctx context.Context, codes ...string) ([]*model.Cur
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
c.logger.Error("failed to list currencies", zap.Error(err))
|
||||
c.logger.Warn("Failed to list currencies", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
c.logger.Debug("listed currencies", zap.Int("count", len(currencies)))
|
||||
c.logger.Debug("Listed currencies", zap.Int("count", len(currencies)))
|
||||
return currencies, nil
|
||||
}
|
||||
|
||||
func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) error {
|
||||
if currency == nil {
|
||||
c.logger.Warn("attempt to upsert nil currency")
|
||||
c.logger.Warn("Attempt to upsert nil currency")
|
||||
return merrors.InvalidArgument("currencyStore: nil currency")
|
||||
}
|
||||
if currency.Code == "" {
|
||||
c.logger.Warn("attempt to upsert currency with empty code")
|
||||
c.logger.Warn("Attempt to upsert currency with empty code")
|
||||
return merrors.InvalidArgument("currencyStore: empty code")
|
||||
}
|
||||
|
||||
@@ -98,16 +98,16 @@ func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) er
|
||||
filter := repository.Filter("code", currency.Code)
|
||||
if err := c.repo.FindOneByFilter(ctx, filter, existing); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.logger.Debug("inserting new currency", zap.String("code", currency.Code))
|
||||
c.logger.Debug("Inserting new currency", zap.String("code", currency.Code))
|
||||
return c.repo.Insert(ctx, currency, filter)
|
||||
}
|
||||
c.logger.Error("failed to fetch currency", zap.Error(err), zap.String("code", currency.Code))
|
||||
c.logger.Warn("Failed to fetch currency", zap.Error(err), zap.String("code", currency.Code))
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.GetID() != nil {
|
||||
currency.SetID(*existing.GetID())
|
||||
}
|
||||
c.logger.Debug("updating currency", zap.String("code", currency.Code))
|
||||
c.logger.Debug("Updating currency", zap.String("code", currency.Code))
|
||||
return c.repo.Update(ctx, currency)
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ func NewPair(logger mlogger.Logger, db *mongo.Database) (storage.PairStore, erro
|
||||
Unique: true,
|
||||
}
|
||||
if err := repo.CreateIndex(index); err != nil {
|
||||
logger.Error("failed to ensure pairs index", zap.Error(err))
|
||||
logger.Error("Failed to ensure pairs index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("pair store initialised", zap.String("collection", model.PairsCollection))
|
||||
logger.Debug("Pair store initialised", zap.String("collection", model.PairsCollection))
|
||||
|
||||
return &pairStore{
|
||||
logger: logger.Named(model.PairsCollection),
|
||||
@@ -53,16 +53,16 @@ func (p *pairStore) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
p.logger.Error("failed to list enabled pairs", zap.Error(err))
|
||||
p.logger.Warn("Failed to list enabled pairs", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.logger.Debug("listed enabled pairs", zap.Int("count", len(pairs)))
|
||||
p.logger.Debug("Listed enabled pairs", zap.Int("count", len(pairs)))
|
||||
return pairs, nil
|
||||
}
|
||||
|
||||
func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
if pair.Base == "" || pair.Quote == "" {
|
||||
p.logger.Warn("attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
p.logger.Warn("Attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
return nil, merrors.InvalidArgument("pairStore: incomplete pair")
|
||||
}
|
||||
result := &model.Pair{}
|
||||
@@ -71,21 +71,21 @@ func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pa
|
||||
Filter(repository.Field("pair").Dot("quote"), pair.Quote)
|
||||
if err := p.repo.FindOneByFilter(ctx, query, result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
p.logger.Debug("pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
p.logger.Debug("Pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
p.logger.Debug("pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
p.logger.Debug("Pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
|
||||
if pair == nil {
|
||||
p.logger.Warn("attempt to upsert nil pair")
|
||||
p.logger.Warn("Attempt to upsert nil pair")
|
||||
return merrors.InvalidArgument("pairStore: nil pair")
|
||||
}
|
||||
if pair.Pair.Base == "" || pair.Pair.Quote == "" {
|
||||
p.logger.Warn("attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
p.logger.Warn("Attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
return merrors.InvalidArgument("pairStore: incomplete pair")
|
||||
}
|
||||
|
||||
@@ -96,16 +96,16 @@ func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
|
||||
err := p.repo.FindOneByFilter(ctx, query, existing)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
p.logger.Debug("inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
p.logger.Debug("Inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
return p.repo.Insert(ctx, pair, query)
|
||||
}
|
||||
p.logger.Error("failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
p.logger.Warn("Failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.GetID() != nil {
|
||||
pair.SetID(*existing.GetID())
|
||||
}
|
||||
p.logger.Debug("updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
p.logger.Debug("Updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
return p.repo.Update(ctx, pair)
|
||||
}
|
||||
|
||||
@@ -56,12 +56,12 @@ func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.
|
||||
|
||||
for _, def := range indexes {
|
||||
if err := repo.CreateIndex(def); err != nil {
|
||||
logger.Error("failed to ensure quotes index", zap.Error(err))
|
||||
logger.Error("Failed to ensure quotes index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
childLogger := logger.Named(model.QuotesCollection)
|
||||
childLogger.Debug("quotes store initialised", zap.String("collection", model.QuotesCollection))
|
||||
childLogger.Debug("Quotes store initialised", zap.String("collection", model.QuotesCollection))
|
||||
|
||||
return "esStore{
|
||||
logger: childLogger,
|
||||
@@ -72,11 +72,11 @@ func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.
|
||||
|
||||
func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
|
||||
if quote == nil {
|
||||
q.logger.Warn("attempt to issue nil quote")
|
||||
q.logger.Warn("Attempt to issue nil quote")
|
||||
return merrors.InvalidArgument("quotesStore: nil quote")
|
||||
}
|
||||
if quote.QuoteRef == "" {
|
||||
q.logger.Warn("attempt to issue quote with empty ref")
|
||||
q.logger.Warn("Attempt to issue quote with empty ref")
|
||||
return merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||
}
|
||||
|
||||
@@ -89,32 +89,32 @@ func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
|
||||
quote.ConsumedByLedgerTxnRef = ""
|
||||
quote.ConsumedAtUnixMs = nil
|
||||
if err := q.repo.Insert(ctx, quote, repository.Filter("quoteRef", quote.QuoteRef)); err != nil {
|
||||
q.logger.Error("failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef))
|
||||
q.logger.Warn("Failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef))
|
||||
return err
|
||||
}
|
||||
q.logger.Debug("quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm))
|
||||
q.logger.Debug("Quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *quotesStore) GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error) {
|
||||
if quoteRef == "" {
|
||||
q.logger.Warn("attempt to fetch quote with empty ref")
|
||||
q.logger.Warn("Attempt to fetch quote with empty ref")
|
||||
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||
}
|
||||
quote := &model.Quote{}
|
||||
if err := q.repo.FindOneByFilter(ctx, repository.Filter("quoteRef", quoteRef), quote); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
q.logger.Debug("quote not found", zap.String("quote_ref", quoteRef))
|
||||
q.logger.Debug("Quote not found", zap.String("quote_ref", quoteRef))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
q.logger.Debug("quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status)))
|
||||
q.logger.Debug("Quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status)))
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error) {
|
||||
if quoteRef == "" || ledgerTxnRef == "" {
|
||||
q.logger.Warn("attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
q.logger.Warn("Attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
return nil, merrors.InvalidArgument("quotesStore: missing identifiers")
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
||||
when = time.Now()
|
||||
}
|
||||
|
||||
q.logger.Debug("consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
q.logger.Debug("Consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
txn := q.txFactory.CreateTransaction()
|
||||
result, err := txn.Execute(ctx, func(txCtx context.Context) (any, error) {
|
||||
quote := &model.Quote{}
|
||||
@@ -131,7 +131,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
||||
}
|
||||
|
||||
if !quote.Firm {
|
||||
q.logger.Warn("quote not firm", zap.String("quote_ref", quoteRef))
|
||||
q.logger.Warn("Quote not firm", zap.String("quote_ref", quoteRef))
|
||||
return nil, storage.ErrQuoteNotFirm
|
||||
}
|
||||
|
||||
@@ -140,16 +140,16 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
||||
if err := q.repo.Update(txCtx, quote); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.logger.Info("quote expired during consume", zap.String("quote_ref", quoteRef))
|
||||
q.logger.Info("Quote expired during consume", zap.String("quote_ref", quoteRef))
|
||||
return nil, storage.ErrQuoteExpired
|
||||
}
|
||||
|
||||
if quote.Status == model.QuoteStatusConsumed {
|
||||
if quote.ConsumedByLedgerTxnRef == ledgerTxnRef {
|
||||
q.logger.Debug("quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
q.logger.Debug("Quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
return quote, nil
|
||||
}
|
||||
q.logger.Warn("quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef))
|
||||
q.logger.Warn("Quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef))
|
||||
return nil, storage.ErrQuoteConsumed
|
||||
}
|
||||
|
||||
@@ -157,11 +157,11 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
||||
if err := q.repo.Update(txCtx, quote); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.logger.Info("quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
q.logger.Info("Quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
return quote, nil
|
||||
})
|
||||
if err != nil {
|
||||
q.logger.Error("quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
q.logger.Warn("Quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
return nil, err
|
||||
}
|
||||
quote, _ := result.(*model.Quote)
|
||||
@@ -173,7 +173,7 @@ func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string
|
||||
|
||||
func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
|
||||
if cutoff.IsZero() {
|
||||
q.logger.Warn("attempt to expire quotes with zero cutoff")
|
||||
q.logger.Warn("Attempt to expire quotes with zero cutoff")
|
||||
return 0, merrors.InvalidArgument("quotesStore: cutoff time is zero")
|
||||
}
|
||||
|
||||
@@ -188,11 +188,11 @@ func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time)
|
||||
|
||||
updated, err := q.repo.PatchMany(ctx, filter, patch)
|
||||
if err != nil {
|
||||
q.logger.Error("failed to expire quotes", zap.Error(err))
|
||||
q.logger.Warn("Failed to expire quotes", zap.Error(err))
|
||||
return 0, err
|
||||
}
|
||||
if updated > 0 {
|
||||
q.logger.Info("quotes expired", zap.Int("count", updated))
|
||||
q.logger.Info("Quotes expired", zap.Int("count", updated))
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
@@ -51,11 +51,11 @@ func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, er
|
||||
|
||||
for _, def := range indexes {
|
||||
if err := repo.CreateIndex(def); err != nil {
|
||||
logger.Error("failed to ensure rates index", zap.Error(err))
|
||||
logger.Error("Failed to ensure rates index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
logger.Debug("rates store initialised", zap.String("collection", model.RatesCollection))
|
||||
logger.Debug("Rates store initialised", zap.String("collection", model.RatesCollection))
|
||||
return &ratesStore{
|
||||
logger: logger.Named(model.RatesCollection),
|
||||
repo: repo,
|
||||
@@ -64,11 +64,11 @@ func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, er
|
||||
|
||||
func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
|
||||
if snapshot == nil {
|
||||
r.logger.Warn("attempt to upsert nil snapshot")
|
||||
r.logger.Warn("Attempt to upsert nil snapshot")
|
||||
return merrors.InvalidArgument("ratesStore: nil snapshot")
|
||||
}
|
||||
if snapshot.RateRef == "" {
|
||||
r.logger.Warn("attempt to upsert snapshot with empty rate_ref")
|
||||
r.logger.Warn("Attempt to upsert snapshot with empty rate_ref")
|
||||
return merrors.InvalidArgument("ratesStore: empty rateRef")
|
||||
}
|
||||
|
||||
@@ -82,17 +82,17 @@ func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSna
|
||||
err := r.repo.FindOneByFilter(ctx, filter, existing)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
r.logger.Debug("inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
||||
r.logger.Debug("Inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
||||
return r.repo.Insert(ctx, snapshot, filter)
|
||||
}
|
||||
r.logger.Error("failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef))
|
||||
r.logger.Warn("Failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef))
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.GetID() != nil {
|
||||
snapshot.SetID(*existing.GetID())
|
||||
}
|
||||
r.logger.Debug("updating rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
||||
r.logger.Debug("Updating rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
||||
return r.repo.Update(ctx, snapshot)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ type Client interface {
|
||||
GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
||||
ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
||||
EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
||||
ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error)
|
||||
EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -36,6 +38,8 @@ type grpcGatewayClient interface {
|
||||
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error)
|
||||
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error)
|
||||
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
|
||||
ComputeGasTopUp(ctx context.Context, in *chainv1.ComputeGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.ComputeGasTopUpResponse, error)
|
||||
EnsureGasTopUp(ctx context.Context, in *chainv1.EnsureGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.EnsureGasTopUpResponse, error)
|
||||
}
|
||||
|
||||
type chainGatewayClient struct {
|
||||
@@ -139,6 +143,18 @@ func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chain
|
||||
return c.client.EstimateTransferFee(ctx, req)
|
||||
}
|
||||
|
||||
func (c *chainGatewayClient) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.ComputeGasTopUp(ctx, req)
|
||||
}
|
||||
|
||||
func (c *chainGatewayClient) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||
ctx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
return c.client.EnsureGasTopUp(ctx, req)
|
||||
}
|
||||
|
||||
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
timeout := c.cfg.CallTimeout
|
||||
if timeout <= 0 {
|
||||
|
||||
@@ -16,6 +16,8 @@ type Fake struct {
|
||||
GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error)
|
||||
ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error)
|
||||
EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error)
|
||||
ComputeGasTopUpFn func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error)
|
||||
EnsureGasTopUpFn func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error)
|
||||
CloseFn func() error
|
||||
}
|
||||
|
||||
@@ -75,6 +77,20 @@ func (f *Fake) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTra
|
||||
return &chainv1.EstimateTransferFeeResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||
if f.ComputeGasTopUpFn != nil {
|
||||
return f.ComputeGasTopUpFn(ctx, req)
|
||||
}
|
||||
return &chainv1.ComputeGasTopUpResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||
if f.EnsureGasTopUpFn != nil {
|
||||
return f.EnsureGasTopUpFn(ctx, req)
|
||||
}
|
||||
return &chainv1.EnsureGasTopUpResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) Close() error {
|
||||
if f.CloseFn != nil {
|
||||
return f.CloseFn()
|
||||
|
||||
@@ -38,6 +38,11 @@ chains:
|
||||
chain_id: 728126428 # 0x2b6653dc
|
||||
native_token: TRX
|
||||
rpc_url_env: CHAIN_GATEWAY_RPC_URL
|
||||
gas_topup_policy:
|
||||
buffer_percent: 0.10
|
||||
min_native_balance_trx: 10
|
||||
rounding_unit_trx: 1
|
||||
max_topup_trx: 100
|
||||
tokens:
|
||||
- symbol: USDT
|
||||
contract: "0xa614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||
|
||||
@@ -22,7 +22,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
|
||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04 h1:wCr/SrKzMrtW9wG85ApPfncRr7ajzkRevhsWnCkl2sE=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251223223124-03e3cef63e04/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b h1:g/wCbvJGhOAqfGBjWnqtD6CVsXdr3G4GCbjLR6z9kNw=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
|
||||
@@ -45,22 +45,22 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
||||
}
|
||||
address := strings.TrimSpace(cfg.Address)
|
||||
if address == "" {
|
||||
logger.Error("vault address missing")
|
||||
logger.Error("Vault address missing")
|
||||
return nil, merrors.InvalidArgument("vault key manager: address is required")
|
||||
}
|
||||
tokenEnv := strings.TrimSpace(cfg.TokenEnv)
|
||||
if tokenEnv == "" {
|
||||
logger.Error("vault token env missing")
|
||||
logger.Error("Vault token env missing")
|
||||
return nil, merrors.InvalidArgument("vault key manager: token_env is required")
|
||||
}
|
||||
token := strings.TrimSpace(os.Getenv(tokenEnv))
|
||||
if token == "" {
|
||||
logger.Error("vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
|
||||
logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
|
||||
return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
|
||||
}
|
||||
mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/")
|
||||
if mountPath == "" {
|
||||
logger.Error("vault mount path missing")
|
||||
logger.Error("Vault mount path missing")
|
||||
return nil, merrors.InvalidArgument("vault key manager: mount_path is required")
|
||||
}
|
||||
keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/")
|
||||
@@ -73,7 +73,7 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
||||
|
||||
client, err := api.NewClient(clientCfg)
|
||||
if err != nil {
|
||||
logger.Error("failed to create vault client", zap.Error(err))
|
||||
logger.Error("Failed to create vault client", zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error())
|
||||
}
|
||||
client.SetToken(token)
|
||||
@@ -94,17 +94,17 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) {
|
||||
// CreateManagedWalletKey creates a new managed wallet key and stores it in Vault.
|
||||
func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) {
|
||||
if strings.TrimSpace(walletRef) == "" {
|
||||
m.logger.Warn("walletRef missing for managed key creation", zap.String("network", network))
|
||||
m.logger.Warn("WalletRef missing for managed key creation", zap.String("network", network))
|
||||
return nil, merrors.InvalidArgument("vault key manager: walletRef is required")
|
||||
}
|
||||
if strings.TrimSpace(network) == "" {
|
||||
m.logger.Warn("network missing for managed key creation", zap.String("wallet_ref", walletRef))
|
||||
m.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef))
|
||||
return nil, merrors.InvalidArgument("vault key manager: network is required")
|
||||
}
|
||||
|
||||
privateKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader)
|
||||
if err != nil {
|
||||
m.logger.Warn("failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
||||
m.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error())
|
||||
}
|
||||
privateKeyBytes := crypto.FromECDSA(privateKey)
|
||||
@@ -115,7 +115,7 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string,
|
||||
|
||||
err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address)
|
||||
if err != nil {
|
||||
m.logger.Warn("failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
||||
m.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
||||
zeroBytes(privateKeyBytes)
|
||||
zeroBytes(publicKeyBytes)
|
||||
return nil, err
|
||||
@@ -123,7 +123,7 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string,
|
||||
zeroBytes(privateKeyBytes)
|
||||
zeroBytes(publicKeyBytes)
|
||||
|
||||
m.logger.Info("managed wallet key created",
|
||||
m.logger.Info("Managed wallet key created",
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("network", network),
|
||||
zap.String("address", strings.ToLower(address)),
|
||||
@@ -158,43 +158,43 @@ func (m *Manager) buildKeyID(network, walletRef string) string {
|
||||
// SignTransaction loads the key material from Vault and signs the transaction.
|
||||
func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
|
||||
if strings.TrimSpace(keyID) == "" {
|
||||
m.logger.Warn("signing failed: empty key id")
|
||||
m.logger.Warn("Signing failed: empty key id")
|
||||
return nil, merrors.InvalidArgument("vault key manager: keyID is required")
|
||||
}
|
||||
if tx == nil {
|
||||
m.logger.Warn("signing failed: nil transaction", zap.String("key_id", keyID))
|
||||
m.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID))
|
||||
return nil, merrors.InvalidArgument("vault key manager: transaction is nil")
|
||||
}
|
||||
if chainID == nil {
|
||||
m.logger.Warn("signing failed: nil chain id", zap.String("key_id", keyID))
|
||||
m.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID))
|
||||
return nil, merrors.InvalidArgument("vault key manager: chainID is nil")
|
||||
}
|
||||
|
||||
material, err := m.loadKey(ctx, keyID)
|
||||
if err != nil {
|
||||
m.logger.Warn("failed to load key material", zap.String("key_id", keyID), zap.Error(err))
|
||||
m.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyBytes, err := hex.DecodeString(material.PrivateKey)
|
||||
if err != nil {
|
||||
m.logger.Warn("invalid key material", zap.String("key_id", keyID), zap.Error(err))
|
||||
m.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error())
|
||||
}
|
||||
defer zeroBytes(keyBytes)
|
||||
|
||||
privateKey, err := crypto.ToECDSA(keyBytes)
|
||||
if err != nil {
|
||||
m.logger.Warn("failed to construct private key", zap.String("key_id", keyID), zap.Error(err))
|
||||
m.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error())
|
||||
}
|
||||
|
||||
signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
|
||||
if err != nil {
|
||||
m.logger.Warn("failed to sign transaction", zap.String("key_id", keyID), zap.Error(err))
|
||||
m.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error())
|
||||
}
|
||||
m.logger.Info("transaction signed with managed key",
|
||||
m.logger.Info("Transaction signed with managed key",
|
||||
zap.String("key_id", keyID),
|
||||
zap.String("network", material.Network),
|
||||
zap.String("tx_hash", signed.Hash().Hex()),
|
||||
@@ -213,23 +213,23 @@ func (m *Manager) loadKey(ctx context.Context, keyID string) (*keyMaterial, erro
|
||||
secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/")
|
||||
secret, err := m.store.Get(ctx, secretPath)
|
||||
if err != nil {
|
||||
m.logger.Warn("failed to read secret", zap.String("path", secretPath), zap.Error(err))
|
||||
m.logger.Warn("Failed to read secret", zap.String("path", secretPath), zap.Error(err))
|
||||
return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error())
|
||||
}
|
||||
if secret == nil || secret.Data == nil {
|
||||
m.logger.Warn("secret not found", zap.String("path", secretPath))
|
||||
m.logger.Warn("Secret not found", zap.String("path", secretPath))
|
||||
return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found")
|
||||
}
|
||||
|
||||
getString := func(key string) (string, error) {
|
||||
val, ok := secret.Data[key]
|
||||
if !ok {
|
||||
m.logger.Warn("secret missing field", zap.String("path", secretPath), zap.String("field", key))
|
||||
m.logger.Warn("Secret missing field", zap.String("path", secretPath), zap.String("field", key))
|
||||
return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key)
|
||||
}
|
||||
str, ok := val.(string)
|
||||
if !ok || strings.TrimSpace(str) == "" {
|
||||
m.logger.Warn("secret field invalid", zap.String("path", secretPath), zap.String("field", key))
|
||||
m.logger.Warn("Secret field invalid", zap.String("path", secretPath), zap.String("field", key))
|
||||
return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key)
|
||||
}
|
||||
return str, nil
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||
vaultmanager "github.com/tech/sendico/gateway/chain/internal/keymanager/vault"
|
||||
gatewayservice "github.com/tech/sendico/gateway/chain/internal/service/gateway"
|
||||
@@ -46,11 +47,12 @@ type config struct {
|
||||
}
|
||||
|
||||
type chainConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
RPCURLEnv string `yaml:"rpc_url_env"`
|
||||
ChainID uint64 `yaml:"chain_id"`
|
||||
NativeToken string `yaml:"native_token"`
|
||||
Tokens []tokenConfig `yaml:"tokens"`
|
||||
Name string `yaml:"name"`
|
||||
RPCURLEnv string `yaml:"rpc_url_env"`
|
||||
ChainID uint64 `yaml:"chain_id"`
|
||||
NativeToken string `yaml:"native_token"`
|
||||
Tokens []tokenConfig `yaml:"tokens"`
|
||||
GasTopUpPolicy *gasTopUpPolicyConfig `yaml:"gas_topup_policy"`
|
||||
}
|
||||
|
||||
type serviceWalletConfig struct {
|
||||
@@ -66,6 +68,19 @@ type tokenConfig struct {
|
||||
ContractEnv string `yaml:"contract_env"`
|
||||
}
|
||||
|
||||
type gasTopUpPolicyConfig struct {
|
||||
gasTopUpRuleConfig `yaml:",inline"`
|
||||
Native *gasTopUpRuleConfig `yaml:"native"`
|
||||
Contract *gasTopUpRuleConfig `yaml:"contract"`
|
||||
}
|
||||
|
||||
type gasTopUpRuleConfig struct {
|
||||
BufferPercent float64 `yaml:"buffer_percent"`
|
||||
MinNativeBalanceTRX float64 `yaml:"min_native_balance_trx"`
|
||||
RoundingUnitTRX float64 `yaml:"rounding_unit_trx"`
|
||||
MaxTopUpTRX float64 `yaml:"max_topup_trx"`
|
||||
}
|
||||
|
||||
// Create initialises the chain gateway server implementation.
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
return &Imp{
|
||||
@@ -108,12 +123,12 @@ func (i *Imp) Start() error {
|
||||
cl := i.logger.Named("config")
|
||||
networkConfigs, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains)
|
||||
if err != nil {
|
||||
i.logger.Error("invalid chain network configuration", zap.Error(err))
|
||||
i.logger.Error("Invalid chain network configuration", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs)
|
||||
if err != nil {
|
||||
i.logger.Error("failed to prepare rpc clients", zap.Error(err))
|
||||
i.logger.Error("Failed to prepare rpc clients", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
i.rpcClients = rpcClients
|
||||
@@ -151,7 +166,7 @@ func (i *Imp) Start() error {
|
||||
func (i *Imp) loadConfig() (*config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -159,7 +174,7 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
Config: &grpcapp.Config{},
|
||||
}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("failed to parse configuration", zap.Error(err))
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -183,7 +198,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
|
||||
result := make([]gatewayshared.Network, 0, len(chains))
|
||||
for _, chain := range chains {
|
||||
if strings.TrimSpace(chain.Name) == "" {
|
||||
logger.Warn("skipping unnamed chain configuration")
|
||||
logger.Warn("Skipping unnamed chain configuration")
|
||||
continue
|
||||
}
|
||||
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
|
||||
@@ -195,7 +210,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
|
||||
for _, token := range chain.Tokens {
|
||||
symbol := strings.TrimSpace(token.Symbol)
|
||||
if symbol == "" {
|
||||
logger.Warn("skipping token with empty symbol", zap.String("chain", chain.Name))
|
||||
logger.Warn("Skipping token with empty symbol", zap.String("chain", chain.Name))
|
||||
continue
|
||||
}
|
||||
addr := strings.TrimSpace(token.Contract)
|
||||
@@ -205,9 +220,9 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
|
||||
}
|
||||
if addr == "" {
|
||||
if env != "" {
|
||||
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name))
|
||||
logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name))
|
||||
} else {
|
||||
logger.Warn("token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name))
|
||||
logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -217,17 +232,86 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
|
||||
})
|
||||
}
|
||||
|
||||
gasPolicy, err := buildGasTopUpPolicy(chain.Name, chain.GasTopUpPolicy)
|
||||
if err != nil {
|
||||
logger.Error("Invalid gas top-up policy", zap.String("chain", chain.Name), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, gatewayshared.Network{
|
||||
Name: chain.Name,
|
||||
RPCURL: rpcURL,
|
||||
ChainID: chain.ChainID,
|
||||
NativeToken: chain.NativeToken,
|
||||
TokenConfigs: contracts,
|
||||
Name: chain.Name,
|
||||
RPCURL: rpcURL,
|
||||
ChainID: chain.ChainID,
|
||||
NativeToken: chain.NativeToken,
|
||||
TokenConfigs: contracts,
|
||||
GasTopUpPolicy: gasPolicy,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
defaultRule, defaultSet, err := parseGasTopUpRule(chainName, "default", cfg.gasTopUpRuleConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !defaultSet {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy is required", chainName))
|
||||
}
|
||||
|
||||
policy := &gatewayshared.GasTopUpPolicy{
|
||||
Default: defaultRule,
|
||||
}
|
||||
|
||||
if cfg.Native != nil {
|
||||
rule, set, err := parseGasTopUpRule(chainName, "native", *cfg.Native)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if set {
|
||||
policy.Native = &rule
|
||||
}
|
||||
}
|
||||
if cfg.Contract != nil {
|
||||
rule, set, err := parseGasTopUpRule(chainName, "contract", *cfg.Contract)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if set {
|
||||
policy.Contract = &rule
|
||||
}
|
||||
}
|
||||
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func parseGasTopUpRule(chainName, label string, cfg gasTopUpRuleConfig) (gatewayshared.GasTopUpRule, bool, error) {
|
||||
if cfg.BufferPercent == 0 && cfg.MinNativeBalanceTRX == 0 && cfg.RoundingUnitTRX == 0 && cfg.MaxTopUpTRX == 0 {
|
||||
return gatewayshared.GasTopUpRule{}, false, nil
|
||||
}
|
||||
if cfg.BufferPercent < 0 {
|
||||
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s buffer_percent must be >= 0", chainName, label))
|
||||
}
|
||||
if cfg.MinNativeBalanceTRX < 0 {
|
||||
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s min_native_balance_trx must be >= 0", chainName, label))
|
||||
}
|
||||
if cfg.RoundingUnitTRX <= 0 {
|
||||
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s rounding_unit_trx must be > 0", chainName, label))
|
||||
}
|
||||
if cfg.MaxTopUpTRX <= 0 {
|
||||
return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s max_topup_trx must be > 0", chainName, label))
|
||||
}
|
||||
return gatewayshared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(cfg.BufferPercent),
|
||||
MinNativeBalance: decimal.NewFromFloat(cfg.MinNativeBalanceTRX),
|
||||
RoundingUnit: decimal.NewFromFloat(cfg.RoundingUnitTRX),
|
||||
MaxTopUp: decimal.NewFromFloat(cfg.MaxTopUpTRX),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet {
|
||||
address := strings.TrimSpace(cfg.Address)
|
||||
if address == "" && cfg.AddressEnv != "" {
|
||||
@@ -238,13 +322,13 @@ func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewa
|
||||
|
||||
if address == "" {
|
||||
if cfg.AddressEnv != "" {
|
||||
logger.Warn("service wallet address not configured", zap.String("env", cfg.AddressEnv))
|
||||
logger.Warn("Service wallet address not configured", zap.String("env", cfg.AddressEnv))
|
||||
} else {
|
||||
logger.Warn("service wallet address not configured", zap.String("chain", cfg.Chain))
|
||||
logger.Warn("Service wallet address not configured", zap.String("chain", cfg.Chain))
|
||||
}
|
||||
}
|
||||
if privateKey == "" {
|
||||
logger.Warn("service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
|
||||
logger.Warn("Service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv))
|
||||
}
|
||||
|
||||
return gatewayshared.ServiceWallet{
|
||||
@@ -258,7 +342,7 @@ func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager
|
||||
driver := strings.ToLower(strings.TrimSpace(string(cfg.Driver)))
|
||||
if driver == "" {
|
||||
err := merrors.InvalidArgument("key management driver is not configured")
|
||||
logger.Error("key management driver missing")
|
||||
logger.Error("Key management driver missing")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -267,19 +351,19 @@ func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager
|
||||
settings := vaultmanager.Config{}
|
||||
if len(cfg.Settings) > 0 {
|
||||
if err := mapstructure.Decode(cfg.Settings, &settings); err != nil {
|
||||
logger.Error("failed to decode vault key manager settings", zap.Error(err), zap.Any("settings", cfg.Settings))
|
||||
logger.Error("Failed to decode vault key manager settings", zap.Error(err), zap.Any("settings", cfg.Settings))
|
||||
return nil, merrors.InvalidArgument("invalid vault key manager settings: " + err.Error())
|
||||
}
|
||||
}
|
||||
manager, err := vaultmanager.New(logger, settings)
|
||||
if err != nil {
|
||||
logger.Error("failed to initialise vault key manager", zap.Error(err))
|
||||
logger.Error("Failed to initialise vault key manager", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
return manager, nil
|
||||
default:
|
||||
err := merrors.InvalidArgument("unsupported key management driver: " + driver)
|
||||
logger.Error("unsupported key management driver", zap.String("driver", driver))
|
||||
logger.Error("Unsupported key management driver", zap.String("driver", driver))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ type Registry struct {
|
||||
GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse]
|
||||
ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse]
|
||||
EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse]
|
||||
ComputeGasTopUp Unary[chainv1.ComputeGasTopUpRequest, chainv1.ComputeGasTopUpResponse]
|
||||
EnsureGasTopUp Unary[chainv1.EnsureGasTopUpRequest, chainv1.EnsureGasTopUpResponse]
|
||||
}
|
||||
|
||||
type RegistryDeps struct {
|
||||
@@ -40,5 +42,7 @@ func NewRegistry(deps RegistryDeps) Registry {
|
||||
GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")),
|
||||
ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")),
|
||||
EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")),
|
||||
ComputeGasTopUp: transfer.NewComputeGasTopUp(deps.Transfer.WithLogger("gas_topup.compute")),
|
||||
EnsureGasTopUp: transfer.NewEnsureGasTopUp(deps.Transfer.WithLogger("gas_topup.ensure")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,21 +17,21 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
||||
managedRef := strings.TrimSpace(dest.GetManagedWalletRef())
|
||||
external := strings.TrimSpace(dest.GetExternalAddress())
|
||||
if managedRef != "" && external != "" {
|
||||
deps.Logger.Warn("both managed and external destination provided")
|
||||
deps.Logger.Warn("Both managed and external destination provided")
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address")
|
||||
}
|
||||
if managedRef != "" {
|
||||
wallet, err := deps.Storage.Wallets().Get(ctx, managedRef)
|
||||
if err != nil {
|
||||
deps.Logger.Warn("destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
|
||||
deps.Logger.Warn("Destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef))
|
||||
return model.TransferDestination{}, err
|
||||
}
|
||||
if !strings.EqualFold(wallet.Network, source.Network) {
|
||||
deps.Logger.Warn("destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
|
||||
deps.Logger.Warn("Destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network))
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
|
||||
}
|
||||
if strings.TrimSpace(wallet.DepositAddress) == "" {
|
||||
deps.Logger.Warn("destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
|
||||
deps.Logger.Warn("Destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef))
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address")
|
||||
}
|
||||
return model.TransferDestination{
|
||||
@@ -40,25 +40,26 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
|
||||
}, nil
|
||||
}
|
||||
if external == "" {
|
||||
deps.Logger.Warn("destination external address missing")
|
||||
deps.Logger.Warn("Destination external address missing")
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("destination is required")
|
||||
}
|
||||
if deps.Drivers == nil {
|
||||
deps.Logger.Warn("chain drivers missing", zap.String("network", source.Network))
|
||||
deps.Logger.Warn("Chain drivers missing", zap.String("network", source.Network))
|
||||
return model.TransferDestination{}, merrors.Internal("chain drivers not configured")
|
||||
}
|
||||
chainDriver, err := deps.Drivers.Driver(source.Network)
|
||||
if err != nil {
|
||||
deps.Logger.Warn("unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
|
||||
deps.Logger.Warn("Unsupported chain driver", zap.String("network", source.Network), zap.Error(err))
|
||||
return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet")
|
||||
}
|
||||
normalized, err := chainDriver.NormalizeAddress(external)
|
||||
if err != nil {
|
||||
deps.Logger.Warn("invalid external address", zap.Error(err))
|
||||
deps.Logger.Warn("Invalid external address", zap.Error(err))
|
||||
return model.TransferDestination{}, err
|
||||
}
|
||||
return model.TransferDestination{
|
||||
ExternalAddress: normalized,
|
||||
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||
ExternalAddress: normalized,
|
||||
ExternalAddressOriginal: external,
|
||||
Memo: strings.TrimSpace(dest.GetMemo()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
@@ -23,81 +24,94 @@ func NewEstimateTransfer(deps Deps) *estimateTransferFeeCommand {
|
||||
|
||||
func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) gsresponse.Responder[chainv1.EstimateTransferFeeResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Empty request received")
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||
}
|
||||
|
||||
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||
if sourceWalletRef == "" {
|
||||
c.deps.Logger.Warn("source wallet ref missing")
|
||||
c.deps.Logger.Warn("Source wallet ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||
}
|
||||
amount := req.GetAmount()
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
c.deps.Logger.Warn("amount missing or incomplete")
|
||||
c.deps.Logger.Warn("Amount missing or incomplete")
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||
}
|
||||
|
||||
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||
if !ok {
|
||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||
c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey))
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||
}
|
||||
if c.deps.Drivers == nil {
|
||||
c.deps.Logger.Warn("chain drivers missing", zap.String("network", networkKey))
|
||||
c.deps.Logger.Warn("Chain drivers missing", zap.String("network", networkKey))
|
||||
return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
|
||||
}
|
||||
chainDriver, err := c.deps.Drivers.Driver(networkKey)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("unsupported chain driver", zap.String("network", networkKey), zap.Error(err))
|
||||
c.deps.Logger.Warn("Unsupported chain driver", zap.String("network", networkKey), zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||
}
|
||||
|
||||
dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("invalid destination", zap.Error(err))
|
||||
c.deps.Logger.Warn("Invalid destination", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
destinationAddress, err := destinationAddress(ctx, c.deps, chainDriver, dest)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("failed to resolve destination address", zap.Error(err))
|
||||
c.deps.Logger.Warn("Failed to resolve destination address", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
walletForFee := sourceWallet
|
||||
nativeCurrency := shared.NativeCurrency(networkCfg)
|
||||
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amount.GetCurrency()) {
|
||||
copyWallet := *sourceWallet
|
||||
copyWallet.ContractAddress = ""
|
||||
copyWallet.TokenSymbol = nativeCurrency
|
||||
walletForFee = ©Wallet
|
||||
}
|
||||
|
||||
driverDeps := driver.Deps{
|
||||
Logger: c.deps.Logger,
|
||||
Registry: c.deps.Networks,
|
||||
RPCTimeout: c.deps.RPCTimeout,
|
||||
}
|
||||
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, sourceWallet, destinationAddress, amount)
|
||||
feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, walletForFee, destinationAddress, amount)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("fee estimation failed", zap.Error(err))
|
||||
c.deps.Logger.Warn("Fee estimation failed", zap.Error(err))
|
||||
return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
contextLabel := "erc20_transfer"
|
||||
if strings.TrimSpace(walletForFee.ContractAddress) == "" {
|
||||
contextLabel = "native_transfer"
|
||||
}
|
||||
resp := &chainv1.EstimateTransferFeeResponse{
|
||||
NetworkFee: feeMoney,
|
||||
EstimationContext: "erc20_transfer",
|
||||
EstimationContext: contextLabel,
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/wallet"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/evm"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/tron"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type computeGasTopUpCommand struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewComputeGasTopUp(deps Deps) *computeGasTopUpCommand {
|
||||
return &computeGasTopUpCommand{deps: deps}
|
||||
}
|
||||
|
||||
func (c *computeGasTopUpCommand) Execute(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) gsresponse.Responder[chainv1.ComputeGasTopUpResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||
}
|
||||
|
||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
c.deps.Logger.Warn("Wallet ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
estimatedFee := req.GetEstimatedTotalFee()
|
||||
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||
c.deps.Logger.Warn("Estimated fee missing")
|
||||
return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
|
||||
}
|
||||
|
||||
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, walletRef, estimatedFee)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
logDecision(c.deps.Logger, walletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
|
||||
|
||||
return gsresponse.Success(&chainv1.ComputeGasTopUpResponse{
|
||||
TopupAmount: topUp,
|
||||
CapHit: capHit,
|
||||
})
|
||||
}
|
||||
|
||||
type ensureGasTopUpCommand struct {
|
||||
deps Deps
|
||||
}
|
||||
|
||||
func NewEnsureGasTopUp(deps Deps) *ensureGasTopUpCommand {
|
||||
return &ensureGasTopUpCommand{deps: deps}
|
||||
}
|
||||
|
||||
func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) gsresponse.Responder[chainv1.EnsureGasTopUpResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required"))
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
c.deps.Logger.Warn("Idempotency key missing")
|
||||
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if organizationRef == "" {
|
||||
c.deps.Logger.Warn("Organization ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||
if sourceWalletRef == "" {
|
||||
c.deps.Logger.Warn("Source wallet ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||
}
|
||||
targetWalletRef := strings.TrimSpace(req.GetTargetWalletRef())
|
||||
if targetWalletRef == "" {
|
||||
c.deps.Logger.Warn("Target wallet ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("target_wallet_ref is required"))
|
||||
}
|
||||
estimatedFee := req.GetEstimatedTotalFee()
|
||||
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||
c.deps.Logger.Warn("Estimated fee missing")
|
||||
return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required"))
|
||||
}
|
||||
|
||||
topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, targetWalletRef, estimatedFee)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
logDecision(c.deps.Logger, targetWalletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel)
|
||||
|
||||
if topUp == nil || strings.TrimSpace(topUp.GetAmount()) == "" {
|
||||
return gsresponse.Success(&chainv1.EnsureGasTopUpResponse{
|
||||
TopupAmount: nil,
|
||||
CapHit: capHit,
|
||||
})
|
||||
}
|
||||
|
||||
submitReq := &chainv1.SubmitTransferRequest{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
OrganizationRef: organizationRef,
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef},
|
||||
},
|
||||
Amount: topUp,
|
||||
Metadata: shared.CloneMetadata(req.GetMetadata()),
|
||||
ClientReference: strings.TrimSpace(req.GetClientReference()),
|
||||
}
|
||||
|
||||
submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq)
|
||||
return func(ctx context.Context) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||
submitResp, err := submitResponder(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &chainv1.EnsureGasTopUpResponse{
|
||||
TopupAmount: topUp,
|
||||
CapHit: capHit,
|
||||
Transfer: submitResp.GetTransfer(),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func computeGasTopUp(ctx context.Context, deps Deps, walletRef string, estimatedFee *moneyv1.Money) (*moneyv1.Money, bool, *tron.GasTopUpDecision, *moneyv1.Money, *model.ManagedWallet, error) {
|
||||
walletRef = strings.TrimSpace(walletRef)
|
||||
estimatedFee = shared.CloneMoney(estimatedFee)
|
||||
walletModel, err := deps.Storage.Wallets().Get(ctx, walletRef)
|
||||
if err != nil {
|
||||
return nil, false, nil, nil, nil, err
|
||||
}
|
||||
|
||||
networkKey := strings.ToLower(strings.TrimSpace(walletModel.Network))
|
||||
networkCfg, ok := deps.Networks.Network(networkKey)
|
||||
if !ok {
|
||||
return nil, false, nil, nil, nil, merrors.InvalidArgument("unsupported chain for wallet")
|
||||
}
|
||||
|
||||
nativeBalance, err := nativeBalanceForWallet(ctx, deps, walletModel)
|
||||
if err != nil {
|
||||
return nil, false, nil, nil, nil, err
|
||||
}
|
||||
|
||||
if strings.HasPrefix(networkKey, "tron") {
|
||||
topUp, decision, err := tron.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
|
||||
if err != nil {
|
||||
return nil, false, nil, nil, nil, err
|
||||
}
|
||||
return topUp, decision.CapHit, &decision, nativeBalance, walletModel, nil
|
||||
}
|
||||
|
||||
if networkCfg.GasTopUpPolicy != nil {
|
||||
topUp, capHit, err := evm.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance)
|
||||
if err != nil {
|
||||
return nil, false, nil, nil, nil, err
|
||||
}
|
||||
return topUp, capHit, nil, nativeBalance, walletModel, nil
|
||||
}
|
||||
|
||||
topUp, err := defaultGasTopUp(estimatedFee, nativeBalance)
|
||||
if err != nil {
|
||||
return nil, false, nil, nil, nil, err
|
||||
}
|
||||
return topUp, false, nil, nativeBalance, walletModel, nil
|
||||
}
|
||||
|
||||
func nativeBalanceForWallet(ctx context.Context, deps Deps, walletModel *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
if walletModel == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
walletDeps := wallet.Deps{
|
||||
Logger: deps.Logger.Named("wallet"),
|
||||
Drivers: deps.Drivers,
|
||||
Networks: deps.Networks,
|
||||
KeyManager: nil,
|
||||
Storage: deps.Storage,
|
||||
Clock: deps.Clock,
|
||||
BalanceCacheTTL: 0,
|
||||
RPCTimeout: deps.RPCTimeout,
|
||||
EnsureRepository: deps.EnsureRepository,
|
||||
}
|
||||
_, nativeBalance, err := wallet.OnChainWalletBalances(ctx, walletDeps, walletModel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nativeBalance == nil || strings.TrimSpace(nativeBalance.GetAmount()) == "" || strings.TrimSpace(nativeBalance.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("native balance is unavailable")
|
||||
}
|
||||
return nativeBalance, nil
|
||||
}
|
||||
|
||||
func defaultGasTopUp(estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("estimated fee is required")
|
||||
}
|
||||
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("native balance is required")
|
||||
}
|
||||
if !strings.EqualFold(estimatedFee.GetCurrency(), currentBalance.GetCurrency()) {
|
||||
return nil, merrors.InvalidArgument("native balance currency mismatch")
|
||||
}
|
||||
|
||||
estimated, err := decimal.NewFromString(strings.TrimSpace(estimatedFee.GetAmount()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := decimal.NewFromString(strings.TrimSpace(currentBalance.GetAmount()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
required := estimated.Sub(current)
|
||||
if !required.IsPositive() {
|
||||
return nil, nil
|
||||
}
|
||||
return &moneyv1.Money{
|
||||
Currency: strings.ToUpper(strings.TrimSpace(estimatedFee.GetCurrency())),
|
||||
Amount: required.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func logDecision(logger mlogger.Logger, walletRef string, estimatedFee *moneyv1.Money, nativeBalance *moneyv1.Money, topUp *moneyv1.Money, capHit bool, decision *tron.GasTopUpDecision, walletModel *model.ManagedWallet) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("estimated_total_fee", amountString(estimatedFee)),
|
||||
zap.String("current_native_balance", amountString(nativeBalance)),
|
||||
zap.String("topup_amount", amountString(topUp)),
|
||||
zap.Bool("cap_hit", capHit),
|
||||
}
|
||||
if walletModel != nil {
|
||||
fields = append(fields, zap.String("network", strings.TrimSpace(walletModel.Network)))
|
||||
}
|
||||
if decision != nil {
|
||||
fields = append(fields,
|
||||
zap.String("estimated_total_fee_trx", decision.EstimatedFeeTRX.String()),
|
||||
zap.String("current_native_balance_trx", decision.CurrentBalanceTRX.String()),
|
||||
zap.String("required_trx", decision.RequiredTRX.String()),
|
||||
zap.String("buffered_required_trx", decision.BufferedRequiredTRX.String()),
|
||||
zap.String("min_balance_topup_trx", decision.MinBalanceTopUpTRX.String()),
|
||||
zap.String("raw_topup_trx", decision.RawTopUpTRX.String()),
|
||||
zap.String("rounded_topup_trx", decision.RoundedTopUpTRX.String()),
|
||||
zap.String("topup_trx", decision.TopUpTRX.String()),
|
||||
zap.String("operation_type", decision.OperationType),
|
||||
)
|
||||
}
|
||||
logger.Info("Gas top-up decision", fields...)
|
||||
}
|
||||
|
||||
func amountString(m *moneyv1.Money) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
amount := strings.TrimSpace(m.GetAmount())
|
||||
currency := strings.TrimSpace(m.GetCurrency())
|
||||
if amount == "" && currency == "" {
|
||||
return ""
|
||||
}
|
||||
if currency == "" {
|
||||
return amount
|
||||
}
|
||||
if amount == "" {
|
||||
return currency
|
||||
}
|
||||
return amount + " " + currency
|
||||
}
|
||||
@@ -22,25 +22,25 @@ func NewGetTransfer(deps Deps) *getTransferCommand {
|
||||
|
||||
func (c *getTransferCommand) Execute(ctx context.Context, req *chainv1.GetTransferRequest) gsresponse.Responder[chainv1.GetTransferResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
transferRef := strings.TrimSpace(req.GetTransferRef())
|
||||
if transferRef == "" {
|
||||
c.deps.Logger.Warn("transfer_ref missing")
|
||||
c.deps.Logger.Warn("Transfer_ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required"))
|
||||
}
|
||||
transfer, err := c.deps.Storage.Transfers().Get(ctx, transferRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("not found", zap.String("transfer_ref", transferRef))
|
||||
c.deps.Logger.Warn("Not found", zap.String("transfer_ref", transferRef))
|
||||
return gsresponse.NotFound[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef))
|
||||
c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef))
|
||||
return gsresponse.Auto[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Success(&chainv1.GetTransferResponse{Transfer: toProtoTransfer(transfer)})
|
||||
|
||||
@@ -23,7 +23,7 @@ func NewListTransfers(deps Deps) *listTransfersCommand {
|
||||
|
||||
func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTransfersRequest) gsresponse.Responder[chainv1.ListTransfersResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
filter := model.TransferFilter{}
|
||||
@@ -41,7 +41,7 @@ func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTra
|
||||
|
||||
result, err := c.deps.Storage.Transfers().List(ctx, filter)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("storage list failed", zap.Error(err))
|
||||
c.deps.Logger.Warn("Storage list failed", zap.Error(err))
|
||||
return gsresponse.Auto[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,94 +25,102 @@ func NewSubmitTransfer(deps Deps) *submitTransferCommand {
|
||||
|
||||
func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
c.deps.Logger.Warn("missing idempotency key")
|
||||
c.deps.Logger.Warn("Missing idempotency key")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if organizationRef == "" {
|
||||
c.deps.Logger.Warn("missing organization ref")
|
||||
c.deps.Logger.Warn("Missing organization ref")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef())
|
||||
if sourceWalletRef == "" {
|
||||
c.deps.Logger.Warn("missing source wallet ref")
|
||||
c.deps.Logger.Warn("Missing source wallet ref")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required"))
|
||||
}
|
||||
amount := req.GetAmount()
|
||||
if amount == nil {
|
||||
c.deps.Logger.Warn("missing amount")
|
||||
c.deps.Logger.Warn("Missing amount")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required"))
|
||||
}
|
||||
amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency()))
|
||||
if amountCurrency == "" {
|
||||
c.deps.Logger.Warn("missing amount currency")
|
||||
c.deps.Logger.Warn("Missing amount currency")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required"))
|
||||
}
|
||||
amountValue := strings.TrimSpace(amount.GetAmount())
|
||||
if amountValue == "" {
|
||||
c.deps.Logger.Warn("missing amount value")
|
||||
c.deps.Logger.Warn("Missing amount value")
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
|
||||
}
|
||||
|
||||
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) {
|
||||
c.deps.Logger.Warn("organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
|
||||
c.deps.Logger.Warn("Organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet"))
|
||||
}
|
||||
networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network))
|
||||
networkCfg, ok := c.deps.Networks.Network(networkKey)
|
||||
if !ok {
|
||||
c.deps.Logger.Warn("unsupported chain", zap.String("network", networkKey))
|
||||
c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
|
||||
}
|
||||
|
||||
destination, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef()))
|
||||
return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("invalid destination", zap.Error(err))
|
||||
c.deps.Logger.Warn("Invalid destination", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
fees, feeSum, err := convertFees(req.GetFees(), amountCurrency)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("fee conversion failed", zap.Error(err))
|
||||
c.deps.Logger.Warn("Fee conversion failed", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
amountDec, err := decimal.NewFromString(amountValue)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("invalid amount", zap.Error(err))
|
||||
c.deps.Logger.Warn("Invalid amount", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount"))
|
||||
}
|
||||
netDec := amountDec.Sub(feeSum)
|
||||
if netDec.IsNegative() {
|
||||
c.deps.Logger.Warn("fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
|
||||
c.deps.Logger.Warn("Fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String()))
|
||||
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount"))
|
||||
}
|
||||
|
||||
netAmount := shared.CloneMoney(amount)
|
||||
netAmount.Amount = netDec.String()
|
||||
|
||||
effectiveTokenSymbol := sourceWallet.TokenSymbol
|
||||
effectiveContractAddress := sourceWallet.ContractAddress
|
||||
nativeCurrency := shared.NativeCurrency(networkCfg)
|
||||
if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amountCurrency) {
|
||||
effectiveTokenSymbol = nativeCurrency
|
||||
effectiveContractAddress = ""
|
||||
}
|
||||
|
||||
transfer := &model.Transfer{
|
||||
IdempotencyKey: idempotencyKey,
|
||||
TransferRef: shared.GenerateTransferRef(),
|
||||
@@ -120,8 +128,8 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: destination,
|
||||
Network: sourceWallet.Network,
|
||||
TokenSymbol: sourceWallet.TokenSymbol,
|
||||
ContractAddress: sourceWallet.ContractAddress,
|
||||
TokenSymbol: effectiveTokenSymbol,
|
||||
ContractAddress: effectiveContractAddress,
|
||||
RequestedAmount: shared.CloneMoney(amount),
|
||||
NetAmount: netAmount,
|
||||
Fees: fees,
|
||||
@@ -133,10 +141,10 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
|
||||
saved, err := c.deps.Storage.Transfers().Create(ctx, transfer)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
c.deps.Logger.Debug("transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
|
||||
c.deps.Logger.Debug("Transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey))
|
||||
return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)})
|
||||
}
|
||||
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||
c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||
return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,41 +29,41 @@ func NewGetWalletBalance(deps Deps) *getWalletBalanceCommand {
|
||||
|
||||
func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetWalletBalanceRequest) gsresponse.Responder[chainv1.GetWalletBalanceResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
c.deps.Logger.Warn("wallet_ref missing")
|
||||
c.deps.Logger.Warn("Wallet_ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("Not found", zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet)
|
||||
tokenBalance, nativeBalance, chainErr := OnChainWalletBalances(ctx, c.deps, wallet)
|
||||
if chainErr != nil {
|
||||
c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("On-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
||||
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("cached balance not found", zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("Cached balance not found", zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
|
||||
}
|
||||
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if c.isCachedBalanceStale(stored) {
|
||||
c.deps.Logger.Warn("cached balance is stale",
|
||||
c.deps.Logger.Info("Cached balance is stale",
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.Time("calculated_at", stored.CalculatedAt),
|
||||
zap.Duration("ttl", c.cacheTTL()),
|
||||
@@ -74,39 +74,49 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetW
|
||||
}
|
||||
|
||||
calculatedAt := c.now()
|
||||
c.persistCachedBalance(ctx, walletRef, balance, calculatedAt)
|
||||
c.persistCachedBalance(ctx, walletRef, tokenBalance, nativeBalance, calculatedAt)
|
||||
|
||||
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
|
||||
Balance: onChainBalanceToProto(balance, calculatedAt),
|
||||
Balance: onChainBalanceToProto(tokenBalance, nativeBalance, calculatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
func onChainBalanceToProto(balance *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
|
||||
if balance == nil {
|
||||
func onChainBalanceToProto(balance *moneyv1.Money, native *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
|
||||
if balance == nil && native == nil {
|
||||
return nil
|
||||
}
|
||||
zero := zeroMoney(balance.Currency)
|
||||
currency := ""
|
||||
if balance != nil {
|
||||
currency = balance.Currency
|
||||
}
|
||||
zero := zeroMoney(currency)
|
||||
return &chainv1.WalletBalance{
|
||||
Available: balance,
|
||||
NativeAvailable: native,
|
||||
PendingInbound: zero,
|
||||
PendingOutbound: zero,
|
||||
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, calculatedAt time.Time) {
|
||||
if available == nil {
|
||||
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, nativeAvailable *moneyv1.Money, calculatedAt time.Time) {
|
||||
if available == nil && nativeAvailable == nil {
|
||||
return
|
||||
}
|
||||
record := &model.WalletBalance{
|
||||
WalletRef: walletRef,
|
||||
Available: shared.CloneMoney(available),
|
||||
PendingInbound: zeroMoney(available.Currency),
|
||||
PendingOutbound: zeroMoney(available.Currency),
|
||||
NativeAvailable: shared.CloneMoney(nativeAvailable),
|
||||
CalculatedAt: calculatedAt,
|
||||
}
|
||||
currency := ""
|
||||
if available != nil {
|
||||
currency = available.Currency
|
||||
}
|
||||
record.PendingInbound = zeroMoney(currency)
|
||||
record.PendingOutbound = zeroMoney(currency)
|
||||
if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil {
|
||||
c.deps.Logger.Warn("failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||
c.deps.Logger.Warn("Failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,88 +25,90 @@ func NewCreateManagedWallet(deps Deps) *createManagedWalletCommand {
|
||||
|
||||
func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.CreateManagedWalletRequest) gsresponse.Responder[chainv1.CreateManagedWalletResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||
if idempotencyKey == "" {
|
||||
c.deps.Logger.Warn("missing idempotency key")
|
||||
c.deps.Logger.Warn("Missing idempotency key")
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
|
||||
}
|
||||
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
|
||||
if organizationRef == "" {
|
||||
c.deps.Logger.Warn("missing organization ref")
|
||||
c.deps.Logger.Warn("Missing organization ref")
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
ownerRef := strings.TrimSpace(req.GetOwnerRef())
|
||||
if ownerRef == "" {
|
||||
c.deps.Logger.Warn("missing owner ref")
|
||||
c.deps.Logger.Warn("Missing owner ref")
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("owner_ref is required"))
|
||||
}
|
||||
|
||||
asset := req.GetAsset()
|
||||
if asset == nil {
|
||||
c.deps.Logger.Warn("missing asset")
|
||||
c.deps.Logger.Warn("Missing asset")
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required"))
|
||||
}
|
||||
|
||||
chainKey, _ := shared.ChainKeyFromEnum(asset.GetChain())
|
||||
if chainKey == "" {
|
||||
c.deps.Logger.Warn("unsupported chain", zap.Any("chain", asset.GetChain()))
|
||||
c.deps.Logger.Warn("Unsupported chain", zap.Any("chain", asset.GetChain()))
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||
}
|
||||
networkCfg, ok := c.deps.Networks.Network(chainKey)
|
||||
if !ok {
|
||||
c.deps.Logger.Warn("unsupported chain in config", zap.String("chain", chainKey))
|
||||
c.deps.Logger.Warn("Unsupported chain in config", zap.String("chain", chainKey))
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||
}
|
||||
if c.deps.Drivers == nil {
|
||||
c.deps.Logger.Warn("chain drivers missing", zap.String("chain", chainKey))
|
||||
c.deps.Logger.Warn("Chain drivers missing", zap.String("chain", chainKey))
|
||||
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
|
||||
}
|
||||
chainDriver, err := c.deps.Drivers.Driver(chainKey)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("unsupported chain driver", zap.String("chain", chainKey), zap.Error(err))
|
||||
c.deps.Logger.Warn("Unsupported chain driver", zap.String("chain", chainKey), zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
|
||||
}
|
||||
|
||||
tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||
if tokenSymbol == "" {
|
||||
c.deps.Logger.Warn("missing token symbol")
|
||||
c.deps.Logger.Warn("Missing token symbol")
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required"))
|
||||
}
|
||||
contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress()))
|
||||
if contractAddress == "" {
|
||||
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
||||
if contractAddress == "" {
|
||||
c.deps.Logger.Warn("unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
|
||||
if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) {
|
||||
contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol)
|
||||
if contractAddress == "" {
|
||||
c.deps.Logger.Warn("Unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey))
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walletRef := shared.GenerateWalletRef()
|
||||
if c.deps.KeyManager == nil {
|
||||
c.deps.Logger.Warn("key manager missing")
|
||||
c.deps.Logger.Warn("Key manager missing")
|
||||
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager not configured"))
|
||||
}
|
||||
|
||||
keyInfo, err := c.deps.KeyManager.CreateManagedWalletKey(ctx, walletRef, chainKey)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("key manager error", zap.Error(err))
|
||||
c.deps.Logger.Warn("Key manager error", zap.Error(err))
|
||||
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" {
|
||||
c.deps.Logger.Warn("key manager returned empty address")
|
||||
c.deps.Logger.Warn("Key manager returned empty address")
|
||||
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
|
||||
}
|
||||
depositAddress, err := chainDriver.FormatAddress(keyInfo.Address)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("invalid derived deposit address", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||
c.deps.Logger.Warn("Invalid derived deposit address", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
@@ -154,10 +156,10 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
|
||||
created, err := c.deps.Storage.Wallets().Create(ctx, wallet)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
c.deps.Logger.Debug("wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
|
||||
c.deps.Logger.Debug("Wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey))
|
||||
return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)})
|
||||
}
|
||||
c.deps.Logger.Warn("storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,25 +22,25 @@ func NewGetManagedWallet(deps Deps) *getManagedWalletCommand {
|
||||
|
||||
func (c *getManagedWalletCommand) Execute(ctx context.Context, req *chainv1.GetManagedWalletRequest) gsresponse.Responder[chainv1.GetManagedWalletResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
if req == nil {
|
||||
c.deps.Logger.Warn("nil request")
|
||||
c.deps.Logger.Warn("Nil request")
|
||||
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
walletRef := strings.TrimSpace(req.GetWalletRef())
|
||||
if walletRef == "" {
|
||||
c.deps.Logger.Warn("wallet_ref missing")
|
||||
c.deps.Logger.Warn("Wallet_ref missing")
|
||||
return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required"))
|
||||
}
|
||||
wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.deps.Logger.Warn("not found", zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("Not found", zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.NotFound[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef))
|
||||
return gsresponse.Auto[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
return gsresponse.Success(&chainv1.GetManagedWalletResponse{Wallet: toProtoManagedWallet(wallet)})
|
||||
|
||||
@@ -23,7 +23,7 @@ func NewListManagedWallets(deps Deps) *listManagedWalletsCommand {
|
||||
|
||||
func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.ListManagedWalletsRequest) gsresponse.Responder[chainv1.ListManagedWalletsResponse] {
|
||||
if err := c.deps.EnsureRepository(ctx); err != nil {
|
||||
c.deps.Logger.Warn("repository unavailable", zap.Error(err))
|
||||
c.deps.Logger.Warn("Repository unavailable", zap.Error(err))
|
||||
return gsresponse.Unavailable[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
filter := model.ManagedWalletFilter{}
|
||||
@@ -42,7 +42,7 @@ func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.Li
|
||||
|
||||
result, err := c.deps.Storage.Wallets().List(ctx, filter)
|
||||
if err != nil {
|
||||
c.deps.Logger.Warn("storage list failed", zap.Error(err))
|
||||
c.deps.Logger.Warn("Storage list failed", zap.Error(err))
|
||||
return gsresponse.Auto[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,16 +12,16 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
func OnChainWalletBalances(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, *moneyv1.Money, error) {
|
||||
logger := deps.Logger
|
||||
if wallet == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
return nil, nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
if deps.Networks == nil {
|
||||
return nil, merrors.Internal("rpc clients not initialised")
|
||||
return nil, nil, merrors.Internal("rpc clients not initialised")
|
||||
}
|
||||
if deps.Drivers == nil {
|
||||
return nil, merrors.Internal("chain drivers not configured")
|
||||
return nil, nil, merrors.Internal("chain drivers not configured")
|
||||
}
|
||||
|
||||
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
|
||||
@@ -31,7 +31,7 @@ func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedW
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", networkKey),
|
||||
)
|
||||
return nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey))
|
||||
return nil, nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey))
|
||||
}
|
||||
|
||||
chainDriver, err := deps.Drivers.Driver(networkKey)
|
||||
@@ -41,7 +41,7 @@ func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedW
|
||||
zap.String("network", networkKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, merrors.InvalidArgument("unsupported chain")
|
||||
return nil, nil, merrors.InvalidArgument("unsupported chain")
|
||||
}
|
||||
|
||||
driverDeps := driver.Deps{
|
||||
@@ -50,5 +50,13 @@ func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedW
|
||||
KeyManager: deps.KeyManager,
|
||||
RPCTimeout: deps.RPCTimeout,
|
||||
}
|
||||
return chainDriver.Balance(ctx, driverDeps, network, wallet)
|
||||
tokenBalance, err := chainDriver.Balance(ctx, driverDeps, network, wallet)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
nativeBalance, err := chainDriver.NativeBalance(ctx, driverDeps, network, wallet)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return tokenBalance, nativeBalance, nil
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ func toProtoWalletBalance(balance *model.WalletBalance) *chainv1.WalletBalance {
|
||||
}
|
||||
return &chainv1.WalletBalance{
|
||||
Available: shared.CloneMoney(balance.Available),
|
||||
NativeAvailable: shared.CloneMoney(balance.NativeAvailable),
|
||||
PendingInbound: shared.CloneMoney(balance.PendingInbound),
|
||||
PendingOutbound: shared.CloneMoney(balance.PendingOutbound),
|
||||
CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()),
|
||||
|
||||
@@ -27,25 +27,25 @@ func (d *Driver) Name() string {
|
||||
}
|
||||
|
||||
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||
d.logger.Debug("format address", zap.String("address", address))
|
||||
d.logger.Debug("Format address", zap.String("address", address))
|
||||
normalized, err := evm.NormalizeAddress(address)
|
||||
if err != nil {
|
||||
d.logger.Warn("format address failed", zap.String("address", address), zap.Error(err))
|
||||
d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
|
||||
}
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||
d.logger.Debug("normalize address", zap.String("address", address))
|
||||
d.logger.Debug("Normalize address", zap.String("address", address))
|
||||
normalized, err := evm.NormalizeAddress(address)
|
||||
if err != nil {
|
||||
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err))
|
||||
d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
|
||||
}
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("balance request",
|
||||
d.logger.Debug("Balance request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
@@ -53,13 +53,38 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("balance failed",
|
||||
d.logger.Warn("Balance failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("balance result",
|
||||
d.logger.Debug("Balance result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
zap.String("currency", result.Currency),
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("Native balance request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("Native balance failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Native balance result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
@@ -70,7 +95,7 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
|
||||
}
|
||||
|
||||
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("estimate fee request",
|
||||
d.logger.Debug("Estimate fee request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", destination),
|
||||
@@ -79,13 +104,13 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||
if err != nil {
|
||||
d.logger.Warn("estimate fee failed",
|
||||
d.logger.Warn("Estimate fee failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("estimate fee result",
|
||||
d.logger.Debug("Estimate fee result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
@@ -96,7 +121,7 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
||||
}
|
||||
|
||||
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||
d.logger.Debug("submit transfer request",
|
||||
d.logger.Debug("Submit transfer request",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", destination),
|
||||
@@ -105,13 +130,13 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s
|
||||
driverDeps.Logger = d.logger
|
||||
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
|
||||
if err != nil {
|
||||
d.logger.Warn("submit transfer failed",
|
||||
d.logger.Warn("Submit transfer failed",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
d.logger.Debug("submit transfer result",
|
||||
d.logger.Debug("Submit transfer result",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("tx_hash", txHash),
|
||||
@@ -121,7 +146,7 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s
|
||||
}
|
||||
|
||||
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||
d.logger.Debug("await confirmation",
|
||||
d.logger.Debug("Await confirmation",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
@@ -129,13 +154,13 @@ func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, networ
|
||||
driverDeps.Logger = d.logger
|
||||
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||
if err != nil {
|
||||
d.logger.Warn("await confirmation failed",
|
||||
d.logger.Warn("Await confirmation failed",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if receipt != nil {
|
||||
d.logger.Debug("await confirmation result",
|
||||
d.logger.Debug("Await confirmation result",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||
|
||||
@@ -27,6 +27,7 @@ type Driver interface {
|
||||
FormatAddress(address string) (string, error)
|
||||
NormalizeAddress(address string) (string, error)
|
||||
Balance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
|
||||
NativeBalance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error)
|
||||
EstimateFee(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error)
|
||||
SubmitTransfer(ctx context.Context, deps Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error)
|
||||
AwaitConfirmation(ctx context.Context, deps Deps, network shared.Network, txHash string) (*types.Receipt, error)
|
||||
|
||||
@@ -27,25 +27,25 @@ func (d *Driver) Name() string {
|
||||
}
|
||||
|
||||
func (d *Driver) FormatAddress(address string) (string, error) {
|
||||
d.logger.Debug("format address", zap.String("address", address))
|
||||
d.logger.Debug("Format address", zap.String("address", address))
|
||||
normalized, err := evm.NormalizeAddress(address)
|
||||
if err != nil {
|
||||
d.logger.Warn("format address failed", zap.String("address", address), zap.Error(err))
|
||||
d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err))
|
||||
}
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||
d.logger.Debug("normalize address", zap.String("address", address))
|
||||
d.logger.Debug("Normalize address", zap.String("address", address))
|
||||
normalized, err := evm.NormalizeAddress(address)
|
||||
if err != nil {
|
||||
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err))
|
||||
d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
|
||||
}
|
||||
return normalized, err
|
||||
}
|
||||
|
||||
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("balance request",
|
||||
d.logger.Debug("Balance request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
@@ -53,13 +53,38 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.Balance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("balance failed",
|
||||
d.logger.Warn("Balance failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("balance result",
|
||||
d.logger.Debug("Balance result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
zap.String("currency", result.Currency),
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("Native balance request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, wallet.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("Native balance failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Native balance result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
@@ -70,7 +95,7 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
|
||||
}
|
||||
|
||||
func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
d.logger.Debug("estimate fee request",
|
||||
d.logger.Debug("Estimate fee request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", destination),
|
||||
@@ -79,13 +104,13 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, wallet.DepositAddress, destination, amount)
|
||||
if err != nil {
|
||||
d.logger.Warn("estimate fee failed",
|
||||
d.logger.Warn("Estimate fee failed",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("estimate fee result",
|
||||
d.logger.Debug("Estimate fee result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
@@ -96,7 +121,7 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
||||
}
|
||||
|
||||
func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) {
|
||||
d.logger.Debug("submit transfer request",
|
||||
d.logger.Debug("Submit transfer request",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("destination", destination),
|
||||
@@ -105,13 +130,13 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s
|
||||
driverDeps.Logger = d.logger
|
||||
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, source.DepositAddress, destination)
|
||||
if err != nil {
|
||||
d.logger.Warn("submit transfer failed",
|
||||
d.logger.Warn("Submit transfer failed",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
d.logger.Debug("submit transfer result",
|
||||
d.logger.Debug("Submit transfer result",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("tx_hash", txHash),
|
||||
@@ -121,7 +146,7 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s
|
||||
}
|
||||
|
||||
func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||
d.logger.Debug("await confirmation",
|
||||
d.logger.Debug("Await confirmation",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
@@ -129,13 +154,13 @@ func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, networ
|
||||
driverDeps.Logger = d.logger
|
||||
receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash)
|
||||
if err != nil {
|
||||
d.logger.Warn("await confirmation failed",
|
||||
d.logger.Warn("Await confirmation failed",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else if receipt != nil {
|
||||
d.logger.Debug("await confirmation result",
|
||||
d.logger.Debug("Await confirmation result",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package evm
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTronEstimateCallUsesData(t *testing.T) {
|
||||
from := common.HexToAddress("0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8")
|
||||
to := common.HexToAddress("0xa614f803b6fd780986a42c78ec9c7f77e6ded13c")
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: from,
|
||||
To: &to,
|
||||
GasPrice: big.NewInt(100),
|
||||
Data: []byte{0xa9, 0x05, 0x9c, 0xbb},
|
||||
}
|
||||
|
||||
call := tronEstimateCall(callMsg)
|
||||
|
||||
require.Equal(t, strings.ToLower(from.Hex()), call["from"])
|
||||
require.Equal(t, strings.ToLower(to.Hex()), call["to"])
|
||||
require.Equal(t, "0x64", call["gasPrice"])
|
||||
require.Equal(t, "0xa9059cbb", call["data"])
|
||||
_, hasInput := call["input"]
|
||||
require.False(t, hasInput)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/ethereum/go-ethereum"
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/common/hexutil"
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/ethereum/go-ethereum/rpc"
|
||||
"github.com/shopspring/decimal"
|
||||
@@ -70,9 +71,32 @@ func NormalizeAddress(address string) (string, error) {
|
||||
return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil
|
||||
}
|
||||
|
||||
func nativeCurrency(network shared.Network) string {
|
||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||
if currency == "" {
|
||||
currency = strings.ToUpper(network.Name)
|
||||
}
|
||||
return currency
|
||||
}
|
||||
|
||||
func parseBaseUnitAmount(amount string) (*big.Int, error) {
|
||||
trimmed := strings.TrimSpace(amount)
|
||||
if trimmed == "" {
|
||||
return nil, merrors.InvalidArgument("amount is required")
|
||||
}
|
||||
value, ok := new(big.Int).SetString(trimmed, 10)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("invalid amount")
|
||||
}
|
||||
if value.Sign() < 0 {
|
||||
return nil, merrors.InvalidArgument("amount must be non-negative")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Balance fetches ERC20 token balance for the provided address.
|
||||
func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
|
||||
logger := deps.Logger
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if registry == nil {
|
||||
@@ -101,7 +125,11 @@ func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wall
|
||||
}
|
||||
|
||||
contract := strings.TrimSpace(wallet.ContractAddress)
|
||||
if contract == "" || !common.IsHexAddress(contract) {
|
||||
if contract == "" {
|
||||
logger.Debug("Native balance requested", logFields...)
|
||||
return NativeBalance(ctx, deps, network, wallet, normalizedAddress)
|
||||
}
|
||||
if !common.IsHexAddress(contract) {
|
||||
logger.Warn("Invalid contract address for balance fetch", logFields...)
|
||||
return nil, merrors.InvalidArgument("invalid contract address")
|
||||
}
|
||||
@@ -146,9 +174,67 @@ func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wall
|
||||
return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil
|
||||
}
|
||||
|
||||
// NativeBalance fetches native token balance for the provided address.
|
||||
func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) {
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if registry == nil {
|
||||
return nil, merrors.Internal("rpc clients not initialised")
|
||||
}
|
||||
if wallet == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
|
||||
normalizedAddress, err := NormalizeAddress(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
logFields := []zap.Field{
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", strings.ToLower(strings.TrimSpace(network.Name))),
|
||||
zap.String("wallet_address", normalizedAddress),
|
||||
}
|
||||
if rpcURL == "" {
|
||||
logger.Warn("Network rpc url is not configured", logFields...)
|
||||
return nil, merrors.Internal("network rpc url is not configured")
|
||||
}
|
||||
|
||||
client, err := registry.Client(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeout := deps.RPCTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
bal, err := client.BalanceAt(timeoutCtx, common.HexToAddress(normalizedAddress), nil)
|
||||
if err != nil {
|
||||
logger.Warn("Native balance call failed", append(logFields, zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Info("On-chain native balance fetched",
|
||||
append(logFields,
|
||||
zap.String("balance_raw", bal.String()),
|
||||
)...,
|
||||
)
|
||||
return &moneyv1.Money{
|
||||
Currency: nativeCurrency(network),
|
||||
Amount: bal.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EstimateFee estimates ERC20 transfer fees for the given parameters.
|
||||
func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, fromAddress, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
logger := deps.Logger
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if registry == nil {
|
||||
@@ -165,12 +251,6 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
||||
if rpcURL == "" {
|
||||
return nil, merrors.InvalidArgument("network rpc url not configured")
|
||||
}
|
||||
if strings.TrimSpace(wallet.ContractAddress) == "" {
|
||||
return nil, merrors.NotImplemented("native token transfers not supported")
|
||||
}
|
||||
if !common.IsHexAddress(wallet.ContractAddress) {
|
||||
return nil, merrors.InvalidArgument("invalid token contract address")
|
||||
}
|
||||
if _, err := NormalizeAddress(fromAddress); err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid source wallet address")
|
||||
}
|
||||
@@ -180,10 +260,12 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
||||
|
||||
client, err := registry.Client(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to resolve client", zap.Error(err), zap.String("network_name", network.Name))
|
||||
return nil, err
|
||||
}
|
||||
rpcClient, err := registry.RPCClient(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to resolve RPC client", zap.Error(err), zap.String("network_name", network.Name))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -194,10 +276,46 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
tokenAddr := common.HexToAddress(wallet.ContractAddress)
|
||||
contract := strings.TrimSpace(wallet.ContractAddress)
|
||||
toAddr := common.HexToAddress(destination)
|
||||
fromAddr := common.HexToAddress(fromAddress)
|
||||
|
||||
if contract == "" {
|
||||
amountBase, err := parseBaseUnitAmount(amount.GetAmount())
|
||||
if err != nil {
|
||||
logger.Warn("Failed to parse base unit amount", zap.Error(err), zap.String("amount", amount.GetAmount()))
|
||||
return nil, err
|
||||
}
|
||||
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to suggest gas price", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
||||
}
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: fromAddr,
|
||||
To: &toAddr,
|
||||
GasPrice: gasPrice,
|
||||
Value: amountBase,
|
||||
}
|
||||
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_mesasge", callMsg))
|
||||
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
||||
}
|
||||
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
||||
feeDec := decimal.NewFromBigInt(fee, 0)
|
||||
return &moneyv1.Money{
|
||||
Currency: nativeCurrency(network),
|
||||
Amount: feeDec.String(),
|
||||
}, nil
|
||||
}
|
||||
if !common.IsHexAddress(contract) {
|
||||
logger.Warn("Failed to validate contract", zap.String("contract", contract))
|
||||
return nil, merrors.InvalidArgument("invalid token contract address")
|
||||
}
|
||||
|
||||
tokenAddr := common.HexToAddress(contract)
|
||||
|
||||
decimals, err := erc20Decimals(timeoutCtx, rpcClient, tokenAddr)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to read token decimals", zap.Error(err))
|
||||
@@ -211,11 +329,13 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
||||
|
||||
input, err := erc20ABI.Pack("transfer", toAddr, amountBase)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to encode transfer call", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to encode transfer call: " + err.Error())
|
||||
}
|
||||
|
||||
gasPrice, err := client.SuggestGasPrice(timeoutCtx)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to suggest gas price", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to suggest gas price: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -225,28 +345,24 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network,
|
||||
GasPrice: gasPrice,
|
||||
Data: input,
|
||||
}
|
||||
gasLimit, err := client.EstimateGas(timeoutCtx, callMsg)
|
||||
gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_message", callMsg))
|
||||
return nil, merrors.Internal("failed to estimate gas: " + err.Error())
|
||||
}
|
||||
|
||||
fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit))
|
||||
feeDec := decimal.NewFromBigInt(fee, 0)
|
||||
|
||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||
if currency == "" {
|
||||
currency = strings.ToUpper(network.Name)
|
||||
}
|
||||
|
||||
return &moneyv1.Money{
|
||||
Currency: currency,
|
||||
Currency: nativeCurrency(network),
|
||||
Amount: feeDec.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SubmitTransfer submits an ERC20 transfer on an EVM-compatible chain.
|
||||
func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, fromAddress, destination string) (string, error) {
|
||||
logger := deps.Logger
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if deps.KeyManager == nil {
|
||||
@@ -278,7 +394,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
|
||||
return "", executorInvalid("invalid destination address " + destination)
|
||||
}
|
||||
|
||||
logger.Info("submitting transfer",
|
||||
logger.Info("Submitting transfer",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("source_wallet_ref", source.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
@@ -287,12 +403,12 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
|
||||
|
||||
client, err := registry.Client(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to initialise rpc client", zap.Error(err), zap.String("network", network.Name))
|
||||
logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name))
|
||||
return "", err
|
||||
}
|
||||
rpcClient, err := registry.RPCClient(network.Name)
|
||||
if err != nil {
|
||||
logger.Warn("failed to initialise rpc client", zap.String("network", network.Name))
|
||||
logger.Warn("Failed to initialise RPC client", zap.String("network", network.Name))
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -322,66 +438,86 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
|
||||
|
||||
chainID := new(big.Int).SetUint64(network.ChainID)
|
||||
|
||||
if strings.TrimSpace(transfer.ContractAddress) == "" {
|
||||
logger.Warn("Native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
|
||||
return "", merrors.NotImplemented("executor: native token transfers not yet supported")
|
||||
}
|
||||
|
||||
if !common.IsHexAddress(transfer.ContractAddress) {
|
||||
logger.Warn("Invalid token contract address",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", transfer.ContractAddress),
|
||||
)
|
||||
return "", executorInvalid("invalid token contract address " + transfer.ContractAddress)
|
||||
}
|
||||
tokenAddress := common.HexToAddress(transfer.ContractAddress)
|
||||
|
||||
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to read token decimals", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", transfer.ContractAddress),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
contract := strings.TrimSpace(transfer.ContractAddress)
|
||||
amount := transfer.NetAmount
|
||||
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
||||
logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
||||
return "", executorInvalid("transfer missing net amount")
|
||||
}
|
||||
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to convert amount to base units", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("amount", amount.Amount),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
input, err := erc20ABI.Pack("transfer", destinationAddr, amountInt)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to encode transfer call", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
)
|
||||
return "", executorInternal("failed to encode transfer call", err)
|
||||
}
|
||||
var tx *types.Transaction
|
||||
if contract == "" {
|
||||
amountInt, err := parseBaseUnitAmount(amount.Amount)
|
||||
if err != nil {
|
||||
logger.Warn("Invalid native amount", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
|
||||
return "", err
|
||||
}
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: sourceAddress,
|
||||
To: &destinationAddr,
|
||||
GasPrice: gasPrice,
|
||||
Value: amountInt,
|
||||
}
|
||||
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
)
|
||||
return "", executorInternal("failed to estimate gas", err)
|
||||
}
|
||||
tx = types.NewTransaction(nonce, destinationAddr, amountInt, gasLimit, gasPrice, nil)
|
||||
} else {
|
||||
if !common.IsHexAddress(contract) {
|
||||
logger.Warn("Invalid token contract address",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", contract),
|
||||
)
|
||||
return "", executorInvalid("invalid token contract address " + contract)
|
||||
}
|
||||
tokenAddress := common.HexToAddress(contract)
|
||||
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: sourceAddress,
|
||||
To: &tokenAddress,
|
||||
GasPrice: gasPrice,
|
||||
Data: input,
|
||||
}
|
||||
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
)
|
||||
return "", executorInternal("failed to estimate gas", err)
|
||||
}
|
||||
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to read token decimals", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", contract),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
tx := types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input)
|
||||
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to convert amount to base units", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("amount", amount.Amount),
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
|
||||
input, err := erc20ABI.Pack("transfer", destinationAddr, amountInt)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to encode transfer call", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
)
|
||||
return "", executorInternal("failed to encode transfer call", err)
|
||||
}
|
||||
|
||||
callMsg := ethereum.CallMsg{
|
||||
From: sourceAddress,
|
||||
To: &tokenAddress,
|
||||
GasPrice: gasPrice,
|
||||
Data: input,
|
||||
}
|
||||
gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to estimate gas", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
)
|
||||
return "", executorInternal("failed to estimate gas", err)
|
||||
}
|
||||
|
||||
tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input)
|
||||
}
|
||||
|
||||
signedTx, err := deps.KeyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||
if err != nil {
|
||||
@@ -411,7 +547,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
|
||||
|
||||
// AwaitConfirmation waits for the transaction receipt.
|
||||
func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||
logger := deps.Logger
|
||||
logger := deps.Logger.Named("evm")
|
||||
registry := deps.Registry
|
||||
|
||||
if strings.TrimSpace(txHash) == "" {
|
||||
@@ -526,6 +662,63 @@ func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address
|
||||
return val, nil
|
||||
}
|
||||
|
||||
type gasEstimator interface {
|
||||
EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error)
|
||||
}
|
||||
|
||||
func estimateGas(ctx context.Context, network shared.Network, client gasEstimator, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) {
|
||||
if isTronNetwork(network) {
|
||||
if rpcClient == nil {
|
||||
return 0, merrors.Internal("rpc client not initialised")
|
||||
}
|
||||
return estimateGasTron(ctx, rpcClient, callMsg)
|
||||
}
|
||||
return client.EstimateGas(ctx, callMsg)
|
||||
}
|
||||
|
||||
func estimateGasTron(ctx context.Context, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) {
|
||||
call := tronEstimateCall(callMsg)
|
||||
var hexResp string
|
||||
if err := rpcClient.CallContext(ctx, &hexResp, "eth_estimateGas", call); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
val, err := shared.DecodeHexBig(hexResp)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if val == nil {
|
||||
return 0, merrors.Internal("failed to decode gas estimate")
|
||||
}
|
||||
return val.Uint64(), nil
|
||||
}
|
||||
|
||||
func tronEstimateCall(callMsg ethereum.CallMsg) map[string]string {
|
||||
call := make(map[string]string)
|
||||
if callMsg.From != (common.Address{}) {
|
||||
call["from"] = strings.ToLower(callMsg.From.Hex())
|
||||
}
|
||||
if callMsg.To != nil {
|
||||
call["to"] = strings.ToLower(callMsg.To.Hex())
|
||||
}
|
||||
if callMsg.Gas > 0 {
|
||||
call["gas"] = hexutil.EncodeUint64(callMsg.Gas)
|
||||
}
|
||||
if callMsg.GasPrice != nil {
|
||||
call["gasPrice"] = hexutil.EncodeBig(callMsg.GasPrice)
|
||||
}
|
||||
if callMsg.Value != nil {
|
||||
call["value"] = hexutil.EncodeBig(callMsg.Value)
|
||||
}
|
||||
if len(callMsg.Data) > 0 {
|
||||
call["data"] = hexutil.Encode(callMsg.Data)
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
func isTronNetwork(network shared.Network) bool {
|
||||
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(network.Name)), "tron")
|
||||
}
|
||||
|
||||
func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
|
||||
value, err := decimal.NewFromString(strings.TrimSpace(amount))
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package evm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
var evmBaseUnitFactor = decimal.NewFromInt(1_000_000_000_000_000_000)
|
||||
|
||||
// ComputeGasTopUp applies the network policy to decide an EVM native-token top-up amount.
|
||||
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, bool, error) {
|
||||
if wallet == nil {
|
||||
return nil, false, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||
return nil, false, merrors.InvalidArgument("estimated fee is required")
|
||||
}
|
||||
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||
return nil, false, merrors.InvalidArgument("current native balance is required")
|
||||
}
|
||||
if network.GasTopUpPolicy == nil {
|
||||
return nil, false, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||
}
|
||||
|
||||
nativeCurrency := strings.TrimSpace(network.NativeToken)
|
||||
if nativeCurrency == "" {
|
||||
nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||
}
|
||||
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
|
||||
return nil, false, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
|
||||
}
|
||||
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
|
||||
return nil, false, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
|
||||
}
|
||||
|
||||
estimatedNative, err := evmToNative(estimatedFee)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
currentNative, err := evmToNative(currentBalance)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
|
||||
rule, ok := network.GasTopUpPolicy.Rule(isContract)
|
||||
if !ok {
|
||||
return nil, false, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||
}
|
||||
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
|
||||
return nil, false, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
|
||||
}
|
||||
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
|
||||
return nil, false, merrors.InvalidArgument("gas top-up max top-up must be > 0")
|
||||
}
|
||||
|
||||
required := estimatedNative.Sub(currentNative)
|
||||
if required.IsNegative() {
|
||||
required = decimal.Zero
|
||||
}
|
||||
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
|
||||
|
||||
minBalanceTopUp := rule.MinNativeBalance.Sub(currentNative)
|
||||
if minBalanceTopUp.IsNegative() {
|
||||
minBalanceTopUp = decimal.Zero
|
||||
}
|
||||
|
||||
rawTopUp := bufferedRequired
|
||||
if minBalanceTopUp.GreaterThan(rawTopUp) {
|
||||
rawTopUp = minBalanceTopUp
|
||||
}
|
||||
|
||||
roundedTopUp := decimal.Zero
|
||||
if rawTopUp.IsPositive() {
|
||||
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
|
||||
}
|
||||
|
||||
topUp := roundedTopUp
|
||||
capHit := false
|
||||
if topUp.GreaterThan(rule.MaxTopUp) {
|
||||
topUp = rule.MaxTopUp
|
||||
capHit = true
|
||||
}
|
||||
|
||||
if !topUp.IsPositive() {
|
||||
return nil, capHit, nil
|
||||
}
|
||||
|
||||
baseUnits := topUp.Mul(evmBaseUnitFactor).Ceil().Truncate(0)
|
||||
return &moneyv1.Money{
|
||||
Currency: strings.ToUpper(nativeCurrency),
|
||||
Amount: baseUnits.StringFixed(0),
|
||||
}, capHit, nil
|
||||
}
|
||||
|
||||
func evmToNative(amount *moneyv1.Money) (decimal.Decimal, error) {
|
||||
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
|
||||
if err != nil {
|
||||
return decimal.Zero, err
|
||||
}
|
||||
return value.Div(evmBaseUnitFactor), nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package evm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
|
||||
network := ethNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("30"))
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, topUp)
|
||||
require.False(t, capHit)
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
|
||||
network := ethNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("50"), ethMoney("10"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.False(t, capHit)
|
||||
require.Equal(t, "46000000000000000000", topUp.GetAmount())
|
||||
require.Equal(t, "ETH", topUp.GetCurrency())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
|
||||
network := ethNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("1"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.False(t, capHit)
|
||||
require.Equal(t, "19000000000000000000", topUp.GetAmount())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
|
||||
policy := shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0),
|
||||
MinNativeBalance: decimal.NewFromFloat(0),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(100),
|
||||
},
|
||||
}
|
||||
network := ethNetwork(&policy)
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("1.1"), ethMoney("0"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.False(t, capHit)
|
||||
require.Equal(t, "2000000000000000000", topUp.GetAmount())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_CapHit(t *testing.T) {
|
||||
policy := shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0),
|
||||
MinNativeBalance: decimal.NewFromFloat(0),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(10),
|
||||
},
|
||||
}
|
||||
network := ethNetwork(&policy)
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("100"), ethMoney("0"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.True(t, capHit)
|
||||
require.Equal(t, "10000000000000000000", topUp.GetAmount())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
|
||||
network := ethNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("0"), ethMoney("5"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.False(t, capHit)
|
||||
require.Equal(t, "15000000000000000000", topUp.GetAmount())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
|
||||
policy := shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0.1),
|
||||
MinNativeBalance: decimal.NewFromFloat(10),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(100),
|
||||
},
|
||||
Contract: &shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0.5),
|
||||
MinNativeBalance: decimal.NewFromFloat(5),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(100),
|
||||
},
|
||||
}
|
||||
network := ethNetwork(&policy)
|
||||
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
|
||||
|
||||
topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("10"), ethMoney("0"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.False(t, capHit)
|
||||
require.Equal(t, "15000000000000000000", topUp.GetAmount())
|
||||
}
|
||||
|
||||
func defaultPolicy() *shared.GasTopUpPolicy {
|
||||
return &shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0.15),
|
||||
MinNativeBalance: decimal.NewFromFloat(20),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(500),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ethNetwork(policy *shared.GasTopUpPolicy) shared.Network {
|
||||
return shared.Network{
|
||||
Name: "ethereum_mainnet",
|
||||
NativeToken: "ETH",
|
||||
GasTopUpPolicy: policy,
|
||||
}
|
||||
}
|
||||
|
||||
func ethMoney(eth string) *moneyv1.Money {
|
||||
value, _ := decimal.NewFromString(eth)
|
||||
baseUnits := value.Mul(evmBaseUnitFactor).Truncate(0)
|
||||
return &moneyv1.Money{
|
||||
Currency: "ETH",
|
||||
Amount: baseUnits.StringFixed(0),
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package tron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
@@ -40,7 +41,7 @@ func (d *Driver) NormalizeAddress(address string) (string, error) {
|
||||
d.logger.Debug("Normalize address", zap.String("address", address))
|
||||
normalized, err := normalizeAddress(address)
|
||||
if err != nil {
|
||||
d.logger.Warn("normalize address failed", zap.String("address", address), zap.Error(err))
|
||||
d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err))
|
||||
}
|
||||
return normalized, err
|
||||
}
|
||||
@@ -67,7 +68,39 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("balance result",
|
||||
d.logger.Debug("Balance result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
zap.String("currency", result.Currency),
|
||||
)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
|
||||
if wallet == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
d.logger.Debug("Native balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name))
|
||||
rpcAddr, err := rpcAddress(wallet.DepositAddress)
|
||||
if err != nil {
|
||||
d.logger.Warn("Native balance address conversion failed", zap.Error(err),
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("address", wallet.DepositAddress),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, rpcAddr)
|
||||
if err != nil {
|
||||
d.logger.Warn("Native balance failed", zap.Error(err),
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Native balance result",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("amount", result.Amount),
|
||||
@@ -81,6 +114,9 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
||||
if wallet == nil {
|
||||
return nil, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
if amount == nil {
|
||||
return nil, merrors.InvalidArgument("amount is required")
|
||||
}
|
||||
d.logger.Debug("Estimate fee request",
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
@@ -102,6 +138,12 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if rpcFrom == rpcTo {
|
||||
return &moneyv1.Money{
|
||||
Currency: nativeCurrency(network),
|
||||
Amount: "0",
|
||||
}, nil
|
||||
}
|
||||
driverDeps := deps
|
||||
driverDeps.Logger = d.logger
|
||||
result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, rpcFrom, rpcTo, amount)
|
||||
@@ -109,6 +151,10 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
|
||||
d.logger.Warn("Estimate fee failed", zap.Error(err),
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.String("from_address", wallet.DepositAddress),
|
||||
zap.String("from_rpc", rpcFrom),
|
||||
zap.String("to_address", destination),
|
||||
zap.String("to_rpc", rpcTo),
|
||||
)
|
||||
} else if result != nil {
|
||||
d.logger.Debug("Estimate fee result",
|
||||
@@ -150,7 +196,7 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s
|
||||
driverDeps.Logger = d.logger
|
||||
txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, rpcFrom, rpcTo)
|
||||
if err != nil {
|
||||
d.logger.Warn("submit transfer failed", zap.Error(err),
|
||||
d.logger.Warn("Submit transfer failed", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
@@ -188,4 +234,12 @@ func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, networ
|
||||
return receipt, err
|
||||
}
|
||||
|
||||
func nativeCurrency(network shared.Network) string {
|
||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||
if currency == "" {
|
||||
currency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||
}
|
||||
return currency
|
||||
}
|
||||
|
||||
var _ driver.Driver = (*Driver)(nil)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestEstimateFeeSelfTransferReturnsZero(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
d := New(logger)
|
||||
wallet := &model.ManagedWallet{
|
||||
WalletRef: "wallet_ref",
|
||||
DepositAddress: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF",
|
||||
}
|
||||
network := shared.Network{
|
||||
Name: "tron_mainnet",
|
||||
NativeToken: "TRX",
|
||||
}
|
||||
amount := &moneyv1.Money{Currency: "TRX", Amount: "1000000"}
|
||||
|
||||
fee, err := d.EstimateFee(context.Background(), driver.Deps{}, network, wallet, wallet.DepositAddress, amount)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, fee)
|
||||
require.Equal(t, "TRX", fee.GetCurrency())
|
||||
require.Equal(t, "0", fee.GetAmount())
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
var tronBaseUnitFactor = decimal.NewFromInt(1_000_000)
|
||||
|
||||
// GasTopUpDecision captures the applied policy inputs and outputs (in TRX units).
|
||||
type GasTopUpDecision struct {
|
||||
CurrentBalanceTRX decimal.Decimal
|
||||
EstimatedFeeTRX decimal.Decimal
|
||||
RequiredTRX decimal.Decimal
|
||||
BufferedRequiredTRX decimal.Decimal
|
||||
MinBalanceTopUpTRX decimal.Decimal
|
||||
RawTopUpTRX decimal.Decimal
|
||||
RoundedTopUpTRX decimal.Decimal
|
||||
TopUpTRX decimal.Decimal
|
||||
CapHit bool
|
||||
OperationType string
|
||||
}
|
||||
|
||||
// ComputeGasTopUp applies the network policy to decide a TRX top-up amount.
|
||||
func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, GasTopUpDecision, error) {
|
||||
decision := GasTopUpDecision{}
|
||||
if wallet == nil {
|
||||
return nil, decision, merrors.InvalidArgument("wallet is required")
|
||||
}
|
||||
if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" {
|
||||
return nil, decision, merrors.InvalidArgument("estimated fee is required")
|
||||
}
|
||||
if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" {
|
||||
return nil, decision, merrors.InvalidArgument("current native balance is required")
|
||||
}
|
||||
if network.GasTopUpPolicy == nil {
|
||||
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||
}
|
||||
|
||||
nativeCurrency := strings.TrimSpace(network.NativeToken)
|
||||
if nativeCurrency == "" {
|
||||
nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||
}
|
||||
if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) {
|
||||
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency))
|
||||
}
|
||||
if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) {
|
||||
return nil, decision, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency))
|
||||
}
|
||||
|
||||
estimatedTRX, err := tronToTRX(estimatedFee)
|
||||
if err != nil {
|
||||
return nil, decision, err
|
||||
}
|
||||
currentTRX, err := tronToTRX(currentBalance)
|
||||
if err != nil {
|
||||
return nil, decision, err
|
||||
}
|
||||
|
||||
isContract := strings.TrimSpace(wallet.ContractAddress) != ""
|
||||
rule, ok := network.GasTopUpPolicy.Rule(isContract)
|
||||
if !ok {
|
||||
return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured")
|
||||
}
|
||||
if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) {
|
||||
return nil, decision, merrors.InvalidArgument("gas top-up rounding unit must be > 0")
|
||||
}
|
||||
if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) {
|
||||
return nil, decision, merrors.InvalidArgument("gas top-up max top-up must be > 0")
|
||||
}
|
||||
|
||||
required := estimatedTRX.Sub(currentTRX)
|
||||
if required.IsNegative() {
|
||||
required = decimal.Zero
|
||||
}
|
||||
bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent))
|
||||
|
||||
minBalanceTopUp := rule.MinNativeBalance.Sub(currentTRX)
|
||||
if minBalanceTopUp.IsNegative() {
|
||||
minBalanceTopUp = decimal.Zero
|
||||
}
|
||||
|
||||
rawTopUp := bufferedRequired
|
||||
if minBalanceTopUp.GreaterThan(rawTopUp) {
|
||||
rawTopUp = minBalanceTopUp
|
||||
}
|
||||
|
||||
roundedTopUp := decimal.Zero
|
||||
if rawTopUp.IsPositive() {
|
||||
roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit)
|
||||
}
|
||||
|
||||
topUp := roundedTopUp
|
||||
capHit := false
|
||||
if topUp.GreaterThan(rule.MaxTopUp) {
|
||||
topUp = rule.MaxTopUp
|
||||
capHit = true
|
||||
}
|
||||
|
||||
decision = GasTopUpDecision{
|
||||
CurrentBalanceTRX: currentTRX,
|
||||
EstimatedFeeTRX: estimatedTRX,
|
||||
RequiredTRX: required,
|
||||
BufferedRequiredTRX: bufferedRequired,
|
||||
MinBalanceTopUpTRX: minBalanceTopUp,
|
||||
RawTopUpTRX: rawTopUp,
|
||||
RoundedTopUpTRX: roundedTopUp,
|
||||
TopUpTRX: topUp,
|
||||
CapHit: capHit,
|
||||
OperationType: operationType(isContract),
|
||||
}
|
||||
|
||||
if !topUp.IsPositive() {
|
||||
return nil, decision, nil
|
||||
}
|
||||
|
||||
baseUnits := topUp.Mul(tronBaseUnitFactor).Ceil().Truncate(0)
|
||||
return &moneyv1.Money{
|
||||
Currency: strings.ToUpper(nativeCurrency),
|
||||
Amount: baseUnits.StringFixed(0),
|
||||
}, decision, nil
|
||||
}
|
||||
|
||||
func tronToTRX(amount *moneyv1.Money) (decimal.Decimal, error) {
|
||||
value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount()))
|
||||
if err != nil {
|
||||
return decimal.Zero, err
|
||||
}
|
||||
return value.Div(tronBaseUnitFactor), nil
|
||||
}
|
||||
|
||||
func operationType(contract bool) string {
|
||||
if contract {
|
||||
return "trc20"
|
||||
}
|
||||
return "native"
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package tron
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
func TestComputeGasTopUp_BalanceSufficient(t *testing.T) {
|
||||
network := tronNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("30"))
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, topUp)
|
||||
require.True(t, decision.TopUpTRX.IsZero())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_BufferedRequired(t *testing.T) {
|
||||
network := tronNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("50"), tronMoney("10"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.Equal(t, "46000000", topUp.GetAmount())
|
||||
require.Equal(t, "TRX", topUp.GetCurrency())
|
||||
require.Equal(t, "46", decision.TopUpTRX.String())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) {
|
||||
network := tronNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("1"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.Equal(t, "19000000", topUp.GetAmount())
|
||||
require.Equal(t, "19", decision.TopUpTRX.String())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_RoundsUp(t *testing.T) {
|
||||
policy := shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0),
|
||||
MinNativeBalance: decimal.NewFromFloat(0),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(100),
|
||||
},
|
||||
}
|
||||
network := tronNetwork(&policy)
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("1.1"), tronMoney("0"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.Equal(t, "2000000", topUp.GetAmount())
|
||||
require.Equal(t, "2", decision.TopUpTRX.String())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_CapHit(t *testing.T) {
|
||||
policy := shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0),
|
||||
MinNativeBalance: decimal.NewFromFloat(0),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(10),
|
||||
},
|
||||
}
|
||||
network := tronNetwork(&policy)
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("100"), tronMoney("0"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.Equal(t, "10000000", topUp.GetAmount())
|
||||
require.True(t, decision.CapHit)
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) {
|
||||
network := tronNetwork(defaultPolicy())
|
||||
wallet := &model.ManagedWallet{}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("0"), tronMoney("5"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.Equal(t, "15000000", topUp.GetAmount())
|
||||
require.Equal(t, "15", decision.TopUpTRX.String())
|
||||
}
|
||||
|
||||
func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) {
|
||||
policy := shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0.1),
|
||||
MinNativeBalance: decimal.NewFromFloat(10),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(100),
|
||||
},
|
||||
Contract: &shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0.5),
|
||||
MinNativeBalance: decimal.NewFromFloat(5),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(100),
|
||||
},
|
||||
}
|
||||
network := tronNetwork(&policy)
|
||||
wallet := &model.ManagedWallet{ContractAddress: "0xcontract"}
|
||||
|
||||
topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("10"), tronMoney("0"))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, topUp)
|
||||
require.Equal(t, "15000000", topUp.GetAmount())
|
||||
require.Equal(t, "15", decision.TopUpTRX.String())
|
||||
require.Equal(t, "trc20", decision.OperationType)
|
||||
}
|
||||
|
||||
func defaultPolicy() *shared.GasTopUpPolicy {
|
||||
return &shared.GasTopUpPolicy{
|
||||
Default: shared.GasTopUpRule{
|
||||
BufferPercent: decimal.NewFromFloat(0.15),
|
||||
MinNativeBalance: decimal.NewFromFloat(20),
|
||||
RoundingUnit: decimal.NewFromFloat(1),
|
||||
MaxTopUp: decimal.NewFromFloat(500),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func tronNetwork(policy *shared.GasTopUpPolicy) shared.Network {
|
||||
return shared.Network{
|
||||
Name: "tron_mainnet",
|
||||
NativeToken: "TRX",
|
||||
GasTopUpPolicy: policy,
|
||||
}
|
||||
}
|
||||
|
||||
func tronMoney(trx string) *moneyv1.Money {
|
||||
value, _ := decimal.NewFromString(trx)
|
||||
baseUnits := value.Mul(tronBaseUnitFactor).Truncate(0)
|
||||
return &moneyv1.Money{
|
||||
Currency: "TRX",
|
||||
Amount: baseUnits.StringFixed(0),
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, e
|
||||
}
|
||||
chainDriver, err := resolveDriver(logger, name)
|
||||
if err != nil {
|
||||
logger.Error("unsupported chain driver", zap.String("network", name), zap.Error(err))
|
||||
logger.Error("Unsupported chain driver", zap.String("network", name), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
result.byNetwork[name] = chainDriver
|
||||
@@ -40,7 +40,7 @@ func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, e
|
||||
if len(result.byNetwork) == 0 {
|
||||
return nil, merrors.InvalidArgument("driver registry: no supported networks configured")
|
||||
}
|
||||
logger.Info("chain drivers configured", zap.Int("count", len(result.byNetwork)))
|
||||
logger.Info("Chain drivers configured", zap.Int("count", len(result.byNetwork)))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -47,32 +47,32 @@ type onChainExecutor struct {
|
||||
|
||||
func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) {
|
||||
if o.keyManager == nil {
|
||||
o.logger.Warn("key manager not configured")
|
||||
o.logger.Warn("Key manager not configured")
|
||||
return "", executorInternal("key manager is not configured", nil)
|
||||
}
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if rpcURL == "" {
|
||||
o.logger.Warn("network rpc url missing", zap.String("network", network.Name))
|
||||
o.logger.Warn("Network rpc url missing", zap.String("network", network.Name))
|
||||
return "", executorInvalid("network rpc url is not configured")
|
||||
}
|
||||
if source == nil || transfer == nil {
|
||||
o.logger.Warn("transfer context missing")
|
||||
o.logger.Warn("Transfer context missing")
|
||||
return "", executorInvalid("transfer context missing")
|
||||
}
|
||||
if strings.TrimSpace(source.KeyReference) == "" {
|
||||
o.logger.Warn("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
||||
o.logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
|
||||
return "", executorInvalid("source wallet missing key reference")
|
||||
}
|
||||
if strings.TrimSpace(source.DepositAddress) == "" {
|
||||
o.logger.Warn("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
|
||||
o.logger.Warn("Source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
|
||||
return "", executorInvalid("source wallet missing deposit address")
|
||||
}
|
||||
if !common.IsHexAddress(destinationAddress) {
|
||||
o.logger.Warn("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
|
||||
o.logger.Warn("Invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
|
||||
return "", executorInvalid("invalid destination address " + destinationAddress)
|
||||
}
|
||||
|
||||
o.logger.Info("submitting transfer",
|
||||
o.logger.Info("Submitting transfer",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("source_wallet_ref", source.WalletRef),
|
||||
zap.String("network", network.Name),
|
||||
@@ -81,12 +81,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
client, err := o.clients.Client(network.Name)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to initialise rpc client", zap.Error(err), zap.String("network", network.Name))
|
||||
o.logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name))
|
||||
return "", err
|
||||
}
|
||||
rpcClient, err := o.clients.RPCClient(network.Name)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to initialise rpc client",
|
||||
o.logger.Warn("Failed to initialise RPC client",
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
@@ -101,7 +101,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
nonce, err := client.PendingNonceAt(ctx, sourceAddress)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to fetch nonce", zap.Error(err),
|
||||
o.logger.Warn("Failed to fetch nonce", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("wallet_ref", source.WalletRef),
|
||||
)
|
||||
@@ -110,7 +110,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
gasPrice, err := client.SuggestGasPrice(ctx)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to suggest gas price",
|
||||
o.logger.Warn("Failed to suggest gas price",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
@@ -124,12 +124,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
chainID := new(big.Int).SetUint64(network.ChainID)
|
||||
|
||||
if strings.TrimSpace(transfer.ContractAddress) == "" {
|
||||
o.logger.Warn("native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
|
||||
o.logger.Warn("Native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
|
||||
return "", merrors.NotImplemented("executor: native token transfers not yet supported")
|
||||
}
|
||||
|
||||
if !common.IsHexAddress(transfer.ContractAddress) {
|
||||
o.logger.Warn("invalid token contract address",
|
||||
o.logger.Warn("Invalid token contract address",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", transfer.ContractAddress),
|
||||
)
|
||||
@@ -139,7 +139,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to read token decimals", zap.Error(err),
|
||||
o.logger.Warn("Failed to read token decimals", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("contract", transfer.ContractAddress),
|
||||
)
|
||||
@@ -148,12 +148,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
amount := transfer.NetAmount
|
||||
if amount == nil || strings.TrimSpace(amount.Amount) == "" {
|
||||
o.logger.Warn("transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
||||
o.logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
|
||||
return "", executorInvalid("transfer missing net amount")
|
||||
}
|
||||
amountInt, err := toBaseUnits(amount.Amount, decimals)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to convert amount to base units", zap.Error(err),
|
||||
o.logger.Warn("Failed to convert amount to base units", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("amount", amount.Amount),
|
||||
)
|
||||
@@ -162,7 +162,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
input, err := erc20ABI.Pack("transfer", destination, amountInt)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to encode transfer call",
|
||||
o.logger.Warn("Failed to encode transfer call",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
@@ -177,7 +177,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
}
|
||||
gasLimit, err := client.EstimateGas(ctx, callMsg)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to estimate gas",
|
||||
o.logger.Warn("Failed to estimate gas",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
@@ -188,7 +188,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to sign transaction", zap.Error(err),
|
||||
o.logger.Warn("Failed to sign transaction", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("wallet_ref", source.WalletRef),
|
||||
)
|
||||
@@ -196,14 +196,14 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
}
|
||||
|
||||
if err := client.SendTransaction(ctx, signedTx); err != nil {
|
||||
o.logger.Warn("failed to send transaction", zap.Error(err),
|
||||
o.logger.Warn("Failed to send transaction", zap.Error(err),
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
)
|
||||
return "", executorInternal("failed to send transaction", err)
|
||||
}
|
||||
|
||||
txHash = signedTx.Hash().Hex()
|
||||
o.logger.Info("transaction submitted",
|
||||
o.logger.Info("Transaction submitted",
|
||||
zap.String("transfer_ref", transfer.TransferRef),
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
@@ -214,12 +214,12 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
|
||||
|
||||
func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) {
|
||||
if strings.TrimSpace(txHash) == "" {
|
||||
o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||
o.logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name))
|
||||
return nil, executorInvalid("tx hash is required")
|
||||
}
|
||||
rpcURL := strings.TrimSpace(network.RPCURL)
|
||||
if rpcURL == "" {
|
||||
o.logger.Warn("network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
||||
o.logger.Warn("Network RPC url missing while awaiting confirmation", zap.String("tx_hash", txHash))
|
||||
return nil, executorInvalid("network rpc url is not configured")
|
||||
}
|
||||
|
||||
@@ -238,27 +238,27 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.
|
||||
if errors.Is(err, ethereum.NotFound) {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
o.logger.Debug("transaction not yet mined",
|
||||
o.logger.Debug("Transaction not yet mined",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
o.logger.Warn("context cancelled while awaiting confirmation",
|
||||
o.logger.Warn("Context cancelled while awaiting confirmation",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
o.logger.Warn("failed to fetch transaction receipt",
|
||||
o.logger.Warn("Failed to fetch transaction receipt",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, executorInternal("failed to fetch transaction receipt", err)
|
||||
}
|
||||
o.logger.Info("transaction confirmed",
|
||||
o.logger.Info("Transaction confirmed",
|
||||
zap.String("tx_hash", txHash),
|
||||
zap.String("network", network.Name),
|
||||
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
|
||||
|
||||
@@ -2,6 +2,7 @@ package rpcclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -48,14 +49,14 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
||||
if rpcURL == "" {
|
||||
result.Close()
|
||||
err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", name))
|
||||
clientLogger.Warn("rpc url missing", zap.String("network", name))
|
||||
clientLogger.Warn("Rpc url missing", zap.String("network", name))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("network", name),
|
||||
}
|
||||
clientLogger.Info("initialising rpc client", fields...)
|
||||
clientLogger.Info("Initialising rpc client", fields...)
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
httpClient := &http.Client{
|
||||
@@ -70,7 +71,7 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
||||
cancel()
|
||||
if err != nil {
|
||||
result.Close()
|
||||
clientLogger.Warn("failed to dial rpc endpoint", append(fields, zap.Error(err))...)
|
||||
clientLogger.Warn("Failed to dial rpc endpoint", append(fields, zap.Error(err))...)
|
||||
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error()))
|
||||
}
|
||||
client := ethclient.NewClient(rpcCli)
|
||||
@@ -78,7 +79,7 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
||||
eth: client,
|
||||
rpc: rpcCli,
|
||||
}
|
||||
clientLogger.Info("rpc client ready", fields...)
|
||||
clientLogger.Info("RPC client ready", fields...)
|
||||
}
|
||||
|
||||
if len(result.clients) == 0 {
|
||||
@@ -94,12 +95,12 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
|
||||
// Client returns a prepared client for the given network name.
|
||||
func (c *Clients) Client(network string) (*ethclient.Client, error) {
|
||||
if c == nil {
|
||||
return nil, merrors.Internal("rpc clients not initialised")
|
||||
return nil, merrors.Internal("RPC clients not initialised")
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(network))
|
||||
entry, ok := c.clients[name]
|
||||
if !ok || entry.eth == nil {
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name))
|
||||
return nil, merrors.InvalidArgument(fmt.Sprintf("RPC client not configured for network %s", name))
|
||||
}
|
||||
return entry.eth, nil
|
||||
}
|
||||
@@ -129,7 +130,7 @@ func (c *Clients) Close() {
|
||||
entry.eth.Close()
|
||||
}
|
||||
if c.logger != nil {
|
||||
c.logger.Info("rpc client closed", zap.String("network", name))
|
||||
c.logger.Info("RPC client closed", zap.String("network", name))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,16 +156,15 @@ func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
||||
|
||||
fields := []zap.Field{
|
||||
zap.String("network", l.network),
|
||||
zap.String("rpc_endpoint", l.endpoint),
|
||||
}
|
||||
if len(reqBody) > 0 {
|
||||
fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048)))
|
||||
}
|
||||
l.logger.Debug("rpc request", fields...)
|
||||
l.logger.Debug("RPC request", fields...)
|
||||
|
||||
resp, err := l.base.RoundTrip(req)
|
||||
if err != nil {
|
||||
l.logger.Warn("rpc http request failed", append(fields, zap.Error(err))...)
|
||||
l.logger.Warn("RPC http request failed", append(fields, zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -175,11 +175,19 @@ func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
|
||||
respFields := append(fields,
|
||||
zap.Int("status_code", resp.StatusCode),
|
||||
)
|
||||
if contentType := strings.TrimSpace(resp.Header.Get("Content-Type")); contentType != "" {
|
||||
respFields = append(respFields, zap.String("content_type", contentType))
|
||||
}
|
||||
if len(bodyBytes) > 0 {
|
||||
respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048)))
|
||||
}
|
||||
l.logger.Debug("RPC response", respFields...)
|
||||
if resp.StatusCode >= 400 {
|
||||
l.logger.Warn("RPC response error", respFields...)
|
||||
} else if len(bodyBytes) == 0 {
|
||||
l.logger.Warn("RPC response empty body", respFields...)
|
||||
} else if len(bodyBytes) > 0 && !json.Valid(bodyBytes) {
|
||||
l.logger.Warn("RPC response invalid JSON", respFields...)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
|
||||
@@ -126,6 +126,14 @@ func (s *Service) EstimateTransferFee(ctx context.Context, req *chainv1.Estimate
|
||||
return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||
return executeUnary(ctx, s, "ComputeGasTopUp", s.commands.ComputeGasTopUp.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||
return executeUnary(ctx, s, "EnsureGasTopUp", s.commands.EnsureGasTopUp.Execute, req)
|
||||
}
|
||||
|
||||
func (s *Service) ensureRepository(ctx context.Context) error {
|
||||
if s.storage == nil {
|
||||
return errStorageUnavailable
|
||||
|
||||
@@ -66,6 +66,25 @@ func TestCreateManagedWallet_Idempotent(t *testing.T) {
|
||||
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()
|
||||
@@ -144,6 +163,37 @@ func TestGetWalletBalance_NotFound(t *testing.T) {
|
||||
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 {
|
||||
@@ -531,7 +581,8 @@ func newTestService(t *testing.T) (*Service, *inMemoryRepository) {
|
||||
repo := newInMemoryRepository()
|
||||
logger := zap.NewNop()
|
||||
networks := []shared.Network{{
|
||||
Name: "ethereum_mainnet",
|
||||
Name: "ethereum_mainnet",
|
||||
NativeToken: "ETH",
|
||||
TokenConfigs: []shared.TokenContract{
|
||||
{Symbol: "USDC", ContractAddress: "0xusdc"},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package shared
|
||||
|
||||
import "github.com/shopspring/decimal"
|
||||
|
||||
// GasTopUpRule defines buffer, minimum, rounding, and cap behavior for native gas top-ups.
|
||||
type GasTopUpRule struct {
|
||||
BufferPercent decimal.Decimal
|
||||
MinNativeBalance decimal.Decimal
|
||||
RoundingUnit decimal.Decimal
|
||||
MaxTopUp decimal.Decimal
|
||||
}
|
||||
|
||||
// GasTopUpPolicy captures default and optional overrides for native vs contract transfers.
|
||||
type GasTopUpPolicy struct {
|
||||
Default GasTopUpRule
|
||||
Native *GasTopUpRule
|
||||
Contract *GasTopUpRule
|
||||
}
|
||||
|
||||
// Rule selects the policy rule for the transfer type.
|
||||
func (p *GasTopUpPolicy) Rule(contractTransfer bool) (GasTopUpRule, bool) {
|
||||
if p == nil {
|
||||
return GasTopUpRule{}, false
|
||||
}
|
||||
if contractTransfer && p.Contract != nil {
|
||||
return *p.Contract, true
|
||||
}
|
||||
if !contractTransfer && p.Native != nil {
|
||||
return *p.Native, true
|
||||
}
|
||||
return p.Default, true
|
||||
}
|
||||
@@ -119,13 +119,23 @@ func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// NativeCurrency returns the canonical native token symbol for a network.
|
||||
func NativeCurrency(network Network) string {
|
||||
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
|
||||
if currency == "" {
|
||||
currency = strings.ToUpper(strings.TrimSpace(network.Name))
|
||||
}
|
||||
return currency
|
||||
}
|
||||
|
||||
// Network describes a supported blockchain network and known token contracts.
|
||||
type Network struct {
|
||||
Name string
|
||||
RPCURL string
|
||||
ChainID uint64
|
||||
NativeToken string
|
||||
TokenConfigs []TokenContract
|
||||
Name string
|
||||
RPCURL string
|
||||
ChainID uint64
|
||||
NativeToken string
|
||||
TokenConfigs []TokenContract
|
||||
GasTopUpPolicy *GasTopUpPolicy
|
||||
}
|
||||
|
||||
// TokenContract captures the metadata needed to work with a specific on-chain token.
|
||||
|
||||
@@ -24,7 +24,7 @@ func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, n
|
||||
defer cancel()
|
||||
|
||||
if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil {
|
||||
s.logger.Warn("failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
|
||||
s.logger.Warn("Failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err))
|
||||
}
|
||||
}(transferRef, sourceWalletRef, network)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
||||
}
|
||||
|
||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil {
|
||||
s.logger.Warn("failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
s.logger.Warn("Failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
|
||||
driverDeps := s.driverDeps()
|
||||
@@ -57,6 +57,23 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
||||
return err
|
||||
}
|
||||
|
||||
sourceAddress, err := chainDriver.NormalizeAddress(sourceWallet.DepositAddress)
|
||||
if err != nil {
|
||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||
return err
|
||||
}
|
||||
if chainDriver.Name() == "tron" && sourceAddress == destinationAddress {
|
||||
s.logger.Info("Self transfer detected; skipping submission",
|
||||
zap.String("transfer_ref", transferRef),
|
||||
zap.String("wallet_ref", sourceWalletRef),
|
||||
zap.String("network", network.Name),
|
||||
)
|
||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", ""); err != nil {
|
||||
s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
|
||||
if err != nil {
|
||||
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
|
||||
@@ -64,7 +81,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
||||
}
|
||||
|
||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil {
|
||||
s.logger.Warn("failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
s.logger.Warn("Failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
|
||||
receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
@@ -72,20 +89,20 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
|
||||
receipt, err := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, network, txHash)
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
|
||||
s.logger.Warn("failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
s.logger.Warn("Failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful {
|
||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", txHash); err != nil {
|
||||
s.logger.Warn("failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, "transaction reverted", txHash); err != nil {
|
||||
s.logger.Warn("failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
s.logger.Warn("Failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -28,9 +28,10 @@ type ServiceFee struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
ManagedWalletRef string `bson:"managedWalletRef,omitempty" json:"managedWalletRef,omitempty"`
|
||||
ExternalAddress string `bson:"externalAddress,omitempty" json:"externalAddress,omitempty"`
|
||||
ExternalAddressOriginal string `bson:"externalAddressOriginal,omitempty" json:"externalAddressOriginal,omitempty"`
|
||||
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
|
||||
}
|
||||
|
||||
// Transfer models an on-chain transfer orchestrated by the gateway.
|
||||
@@ -85,7 +86,8 @@ func (t *Transfer) Normalize() {
|
||||
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.ExternalAddress = normalizeWalletAddress(t.Destination.ExternalAddress)
|
||||
t.Destination.ExternalAddressOriginal = strings.TrimSpace(t.Destination.ExternalAddressOriginal)
|
||||
t.Destination.Memo = strings.TrimSpace(t.Destination.Memo)
|
||||
t.ClientReference = strings.TrimSpace(t.ClientReference)
|
||||
}
|
||||
|
||||
50
api/gateway/chain/storage/model/transfer_test.go
Normal file
50
api/gateway/chain/storage/model/transfer_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTransferNormalizePreservesBase58ExternalAddress(t *testing.T) {
|
||||
address := "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
|
||||
transfer := &Transfer{
|
||||
IdempotencyKey: "idemp",
|
||||
TransferRef: "ref",
|
||||
OrganizationRef: "org",
|
||||
SourceWalletRef: "wallet",
|
||||
Network: "tron_mainnet",
|
||||
TokenSymbol: "USDT",
|
||||
Destination: TransferDestination{
|
||||
ExternalAddress: address,
|
||||
ExternalAddressOriginal: address,
|
||||
},
|
||||
}
|
||||
|
||||
transfer.Normalize()
|
||||
|
||||
if transfer.Destination.ExternalAddress != address {
|
||||
t.Fatalf("expected external address to preserve case, got %q", transfer.Destination.ExternalAddress)
|
||||
}
|
||||
if transfer.Destination.ExternalAddressOriginal != address {
|
||||
t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferNormalizeLowercasesHexExternalAddress(t *testing.T) {
|
||||
address := "0xAABBCCDDEEFF00112233445566778899AABBCCDD"
|
||||
transfer := &Transfer{
|
||||
Destination: TransferDestination{
|
||||
ExternalAddress: address,
|
||||
ExternalAddressOriginal: address,
|
||||
},
|
||||
}
|
||||
|
||||
transfer.Normalize()
|
||||
|
||||
if transfer.Destination.ExternalAddress != strings.ToLower(address) {
|
||||
t.Fatalf("expected hex external address to be lowercased, got %q", transfer.Destination.ExternalAddress)
|
||||
}
|
||||
if transfer.Destination.ExternalAddressOriginal != address {
|
||||
t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ type WalletBalance struct {
|
||||
|
||||
WalletRef string `bson:"walletRef" json:"walletRef"`
|
||||
Available *moneyv1.Money `bson:"available" json:"available"`
|
||||
NativeAvailable *moneyv1.Money `bson:"nativeAvailable,omitempty" json:"nativeAvailable,omitempty"`
|
||||
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"`
|
||||
|
||||
@@ -44,23 +44,23 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
defer cancel()
|
||||
|
||||
if err := result.Ping(ctx); err != nil {
|
||||
result.logger.Error("mongo ping failed during repository initialisation", zap.Error(err))
|
||||
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))
|
||||
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))
|
||||
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))
|
||||
result.logger.Error("Failed to initialise deposits store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -48,13 +48,13 @@ func NewDeposits(logger mlogger.Logger, db *mongo.Database) (*Deposits, error) {
|
||||
}
|
||||
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()))
|
||||
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")
|
||||
childLogger.Debug("Deposits store initialised")
|
||||
|
||||
return &Deposits{logger: childLogger, repo: repo}, nil
|
||||
}
|
||||
|
||||
@@ -53,13 +53,13 @@ func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error)
|
||||
}
|
||||
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()))
|
||||
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")
|
||||
childLogger.Debug("Transfers store initialised")
|
||||
|
||||
return &Transfers{
|
||||
logger: childLogger,
|
||||
@@ -89,12 +89,12 @@ func (t *Transfers) Create(ctx context.Context, transfer *model.Transfer) (*mode
|
||||
}
|
||||
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))
|
||||
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))
|
||||
t.logger.Debug("Transfer created", zap.String("transfer_ref", transfer.TransferRef))
|
||||
return transfer, nil
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*mod
|
||||
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))
|
||||
t.logger.Warn("Ignoring invalid transfer cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ func NewWallets(logger mlogger.Logger, db *mongo.Database) (*Wallets, error) {
|
||||
}
|
||||
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))
|
||||
logger.Error("Failed to ensure wallet index", zap.String("collection", walletRepo.Collection()), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -70,13 +70,13 @@ func NewWallets(logger mlogger.Logger, db *mongo.Database) (*Wallets, error) {
|
||||
}
|
||||
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))
|
||||
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")
|
||||
childLogger.Debug("Wallet stores initialised")
|
||||
|
||||
return &Wallets{
|
||||
logger: childLogger,
|
||||
@@ -99,24 +99,49 @@ func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*mod
|
||||
if strings.TrimSpace(wallet.IdempotencyKey) == "" {
|
||||
return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey")
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("wallet_ref", wallet.WalletRef),
|
||||
zap.String("idempotency_key", wallet.IdempotencyKey),
|
||||
}
|
||||
if wallet.OrganizationRef != "" {
|
||||
fields = append(fields, zap.String("organization_ref", wallet.OrganizationRef))
|
||||
}
|
||||
if wallet.OwnerRef != "" {
|
||||
fields = append(fields, zap.String("owner_ref", wallet.OwnerRef))
|
||||
}
|
||||
if wallet.Network != "" {
|
||||
fields = append(fields, zap.String("network", wallet.Network))
|
||||
}
|
||||
if wallet.TokenSymbol != "" {
|
||||
fields = append(fields, zap.String("token_symbol", wallet.TokenSymbol))
|
||||
}
|
||||
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))
|
||||
w.logger.Debug("Wallet already exists", fields...)
|
||||
return wallet, nil
|
||||
}
|
||||
w.logger.Warn("Wallet create failed", append(fields, zap.Error(err))...)
|
||||
return nil, err
|
||||
}
|
||||
w.logger.Debug("wallet created", zap.String("wallet_ref", wallet.WalletRef))
|
||||
w.logger.Debug("Wallet created", fields...)
|
||||
return wallet, nil
|
||||
}
|
||||
|
||||
func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) {
|
||||
walletRef = strings.TrimSpace(walletRef)
|
||||
if walletRef == "" {
|
||||
func (w *Wallets) Get(ctx context.Context, walletID string) (*model.ManagedWallet, error) {
|
||||
walletID = strings.TrimSpace(walletID)
|
||||
if walletID == "" {
|
||||
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("wallet_id", walletID),
|
||||
}
|
||||
wallet := &model.ManagedWallet{}
|
||||
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), wallet); err != nil {
|
||||
if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), wallet); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
w.logger.Debug("Wallet not found", fields...)
|
||||
} else {
|
||||
w.logger.Warn("Wallet lookup failed", append(fields, zap.Error(err))...)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return wallet, nil
|
||||
@@ -124,29 +149,38 @@ func (w *Wallets) Get(ctx context.Context, walletRef string) (*model.ManagedWall
|
||||
|
||||
func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) {
|
||||
query := repository.Query()
|
||||
fields := make([]zap.Field, 0, 6)
|
||||
|
||||
if org := strings.TrimSpace(filter.OrganizationRef); org != "" {
|
||||
query = query.Filter(repository.Field("organizationRef"), org)
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
if owner := strings.TrimSpace(filter.OwnerRef); owner != "" {
|
||||
query = query.Filter(repository.Field("ownerRef"), owner)
|
||||
fields = append(fields, zap.String("owner_ref", owner))
|
||||
}
|
||||
if network := strings.TrimSpace(filter.Network); network != "" {
|
||||
query = query.Filter(repository.Field("network"), strings.ToLower(network))
|
||||
normalized := strings.ToLower(network)
|
||||
query = query.Filter(repository.Field("network"), normalized)
|
||||
fields = append(fields, zap.String("network", normalized))
|
||||
}
|
||||
if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
|
||||
query = query.Filter(repository.Field("tokenSymbol"), strings.ToUpper(token))
|
||||
normalized := strings.ToUpper(token)
|
||||
query = query.Filter(repository.Field("tokenSymbol"), normalized)
|
||||
fields = append(fields, zap.String("token_symbol", normalized))
|
||||
}
|
||||
|
||||
if cursor := strings.TrimSpace(filter.Cursor); cursor != "" {
|
||||
if oid, err := primitive.ObjectIDFromHex(cursor); err == nil {
|
||||
query = query.Comparison(repository.IDField(), builder.Gt, oid)
|
||||
fields = append(fields, zap.String("cursor", cursor))
|
||||
} else {
|
||||
w.logger.Warn("ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||
w.logger.Warn("Ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
limit := sanitizeWalletLimit(filter.Limit)
|
||||
fields = append(fields, zap.Int64("limit", limit))
|
||||
fetchLimit := limit + 1
|
||||
query = query.Sort(repository.IDField(), true).Limit(&fetchLimit)
|
||||
|
||||
@@ -160,8 +194,10 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := w.walletRepo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
listErr := w.walletRepo.FindManyByFilter(ctx, query, decoder)
|
||||
if listErr != nil && !errors.Is(listErr, merrors.ErrNoData) {
|
||||
w.logger.Warn("Wallet list failed", append(fields, zap.Error(listErr))...)
|
||||
return nil, listErr
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
@@ -171,10 +207,21 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
|
||||
wallets = wallets[:len(wallets)-1]
|
||||
}
|
||||
|
||||
return &model.ManagedWalletList{
|
||||
result := &model.ManagedWalletList{
|
||||
Items: wallets,
|
||||
NextCursor: nextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
fields = append(fields,
|
||||
zap.Int("count", len(result.Items)),
|
||||
zap.String("next_cursor", result.NextCursor),
|
||||
)
|
||||
if errors.Is(listErr, merrors.ErrNoData) {
|
||||
w.logger.Debug("Wallet list empty", fields...)
|
||||
} else {
|
||||
w.logger.Debug("Wallet list fetched", fields...)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error {
|
||||
@@ -188,6 +235,7 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
|
||||
if balance.CalculatedAt.IsZero() {
|
||||
balance.CalculatedAt = time.Now().UTC()
|
||||
}
|
||||
fields := []zap.Field{zap.String("wallet_ref", balance.WalletRef)}
|
||||
|
||||
existing := &model.WalletBalance{}
|
||||
err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing)
|
||||
@@ -198,28 +246,40 @@ func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance)
|
||||
existing.PendingOutbound = balance.PendingOutbound
|
||||
existing.CalculatedAt = balance.CalculatedAt
|
||||
if err := w.balanceRepo.Update(ctx, existing); err != nil {
|
||||
w.logger.Warn("Wallet balance update failed", append(fields, zap.Error(err))...)
|
||||
return err
|
||||
}
|
||||
w.logger.Debug("Wallet balance updated", fields...)
|
||||
return nil
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil {
|
||||
w.logger.Warn("Wallet balance create failed", append(fields, zap.Error(err))...)
|
||||
return err
|
||||
}
|
||||
w.logger.Debug("Wallet balance created", fields...)
|
||||
return nil
|
||||
default:
|
||||
w.logger.Warn("Wallet balance lookup failed", append(fields, zap.Error(err))...)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Wallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) {
|
||||
walletRef = strings.TrimSpace(walletRef)
|
||||
if walletRef == "" {
|
||||
func (w *Wallets) GetBalance(ctx context.Context, walletID string) (*model.WalletBalance, error) {
|
||||
walletID = strings.TrimSpace(walletID)
|
||||
if walletID == "" {
|
||||
return nil, merrors.InvalidArgument("walletsStore: empty walletRef")
|
||||
}
|
||||
fields := []zap.Field{zap.String("wallet_ref", walletID)}
|
||||
balance := &model.WalletBalance{}
|
||||
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletRef), balance); err != nil {
|
||||
if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), balance); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
w.logger.Debug("Wallet balance not found", fields...)
|
||||
} else {
|
||||
w.logger.Warn("Wallet balance lookup failed", append(fields, zap.Error(err))...)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
w.logger.Debug("Wallet balance fetched", fields...)
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ monetix:
|
||||
base_url_env: MONETIX_BASE_URL
|
||||
project_id_env: MONETIX_PROJECT_ID
|
||||
secret_key_env: MONETIX_SECRET_KEY
|
||||
allowed_currencies: ["USD", "EUR"]
|
||||
allowed_currencies: ["RUB"]
|
||||
require_customer_address: false
|
||||
request_timeout_seconds: 15
|
||||
status_success: "success"
|
||||
|
||||
@@ -95,22 +95,49 @@ func (i *Imp) Shutdown() {
|
||||
}
|
||||
|
||||
func (i *Imp) Start() error {
|
||||
i.logger.Info("Starting Monetix gateway", zap.String("config_file", i.file), zap.Bool("debug", i.debug))
|
||||
|
||||
cfg, err := i.loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.config = cfg
|
||||
|
||||
i.logger.Info("Configuration loaded",
|
||||
zap.String("grpc_address", cfg.GRPC.Address),
|
||||
zap.String("metrics_address", cfg.Metrics.Address),
|
||||
)
|
||||
|
||||
monetixCfg, err := i.resolveMonetixConfig(cfg.Monetix)
|
||||
if err != nil {
|
||||
i.logger.Error("Failed to resolve Monetix configuration", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback)
|
||||
if err != nil {
|
||||
i.logger.Error("Failed to resolve callback configuration", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
i.logger.Info("Monetix configuration resolved",
|
||||
zap.Bool("base_url_set", strings.TrimSpace(monetixCfg.BaseURL) != ""),
|
||||
zap.Int64("project_id", monetixCfg.ProjectID),
|
||||
zap.Bool("secret_key_set", strings.TrimSpace(monetixCfg.SecretKey) != ""),
|
||||
zap.Int("allowed_currencies", len(monetixCfg.AllowedCurrencies)),
|
||||
zap.Bool("require_customer_address", monetixCfg.RequireCustomerAddress),
|
||||
zap.Duration("request_timeout", monetixCfg.RequestTimeout),
|
||||
zap.String("status_success", monetixCfg.SuccessStatus()),
|
||||
zap.String("status_processing", monetixCfg.ProcessingStatus()),
|
||||
)
|
||||
|
||||
i.logger.Info("Callback configuration resolved",
|
||||
zap.String("address", callbackCfg.Address),
|
||||
zap.String("path", callbackCfg.Path),
|
||||
zap.Int("allowed_cidrs", len(callbackCfg.AllowedCIDRs)),
|
||||
zap.Int64("max_body_bytes", callbackCfg.MaxBodyBytes),
|
||||
)
|
||||
|
||||
serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) {
|
||||
svc := mntxservice.NewService(logger,
|
||||
mntxservice.WithProducer(producer),
|
||||
@@ -137,7 +164,7 @@ func (i *Imp) Start() error {
|
||||
func (i *Imp) loadConfig() (*config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -145,7 +172,7 @@ func (i *Imp) loadConfig() (*config, error) {
|
||||
Config: &grpcapp.Config{},
|
||||
}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("failed to parse configuration", zap.Error(err))
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -245,7 +272,7 @@ func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig,
|
||||
}
|
||||
_, block, err := net.ParseCIDR(clean)
|
||||
if err != nil {
|
||||
i.logger.Warn("invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err))
|
||||
i.logger.Warn("Invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
cidrs = append(cidrs, block)
|
||||
@@ -270,20 +297,36 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) {
|
||||
log := i.logger.Named("callback_http")
|
||||
log.Debug("Callback request received",
|
||||
zap.String("remote_addr", strings.TrimSpace(r.RemoteAddr)),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("method", r.Method),
|
||||
)
|
||||
|
||||
if len(cfg.AllowedCIDRs) > 0 && !clientAllowed(r, cfg.AllowedCIDRs) {
|
||||
ip := clientIPFromRequest(r)
|
||||
remoteIP := ""
|
||||
if ip != nil {
|
||||
remoteIP = ip.String()
|
||||
}
|
||||
log.Warn("Callback rejected by CIDR allowlist", zap.String("remote_ip", remoteIP))
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes))
|
||||
if err != nil {
|
||||
log.Warn("Callback body read failed", zap.Error(err))
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
status, err := svc.ProcessMonetixCallback(r.Context(), body)
|
||||
if err != nil {
|
||||
log.Warn("Callback processing failed", zap.Error(err), zap.Int("status", status))
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
log.Debug("Callback processed", zap.Int("status", status))
|
||||
w.WriteHeader(status)
|
||||
})
|
||||
|
||||
@@ -301,7 +344,7 @@ func (i *Imp) startHTTPCallbackServer(svc *mntxservice.Service, cfg callbackRunt
|
||||
|
||||
go func() {
|
||||
if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
i.logger.Error("Monetix callback server stopped with error", zap.Error(err))
|
||||
i.logger.Warn("Monetix callback server stopped with error", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
52
api/gateway/mntx/internal/server/internal/serverimp_test.go
Normal file
52
api/gateway/mntx/internal/server/internal/serverimp_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package serverimp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientIPFromRequest(t *testing.T) {
|
||||
req := &http.Request{
|
||||
Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}},
|
||||
RemoteAddr: "9.8.7.6:1234",
|
||||
}
|
||||
ip := clientIPFromRequest(req)
|
||||
if ip == nil || ip.String() != "1.2.3.4" {
|
||||
t.Fatalf("expected forwarded ip, got %v", ip)
|
||||
}
|
||||
|
||||
req = &http.Request{RemoteAddr: "9.8.7.6:1234"}
|
||||
ip = clientIPFromRequest(req)
|
||||
if ip == nil || ip.String() != "9.8.7.6" {
|
||||
t.Fatalf("expected remote addr ip, got %v", ip)
|
||||
}
|
||||
|
||||
req = &http.Request{RemoteAddr: "invalid"}
|
||||
ip = clientIPFromRequest(req)
|
||||
if ip != nil {
|
||||
t.Fatalf("expected nil ip, got %v", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAllowed(t *testing.T) {
|
||||
_, cidr, err := net.ParseCIDR("10.0.0.0/8")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse cidr: %v", err)
|
||||
}
|
||||
|
||||
allowedReq := &http.Request{RemoteAddr: "10.1.2.3:1234"}
|
||||
if !clientAllowed(allowedReq, []*net.IPNet{cidr}) {
|
||||
t.Fatalf("expected allowed request")
|
||||
}
|
||||
|
||||
deniedReq := &http.Request{RemoteAddr: "8.8.8.8:1234"}
|
||||
if clientAllowed(deniedReq, []*net.IPNet{cidr}) {
|
||||
t.Fatalf("expected denied request")
|
||||
}
|
||||
|
||||
openReq := &http.Request{RemoteAddr: "8.8.8.8:1234"}
|
||||
if !clientAllowed(openReq, nil) {
|
||||
t.Fatalf("expected allow when no cidrs are configured")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
@@ -66,9 +67,12 @@ type monetixCallback struct {
|
||||
|
||||
// ProcessMonetixCallback ingests Monetix provider callbacks and updates payout state.
|
||||
func (s *Service) ProcessMonetixCallback(ctx context.Context, payload []byte) (int, error) {
|
||||
log := s.logger.Named("callback")
|
||||
if s.card == nil {
|
||||
log.Warn("Card payout processor not initialised")
|
||||
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
||||
}
|
||||
log.Debug("Callback processing requested", zap.Int("payload_bytes", len(payload)))
|
||||
return s.card.ProcessCallback(ctx, payload)
|
||||
}
|
||||
|
||||
|
||||
130
api/gateway/mntx/internal/service/gateway/callback_test.go
Normal file
130
api/gateway/mntx/internal/service/gateway/callback_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (f fixedClock) Now() time.Time {
|
||||
return f.now
|
||||
}
|
||||
|
||||
func baseCallback() monetixCallback {
|
||||
cb := monetixCallback{
|
||||
ProjectID: 42,
|
||||
}
|
||||
cb.Payment.ID = "payout-1"
|
||||
cb.Payment.Status = "success"
|
||||
cb.Payment.Sum.Amount = 5000
|
||||
cb.Payment.Sum.Currency = "usd"
|
||||
cb.Customer.ID = "cust-1"
|
||||
cb.Operation.Status = "success"
|
||||
cb.Operation.Code = ""
|
||||
cb.Operation.Message = "ok"
|
||||
cb.Operation.RequestID = "req-1"
|
||||
cb.Operation.Provider.PaymentID = "prov-1"
|
||||
return cb
|
||||
}
|
||||
|
||||
func TestMapCallbackToState_StatusMapping(t *testing.T) {
|
||||
now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
cfg := monetix.DefaultConfig()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
paymentStatus string
|
||||
operationStatus string
|
||||
code string
|
||||
expectedStatus mntxv1.PayoutStatus
|
||||
expectedOutcome string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
paymentStatus: "success",
|
||||
operationStatus: "success",
|
||||
code: "0",
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED,
|
||||
expectedOutcome: monetix.OutcomeSuccess,
|
||||
},
|
||||
{
|
||||
name: "processing",
|
||||
paymentStatus: "processing",
|
||||
operationStatus: "success",
|
||||
code: "",
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
||||
expectedOutcome: monetix.OutcomeProcessing,
|
||||
},
|
||||
{
|
||||
name: "decline",
|
||||
paymentStatus: "failed",
|
||||
operationStatus: "failed",
|
||||
code: "1",
|
||||
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED,
|
||||
expectedOutcome: monetix.OutcomeDecline,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cb := baseCallback()
|
||||
cb.Payment.Status = tc.paymentStatus
|
||||
cb.Operation.Status = tc.operationStatus
|
||||
cb.Operation.Code = tc.code
|
||||
|
||||
state, outcome := mapCallbackToState(fixedClock{now: now}, cfg, cb)
|
||||
if state.Status != tc.expectedStatus {
|
||||
t.Fatalf("expected status %v, got %v", tc.expectedStatus, state.Status)
|
||||
}
|
||||
if outcome != tc.expectedOutcome {
|
||||
t.Fatalf("expected outcome %q, got %q", tc.expectedOutcome, outcome)
|
||||
}
|
||||
if state.Currency != "USD" {
|
||||
t.Fatalf("expected currency USD, got %q", state.Currency)
|
||||
}
|
||||
if !state.UpdatedAt.AsTime().Equal(now) {
|
||||
t.Fatalf("expected updated_at %v, got %v", now, state.UpdatedAt.AsTime())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackProviderPaymentID(t *testing.T) {
|
||||
cb := baseCallback()
|
||||
if got := fallbackProviderPaymentID(cb); got != "prov-1" {
|
||||
t.Fatalf("expected provider payment id, got %q", got)
|
||||
}
|
||||
cb.Operation.Provider.PaymentID = ""
|
||||
if got := fallbackProviderPaymentID(cb); got != "req-1" {
|
||||
t.Fatalf("expected request id fallback, got %q", got)
|
||||
}
|
||||
cb.Operation.RequestID = ""
|
||||
if got := fallbackProviderPaymentID(cb); got != "payout-1" {
|
||||
t.Fatalf("expected payment id fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCallbackSignature(t *testing.T) {
|
||||
secret := "secret"
|
||||
cb := baseCallback()
|
||||
|
||||
sig, err := monetix.SignPayload(cb, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign payload: %v", err)
|
||||
}
|
||||
cb.Signature = sig
|
||||
if err := verifyCallbackSignature(cb, secret); err != nil {
|
||||
t.Fatalf("expected valid signature, got %v", err)
|
||||
}
|
||||
|
||||
cb.Signature = "invalid"
|
||||
if err := verifyCallbackSignature(cb, secret); err == nil {
|
||||
t.Fatalf("expected signature mismatch error")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -17,14 +18,24 @@ func (s *Service) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRe
|
||||
}
|
||||
|
||||
func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) gsresponse.Responder[mntxv1.CardPayoutResponse] {
|
||||
log := s.logger.Named("card_payout")
|
||||
log.Info("Create card payout request received",
|
||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
||||
)
|
||||
if s.card == nil {
|
||||
log.Warn("Card payout processor not initialised")
|
||||
return gsresponse.Internal[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||
}
|
||||
|
||||
resp, err := s.card.Submit(ctx, req)
|
||||
if err != nil {
|
||||
log.Warn("Card payout submission failed", zap.Error(err))
|
||||
return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||
}
|
||||
log.Info("Card payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted()))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -33,14 +44,24 @@ func (s *Service) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTok
|
||||
}
|
||||
|
||||
func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) gsresponse.Responder[mntxv1.CardTokenPayoutResponse] {
|
||||
log := s.logger.Named("card_token_payout")
|
||||
log.Info("Create card token payout request received",
|
||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
||||
)
|
||||
if s.card == nil {
|
||||
log.Warn("Card payout processor not initialised")
|
||||
return gsresponse.Internal[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||
}
|
||||
|
||||
resp, err := s.card.SubmitToken(ctx, req)
|
||||
if err != nil {
|
||||
log.Warn("Card token payout submission failed", zap.Error(err))
|
||||
return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||
}
|
||||
log.Info("Card token payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted()))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -49,14 +70,22 @@ func (s *Service) CreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeR
|
||||
}
|
||||
|
||||
func (s *Service) handleCreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) gsresponse.Responder[mntxv1.CardTokenizeResponse] {
|
||||
log := s.logger.Named("card_tokenize")
|
||||
log.Info("Create card token request received",
|
||||
zap.String("request_id", strings.TrimSpace(req.GetRequestId())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
)
|
||||
if s.card == nil {
|
||||
log.Warn("Card payout processor not initialised")
|
||||
return gsresponse.Internal[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||
}
|
||||
|
||||
resp, err := s.card.Tokenize(ctx, req)
|
||||
if err != nil {
|
||||
log.Warn("Card tokenization failed", zap.Error(err))
|
||||
return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, err)
|
||||
}
|
||||
log.Info("Card tokenization completed", zap.String("request_id", resp.GetRequestId()), zap.Bool("success", resp.GetSuccess()))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -65,14 +94,19 @@ func (s *Service) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPa
|
||||
}
|
||||
|
||||
func (s *Service) handleGetCardPayoutStatus(_ context.Context, req *mntxv1.GetCardPayoutStatusRequest) gsresponse.Responder[mntxv1.GetCardPayoutStatusResponse] {
|
||||
log := s.logger.Named("card_payout_status")
|
||||
log.Info("Get card payout status request received", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())))
|
||||
if s.card == nil {
|
||||
log.Warn("Card payout processor not initialised")
|
||||
return gsresponse.Internal[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised"))
|
||||
}
|
||||
|
||||
state, err := s.card.Status(context.Background(), req.GetPayoutId())
|
||||
if err != nil {
|
||||
log.Warn("Card payout status lookup failed", zap.Error(err))
|
||||
return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, err)
|
||||
}
|
||||
log.Info("Card payout status retrieved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String()))
|
||||
return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func TestValidateCardPayoutRequest_Valid(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardPayoutRequest()
|
||||
if err := validateCardPayoutRequest(req, cfg); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCardPayoutRequest_Errors(t *testing.T) {
|
||||
baseCfg := testMonetixConfig()
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*mntxv1.CardPayoutRequest)
|
||||
config func(monetix.Config) monetix.Config
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "missing_payout_id",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.PayoutId = "" },
|
||||
expected: "missing_payout_id",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_id",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerId = "" },
|
||||
expected: "missing_customer_id",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_ip",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerIp = "" },
|
||||
expected: "missing_customer_ip",
|
||||
},
|
||||
{
|
||||
name: "invalid_amount",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.AmountMinor = 0 },
|
||||
expected: "invalid_amount",
|
||||
},
|
||||
{
|
||||
name: "missing_currency",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "" },
|
||||
expected: "missing_currency",
|
||||
},
|
||||
{
|
||||
name: "unsupported_currency",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "EUR" },
|
||||
config: func(cfg monetix.Config) monetix.Config {
|
||||
cfg.AllowedCurrencies = []string{"USD"}
|
||||
return cfg
|
||||
},
|
||||
expected: "unsupported_currency",
|
||||
},
|
||||
{
|
||||
name: "missing_card_pan",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardPan = "" },
|
||||
expected: "missing_card_pan",
|
||||
},
|
||||
{
|
||||
name: "missing_card_holder",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardHolder = "" },
|
||||
expected: "missing_card_holder",
|
||||
},
|
||||
{
|
||||
name: "invalid_expiry_month",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpMonth = 13 },
|
||||
expected: "invalid_expiry_month",
|
||||
},
|
||||
{
|
||||
name: "invalid_expiry_year",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpYear = 0 },
|
||||
expected: "invalid_expiry_year",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_country_when_required",
|
||||
mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerCountry = "" },
|
||||
config: func(cfg monetix.Config) monetix.Config {
|
||||
cfg.RequireCustomerAddress = true
|
||||
return cfg
|
||||
},
|
||||
expected: "missing_customer_country",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := validCardPayoutRequest()
|
||||
tc.mutate(req)
|
||||
cfg := baseCfg
|
||||
if tc.config != nil {
|
||||
cfg = tc.config(cfg)
|
||||
}
|
||||
err := validateCardPayoutRequest(req, cfg)
|
||||
requireReason(t, err, tc.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -45,14 +45,20 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
if p == nil {
|
||||
return nil, merrors.Internal("card payout processor not initialised")
|
||||
}
|
||||
p.logger.Info("Submitting card payout",
|
||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
||||
)
|
||||
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
|
||||
p.logger.Warn("monetix configuration is incomplete for payout submission")
|
||||
p.logger.Warn("Monetix configuration is incomplete for payout submission")
|
||||
return nil, merrors.Internal("monetix configuration is incomplete")
|
||||
}
|
||||
|
||||
req = sanitizeCardPayoutRequest(req)
|
||||
if err := validateCardPayoutRequest(req, p.config); err != nil {
|
||||
p.logger.Warn("card payout validation failed",
|
||||
p.logger.Warn("Card payout validation failed",
|
||||
zap.String("payout_id", req.GetPayoutId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -65,7 +71,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
projectID = p.config.ProjectID
|
||||
}
|
||||
if projectID == 0 {
|
||||
p.logger.Warn("monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
||||
p.logger.Warn("Monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
||||
return nil, merrors.Internal("monetix project_id is not configured")
|
||||
}
|
||||
|
||||
@@ -95,7 +101,7 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
state.ProviderMessage = err.Error()
|
||||
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||
p.store.Save(state)
|
||||
p.logger.Warn("monetix payout submission failed",
|
||||
p.logger.Warn("Monetix payout submission failed",
|
||||
zap.String("payout_id", req.GetPayoutId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -122,6 +128,13 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
}
|
||||
|
||||
p.logger.Info("Card payout submission stored",
|
||||
zap.String("payout_id", state.GetPayoutId()),
|
||||
zap.String("status", state.GetStatus().String()),
|
||||
zap.Bool("accepted", result.Accepted),
|
||||
zap.String("provider_request_id", result.ProviderRequestID),
|
||||
)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -129,14 +142,20 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
if p == nil {
|
||||
return nil, merrors.Internal("card payout processor not initialised")
|
||||
}
|
||||
p.logger.Info("Submitting card token payout",
|
||||
zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
zap.Int64("amount_minor", req.GetAmountMinor()),
|
||||
zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))),
|
||||
)
|
||||
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
|
||||
p.logger.Warn("monetix configuration is incomplete for token payout submission")
|
||||
p.logger.Warn("Monetix configuration is incomplete for token payout submission")
|
||||
return nil, merrors.Internal("monetix configuration is incomplete")
|
||||
}
|
||||
|
||||
req = sanitizeCardTokenPayoutRequest(req)
|
||||
if err := validateCardTokenPayoutRequest(req, p.config); err != nil {
|
||||
p.logger.Warn("card token payout validation failed",
|
||||
p.logger.Warn("Card token payout validation failed",
|
||||
zap.String("payout_id", req.GetPayoutId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -149,7 +168,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
projectID = p.config.ProjectID
|
||||
}
|
||||
if projectID == 0 {
|
||||
p.logger.Warn("monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
||||
p.logger.Warn("Monetix project_id is not configured", zap.String("payout_id", req.GetPayoutId()))
|
||||
return nil, merrors.Internal("monetix project_id is not configured")
|
||||
}
|
||||
|
||||
@@ -179,7 +198,7 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
state.ProviderMessage = err.Error()
|
||||
state.UpdatedAt = timestamppb.New(p.clock.Now())
|
||||
p.store.Save(state)
|
||||
p.logger.Warn("monetix token payout submission failed",
|
||||
p.logger.Warn("Monetix token payout submission failed",
|
||||
zap.String("payout_id", req.GetPayoutId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -206,6 +225,13 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
}
|
||||
|
||||
p.logger.Info("Card token payout submission stored",
|
||||
zap.String("payout_id", state.GetPayoutId()),
|
||||
zap.String("status", state.GetStatus().String()),
|
||||
zap.Bool("accepted", result.Accepted),
|
||||
zap.String("provider_request_id", result.ProviderRequestID),
|
||||
)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -213,9 +239,13 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
if p == nil {
|
||||
return nil, merrors.Internal("card payout processor not initialised")
|
||||
}
|
||||
p.logger.Info("Submitting card tokenization",
|
||||
zap.String("request_id", strings.TrimSpace(req.GetRequestId())),
|
||||
zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())),
|
||||
)
|
||||
cardInput, err := validateCardTokenizeRequest(req, p.config)
|
||||
if err != nil {
|
||||
p.logger.Warn("card tokenization validation failed",
|
||||
p.logger.Warn("Card tokenization validation failed",
|
||||
zap.String("request_id", req.GetRequestId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -228,7 +258,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
projectID = p.config.ProjectID
|
||||
}
|
||||
if projectID == 0 {
|
||||
p.logger.Warn("monetix project_id is not configured", zap.String("request_id", req.GetRequestId()))
|
||||
p.logger.Warn("Monetix project_id is not configured", zap.String("request_id", req.GetRequestId()))
|
||||
return nil, merrors.Internal("monetix project_id is not configured")
|
||||
}
|
||||
|
||||
@@ -238,7 +268,7 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
apiReq := buildCardTokenizeRequest(projectID, req, cardInput)
|
||||
result, err := client.CreateCardTokenization(ctx, apiReq)
|
||||
if err != nil {
|
||||
p.logger.Warn("monetix tokenization request failed",
|
||||
p.logger.Warn("Monetix tokenization request failed",
|
||||
zap.String("request_id", req.GetRequestId()),
|
||||
zap.String("customer_id", req.GetCustomerId()),
|
||||
zap.Error(err),
|
||||
@@ -258,6 +288,12 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
|
||||
resp.ExpiryYear = result.ExpiryYear
|
||||
resp.CardBrand = result.CardBrand
|
||||
|
||||
p.logger.Info("Card tokenization completed",
|
||||
zap.String("request_id", resp.GetRequestId()),
|
||||
zap.Bool("success", resp.GetSuccess()),
|
||||
zap.String("provider_request_id", result.ProviderRequestID),
|
||||
)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -267,16 +303,18 @@ func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(payoutID)
|
||||
p.logger.Info("Card payout status requested", zap.String("payout_id", id))
|
||||
if id == "" {
|
||||
p.logger.Warn("payout status requested with empty payout_id")
|
||||
p.logger.Warn("Payout status requested with empty payout_id")
|
||||
return nil, merrors.InvalidArgument("payout_id is required", "payout_id")
|
||||
}
|
||||
|
||||
state, ok := p.store.Get(id)
|
||||
if !ok || state == nil {
|
||||
p.logger.Warn("payout status not found", zap.String("payout_id", id))
|
||||
p.logger.Warn("Payout status not found", zap.String("payout_id", id))
|
||||
return nil, merrors.NoData("payout not found")
|
||||
}
|
||||
p.logger.Info("Card payout status resolved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String()))
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -284,18 +322,19 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
|
||||
if p == nil {
|
||||
return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised")
|
||||
}
|
||||
p.logger.Debug("Processing Monetix callback", zap.Int("payload_bytes", len(payload)))
|
||||
if len(payload) == 0 {
|
||||
p.logger.Warn("received empty Monetix callback payload")
|
||||
p.logger.Warn("Received empty Monetix callback payload")
|
||||
return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty")
|
||||
}
|
||||
if strings.TrimSpace(p.config.SecretKey) == "" {
|
||||
p.logger.Warn("monetix secret key is not configured; cannot verify callback")
|
||||
p.logger.Warn("Monetix secret key is not configured; cannot verify callback")
|
||||
return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured")
|
||||
}
|
||||
|
||||
var cb monetixCallback
|
||||
if err := json.Unmarshal(payload, &cb); err != nil {
|
||||
p.logger.Warn("failed to unmarshal Monetix callback", zap.Error(err))
|
||||
p.logger.Warn("Failed to unmarshal Monetix callback", zap.Error(err))
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
@@ -337,16 +376,16 @@ func (p *cardPayoutProcessor) emitCardPayoutEvent(state *mntxv1.CardPayoutState)
|
||||
event := &mntxv1.CardPayoutStatusChangedEvent{Payout: state}
|
||||
payload, err := protojson.Marshal(event)
|
||||
if err != nil {
|
||||
p.logger.Warn("failed to marshal payout callback event", zap.Error(err))
|
||||
p.logger.Warn("Failed to marshal payout callback event", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, nm.NAUpdated))
|
||||
if _, err := env.Wrap(payload); err != nil {
|
||||
p.logger.Warn("failed to wrap payout callback event payload", zap.Error(err))
|
||||
p.logger.Warn("Failed to wrap payout callback event payload", zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := p.producer.SendMessage(env); err != nil {
|
||||
p.logger.Warn("failed to publish payout callback event", zap.Error(err))
|
||||
p.logger.Warn("Failed to publish payout callback event", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
149
api/gateway/mntx/internal/service/gateway/card_processor_test.go
Normal file
149
api/gateway/mntx/internal/service/gateway/card_processor_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
|
||||
type staticClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (s staticClock) Now() time.Time {
|
||||
return s.now
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
ProjectID: 99,
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
|
||||
existingCreated := timestamppb.New(time.Date(2020, 2, 3, 0, 0, 0, 0, time.UTC))
|
||||
store := newCardPayoutStore()
|
||||
store.Save(&mntxv1.CardPayoutState{
|
||||
PayoutId: "payout-1",
|
||||
CreatedAt: existingCreated,
|
||||
})
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
resp := monetix.APIResponse{}
|
||||
resp.Operation.RequestID = "req-123"
|
||||
body, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, store, httpClient, nil)
|
||||
|
||||
req := validCardPayoutRequest()
|
||||
req.ProjectId = 0
|
||||
|
||||
resp, err := processor.Submit(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if !resp.GetAccepted() {
|
||||
t.Fatalf("expected accepted payout response")
|
||||
}
|
||||
if resp.GetPayout().GetProjectId() != cfg.ProjectID {
|
||||
t.Fatalf("expected project id %d, got %d", cfg.ProjectID, resp.GetPayout().GetProjectId())
|
||||
}
|
||||
if resp.GetPayout().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING {
|
||||
t.Fatalf("expected pending status, got %v", resp.GetPayout().GetStatus())
|
||||
}
|
||||
if !resp.GetPayout().GetCreatedAt().AsTime().Equal(existingCreated.AsTime()) {
|
||||
t.Fatalf("expected created_at preserved, got %v", resp.GetPayout().GetCreatedAt().AsTime())
|
||||
}
|
||||
|
||||
stored, ok := store.Get(req.GetPayoutId())
|
||||
if !ok || stored == nil {
|
||||
t.Fatalf("expected payout state stored")
|
||||
}
|
||||
if stored.GetProviderPaymentId() == "" {
|
||||
t.Fatalf("expected provider payment id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, clockpkg.NewSystem(), newCardPayoutStore(), &http.Client{}, nil)
|
||||
|
||||
_, err := processor.Submit(context.Background(), validCardPayoutRequest())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !errors.Is(err, merrors.ErrInternal) {
|
||||
t.Fatalf("expected internal error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardPayoutProcessor_ProcessCallback(t *testing.T) {
|
||||
cfg := monetix.Config{
|
||||
SecretKey: "secret",
|
||||
StatusSuccess: "success",
|
||||
StatusProcessing: "processing",
|
||||
AllowedCurrencies: []string{"RUB"},
|
||||
}
|
||||
store := newCardPayoutStore()
|
||||
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)}, store, &http.Client{}, nil)
|
||||
|
||||
cb := baseCallback()
|
||||
cb.Payment.Sum.Currency = "RUB"
|
||||
cb.Signature = ""
|
||||
sig, err := monetix.SignPayload(cb, cfg.SecretKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign callback: %v", err)
|
||||
}
|
||||
cb.Signature = sig
|
||||
|
||||
payload, err := json.Marshal(cb)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal callback: %v", err)
|
||||
}
|
||||
|
||||
status, err := processor.ProcessCallback(context.Background(), payload)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("expected status ok, got %d", status)
|
||||
}
|
||||
|
||||
state, ok := store.Get(cb.Payment.ID)
|
||||
if !ok || state == nil {
|
||||
t.Fatalf("expected payout state stored")
|
||||
}
|
||||
if state.GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED {
|
||||
t.Fatalf("expected processed status, got %v", state.GetStatus())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func TestValidateCardTokenPayoutRequest_Valid(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenPayoutRequest()
|
||||
if err := validateCardTokenPayoutRequest(req, cfg); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCardTokenPayoutRequest_Errors(t *testing.T) {
|
||||
baseCfg := testMonetixConfig()
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*mntxv1.CardTokenPayoutRequest)
|
||||
config func(monetix.Config) monetix.Config
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "missing_payout_id",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.PayoutId = "" },
|
||||
expected: "missing_payout_id",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_id",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerId = "" },
|
||||
expected: "missing_customer_id",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_ip",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerIp = "" },
|
||||
expected: "missing_customer_ip",
|
||||
},
|
||||
{
|
||||
name: "invalid_amount",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.AmountMinor = 0 },
|
||||
expected: "invalid_amount",
|
||||
},
|
||||
{
|
||||
name: "missing_currency",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.Currency = "" },
|
||||
expected: "missing_currency",
|
||||
},
|
||||
{
|
||||
name: "unsupported_currency",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.Currency = "EUR" },
|
||||
config: func(cfg monetix.Config) monetix.Config {
|
||||
cfg.AllowedCurrencies = []string{"USD"}
|
||||
return cfg
|
||||
},
|
||||
expected: "unsupported_currency",
|
||||
},
|
||||
{
|
||||
name: "missing_card_token",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CardToken = "" },
|
||||
expected: "missing_card_token",
|
||||
},
|
||||
{
|
||||
name: "missing_customer_city_when_required",
|
||||
mutate: func(r *mntxv1.CardTokenPayoutRequest) {
|
||||
r.CustomerCountry = "US"
|
||||
r.CustomerCity = ""
|
||||
r.CustomerAddress = "Main St"
|
||||
r.CustomerZip = "12345"
|
||||
},
|
||||
config: func(cfg monetix.Config) monetix.Config {
|
||||
cfg.RequireCustomerAddress = true
|
||||
return cfg
|
||||
},
|
||||
expected: "missing_customer_city",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := validCardTokenPayoutRequest()
|
||||
tc.mutate(req)
|
||||
cfg := baseCfg
|
||||
if tc.config != nil {
|
||||
cfg = tc.config(cfg)
|
||||
}
|
||||
err := validateCardTokenPayoutRequest(req, cfg)
|
||||
requireReason(t, err, tc.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func TestValidateCardTokenizeRequest_ValidTopLevel(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
if _, err := validateCardTokenizeRequest(req, cfg); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_ValidNestedCard(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
req.Card = &mntxv1.CardDetails{
|
||||
Pan: "4111111111111111",
|
||||
ExpMonth: req.CardExpMonth,
|
||||
ExpYear: req.CardExpYear,
|
||||
CardHolder: req.CardHolder,
|
||||
Cvv: req.CardCvv,
|
||||
}
|
||||
req.CardPan = ""
|
||||
req.CardExpMonth = 0
|
||||
req.CardExpYear = 0
|
||||
req.CardHolder = ""
|
||||
req.CardCvv = ""
|
||||
|
||||
if _, err := validateCardTokenizeRequest(req, cfg); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_Expired(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
now := time.Now().UTC()
|
||||
req.CardExpMonth = uint32(now.Month())
|
||||
req.CardExpYear = uint32(now.Year() - 1)
|
||||
|
||||
_, err := validateCardTokenizeRequest(req, cfg)
|
||||
requireReason(t, err, "expired_card")
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_MissingCvv(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
req.CardCvv = ""
|
||||
|
||||
_, err := validateCardTokenizeRequest(req, cfg)
|
||||
requireReason(t, err, "missing_cvv")
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_MissingCardPan(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
req := validCardTokenizeRequest()
|
||||
req.CardPan = ""
|
||||
|
||||
_, err := validateCardTokenizeRequest(req, cfg)
|
||||
requireReason(t, err, "missing_card_pan")
|
||||
}
|
||||
|
||||
func TestValidateCardTokenizeRequest_AddressRequired(t *testing.T) {
|
||||
cfg := testMonetixConfig()
|
||||
cfg.RequireCustomerAddress = true
|
||||
req := validCardTokenizeRequest()
|
||||
req.CustomerCountry = ""
|
||||
|
||||
_, err := validateCardTokenizeRequest(req, cfg)
|
||||
requireReason(t, err, "missing_customer_country")
|
||||
}
|
||||
@@ -164,11 +164,3 @@ func statusLabel(err error) string {
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCallbackStatus(status string) string {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.ToLower(status)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (*mntxv1.GetPayoutResponse, error) {
|
||||
@@ -17,14 +18,19 @@ func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (
|
||||
|
||||
func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] {
|
||||
ref := strings.TrimSpace(req.GetPayoutRef())
|
||||
log := s.logger.Named("payout")
|
||||
log.Info("Get payout request received", zap.String("payout_ref", ref))
|
||||
if ref == "" {
|
||||
log.Warn("Get payout request missing payout_ref")
|
||||
return gsresponse.InvalidArgument[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.InvalidArgument("payout_ref is required", "payout_ref"))
|
||||
}
|
||||
|
||||
payout, ok := s.store.Get(ref)
|
||||
if !ok {
|
||||
log.Warn("Payout not found", zap.String("payout_ref", ref))
|
||||
return gsresponse.NotFound[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.NoData(fmt.Sprintf("payout %s not found", ref)))
|
||||
}
|
||||
|
||||
log.Info("Payout retrieved", zap.String("payout_ref", ref), zap.String("status", payout.GetStatus().String()))
|
||||
return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout})
|
||||
}
|
||||
|
||||
@@ -22,8 +22,17 @@ func (s *Service) SubmitPayout(ctx context.Context, req *mntxv1.SubmitPayoutRequ
|
||||
}
|
||||
|
||||
func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayoutRequest) gsresponse.Responder[mntxv1.SubmitPayoutResponse] {
|
||||
log := s.logger.Named("payout")
|
||||
log.Info("Submit payout request received",
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())),
|
||||
zap.String("currency", strings.TrimSpace(req.GetAmount().GetCurrency())),
|
||||
zap.String("amount", strings.TrimSpace(req.GetAmount().GetAmount())),
|
||||
)
|
||||
|
||||
payout, err := s.buildPayout(req)
|
||||
if err != nil {
|
||||
log.Warn("Submit payout validation failed", zap.Error(err))
|
||||
return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, err)
|
||||
}
|
||||
|
||||
@@ -31,6 +40,7 @@ func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayout
|
||||
s.emitEvent(payout, nm.NAPending)
|
||||
go s.completePayout(payout, strings.TrimSpace(req.GetSimulatedFailureReason()))
|
||||
|
||||
log.Info("Payout accepted", zap.String("payout_ref", payout.GetPayoutRef()), zap.String("status", payout.GetStatus().String()))
|
||||
return gsresponse.Success(&mntxv1.SubmitPayoutResponse{Payout: payout})
|
||||
}
|
||||
|
||||
@@ -79,6 +89,7 @@ func (s *Service) buildPayout(req *mntxv1.SubmitPayoutRequest) (*mntxv1.Payout,
|
||||
}
|
||||
|
||||
func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) {
|
||||
log := s.logger.Named("payout")
|
||||
outcome := clonePayout(original)
|
||||
if outcome == nil {
|
||||
return
|
||||
@@ -95,6 +106,7 @@ func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure strin
|
||||
observePayoutError(simulatedFailure, outcome.Amount)
|
||||
s.store.Save(outcome)
|
||||
s.emitEvent(outcome, nm.NAUpdated)
|
||||
log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String()), zap.String("failure_reason", simulatedFailure))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -102,6 +114,7 @@ func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure strin
|
||||
observePayoutSuccess(outcome.Amount)
|
||||
s.store.Save(outcome)
|
||||
s.emitEvent(outcome, nm.NAUpdated)
|
||||
log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String()))
|
||||
}
|
||||
|
||||
func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction) {
|
||||
@@ -111,18 +124,18 @@ func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction)
|
||||
|
||||
payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout})
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to marshal payout event", zapError(err))
|
||||
s.logger.Warn("Failed to marshal payout event", zapError(err))
|
||||
return
|
||||
}
|
||||
|
||||
env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action))
|
||||
if _, err := env.Wrap(payload); err != nil {
|
||||
s.logger.Warn("failed to wrap payout event payload", zapError(err))
|
||||
s.logger.Warn("Failed to wrap payout event payload", zapError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.producer.SendMessage(env); err != nil {
|
||||
s.logger.Warn("failed to publish payout event", zapError(err))
|
||||
s.logger.Warn("Failed to publish payout event", zapError(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
@@ -97,9 +98,19 @@ func (s *Service) Register(router routers.GRPC) error {
|
||||
}
|
||||
|
||||
func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) {
|
||||
log := svc.logger.Named("rpc")
|
||||
log.Info("RPC request started", zap.String("method", method))
|
||||
|
||||
start := svc.clock.Now()
|
||||
resp, err := gsresponse.Unary(svc.logger, mservice.MntxGateway, handler)(ctx, req)
|
||||
observeRPC(method, err, svc.clock.Now().Sub(start))
|
||||
duration := svc.clock.Now().Sub(start)
|
||||
observeRPC(method, err, duration)
|
||||
|
||||
if err != nil {
|
||||
log.Warn("RPC request failed", zap.String("method", method), zap.Duration("duration", duration), zap.Error(err))
|
||||
} else {
|
||||
log.Info("RPC request completed", zap.String("method", method), zap.Duration("duration", duration))
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
)
|
||||
|
||||
func requireReason(t *testing.T, err error, reason string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error, got %v", err)
|
||||
}
|
||||
reasoned, ok := err.(payoutFailure)
|
||||
if !ok {
|
||||
t.Fatalf("expected payout failure reason, got %T", err)
|
||||
}
|
||||
if reasoned.Reason() != reason {
|
||||
t.Fatalf("expected reason %q, got %q", reason, reasoned.Reason())
|
||||
}
|
||||
}
|
||||
|
||||
func testMonetixConfig() monetix.Config {
|
||||
return monetix.Config{
|
||||
AllowedCurrencies: []string{"RUB", "USD"},
|
||||
}
|
||||
}
|
||||
|
||||
func validCardPayoutRequest() *mntxv1.CardPayoutRequest {
|
||||
return &mntxv1.CardPayoutRequest{
|
||||
PayoutId: "payout-1",
|
||||
CustomerId: "cust-1",
|
||||
CustomerFirstName: "Jane",
|
||||
CustomerLastName: "Doe",
|
||||
CustomerIp: "203.0.113.10",
|
||||
AmountMinor: 1500,
|
||||
Currency: "RUB",
|
||||
CardPan: "4111111111111111",
|
||||
CardHolder: "JANE DOE",
|
||||
CardExpMonth: 12,
|
||||
CardExpYear: 2035,
|
||||
}
|
||||
}
|
||||
|
||||
func validCardTokenPayoutRequest() *mntxv1.CardTokenPayoutRequest {
|
||||
return &mntxv1.CardTokenPayoutRequest{
|
||||
PayoutId: "payout-1",
|
||||
CustomerId: "cust-1",
|
||||
CustomerFirstName: "Jane",
|
||||
CustomerLastName: "Doe",
|
||||
CustomerIp: "203.0.113.11",
|
||||
AmountMinor: 2500,
|
||||
Currency: "USD",
|
||||
CardToken: "tok_123",
|
||||
}
|
||||
}
|
||||
|
||||
func validCardTokenizeRequest() *mntxv1.CardTokenizeRequest {
|
||||
month, year := futureExpiry()
|
||||
return &mntxv1.CardTokenizeRequest{
|
||||
RequestId: "req-1",
|
||||
CustomerId: "cust-1",
|
||||
CustomerFirstName: "Jane",
|
||||
CustomerLastName: "Doe",
|
||||
CustomerIp: "203.0.113.12",
|
||||
CardPan: "4111111111111111",
|
||||
CardHolder: "JANE DOE",
|
||||
CardCvv: "123",
|
||||
CardExpMonth: month,
|
||||
CardExpYear: year,
|
||||
}
|
||||
}
|
||||
|
||||
func futureExpiry() (uint32, uint32) {
|
||||
now := time.Now().UTC()
|
||||
return uint32(now.Month()), uint32(now.Year() + 1)
|
||||
}
|
||||
@@ -2,10 +2,6 @@ package monetix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
@@ -45,21 +41,3 @@ func (c *Client) CreateCardTokenPayout(ctx context.Context, req CardTokenPayoutR
|
||||
func (c *Client) CreateCardTokenization(ctx context.Context, req CardTokenizeRequest) (*TokenizationResult, error) {
|
||||
return c.sendTokenization(ctx, req)
|
||||
}
|
||||
|
||||
func signPayload(payload any, secret string) (string, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
if _, err := h.Write(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// SignPayload exposes signature calculation for callback verification.
|
||||
func SignPayload(payload any, secret string) (string, error) {
|
||||
return signPayload(payload, secret)
|
||||
}
|
||||
|
||||
23
api/gateway/mntx/internal/service/monetix/mask_test.go
Normal file
23
api/gateway/mntx/internal/service/monetix/mask_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package monetix
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMaskPAN(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{input: "1234", expected: "****"},
|
||||
{input: "1234567890", expected: "12******90"},
|
||||
{input: "1234567890123456", expected: "123456******3456"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
got := MaskPAN(tc.input)
|
||||
if got != tc.expected {
|
||||
t.Fatalf("expected %q, got %q", tc.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func (c *Client) sendCardPayout(ctx context.Context, req CardPayoutRequest) (*Ca
|
||||
maskedPAN := MaskPAN(req.Card.PAN)
|
||||
return c.send(ctx, &req, "/v2/payment/card/payout",
|
||||
func() {
|
||||
c.logger.Info("dispatching Monetix card payout",
|
||||
c.logger.Info("Dispatching Monetix card payout",
|
||||
zap.String("payout_id", req.General.PaymentID),
|
||||
zap.Int64("amount_minor", req.Payment.Amount),
|
||||
zap.String("currency", req.Payment.Currency),
|
||||
@@ -47,7 +47,7 @@ func (c *Client) sendCardPayout(ctx context.Context, req CardPayoutRequest) (*Ca
|
||||
func (c *Client) sendCardTokenPayout(ctx context.Context, req CardTokenPayoutRequest) (*CardPayoutSendResult, error) {
|
||||
return c.send(ctx, &req, "/v2/payment/card/payout/token",
|
||||
func() {
|
||||
c.logger.Info("dispatching Monetix card token payout",
|
||||
c.logger.Info("Dispatching Monetix card token payout",
|
||||
zap.String("payout_id", req.General.PaymentID),
|
||||
zap.Int64("amount_minor", req.Payment.Amount),
|
||||
zap.String("currency", req.Payment.Currency),
|
||||
@@ -101,7 +101,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
|
||||
c.logger.Info("dispatching Monetix card tokenization",
|
||||
c.logger.Info("Dispatching Monetix card tokenization",
|
||||
zap.String("request_id", req.General.PaymentID),
|
||||
zap.String("masked_pan", MaskPAN(req.Card.PAN)),
|
||||
)
|
||||
@@ -111,7 +111,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
|
||||
duration := time.Since(start)
|
||||
if err != nil {
|
||||
observeRequest(outcomeNetworkError, duration)
|
||||
c.logger.Warn("monetix tokenization request failed", zap.Error(err))
|
||||
c.logger.Warn("Monetix tokenization request failed", zap.Error(err))
|
||||
return nil, merrors.Internal("monetix tokenization request failed: " + err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -133,7 +133,7 @@ func (c *Client) sendTokenization(ctx context.Context, req CardTokenizeRequest)
|
||||
var apiResp APIResponse
|
||||
if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
c.logger.Warn("failed to decode Monetix tokenization response", zap.String("request_id", req.General.PaymentID), zap.Int("status_code", resp.StatusCode), zap.Error(err))
|
||||
c.logger.Warn("Failed to decode Monetix tokenization response", zap.String("request_id", req.General.PaymentID), zap.Int("status_code", resp.StatusCode), zap.Error(err))
|
||||
} else {
|
||||
var tokenData struct {
|
||||
Token string `json:"token"`
|
||||
@@ -245,7 +245,7 @@ func (c *Client) send(ctx context.Context, req any, path string, dispatchLog fun
|
||||
var apiResp APIResponse
|
||||
if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
c.logger.Warn("failed to decode Monetix response", zap.Int("status_code", resp.StatusCode), zap.Error(err))
|
||||
c.logger.Warn("Failed to decode Monetix response", zap.Int("status_code", resp.StatusCode), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
128
api/gateway/mntx/internal/service/monetix/sender_test.go
Normal file
128
api/gateway/mntx/internal/service/monetix/sender_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package monetix
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
|
||||
func TestSendCardPayout_SignsPayload(t *testing.T) {
|
||||
secret := "secret"
|
||||
var captured CardPayoutRequest
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/v2/payment/card/payout" {
|
||||
t.Fatalf("expected payout path, got %q", r.URL.Path)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, &captured); err != nil {
|
||||
t.Fatalf("failed to decode request: %v", err)
|
||||
}
|
||||
resp := APIResponse{}
|
||||
resp.Operation.RequestID = "req-1"
|
||||
payload, _ := json.Marshal(resp)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader(payload)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: secret,
|
||||
}
|
||||
client := NewClient(cfg, httpClient, zap.NewNop())
|
||||
|
||||
req := CardPayoutRequest{
|
||||
General: General{ProjectID: 1, PaymentID: "payout-1"},
|
||||
Customer: Customer{
|
||||
ID: "cust-1",
|
||||
FirstName: "Jane",
|
||||
LastName: "Doe",
|
||||
IP: "203.0.113.10",
|
||||
},
|
||||
Payment: Payment{Amount: 1000, Currency: "RUB"},
|
||||
Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"},
|
||||
}
|
||||
|
||||
result, err := client.CreateCardPayout(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if !result.Accepted {
|
||||
t.Fatalf("expected accepted response")
|
||||
}
|
||||
if captured.General.Signature == "" {
|
||||
t.Fatalf("expected signature in request")
|
||||
}
|
||||
|
||||
signed := captured
|
||||
signed.General.Signature = ""
|
||||
expectedSig, err := SignPayload(signed, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to compute signature: %v", err)
|
||||
}
|
||||
if captured.General.Signature != expectedSig {
|
||||
t.Fatalf("expected signature %q, got %q", expectedSig, captured.General.Signature)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendCardPayout_HTTPError(t *testing.T) {
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
|
||||
body := `{"code":"E100","message":"denied"}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
BaseURL: "https://monetix.test",
|
||||
SecretKey: "secret",
|
||||
}
|
||||
client := NewClient(cfg, httpClient, zap.NewNop())
|
||||
|
||||
req := CardPayoutRequest{
|
||||
General: General{ProjectID: 1, PaymentID: "payout-1"},
|
||||
Customer: Customer{
|
||||
ID: "cust-1",
|
||||
FirstName: "Jane",
|
||||
LastName: "Doe",
|
||||
IP: "203.0.113.10",
|
||||
},
|
||||
Payment: Payment{Amount: 1000, Currency: "RUB"},
|
||||
Card: Card{PAN: "4111111111111111", Year: 2030, Month: 12, CardHolder: "JANE DOE"},
|
||||
}
|
||||
|
||||
result, err := client.CreateCardPayout(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if result.Accepted {
|
||||
t.Fatalf("expected rejected response")
|
||||
}
|
||||
if result.ErrorCode != "E100" {
|
||||
t.Fatalf("expected error code E100, got %q", result.ErrorCode)
|
||||
}
|
||||
if result.ErrorMessage != "denied" {
|
||||
t.Fatalf("expected error message denied, got %q", result.ErrorMessage)
|
||||
}
|
||||
}
|
||||
112
api/gateway/mntx/internal/service/monetix/signature.go
Normal file
112
api/gateway/mntx/internal/service/monetix/signature.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package monetix
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func signPayload(payload any, secret string) (string, error) {
|
||||
canonical, err := signaturePayloadString(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mac := hmac.New(sha512.New, []byte(secret))
|
||||
if _, err := mac.Write([]byte(canonical)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// SignPayload exposes signature calculation for callback verification.
|
||||
func SignPayload(payload any, secret string) (string, error) {
|
||||
return signPayload(payload, secret)
|
||||
}
|
||||
|
||||
func signaturePayloadString(payload any) (string, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var root any
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&root); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
lines := make([]string, 0)
|
||||
collectSignatureLines(nil, root, &lines)
|
||||
sort.Strings(lines)
|
||||
|
||||
return strings.Join(lines, ";"), nil
|
||||
}
|
||||
|
||||
func collectSignatureLines(path []string, value any, lines *[]string) {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range v {
|
||||
if strings.EqualFold(key, "signature") {
|
||||
continue
|
||||
}
|
||||
collectSignatureLines(append(path, key), child, lines)
|
||||
}
|
||||
case []any:
|
||||
if len(v) == 0 {
|
||||
return
|
||||
}
|
||||
for idx, child := range v {
|
||||
collectSignatureLines(append(path, strconv.Itoa(idx)), child, lines)
|
||||
}
|
||||
default:
|
||||
line := formatSignatureLine(path, v)
|
||||
if line != "" {
|
||||
*lines = append(*lines, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatSignatureLine(path []string, value any) string {
|
||||
if len(path) == 0 {
|
||||
return ""
|
||||
}
|
||||
val := signatureValueString(value)
|
||||
segments := append(append([]string{}, path...), val)
|
||||
return strings.Join(segments, ":")
|
||||
}
|
||||
|
||||
func signatureValueString(value any) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case string:
|
||||
return v
|
||||
case json.Number:
|
||||
return v.String()
|
||||
case bool:
|
||||
if v {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(v), 'f', -1, 32)
|
||||
case int:
|
||||
return strconv.Itoa(v)
|
||||
case int8, int16, int32, int64:
|
||||
return fmt.Sprint(v)
|
||||
case uint, uint8, uint16, uint32, uint64:
|
||||
return fmt.Sprint(v)
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
148
api/gateway/mntx/internal/service/monetix/signature_test.go
Normal file
148
api/gateway/mntx/internal/service/monetix/signature_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package monetix
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSignaturePayloadString_Example(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"general": map[string]any{
|
||||
"project_id": 3254,
|
||||
"payment_id": "id_38202316",
|
||||
"signature": "<ignored>",
|
||||
},
|
||||
"customer": map[string]any{
|
||||
"id": "585741",
|
||||
"email": "johndoe@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"address": "Downing str., 23",
|
||||
"identify": map[string]any{
|
||||
"doc_number": "54122312544",
|
||||
},
|
||||
"ip_address": "198.51.100.47",
|
||||
},
|
||||
"payment": map[string]any{
|
||||
"amount": 10800,
|
||||
"currency": "USD",
|
||||
"description": "Computer keyboards",
|
||||
},
|
||||
"receipt_data": map[string]any{
|
||||
"positions": []any{
|
||||
map[string]any{
|
||||
"quantity": "10",
|
||||
"amount": "108",
|
||||
"description": "Computer keyboard",
|
||||
},
|
||||
},
|
||||
},
|
||||
"return_url": map[string]any{
|
||||
"success": "https://paymentpage.example.com/complete-redirect?id=success",
|
||||
"decline": "https://paymentpage.example.com/complete-redirect?id=decline",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := signaturePayloadString(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build signature string: %v", err)
|
||||
}
|
||||
|
||||
expected := "customer:address:Downing str., 23;customer:email:johndoe@example.com;customer:first_name:John;customer:id:585741;customer:identify:doc_number:54122312544;customer:ip_address:198.51.100.47;customer:last_name:Doe;general:payment_id:id_38202316;general:project_id:3254;payment:amount:10800;payment:currency:USD;payment:description:Computer keyboards;receipt_data:positions:0:amount:108;receipt_data:positions:0:description:Computer keyboard;receipt_data:positions:0:quantity:10;return_url:decline:https://paymentpage.example.com/complete-redirect?id=decline;return_url:success:https://paymentpage.example.com/complete-redirect?id=success"
|
||||
if got != expected {
|
||||
t.Fatalf("unexpected signature string\nexpected: %s\ngot: %s", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignPayload_Example(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"general": map[string]any{
|
||||
"project_id": 3254,
|
||||
"payment_id": "id_38202316",
|
||||
"signature": "<ignored>",
|
||||
},
|
||||
"customer": map[string]any{
|
||||
"id": "585741",
|
||||
"email": "johndoe@example.com",
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"address": "Downing str., 23",
|
||||
"identify": map[string]any{
|
||||
"doc_number": "54122312544",
|
||||
},
|
||||
"ip_address": "198.51.100.47",
|
||||
},
|
||||
"payment": map[string]any{
|
||||
"amount": 10800,
|
||||
"currency": "USD",
|
||||
"description": "Computer keyboards",
|
||||
},
|
||||
"receipt_data": map[string]any{
|
||||
"positions": []any{
|
||||
map[string]any{
|
||||
"quantity": "10",
|
||||
"amount": "108",
|
||||
"description": "Computer keyboard",
|
||||
},
|
||||
},
|
||||
},
|
||||
"return_url": map[string]any{
|
||||
"success": "https://paymentpage.example.com/complete-redirect?id=success",
|
||||
"decline": "https://paymentpage.example.com/complete-redirect?id=decline",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := SignPayload(payload, "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign payload: %v", err)
|
||||
}
|
||||
expected := "lagSnuspAn+F6XkmQISqwtBg0PsiTy62fF9x33TM+278mnufIDZyi1yP0BQALuCxyikkIxIMbodBn2F8hMdRwA=="
|
||||
if got != expected {
|
||||
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignaturePayloadString_BooleansAndArrays(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"flag": true,
|
||||
"false_flag": false,
|
||||
"empty": "",
|
||||
"zero": 0,
|
||||
"nested": map[string]any{
|
||||
"list": []any{},
|
||||
"items": []any{"alpha", "beta"},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := signaturePayloadString(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build signature string: %v", err)
|
||||
}
|
||||
|
||||
expected := "empty:;false_flag:0;flag:1;nested:items:0:alpha;nested:items:1:beta;zero:0"
|
||||
if got != expected {
|
||||
t.Fatalf("unexpected signature string\nexpected: %s\ngot: %s", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignPayload_EthEstimateGasExample(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "eth_estimateGas",
|
||||
"params": []any{
|
||||
map[string]any{
|
||||
"from": "0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8",
|
||||
"to": "0x44162e39eefd9296231e772663a92e72958e182f",
|
||||
"gasPrice": "0x64",
|
||||
"data": "0xa9059cbb00000000000000000000000044162e39eefd9296231e772663a92e72958e182f00000000000000000000000000000000000000000000000000000000000f4240",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := SignPayload(payload, "1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign payload: %v", err)
|
||||
}
|
||||
expected := "C4WbSvXKSMyX8yLamQcUe/Nzr6nSt9m3HYn4jHSyA7yi/FaTiqk0r8BlfIzfxSCoDaRgrSd82ihgZW+DxELhdQ=="
|
||||
if got != expected {
|
||||
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,12 @@ gateway:
|
||||
call_timeout_seconds: 3
|
||||
insecure: true
|
||||
|
||||
mntx:
|
||||
address: "sendico_mntx_gateway:50075"
|
||||
dial_timeout_seconds: 5
|
||||
call_timeout_seconds: 3
|
||||
insecure: true
|
||||
|
||||
oracle:
|
||||
address: "sendico_fx_oracle:50051"
|
||||
dial_timeout_seconds: 5
|
||||
@@ -59,8 +65,8 @@ oracle:
|
||||
|
||||
card_gateways:
|
||||
monetix:
|
||||
funding_address: "wallet_funding_monetix"
|
||||
fee_address: "wallet_fee_monetix"
|
||||
funding_address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
|
||||
fee_wallet_ref: "694c124ed76f9f811ac57133"
|
||||
|
||||
fee_ledger_accounts:
|
||||
monetix: "ledger:fees:monetix"
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
mntxclient "github.com/tech/sendico/gateway/mntx/client"
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
@@ -36,6 +37,7 @@ type Imp struct {
|
||||
feesConn *grpc.ClientConn
|
||||
ledgerClient ledgerclient.Client
|
||||
gatewayClient chainclient.Client
|
||||
mntxClient mntxclient.Client
|
||||
oracleClient oracleclient.Client
|
||||
}
|
||||
|
||||
@@ -44,6 +46,7 @@ type config struct {
|
||||
Fees clientConfig `yaml:"fees"`
|
||||
Ledger clientConfig `yaml:"ledger"`
|
||||
Gateway clientConfig `yaml:"gateway"`
|
||||
Mntx clientConfig `yaml:"mntx"`
|
||||
Oracle clientConfig `yaml:"oracle"`
|
||||
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
|
||||
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
|
||||
@@ -59,6 +62,7 @@ type clientConfig struct {
|
||||
type cardGatewayRouteConfig struct {
|
||||
FundingAddress string `yaml:"funding_address"`
|
||||
FeeAddress string `yaml:"fee_address"`
|
||||
FeeWalletRef string `yaml:"fee_wallet_ref"`
|
||||
}
|
||||
|
||||
func (c clientConfig) address() string {
|
||||
@@ -104,6 +108,9 @@ func (i *Imp) Shutdown() {
|
||||
if i.gatewayClient != nil {
|
||||
_ = i.gatewayClient.Close()
|
||||
}
|
||||
if i.mntxClient != nil {
|
||||
_ = i.mntxClient.Close()
|
||||
}
|
||||
if i.oracleClient != nil {
|
||||
_ = i.oracleClient.Close()
|
||||
}
|
||||
@@ -138,6 +145,11 @@ func (i *Imp) Start() error {
|
||||
i.gatewayClient = gatewayClient
|
||||
}
|
||||
|
||||
mntxClient := i.initMntxClient(cfg.Mntx)
|
||||
if mntxClient != nil {
|
||||
i.mntxClient = mntxClient
|
||||
}
|
||||
|
||||
oracleClient := i.initOracleClient(cfg.Oracle)
|
||||
if oracleClient != nil {
|
||||
i.oracleClient = oracleClient
|
||||
@@ -154,6 +166,9 @@ func (i *Imp) Start() error {
|
||||
if gatewayClient != nil {
|
||||
opts = append(opts, orchestrator.WithChainGatewayClient(gatewayClient))
|
||||
}
|
||||
if mntxClient != nil {
|
||||
opts = append(opts, orchestrator.WithMntxGateway(mntxClient))
|
||||
}
|
||||
if oracleClient != nil {
|
||||
opts = append(opts, orchestrator.WithOracleClient(oracleClient))
|
||||
}
|
||||
@@ -191,11 +206,11 @@ func (i *Imp) initFeesClient(cfg clientConfig) (feesv1.FeeEngineClient, *grpc.Cl
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, addr, grpc.WithTransportCredentials(creds))
|
||||
if err != nil {
|
||||
i.logger.Warn("failed to connect to fees service", zap.String("address", addr), zap.Error(err))
|
||||
i.logger.Warn("Failed to connect to fees service", zap.String("address", addr), zap.Error(err))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
i.logger.Info("connected to fees service", zap.String("address", addr))
|
||||
i.logger.Info("Connected to fees service", zap.String("address", addr))
|
||||
return feesv1.NewFeeEngineClient(conn), conn
|
||||
}
|
||||
|
||||
@@ -215,10 +230,10 @@ func (i *Imp) initLedgerClient(cfg clientConfig) ledgerclient.Client {
|
||||
Insecure: cfg.InsecureTransport,
|
||||
})
|
||||
if err != nil {
|
||||
i.logger.Warn("failed to connect to ledger service", zap.String("address", addr), zap.Error(err))
|
||||
i.logger.Warn("Failed to connect to ledger service", zap.String("address", addr), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
i.logger.Info("connected to ledger service", zap.String("address", addr))
|
||||
i.logger.Info("Connected to ledger service", zap.String("address", addr))
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -245,6 +260,28 @@ func (i *Imp) initGatewayClient(cfg clientConfig) chainclient.Client {
|
||||
return client
|
||||
}
|
||||
|
||||
func (i *Imp) initMntxClient(cfg clientConfig) mntxclient.Client {
|
||||
addr := cfg.address()
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.dialTimeout())
|
||||
defer cancel()
|
||||
|
||||
client, err := mntxclient.New(ctx, mntxclient.Config{
|
||||
Address: addr,
|
||||
DialTimeout: cfg.dialTimeout(),
|
||||
CallTimeout: cfg.callTimeout(),
|
||||
})
|
||||
if err != nil {
|
||||
i.logger.Warn("Failed to connect to mntx gateway service", zap.String("address", addr), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
i.logger.Info("Connected to mntx gateway service", zap.String("address", addr))
|
||||
return client
|
||||
}
|
||||
|
||||
func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
|
||||
addr := cfg.address()
|
||||
if addr == "" {
|
||||
@@ -261,10 +298,10 @@ func (i *Imp) initOracleClient(cfg clientConfig) oracleclient.Client {
|
||||
Insecure: cfg.InsecureTransport,
|
||||
})
|
||||
if err != nil {
|
||||
i.logger.Warn("failed to connect to oracle service", zap.String("address", addr), zap.Error(err))
|
||||
i.logger.Warn("Failed to connect to oracle service", zap.String("address", addr), zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
i.logger.Info("connected to oracle service", zap.String("address", addr))
|
||||
i.logger.Info("Connected to oracle service", zap.String("address", addr))
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -323,6 +360,7 @@ func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]or
|
||||
result[trimmedKey] = orchestrator.CardGatewayRoute{
|
||||
FundingAddress: strings.TrimSpace(route.FundingAddress),
|
||||
FeeAddress: strings.TrimSpace(route.FeeAddress),
|
||||
FeeWalletRef: strings.TrimSpace(route.FeeWalletRef),
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -7,13 +7,21 @@ import (
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const defaultCardGateway = "monetix"
|
||||
const (
|
||||
defaultCardGateway = "monetix"
|
||||
|
||||
stepCodeGasTopUp = "gas_top_up"
|
||||
stepCodeFundingTransfer = "funding_transfer"
|
||||
stepCodeCardPayout = "card_payout"
|
||||
stepCodeFeeTransfer = "fee_transfer"
|
||||
)
|
||||
|
||||
func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) {
|
||||
if len(s.deps.cardRoutes) == 0 {
|
||||
@@ -54,24 +62,214 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef)
|
||||
fundingAddress := strings.TrimSpace(route.FundingAddress)
|
||||
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
|
||||
|
||||
amount := cloneMoney(intent.Amount)
|
||||
if amount == nil {
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return merrors.InvalidArgument("card funding: amount is required")
|
||||
}
|
||||
|
||||
payoutAmount, err := cardPayoutAmount(payment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
feeMoney := (*moneyv1.Money)(nil)
|
||||
if quote != nil {
|
||||
feeMoney = quote.GetExpectedFeeTotal()
|
||||
}
|
||||
if feeMoney == nil && payment.LastQuote != nil {
|
||||
feeMoney = payment.LastQuote.ExpectedFeeTotal
|
||||
}
|
||||
feeDecimal := decimal.Zero
|
||||
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
|
||||
if strings.TrimSpace(feeMoney.GetCurrency()) == "" {
|
||||
return merrors.InvalidArgument("card funding: fee currency is required")
|
||||
}
|
||||
feeDecimal, err = decimalFromMoney(feeMoney)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
feeRequired := feeDecimal.IsPositive()
|
||||
|
||||
fundingDest := &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
|
||||
}
|
||||
fundingFee, err := s.estimateTransferNetworkFee(ctx, sourceWalletRef, fundingDest, amount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var feeTransferFee *moneyv1.Money
|
||||
if feeRequired {
|
||||
if feeWalletRef == "" {
|
||||
return merrors.InvalidArgument("card funding: fee wallet ref is required when fee exists")
|
||||
}
|
||||
feeDest := &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
|
||||
}
|
||||
feeTransferFee, err = s.estimateTransferNetworkFee(ctx, sourceWalletRef, feeDest, feeMoney)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var estimatedTotalFee *moneyv1.Money
|
||||
if gasCurrency != "" && !totalFee.IsNegative() {
|
||||
estimatedTotalFee = makeMoney(gasCurrency, totalFee)
|
||||
}
|
||||
|
||||
var topUpMoney *moneyv1.Money
|
||||
var topUpFee *moneyv1.Money
|
||||
topUpPositive := false
|
||||
if estimatedTotalFee != nil {
|
||||
computeResp, err := s.deps.gateway.client.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
|
||||
WalletRef: sourceWalletRef,
|
||||
EstimatedTotalFee: estimatedTotalFee,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
|
||||
return err
|
||||
}
|
||||
if computeResp != nil {
|
||||
topUpMoney = computeResp.GetTopupAmount()
|
||||
}
|
||||
if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" {
|
||||
amountDec, err := decimalFromMoney(topUpMoney)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
topUpPositive = amountDec.IsPositive()
|
||||
}
|
||||
if topUpMoney != nil && topUpPositive {
|
||||
if strings.TrimSpace(topUpMoney.GetCurrency()) == "" {
|
||||
return merrors.InvalidArgument("card funding: gas top-up currency is required")
|
||||
}
|
||||
if feeWalletRef == "" {
|
||||
return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up")
|
||||
}
|
||||
topUpDest := &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
|
||||
}
|
||||
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, topUpMoney)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plan := ensureExecutionPlan(payment)
|
||||
var gasStep *model.ExecutionStep
|
||||
if topUpMoney != nil && topUpPositive {
|
||||
gasStep = ensureExecutionStep(plan, stepCodeGasTopUp)
|
||||
gasStep.Description = "Top up native gas from fee wallet"
|
||||
gasStep.Amount = cloneMoney(topUpMoney)
|
||||
gasStep.NetworkFee = cloneMoney(topUpFee)
|
||||
gasStep.SourceWalletRef = feeWalletRef
|
||||
gasStep.DestinationRef = sourceWalletRef
|
||||
}
|
||||
|
||||
fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer)
|
||||
fundStep.Description = "Transfer payout amount to card funding wallet"
|
||||
fundStep.Amount = cloneMoney(amount)
|
||||
fundStep.NetworkFee = cloneMoney(fundingFee)
|
||||
fundStep.SourceWalletRef = sourceWalletRef
|
||||
fundStep.DestinationRef = fundingAddress
|
||||
|
||||
cardStep := ensureExecutionStep(plan, stepCodeCardPayout)
|
||||
cardStep.Description = "Submit card payout"
|
||||
cardStep.Amount = cloneMoney(payoutAmount)
|
||||
if card := intent.Destination.Card; card != nil {
|
||||
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
|
||||
cardStep.DestinationRef = masked
|
||||
}
|
||||
}
|
||||
|
||||
if feeRequired {
|
||||
step := ensureExecutionStep(plan, stepCodeFeeTransfer)
|
||||
step.Description = "Transfer fee to fee wallet"
|
||||
step.Amount = cloneMoney(feeMoney)
|
||||
step.NetworkFee = cloneMoney(feeTransferFee)
|
||||
step.SourceWalletRef = sourceWalletRef
|
||||
step.DestinationRef = feeWalletRef
|
||||
}
|
||||
|
||||
updateExecutionPlanTotalNetworkFee(plan)
|
||||
|
||||
exec := payment.Execution
|
||||
if exec == nil {
|
||||
exec = &model.ExecutionRefs{}
|
||||
}
|
||||
|
||||
if topUpMoney != nil && topUpPositive {
|
||||
ensureResp, gasErr := s.deps.gateway.client.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
|
||||
IdempotencyKey: payment.IdempotencyKey + ":card:gas",
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
SourceWalletRef: feeWalletRef,
|
||||
TargetWalletRef: sourceWalletRef,
|
||||
EstimatedTotalFee: estimatedTotalFee,
|
||||
Metadata: cloneMetadata(payment.Metadata),
|
||||
ClientReference: payment.PaymentRef,
|
||||
})
|
||||
if gasErr != nil {
|
||||
s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef))
|
||||
return gasErr
|
||||
}
|
||||
if gasStep != nil {
|
||||
actual := (*moneyv1.Money)(nil)
|
||||
if ensureResp != nil {
|
||||
actual = ensureResp.GetTopupAmount()
|
||||
if transfer := ensureResp.GetTransfer(); transfer != nil {
|
||||
gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef())
|
||||
}
|
||||
}
|
||||
actualPositive := false
|
||||
if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" {
|
||||
actualDec, err := decimalFromMoney(actual)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actualPositive = actualDec.IsPositive()
|
||||
}
|
||||
if actual != nil && actualPositive {
|
||||
gasStep.Amount = cloneMoney(actual)
|
||||
if strings.TrimSpace(actual.GetCurrency()) == "" {
|
||||
return merrors.InvalidArgument("card funding: gas top-up currency is required")
|
||||
}
|
||||
topUpDest := &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef},
|
||||
}
|
||||
topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, actual)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gasStep.NetworkFee = cloneMoney(topUpFee)
|
||||
} else {
|
||||
gasStep.Amount = nil
|
||||
gasStep.NetworkFee = nil
|
||||
}
|
||||
}
|
||||
if gasStep != nil {
|
||||
s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef))
|
||||
}
|
||||
updateExecutionPlanTotalNetworkFee(plan)
|
||||
}
|
||||
|
||||
// Transfer payout amount to funding wallet.
|
||||
fundReq := &chainv1.SubmitTransferRequest{
|
||||
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FundingAddress)},
|
||||
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress},
|
||||
},
|
||||
Amount: amount,
|
||||
Metadata: cloneMetadata(payment.Metadata),
|
||||
@@ -84,42 +282,10 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model
|
||||
}
|
||||
if fundResp != nil && fundResp.GetTransfer() != nil {
|
||||
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
|
||||
fundStep.TransferRef = exec.ChainTransferRef
|
||||
}
|
||||
s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
|
||||
|
||||
feeMoney := quote.GetExpectedFeeTotal()
|
||||
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
|
||||
if strings.TrimSpace(route.FeeAddress) == "" {
|
||||
return merrors.InvalidArgument("card funding: fee address is required when fee exists")
|
||||
}
|
||||
feeDecimal, err := decimalFromMoney(feeMoney)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if feeDecimal.IsPositive() {
|
||||
feeReq := &chainv1.SubmitTransferRequest{
|
||||
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
|
||||
Destination: &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FeeAddress)},
|
||||
},
|
||||
Amount: feeMoney,
|
||||
Metadata: cloneMetadata(payment.Metadata),
|
||||
ClientReference: payment.PaymentRef,
|
||||
}
|
||||
feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq)
|
||||
if feeErr != nil {
|
||||
s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef))
|
||||
return feeErr
|
||||
}
|
||||
if feeResp != nil && feeResp.GetTransfer() != nil {
|
||||
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
|
||||
}
|
||||
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
|
||||
}
|
||||
}
|
||||
|
||||
payment.Execution = exec
|
||||
return nil
|
||||
}
|
||||
@@ -133,9 +299,9 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment)
|
||||
if card == nil {
|
||||
return merrors.InvalidArgument("card payout: card endpoint is required")
|
||||
}
|
||||
amount := cloneMoney(intent.Amount)
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return merrors.InvalidArgument("card payout: amount is required")
|
||||
amount, err := cardPayoutAmount(payment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
amtDec, err := decimalFromMoney(amount)
|
||||
if err != nil {
|
||||
@@ -147,6 +313,47 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment)
|
||||
currency := strings.TrimSpace(amount.GetCurrency())
|
||||
holder := strings.TrimSpace(card.Cardholder)
|
||||
meta := cloneMetadata(payment.Metadata)
|
||||
customer := intent.Customer
|
||||
customerID := ""
|
||||
customerFirstName := ""
|
||||
customerMiddleName := ""
|
||||
customerLastName := ""
|
||||
customerIP := ""
|
||||
customerZip := ""
|
||||
customerCountry := ""
|
||||
customerState := ""
|
||||
customerCity := ""
|
||||
customerAddress := ""
|
||||
if customer != nil {
|
||||
customerID = strings.TrimSpace(customer.ID)
|
||||
customerFirstName = strings.TrimSpace(customer.FirstName)
|
||||
customerMiddleName = strings.TrimSpace(customer.MiddleName)
|
||||
customerLastName = strings.TrimSpace(customer.LastName)
|
||||
customerIP = strings.TrimSpace(customer.IP)
|
||||
customerZip = strings.TrimSpace(customer.Zip)
|
||||
customerCountry = strings.TrimSpace(customer.Country)
|
||||
customerState = strings.TrimSpace(customer.State)
|
||||
customerCity = strings.TrimSpace(customer.City)
|
||||
customerAddress = strings.TrimSpace(customer.Address)
|
||||
}
|
||||
if customerFirstName == "" {
|
||||
customerFirstName = strings.TrimSpace(card.Cardholder)
|
||||
}
|
||||
if customerLastName == "" {
|
||||
customerLastName = strings.TrimSpace(card.CardholderSurname)
|
||||
}
|
||||
if customerID == "" {
|
||||
return merrors.InvalidArgument("card payout: customer id is required")
|
||||
}
|
||||
if customerFirstName == "" {
|
||||
return merrors.InvalidArgument("card payout: customer first name is required")
|
||||
}
|
||||
if customerLastName == "" {
|
||||
return merrors.InvalidArgument("card payout: customer last name is required")
|
||||
}
|
||||
if customerIP == "" {
|
||||
return merrors.InvalidArgument("card payout: customer ip is required")
|
||||
}
|
||||
|
||||
var (
|
||||
state *mntxv1.CardPayoutState
|
||||
@@ -154,13 +361,23 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment)
|
||||
|
||||
if token := strings.TrimSpace(card.Token); token != "" {
|
||||
req := &mntxv1.CardTokenPayoutRequest{
|
||||
PayoutId: payoutID,
|
||||
AmountMinor: minor,
|
||||
Currency: currency,
|
||||
CardToken: token,
|
||||
CardHolder: holder,
|
||||
MaskedPan: strings.TrimSpace(card.MaskedPan),
|
||||
Metadata: meta,
|
||||
PayoutId: payoutID,
|
||||
CustomerId: customerID,
|
||||
CustomerFirstName: customerFirstName,
|
||||
CustomerMiddleName: customerMiddleName,
|
||||
CustomerLastName: customerLastName,
|
||||
CustomerIp: customerIP,
|
||||
CustomerZip: customerZip,
|
||||
CustomerCountry: customerCountry,
|
||||
CustomerState: customerState,
|
||||
CustomerCity: customerCity,
|
||||
CustomerAddress: customerAddress,
|
||||
AmountMinor: minor,
|
||||
Currency: currency,
|
||||
CardToken: token,
|
||||
CardHolder: holder,
|
||||
MaskedPan: strings.TrimSpace(card.MaskedPan),
|
||||
Metadata: meta,
|
||||
}
|
||||
resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req)
|
||||
if err != nil {
|
||||
@@ -170,14 +387,24 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment)
|
||||
state = resp.GetPayout()
|
||||
} else if pan := strings.TrimSpace(card.Pan); pan != "" {
|
||||
req := &mntxv1.CardPayoutRequest{
|
||||
PayoutId: payoutID,
|
||||
AmountMinor: minor,
|
||||
Currency: currency,
|
||||
CardPan: pan,
|
||||
CardExpYear: card.ExpYear,
|
||||
CardExpMonth: card.ExpMonth,
|
||||
CardHolder: holder,
|
||||
Metadata: meta,
|
||||
PayoutId: payoutID,
|
||||
CustomerId: customerID,
|
||||
CustomerFirstName: customerFirstName,
|
||||
CustomerMiddleName: customerMiddleName,
|
||||
CustomerLastName: customerLastName,
|
||||
CustomerIp: customerIP,
|
||||
CustomerZip: customerZip,
|
||||
CustomerCountry: customerCountry,
|
||||
CustomerState: customerState,
|
||||
CustomerCity: customerCity,
|
||||
CustomerAddress: customerAddress,
|
||||
AmountMinor: minor,
|
||||
Currency: currency,
|
||||
CardPan: pan,
|
||||
CardExpYear: card.ExpYear,
|
||||
CardExpMonth: card.ExpMonth,
|
||||
CardHolder: holder,
|
||||
Metadata: meta,
|
||||
}
|
||||
resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req)
|
||||
if err != nil {
|
||||
@@ -193,13 +420,92 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment)
|
||||
return merrors.Internal("card payout: missing payout state")
|
||||
}
|
||||
recordCardPayoutState(payment, state)
|
||||
if payment.Execution == nil {
|
||||
payment.Execution = &model.ExecutionRefs{}
|
||||
exec := payment.Execution
|
||||
if exec == nil {
|
||||
exec = &model.ExecutionRefs{}
|
||||
}
|
||||
if payment.Execution.CardPayoutRef == "" {
|
||||
payment.Execution.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
|
||||
if exec.CardPayoutRef == "" {
|
||||
exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
|
||||
}
|
||||
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", payment.Execution.CardPayoutRef))
|
||||
payment.Execution = exec
|
||||
|
||||
plan := ensureExecutionPlan(payment)
|
||||
if plan != nil {
|
||||
step := ensureExecutionStep(plan, stepCodeCardPayout)
|
||||
step.Description = "Submit card payout"
|
||||
step.Amount = cloneMoney(amount)
|
||||
if masked := strings.TrimSpace(card.MaskedPan); masked != "" {
|
||||
step.DestinationRef = masked
|
||||
}
|
||||
if exec.CardPayoutRef != "" {
|
||||
step.TransferRef = exec.CardPayoutRef
|
||||
}
|
||||
updateExecutionPlanTotalNetworkFee(plan)
|
||||
}
|
||||
|
||||
feeMoney := (*moneyv1.Money)(nil)
|
||||
if payment.LastQuote != nil {
|
||||
feeMoney = payment.LastQuote.ExpectedFeeTotal
|
||||
}
|
||||
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
|
||||
if strings.TrimSpace(feeMoney.GetCurrency()) == "" {
|
||||
return merrors.InvalidArgument("card payout: fee currency is required")
|
||||
}
|
||||
feeDecimal, err := decimalFromMoney(feeMoney)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if feeDecimal.IsPositive() {
|
||||
if !s.deps.gateway.available() {
|
||||
s.logger.Warn("card fee aborted: chain gateway unavailable")
|
||||
return merrors.InvalidArgument("card payout: chain gateway unavailable")
|
||||
}
|
||||
sourceWallet := intent.Source.ManagedWallet
|
||||
if sourceWallet == nil || strings.TrimSpace(sourceWallet.ManagedWalletRef) == "" {
|
||||
return merrors.InvalidArgument("card payout: source managed wallet is required")
|
||||
}
|
||||
route, err := s.cardRoute(defaultCardGateway)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
feeWalletRef := strings.TrimSpace(route.FeeWalletRef)
|
||||
if feeWalletRef == "" {
|
||||
return merrors.InvalidArgument("card payout: fee wallet ref is required when fee exists")
|
||||
}
|
||||
feeReq := &chainv1.SubmitTransferRequest{
|
||||
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
|
||||
OrganizationRef: payment.OrganizationRef.Hex(),
|
||||
SourceWalletRef: strings.TrimSpace(sourceWallet.ManagedWalletRef),
|
||||
Destination: &chainv1.TransferDestination{
|
||||
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef},
|
||||
},
|
||||
Amount: feeMoney,
|
||||
Metadata: cloneMetadata(payment.Metadata),
|
||||
ClientReference: payment.PaymentRef,
|
||||
}
|
||||
feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq)
|
||||
if feeErr != nil {
|
||||
s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef))
|
||||
return feeErr
|
||||
}
|
||||
if feeResp != nil && feeResp.GetTransfer() != nil {
|
||||
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
|
||||
}
|
||||
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
|
||||
|
||||
if plan != nil {
|
||||
step := ensureExecutionStep(plan, stepCodeFeeTransfer)
|
||||
step.Description = "Transfer fee to fee wallet"
|
||||
step.Amount = cloneMoney(feeMoney)
|
||||
step.SourceWalletRef = strings.TrimSpace(sourceWallet.ManagedWalletRef)
|
||||
step.DestinationRef = feeWalletRef
|
||||
step.TransferRef = exec.FeeTransferRef
|
||||
updateExecutionPlanTotalNetworkFee(plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", exec.CardPayoutRef))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -250,3 +556,147 @@ func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutStat
|
||||
// leave as-is for pending/unspecified
|
||||
}
|
||||
}
|
||||
|
||||
func cardPayoutAmount(payment *model.Payment) (*moneyv1.Money, error) {
|
||||
if payment == nil {
|
||||
return nil, merrors.InvalidArgument("payment is required")
|
||||
}
|
||||
amount := cloneMoney(payment.Intent.Amount)
|
||||
if payment.LastQuote != nil {
|
||||
settlement := payment.LastQuote.ExpectedSettlementAmount
|
||||
if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" {
|
||||
amount = cloneMoney(settlement)
|
||||
}
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("card payout: amount is required")
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func (s *Service) estimateTransferNetworkFee(ctx context.Context, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) {
|
||||
if !s.deps.gateway.available() {
|
||||
return nil, merrors.InvalidArgument("chain gateway unavailable")
|
||||
}
|
||||
sourceWalletRef = strings.TrimSpace(sourceWalletRef)
|
||||
if sourceWalletRef == "" {
|
||||
return nil, merrors.InvalidArgument("source wallet ref is required")
|
||||
}
|
||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||
return nil, merrors.InvalidArgument("amount is required")
|
||||
}
|
||||
|
||||
resp, err := s.deps.gateway.client.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{
|
||||
SourceWalletRef: sourceWalletRef,
|
||||
Destination: destination,
|
||||
Amount: cloneMoney(amount),
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn("chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
|
||||
}
|
||||
if resp == nil {
|
||||
s.logger.Warn("chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
|
||||
}
|
||||
fee := resp.GetNetworkFee()
|
||||
if fee == nil || strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" {
|
||||
s.logger.Warn("chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef))
|
||||
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
|
||||
}
|
||||
return cloneMoney(fee), nil
|
||||
}
|
||||
|
||||
func sumNetworkFees(fees ...*moneyv1.Money) (decimal.Decimal, string, error) {
|
||||
total := decimal.Zero
|
||||
currency := ""
|
||||
for _, fee := range fees {
|
||||
if fee == nil {
|
||||
continue
|
||||
}
|
||||
amount := strings.TrimSpace(fee.GetAmount())
|
||||
feeCurrency := strings.TrimSpace(fee.GetCurrency())
|
||||
if amount == "" || feeCurrency == "" {
|
||||
return decimal.Zero, "", merrors.InvalidArgument("network fee is required")
|
||||
}
|
||||
value, err := decimalFromMoney(fee)
|
||||
if err != nil {
|
||||
return decimal.Zero, "", err
|
||||
}
|
||||
if currency == "" {
|
||||
currency = feeCurrency
|
||||
} else if !strings.EqualFold(currency, feeCurrency) {
|
||||
return decimal.Zero, "", merrors.InvalidArgument("network fee currency mismatch")
|
||||
}
|
||||
total = total.Add(value)
|
||||
}
|
||||
return total, currency, nil
|
||||
}
|
||||
|
||||
func ensureExecutionPlan(payment *model.Payment) *model.ExecutionPlan {
|
||||
if payment == nil {
|
||||
return nil
|
||||
}
|
||||
if payment.ExecutionPlan == nil {
|
||||
payment.ExecutionPlan = &model.ExecutionPlan{}
|
||||
}
|
||||
return payment.ExecutionPlan
|
||||
}
|
||||
|
||||
func ensureExecutionStep(plan *model.ExecutionPlan, code string) *model.ExecutionStep {
|
||||
if plan == nil {
|
||||
return nil
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return nil
|
||||
}
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(step.Code, code) {
|
||||
if step.Code == "" {
|
||||
step.Code = code
|
||||
}
|
||||
return step
|
||||
}
|
||||
}
|
||||
step := &model.ExecutionStep{Code: code}
|
||||
plan.Steps = append(plan.Steps, step)
|
||||
return step
|
||||
}
|
||||
|
||||
func updateExecutionPlanTotalNetworkFee(plan *model.ExecutionPlan) {
|
||||
if plan == nil {
|
||||
return
|
||||
}
|
||||
total := decimal.Zero
|
||||
currency := ""
|
||||
hasFee := false
|
||||
for _, step := range plan.Steps {
|
||||
if step == nil || step.NetworkFee == nil {
|
||||
continue
|
||||
}
|
||||
fee := step.NetworkFee
|
||||
if strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" {
|
||||
continue
|
||||
}
|
||||
if currency == "" {
|
||||
currency = strings.TrimSpace(fee.GetCurrency())
|
||||
} else if !strings.EqualFold(currency, fee.GetCurrency()) {
|
||||
continue
|
||||
}
|
||||
value, err := decimalFromMoney(fee)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
total = total.Add(value)
|
||||
hasFee = true
|
||||
}
|
||||
if !hasFee || currency == "" {
|
||||
plan.TotalNetworkFee = nil
|
||||
return
|
||||
}
|
||||
plan.TotalNetworkFee = makeMoney(currency, total)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
mntxclient "github.com/tech/sendico/gateway/mntx/client"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
mo "github.com/tech/sendico/pkg/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
sourceWalletRef = "wallet-src"
|
||||
feeWalletRef = "wallet-fee"
|
||||
fundingAddress = "0xfunding"
|
||||
)
|
||||
|
||||
var estimateCalls []*chainv1.EstimateTransferFeeRequest
|
||||
var computeCalls []*chainv1.ComputeGasTopUpRequest
|
||||
var ensureCalls []*chainv1.EnsureGasTopUpRequest
|
||||
var submitCalls []*chainv1.SubmitTransferRequest
|
||||
|
||||
gateway := &chainclient.Fake{
|
||||
EstimateTransferFeeFn: func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
|
||||
estimateCalls = append(estimateCalls, req)
|
||||
dest := req.GetDestination()
|
||||
if req.GetSourceWalletRef() == feeWalletRef {
|
||||
return &chainv1.EstimateTransferFeeResponse{
|
||||
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.005"},
|
||||
}, nil
|
||||
}
|
||||
if dest != nil && strings.TrimSpace(dest.GetExternalAddress()) != "" {
|
||||
return &chainv1.EstimateTransferFeeResponse{
|
||||
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.01"},
|
||||
}, nil
|
||||
}
|
||||
return &chainv1.EstimateTransferFeeResponse{
|
||||
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.02"},
|
||||
}, nil
|
||||
},
|
||||
ComputeGasTopUpFn: func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
|
||||
computeCalls = append(computeCalls, req)
|
||||
return &chainv1.ComputeGasTopUpResponse{
|
||||
TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"},
|
||||
}, nil
|
||||
},
|
||||
EnsureGasTopUpFn: func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
|
||||
ensureCalls = append(ensureCalls, req)
|
||||
return &chainv1.EnsureGasTopUpResponse{
|
||||
TopupAmount: &moneyv1.Money{Currency: "ETH", Amount: "0.025"},
|
||||
Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()},
|
||||
}, nil
|
||||
},
|
||||
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
||||
submitCalls = append(submitCalls, req)
|
||||
return &chainv1.SubmitTransferResponse{
|
||||
Transfer: &chainv1.Transfer{TransferRef: req.GetIdempotencyKey()},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
deps: serviceDependencies{
|
||||
gateway: gatewayDependency{client: gateway},
|
||||
cardRoutes: map[string]CardGatewayRoute{
|
||||
defaultCardGateway: {
|
||||
FundingAddress: fundingAddress,
|
||||
FeeWalletRef: feeWalletRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payment := &model.Payment{
|
||||
PaymentRef: "pay-1",
|
||||
IdempotencyKey: "pay-1",
|
||||
OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()},
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: sourceWalletRef,
|
||||
},
|
||||
},
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{
|
||||
MaskedPan: "4111",
|
||||
},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
|
||||
},
|
||||
}
|
||||
|
||||
quote := &orchestratorv1.PaymentQuote{
|
||||
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"},
|
||||
}
|
||||
|
||||
if err := svc.submitCardFundingTransfers(ctx, payment, quote); err != nil {
|
||||
t.Fatalf("submitCardFundingTransfers error: %v", err)
|
||||
}
|
||||
|
||||
if len(estimateCalls) != 4 {
|
||||
t.Fatalf("expected 4 fee estimates, got %d", len(estimateCalls))
|
||||
}
|
||||
if len(computeCalls) != 1 {
|
||||
t.Fatalf("expected 1 gas top-up compute call, got %d", len(computeCalls))
|
||||
}
|
||||
if len(ensureCalls) != 1 {
|
||||
t.Fatalf("expected 1 gas top-up ensure call, got %d", len(ensureCalls))
|
||||
}
|
||||
if len(submitCalls) != 1 {
|
||||
t.Fatalf("expected 1 transfer submission, got %d", len(submitCalls))
|
||||
}
|
||||
|
||||
computeCall := computeCalls[0]
|
||||
if computeCall.GetWalletRef() != sourceWalletRef {
|
||||
t.Fatalf("gas top-up compute wallet mismatch: %s", computeCall.GetWalletRef())
|
||||
}
|
||||
if computeCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || computeCall.GetEstimatedTotalFee().GetAmount() != "0.03" {
|
||||
t.Fatalf("gas top-up compute fee mismatch: %s %s", computeCall.GetEstimatedTotalFee().GetCurrency(), computeCall.GetEstimatedTotalFee().GetAmount())
|
||||
}
|
||||
|
||||
ensureCall := ensureCalls[0]
|
||||
if ensureCall.GetSourceWalletRef() != feeWalletRef {
|
||||
t.Fatalf("gas top-up source wallet mismatch: %s", ensureCall.GetSourceWalletRef())
|
||||
}
|
||||
if ensureCall.GetTargetWalletRef() != sourceWalletRef {
|
||||
t.Fatalf("gas top-up destination mismatch: %s", ensureCall.GetTargetWalletRef())
|
||||
}
|
||||
if ensureCall.GetEstimatedTotalFee().GetCurrency() != "ETH" || ensureCall.GetEstimatedTotalFee().GetAmount() != "0.03" {
|
||||
t.Fatalf("gas top-up ensure fee mismatch: %s %s", ensureCall.GetEstimatedTotalFee().GetCurrency(), ensureCall.GetEstimatedTotalFee().GetAmount())
|
||||
}
|
||||
|
||||
fundCall := findSubmitCall(t, submitCalls, "pay-1:card:fund")
|
||||
if fundCall.GetDestination().GetExternalAddress() != fundingAddress {
|
||||
t.Fatalf("funding destination mismatch: %s", fundCall.GetDestination().GetExternalAddress())
|
||||
}
|
||||
if fundCall.GetAmount().GetCurrency() != "USDT" || fundCall.GetAmount().GetAmount() != "5" {
|
||||
t.Fatalf("funding amount mismatch: %s %s", fundCall.GetAmount().GetCurrency(), fundCall.GetAmount().GetAmount())
|
||||
}
|
||||
|
||||
if payment.Execution == nil || payment.Execution.ChainTransferRef != "pay-1:card:fund" {
|
||||
t.Fatalf("expected funding transfer ref recorded, got %v", payment.Execution)
|
||||
}
|
||||
|
||||
plan := payment.ExecutionPlan
|
||||
if plan == nil {
|
||||
t.Fatal("expected execution plan to be populated")
|
||||
}
|
||||
gasStep := findExecutionStep(t, plan, stepCodeGasTopUp)
|
||||
if gasStep.Amount.GetAmount() != "0.025" || gasStep.Amount.GetCurrency() != "ETH" {
|
||||
t.Fatalf("gas step amount mismatch: %s %s", gasStep.Amount.GetCurrency(), gasStep.Amount.GetAmount())
|
||||
}
|
||||
if gasStep.NetworkFee.GetAmount() != "0.005" || gasStep.NetworkFee.GetCurrency() != "ETH" {
|
||||
t.Fatalf("gas step fee mismatch: %s %s", gasStep.NetworkFee.GetCurrency(), gasStep.NetworkFee.GetAmount())
|
||||
}
|
||||
if gasStep.TransferRef != "pay-1:card:gas" {
|
||||
t.Fatalf("expected gas step transfer ref to be set, got %s", gasStep.TransferRef)
|
||||
}
|
||||
|
||||
fundStep := findExecutionStep(t, plan, stepCodeFundingTransfer)
|
||||
if fundStep.NetworkFee.GetAmount() != "0.01" || fundStep.NetworkFee.GetCurrency() != "ETH" {
|
||||
t.Fatalf("funding step fee mismatch: %s %s", fundStep.NetworkFee.GetCurrency(), fundStep.NetworkFee.GetAmount())
|
||||
}
|
||||
if fundStep.TransferRef != "pay-1:card:fund" {
|
||||
t.Fatalf("funding step transfer ref mismatch: %s", fundStep.TransferRef)
|
||||
}
|
||||
|
||||
cardStep := findExecutionStep(t, plan, stepCodeCardPayout)
|
||||
if cardStep.Amount.GetAmount() != "5" || cardStep.Amount.GetCurrency() != "USDT" {
|
||||
t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount())
|
||||
}
|
||||
|
||||
feeStep := findExecutionStep(t, plan, stepCodeFeeTransfer)
|
||||
if feeStep.Amount.GetAmount() != "0.35" || feeStep.Amount.GetCurrency() != "USDT" {
|
||||
t.Fatalf("fee step amount mismatch: %s %s", feeStep.Amount.GetCurrency(), feeStep.Amount.GetAmount())
|
||||
}
|
||||
if feeStep.NetworkFee.GetAmount() != "0.02" || feeStep.NetworkFee.GetCurrency() != "ETH" {
|
||||
t.Fatalf("fee step network fee mismatch: %s %s", feeStep.NetworkFee.GetCurrency(), feeStep.NetworkFee.GetAmount())
|
||||
}
|
||||
if feeStep.TransferRef != "" {
|
||||
t.Fatalf("expected fee step transfer ref to be empty before payout, got %s", feeStep.TransferRef)
|
||||
}
|
||||
|
||||
if plan.TotalNetworkFee == nil || plan.TotalNetworkFee.GetAmount() != "0.035" || plan.TotalNetworkFee.GetCurrency() != "ETH" {
|
||||
t.Fatalf("total network fee mismatch: %v", plan.TotalNetworkFee)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
const (
|
||||
sourceWalletRef = "wallet-src"
|
||||
feeWalletRef = "wallet-fee"
|
||||
)
|
||||
|
||||
var payoutReq *mntxv1.CardPayoutRequest
|
||||
var submitCalls []*chainv1.SubmitTransferRequest
|
||||
|
||||
gateway := &chainclient.Fake{
|
||||
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
||||
submitCalls = append(submitCalls, req)
|
||||
return &chainv1.SubmitTransferResponse{
|
||||
Transfer: &chainv1.Transfer{TransferRef: "fee-transfer"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
mntx := &mntxclient.Fake{
|
||||
CreateCardPayoutFn: func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
|
||||
payoutReq = req
|
||||
return &mntxv1.CardPayoutResponse{
|
||||
Payout: &mntxv1.CardPayoutState{
|
||||
PayoutId: "payout-1",
|
||||
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
deps: serviceDependencies{
|
||||
gateway: gatewayDependency{client: gateway},
|
||||
mntx: mntxDependency{client: mntx},
|
||||
cardRoutes: map[string]CardGatewayRoute{
|
||||
defaultCardGateway: {
|
||||
FundingAddress: "0xfunding",
|
||||
FeeWalletRef: feeWalletRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payment := &model.Payment{
|
||||
PaymentRef: "pay-2",
|
||||
IdempotencyKey: "pay-2",
|
||||
OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()},
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: sourceWalletRef,
|
||||
},
|
||||
},
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{
|
||||
Pan: "5536913762657597",
|
||||
Cardholder: "Stephan",
|
||||
},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
|
||||
Customer: &model.Customer{
|
||||
ID: "recipient-1",
|
||||
FirstName: "Stephan",
|
||||
LastName: "Tester",
|
||||
IP: "198.51.100.10",
|
||||
},
|
||||
},
|
||||
LastQuote: &model.PaymentQuoteSnapshot{
|
||||
ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "392.30"},
|
||||
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := svc.submitCardPayout(ctx, payment); err != nil {
|
||||
t.Fatalf("submitCardPayout error: %v", err)
|
||||
}
|
||||
|
||||
if payoutReq == nil {
|
||||
t.Fatal("expected card payout request to be sent")
|
||||
}
|
||||
if payoutReq.GetCurrency() != "RUB" || payoutReq.GetAmountMinor() != 39230 {
|
||||
t.Fatalf("payout request amount mismatch: %s %d", payoutReq.GetCurrency(), payoutReq.GetAmountMinor())
|
||||
}
|
||||
|
||||
if payment.Execution == nil || payment.Execution.CardPayoutRef != "payout-1" {
|
||||
t.Fatalf("expected card payout ref recorded, got %v", payment.Execution)
|
||||
}
|
||||
if payment.Execution.FeeTransferRef != "fee-transfer" {
|
||||
t.Fatalf("expected fee transfer ref recorded, got %v", payment.Execution)
|
||||
}
|
||||
|
||||
if len(submitCalls) != 1 {
|
||||
t.Fatalf("expected 1 fee transfer submission, got %d", len(submitCalls))
|
||||
}
|
||||
feeCall := submitCalls[0]
|
||||
if feeCall.GetSourceWalletRef() != sourceWalletRef {
|
||||
t.Fatalf("fee transfer source mismatch: %s", feeCall.GetSourceWalletRef())
|
||||
}
|
||||
if feeCall.GetDestination().GetManagedWalletRef() != feeWalletRef {
|
||||
t.Fatalf("fee transfer destination mismatch: %s", feeCall.GetDestination().GetManagedWalletRef())
|
||||
}
|
||||
|
||||
plan := payment.ExecutionPlan
|
||||
if plan == nil {
|
||||
t.Fatal("expected execution plan to be populated")
|
||||
}
|
||||
cardStep := findExecutionStep(t, plan, stepCodeCardPayout)
|
||||
if cardStep.TransferRef != "payout-1" {
|
||||
t.Fatalf("card step transfer ref mismatch: %s", cardStep.TransferRef)
|
||||
}
|
||||
if cardStep.Amount.GetAmount() != "392.30" || cardStep.Amount.GetCurrency() != "RUB" {
|
||||
t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount())
|
||||
}
|
||||
|
||||
feeStep := findExecutionStep(t, plan, stepCodeFeeTransfer)
|
||||
if feeStep.TransferRef != "fee-transfer" {
|
||||
t.Fatalf("fee step transfer ref mismatch: %s", feeStep.TransferRef)
|
||||
}
|
||||
if feeStep.Amount.GetAmount() != "0.35" || feeStep.Amount.GetCurrency() != "USDT" {
|
||||
t.Fatalf("fee step amount mismatch: %s %s", feeStep.Amount.GetCurrency(), feeStep.Amount.GetAmount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitCardFundingTransfers_RequiresFeeWalletRef(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
gateway := &chainclient.Fake{
|
||||
EstimateTransferFeeFn: func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
|
||||
return &chainv1.EstimateTransferFeeResponse{
|
||||
NetworkFee: &moneyv1.Money{Currency: "ETH", Amount: "0.01"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := &Service{
|
||||
logger: zap.NewNop(),
|
||||
deps: serviceDependencies{
|
||||
gateway: gatewayDependency{client: gateway},
|
||||
cardRoutes: map[string]CardGatewayRoute{
|
||||
defaultCardGateway: {
|
||||
FundingAddress: "0xfunding",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payment := &model.Payment{
|
||||
PaymentRef: "pay-3",
|
||||
IdempotencyKey: "pay-3",
|
||||
OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: primitive.NewObjectID()},
|
||||
Intent: model.PaymentIntent{
|
||||
Kind: model.PaymentKindPayout,
|
||||
Source: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeManagedWallet,
|
||||
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||
ManagedWalletRef: "wallet-src",
|
||||
},
|
||||
},
|
||||
Destination: model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{
|
||||
MaskedPan: "4111",
|
||||
},
|
||||
},
|
||||
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
|
||||
},
|
||||
}
|
||||
|
||||
quote := &orchestratorv1.PaymentQuote{
|
||||
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"},
|
||||
}
|
||||
|
||||
err := svc.submitCardFundingTransfers(ctx, payment, quote)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing fee wallet ref")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "fee wallet ref") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func findSubmitCall(t *testing.T, calls []*chainv1.SubmitTransferRequest, idempotencyKey string) *chainv1.SubmitTransferRequest {
|
||||
t.Helper()
|
||||
for _, call := range calls {
|
||||
if call.GetIdempotencyKey() == idempotencyKey {
|
||||
return call
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing submit transfer call for %s", idempotencyKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func findExecutionStep(t *testing.T, plan *model.ExecutionPlan, code string) *model.ExecutionStep {
|
||||
t.Helper()
|
||||
if plan == nil {
|
||||
t.Fatal("execution plan is nil")
|
||||
}
|
||||
for _, step := range plan.Steps {
|
||||
if step != nil && strings.EqualFold(step.Code, code) {
|
||||
return step
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing execution step %s", code)
|
||||
return nil
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
type paymentEngine interface {
|
||||
EnsureRepository(ctx context.Context) error
|
||||
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
|
||||
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error)
|
||||
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error)
|
||||
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
|
||||
Repository() storage.Repository
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef stri
|
||||
return e.svc.buildPaymentQuote(ctx, orgRef, req)
|
||||
}
|
||||
|
||||
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
|
||||
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
|
||||
return e.svc.resolvePaymentQuote(ctx, in)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
|
||||
FeePolicy: src.GetFeePolicy(),
|
||||
SettlementMode: src.GetSettlementMode(),
|
||||
Attributes: cloneMetadata(src.GetAttributes()),
|
||||
Customer: customerFromProto(src.GetCustomer()),
|
||||
}
|
||||
if src.GetFx() != nil {
|
||||
intent.FX = fxIntentFromProto(src.GetFx())
|
||||
@@ -69,13 +70,14 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin
|
||||
if card := src.GetCard(); card != nil {
|
||||
result.Type = model.EndpointTypeCard
|
||||
result.Card = &model.CardEndpoint{
|
||||
Pan: strings.TrimSpace(card.GetPan()),
|
||||
Token: strings.TrimSpace(card.GetToken()),
|
||||
Cardholder: strings.TrimSpace(card.GetCardholderName()),
|
||||
ExpMonth: card.GetExpMonth(),
|
||||
ExpYear: card.GetExpYear(),
|
||||
Country: strings.TrimSpace(card.GetCountry()),
|
||||
MaskedPan: strings.TrimSpace(card.GetMaskedPan()),
|
||||
Pan: strings.TrimSpace(card.GetPan()),
|
||||
Token: strings.TrimSpace(card.GetToken()),
|
||||
Cardholder: strings.TrimSpace(card.GetCardholderName()),
|
||||
CardholderSurname: strings.TrimSpace(card.GetCardholderSurname()),
|
||||
ExpMonth: card.GetExpMonth(),
|
||||
ExpYear: card.GetExpYear(),
|
||||
Country: strings.TrimSpace(card.GetCountry()),
|
||||
MaskedPan: strings.TrimSpace(card.GetMaskedPan()),
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -125,6 +127,7 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
|
||||
FailureReason: src.FailureReason,
|
||||
LastQuote: modelQuoteToProto(src.LastQuote),
|
||||
Execution: protoExecutionFromModel(src.Execution),
|
||||
ExecutionPlan: protoExecutionPlanFromModel(src.ExecutionPlan),
|
||||
Metadata: cloneMetadata(src.Metadata),
|
||||
}
|
||||
if src.CardPayout != nil {
|
||||
@@ -160,6 +163,7 @@ func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent
|
||||
FeePolicy: src.FeePolicy,
|
||||
SettlementMode: src.SettlementMode,
|
||||
Attributes: cloneMetadata(src.Attributes),
|
||||
Customer: protoCustomerFromModel(src.Customer),
|
||||
}
|
||||
if src.FX != nil {
|
||||
intent.Fx = protoFXIntentFromModel(src.FX)
|
||||
@@ -167,6 +171,42 @@ func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent
|
||||
return intent
|
||||
}
|
||||
|
||||
func customerFromProto(src *orchestratorv1.Customer) *model.Customer {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.Customer{
|
||||
ID: strings.TrimSpace(src.GetId()),
|
||||
FirstName: strings.TrimSpace(src.GetFirstName()),
|
||||
MiddleName: strings.TrimSpace(src.GetMiddleName()),
|
||||
LastName: strings.TrimSpace(src.GetLastName()),
|
||||
IP: strings.TrimSpace(src.GetIp()),
|
||||
Zip: strings.TrimSpace(src.GetZip()),
|
||||
Country: strings.TrimSpace(src.GetCountry()),
|
||||
State: strings.TrimSpace(src.GetState()),
|
||||
City: strings.TrimSpace(src.GetCity()),
|
||||
Address: strings.TrimSpace(src.GetAddress()),
|
||||
}
|
||||
}
|
||||
|
||||
func protoCustomerFromModel(src *model.Customer) *orchestratorv1.Customer {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &orchestratorv1.Customer{
|
||||
Id: strings.TrimSpace(src.ID),
|
||||
FirstName: strings.TrimSpace(src.FirstName),
|
||||
MiddleName: strings.TrimSpace(src.MiddleName),
|
||||
LastName: strings.TrimSpace(src.LastName),
|
||||
Ip: strings.TrimSpace(src.IP),
|
||||
Zip: strings.TrimSpace(src.Zip),
|
||||
Country: strings.TrimSpace(src.Country),
|
||||
State: strings.TrimSpace(src.State),
|
||||
City: strings.TrimSpace(src.City),
|
||||
Address: strings.TrimSpace(src.Address),
|
||||
}
|
||||
}
|
||||
|
||||
func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEndpoint {
|
||||
endpoint := &orchestratorv1.PaymentEndpoint{
|
||||
Metadata: cloneMetadata(src.Metadata),
|
||||
@@ -203,11 +243,12 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn
|
||||
case model.EndpointTypeCard:
|
||||
if src.Card != nil {
|
||||
card := &orchestratorv1.CardEndpoint{
|
||||
CardholderName: src.Card.Cardholder,
|
||||
ExpMonth: src.Card.ExpMonth,
|
||||
ExpYear: src.Card.ExpYear,
|
||||
Country: src.Card.Country,
|
||||
MaskedPan: src.Card.MaskedPan,
|
||||
CardholderName: src.Card.Cardholder,
|
||||
CardholderSurname: src.Card.CardholderSurname,
|
||||
ExpMonth: src.Card.ExpMonth,
|
||||
ExpYear: src.Card.ExpYear,
|
||||
Country: src.Card.Country,
|
||||
MaskedPan: src.Card.MaskedPan,
|
||||
}
|
||||
if pan := strings.TrimSpace(src.Card.Pan); pan != "" {
|
||||
card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}
|
||||
@@ -251,6 +292,41 @@ func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.Execution
|
||||
}
|
||||
}
|
||||
|
||||
func protoExecutionStepFromModel(src *model.ExecutionStep) *orchestratorv1.ExecutionStep {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
return &orchestratorv1.ExecutionStep{
|
||||
Code: src.Code,
|
||||
Description: src.Description,
|
||||
Amount: cloneMoney(src.Amount),
|
||||
NetworkFee: cloneMoney(src.NetworkFee),
|
||||
SourceWalletRef: src.SourceWalletRef,
|
||||
DestinationRef: src.DestinationRef,
|
||||
TransferRef: src.TransferRef,
|
||||
Metadata: cloneMetadata(src.Metadata),
|
||||
}
|
||||
}
|
||||
|
||||
func protoExecutionPlanFromModel(src *model.ExecutionPlan) *orchestratorv1.ExecutionPlan {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
steps := make([]*orchestratorv1.ExecutionStep, 0, len(src.Steps))
|
||||
for _, step := range src.Steps {
|
||||
if protoStep := protoExecutionStepFromModel(step); protoStep != nil {
|
||||
steps = append(steps, protoStep)
|
||||
}
|
||||
}
|
||||
if len(steps) == 0 {
|
||||
steps = nil
|
||||
}
|
||||
return &orchestratorv1.ExecutionPlan{
|
||||
Steps: steps,
|
||||
TotalNetworkFee: cloneMoney(src.TotalNetworkFee),
|
||||
}
|
||||
}
|
||||
|
||||
func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote {
|
||||
if src == nil {
|
||||
return nil
|
||||
|
||||
@@ -11,12 +11,13 @@ func TestEndpointFromProtoCard(t *testing.T) {
|
||||
protoEndpoint := &orchestratorv1.PaymentEndpoint{
|
||||
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
|
||||
Card: &orchestratorv1.CardEndpoint{
|
||||
Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "},
|
||||
CardholderName: " Jane Doe ",
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
Country: " US ",
|
||||
MaskedPan: " ****1111 ",
|
||||
Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "},
|
||||
CardholderName: " Jane ",
|
||||
CardholderSurname: " Doe ",
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
Country: " US ",
|
||||
MaskedPan: " ****1111 ",
|
||||
},
|
||||
},
|
||||
Metadata: map[string]string{"k": "v"},
|
||||
@@ -29,7 +30,7 @@ func TestEndpointFromProtoCard(t *testing.T) {
|
||||
if modelEndpoint.Card == nil {
|
||||
t.Fatalf("card payload missing")
|
||||
}
|
||||
if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" {
|
||||
if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane" || modelEndpoint.Card.CardholderSurname != "Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" {
|
||||
t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card)
|
||||
}
|
||||
if modelEndpoint.Metadata["k"] != "v" {
|
||||
@@ -41,12 +42,13 @@ func TestProtoEndpointFromModelCard(t *testing.T) {
|
||||
modelEndpoint := model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeCard,
|
||||
Card: &model.CardEndpoint{
|
||||
Token: "tok_123",
|
||||
Cardholder: "Jane",
|
||||
ExpMonth: 1,
|
||||
ExpYear: 2028,
|
||||
Country: "GB",
|
||||
MaskedPan: "****1234",
|
||||
Token: "tok_123",
|
||||
Cardholder: "Jane",
|
||||
CardholderSurname: "Doe",
|
||||
ExpMonth: 1,
|
||||
ExpYear: 2028,
|
||||
Country: "GB",
|
||||
MaskedPan: "****1234",
|
||||
},
|
||||
Metadata: map[string]string{"k": "v"},
|
||||
}
|
||||
@@ -60,7 +62,7 @@ func TestProtoEndpointFromModelCard(t *testing.T) {
|
||||
if !ok || token.Token != "tok_123" {
|
||||
t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card)
|
||||
}
|
||||
if card.GetCardholderName() != "Jane" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" {
|
||||
if card.GetCardholderName() != "Jane" || card.GetCardholderSurname() != "Doe" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" {
|
||||
t.Fatalf("card details mismatch: %#v", card)
|
||||
}
|
||||
if protoEndpoint.GetMetadata()["k"] != "v" {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
@@ -61,7 +62,13 @@ func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.Q
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Info(
|
||||
"Stored payment quote",
|
||||
zap.String("quote_ref", quoteRef),
|
||||
mzap.ObjRef("org_ref", orgID),
|
||||
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
|
||||
zap.String("kind", intent.GetKind().String()),
|
||||
)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
|
||||
@@ -79,7 +86,7 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
orgID, orgRef, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
@@ -101,7 +108,7 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
|
||||
Intent: intent,
|
||||
PreviewOnly: req.GetPreviewOnly(),
|
||||
}
|
||||
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, quoteReq)
|
||||
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgID, quoteReq)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
@@ -132,11 +139,14 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
record.SetID(primitive.NewObjectID())
|
||||
record.SetOrganizationRef(orgID)
|
||||
record.SetOrganizationRef(orgRef)
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("stored payment quotes", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Info("Stored payment quotes",
|
||||
zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef),
|
||||
zap.String("idempotency_key", baseKey), zap.Int("quote_count", len(quotes)),
|
||||
)
|
||||
}
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
|
||||
@@ -158,7 +168,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
|
||||
if req == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||
}
|
||||
_, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
_, orgRef, err := validateMetaAndOrgRef(req.GetMeta())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
@@ -175,7 +185,7 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
|
||||
if err != nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
record, err := quotesStore.GetByRef(ctx, orgID, quoteRef)
|
||||
record, err := quotesStore.GetByRef(ctx, orgRef, quoteRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
|
||||
@@ -213,14 +223,14 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
|
||||
quoteProto.QuoteRef = quoteRef
|
||||
|
||||
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, perKey); err == nil && existing != nil {
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgRef, perKey); err == nil && existing != nil {
|
||||
payments = append(payments, toProtoPayment(existing))
|
||||
continue
|
||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
entity := newPayment(orgID, intentProto, perKey, req.GetMetadata(), quoteProto)
|
||||
entity := newPayment(orgRef, intentProto, perKey, req.GetMetadata(), quoteProto)
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
||||
@@ -235,6 +245,13 @@ func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestrator
|
||||
payments = append(payments, toProtoPayment(entity))
|
||||
}
|
||||
|
||||
h.logger.Info(
|
||||
"Payments initiated",
|
||||
mzap.ObjRef("org_ref", orgRef),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
zap.String("idempotency_key", idempotencyKey),
|
||||
zap.Int("payment_count", len(payments)),
|
||||
)
|
||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments})
|
||||
}
|
||||
|
||||
@@ -255,13 +272,31 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
quoteRef := strings.TrimSpace(req.GetQuoteRef())
|
||||
hasIntent := intent != nil
|
||||
hasQuote := quoteRef != ""
|
||||
switch {
|
||||
case !hasIntent && !hasQuote:
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent or quote_ref is required"))
|
||||
case hasIntent && hasQuote:
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent and quote_ref are mutually exclusive"))
|
||||
}
|
||||
if hasIntent {
|
||||
if err := requireNonNilIntent(intent); err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
}
|
||||
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Debug(
|
||||
"Initiate payment request accepted",
|
||||
zap.String("org_ref", orgID.Hex()),
|
||||
zap.String("idempotency_key", idempotencyKey),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
zap.Bool("has_intent", hasIntent),
|
||||
)
|
||||
|
||||
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||
if err != nil {
|
||||
@@ -269,18 +304,24 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
}
|
||||
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
|
||||
h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Debug(
|
||||
"idempotent payment request reused",
|
||||
zap.String("payment_ref", existing.PaymentRef),
|
||||
zap.String("org_ref", orgID.Hex()),
|
||||
zap.String("idempotency_key", idempotencyKey),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
)
|
||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
|
||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quoteSnapshot, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
|
||||
quoteSnapshot, resolvedIntent, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
|
||||
OrgRef: orgRef,
|
||||
OrgID: orgID,
|
||||
Meta: req.GetMeta(),
|
||||
Intent: intent,
|
||||
QuoteRef: req.GetQuoteRef(),
|
||||
QuoteRef: quoteRef,
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -301,8 +342,17 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
if quoteSnapshot == nil {
|
||||
quoteSnapshot = &orchestratorv1.PaymentQuote{}
|
||||
}
|
||||
if err := requireNonNilIntent(resolvedIntent); err != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Debug(
|
||||
"Payment quote resolved",
|
||||
zap.String("org_ref", orgID.Hex()),
|
||||
zap.String("quote_ref", quoteRef),
|
||||
zap.Bool("quote_ref_used", quoteRef != ""),
|
||||
)
|
||||
|
||||
entity := newPayment(orgID, intent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
|
||||
entity := newPayment(orgID, resolvedIntent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
|
||||
|
||||
if err = store.Create(ctx, entity); err != nil {
|
||||
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||
@@ -315,7 +365,14 @@ func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String()))
|
||||
h.logger.Info(
|
||||
"Payment initiated",
|
||||
zap.String("payment_ref", entity.PaymentRef),
|
||||
zap.String("org_ref", orgID.Hex()),
|
||||
zap.String("kind", resolvedIntent.GetKind().String()),
|
||||
zap.String("quote_ref", quoteSnapshot.GetQuoteRef()),
|
||||
zap.String("idempotency_key", idempotencyKey),
|
||||
)
|
||||
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
|
||||
Payment: toProtoPayment(entity),
|
||||
})
|
||||
@@ -355,7 +412,7 @@ func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.
|
||||
if err := store.Update(ctx, payment); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
h.logger.Info("payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
|
||||
h.logger.Info("Payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
|
||||
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
|
||||
}
|
||||
|
||||
@@ -396,7 +453,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
|
||||
}
|
||||
|
||||
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
|
||||
h.logger.Debug("idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Debug("Idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
|
||||
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
@@ -439,7 +496,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat
|
||||
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
h.logger.Info("conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
h.logger.Info("Conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
|
||||
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
|
||||
Conversion: toProtoPayment(entity),
|
||||
})
|
||||
|
||||
@@ -56,10 +56,11 @@ func (m mntxDependency) available() bool {
|
||||
return m.client != nil
|
||||
}
|
||||
|
||||
// CardGatewayRoute maps a gateway to its funding and fee destinations (addresses).
|
||||
// CardGatewayRoute maps a gateway to its funding and fee destinations.
|
||||
type CardGatewayRoute struct {
|
||||
FundingAddress string
|
||||
FeeAddress string
|
||||
FeeWalletRef string
|
||||
}
|
||||
|
||||
// WithFeeEngine wires the fee engine client.
|
||||
|
||||
@@ -103,33 +103,40 @@ type quoteResolutionError struct {
|
||||
|
||||
func (e quoteResolutionError) Error() string { return e.err.Error() }
|
||||
|
||||
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
|
||||
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, *orchestratorv1.PaymentIntent, error) {
|
||||
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
|
||||
quotesStore, err := ensureQuotesStore(s.storage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||
return nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
|
||||
return nil, nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
|
||||
}
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
|
||||
return nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
|
||||
return nil, nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
|
||||
}
|
||||
if !proto.Equal(protoIntentFromModel(record.Intent), in.Intent) {
|
||||
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||
intent, err := recordIntentFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
quote := modelQuoteToProto(record.Quote)
|
||||
if quote == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
if in.Intent != nil && !proto.Equal(intent, in.Intent) {
|
||||
return nil, nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
|
||||
}
|
||||
quote, err := recordQuoteFromQuote(record)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
quote.QuoteRef = ref
|
||||
return quote, nil
|
||||
return quote, intent, nil
|
||||
}
|
||||
|
||||
if in.Intent == nil {
|
||||
return nil, nil, merrors.InvalidArgument("intent is required")
|
||||
}
|
||||
req := &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: in.Meta,
|
||||
IdempotencyKey: in.IdempotencyKey,
|
||||
@@ -138,9 +145,41 @@ func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInp
|
||||
}
|
||||
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
return quote, nil
|
||||
return quote, in.Intent, nil
|
||||
}
|
||||
|
||||
func recordIntentFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentIntent, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
if len(record.Intents) > 0 {
|
||||
if len(record.Intents) != 1 {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return protoIntentFromModel(record.Intents[0]), nil
|
||||
}
|
||||
if record.Intent.Amount == nil && (record.Intent.Kind == "" || record.Intent.Kind == model.PaymentKindUnspecified) {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return protoIntentFromModel(record.Intent), nil
|
||||
}
|
||||
|
||||
func recordQuoteFromQuote(record *model.PaymentQuoteRecord) (*orchestratorv1.PaymentQuote, error) {
|
||||
if record == nil {
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
}
|
||||
if record.Quote != nil {
|
||||
return modelQuoteToProto(record.Quote), nil
|
||||
}
|
||||
if len(record.Quotes) > 0 {
|
||||
if len(record.Quotes) != 1 {
|
||||
return nil, merrors.InvalidArgument("stored quote payload is incomplete")
|
||||
}
|
||||
return modelQuoteToProto(record.Quotes[0]), nil
|
||||
}
|
||||
return nil, merrors.InvalidArgument("stored quote is empty")
|
||||
}
|
||||
|
||||
func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) {
|
||||
storage: stubRepo{quotes: &helperQuotesStore{}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -98,7 +98,7 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
|
||||
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
_, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
@@ -110,6 +110,35 @@ func TestResolvePaymentQuote_Expired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) {
|
||||
org := primitive.NewObjectID()
|
||||
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
}
|
||||
svc := &Service{
|
||||
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
quote, resolvedIntent, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
|
||||
OrgRef: org.Hex(),
|
||||
OrgID: org,
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
QuoteRef: "q1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if quote == nil || quote.GetQuoteRef() != "q1" {
|
||||
t.Fatalf("expected quote_ref q1, got %#v", quote)
|
||||
}
|
||||
if resolvedIntent == nil || resolvedIntent.GetAmount().GetAmount() != "1" {
|
||||
t.Fatalf("expected resolved intent with amount, got %#v", resolvedIntent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
org := primitive.NewObjectID()
|
||||
@@ -140,6 +169,42 @@ func TestInitiatePaymentIdempotency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentByQuoteRef(t *testing.T) {
|
||||
logger := mloggerfactory.NewLogger(false)
|
||||
org := primitive.NewObjectID()
|
||||
store := newHelperPaymentStore()
|
||||
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
|
||||
record := &model.PaymentQuoteRecord{
|
||||
QuoteRef: "q1",
|
||||
Intent: intentFromProto(intent),
|
||||
Quote: &model.PaymentQuoteSnapshot{},
|
||||
}
|
||||
svc := NewService(logger, stubRepo{
|
||||
payments: store,
|
||||
quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}},
|
||||
}, WithClock(clockpkg.NewSystem()))
|
||||
svc.ensureHandlers()
|
||||
|
||||
req := &orchestratorv1.InitiatePaymentRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
|
||||
QuoteRef: "q1",
|
||||
IdempotencyKey: "k1",
|
||||
}
|
||||
resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("initiate by quote_ref failed: %v", err)
|
||||
}
|
||||
if resp == nil || resp.GetPayment() == nil {
|
||||
t.Fatalf("expected payment response")
|
||||
}
|
||||
if resp.GetPayment().GetIntent().GetAmount().GetAmount() != "1" {
|
||||
t.Fatalf("expected intent amount to be resolved from quote")
|
||||
}
|
||||
if resp.GetPayment().GetLastQuote().GetQuoteRef() != "q1" {
|
||||
t.Fatalf("expected last quote_ref to be set from stored quote")
|
||||
}
|
||||
}
|
||||
|
||||
// --- test doubles ---
|
||||
|
||||
type stubRepo struct {
|
||||
|
||||
@@ -82,13 +82,14 @@ type ExternalChainEndpoint struct {
|
||||
|
||||
// CardEndpoint describes a card payout destination.
|
||||
type CardEndpoint struct {
|
||||
Pan string `bson:"pan,omitempty" json:"pan,omitempty"`
|
||||
Token string `bson:"token,omitempty" json:"token,omitempty"`
|
||||
Cardholder string `bson:"cardholder,omitempty" json:"cardholder,omitempty"`
|
||||
ExpMonth uint32 `bson:"expMonth,omitempty" json:"expMonth,omitempty"`
|
||||
ExpYear uint32 `bson:"expYear,omitempty" json:"expYear,omitempty"`
|
||||
Country string `bson:"country,omitempty" json:"country,omitempty"`
|
||||
MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"`
|
||||
Pan string `bson:"pan,omitempty" json:"pan,omitempty"`
|
||||
Token string `bson:"token,omitempty" json:"token,omitempty"`
|
||||
Cardholder string `bson:"cardholder,omitempty" json:"cardholder,omitempty"`
|
||||
CardholderSurname string `bson:"cardholderSurname,omitempty" json:"cardholderSurname,omitempty"`
|
||||
ExpMonth uint32 `bson:"expMonth,omitempty" json:"expMonth,omitempty"`
|
||||
ExpYear uint32 `bson:"expYear,omitempty" json:"expYear,omitempty"`
|
||||
Country string `bson:"country,omitempty" json:"country,omitempty"`
|
||||
MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"`
|
||||
}
|
||||
|
||||
// CardPayout stores gateway payout tracking info.
|
||||
@@ -134,6 +135,21 @@ type PaymentIntent struct {
|
||||
FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
|
||||
SettlementMode orchestratorv1.SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"`
|
||||
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
|
||||
Customer *Customer `bson:"customer,omitempty" json:"customer,omitempty"`
|
||||
}
|
||||
|
||||
// Customer captures payer/recipient identity details for downstream processing.
|
||||
type Customer struct {
|
||||
ID string `bson:"id,omitempty" json:"id,omitempty"`
|
||||
FirstName string `bson:"firstName,omitempty" json:"firstName,omitempty"`
|
||||
MiddleName string `bson:"middleName,omitempty" json:"middleName,omitempty"`
|
||||
LastName string `bson:"lastName,omitempty" json:"lastName,omitempty"`
|
||||
IP string `bson:"ip,omitempty" json:"ip,omitempty"`
|
||||
Zip string `bson:"zip,omitempty" json:"zip,omitempty"`
|
||||
Country string `bson:"country,omitempty" json:"country,omitempty"`
|
||||
State string `bson:"state,omitempty" json:"state,omitempty"`
|
||||
City string `bson:"city,omitempty" json:"city,omitempty"`
|
||||
Address string `bson:"address,omitempty" json:"address,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentQuoteSnapshot stores the latest quote info.
|
||||
@@ -158,6 +174,24 @@ type ExecutionRefs struct {
|
||||
FeeTransferRef string `bson:"feeTransferRef,omitempty" json:"feeTransferRef,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionStep describes a planned or executed payment step for reporting.
|
||||
type ExecutionStep struct {
|
||||
Code string `bson:"code,omitempty" json:"code,omitempty"`
|
||||
Description string `bson:"description,omitempty" json:"description,omitempty"`
|
||||
Amount *moneyv1.Money `bson:"amount,omitempty" json:"amount,omitempty"`
|
||||
NetworkFee *moneyv1.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
|
||||
SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"`
|
||||
DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"`
|
||||
TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionPlan captures the ordered list of steps to execute a payment.
|
||||
type ExecutionPlan struct {
|
||||
Steps []*ExecutionStep `bson:"steps,omitempty" json:"steps,omitempty"`
|
||||
TotalNetworkFee *moneyv1.Money `bson:"totalNetworkFee,omitempty" json:"totalNetworkFee,omitempty"`
|
||||
}
|
||||
|
||||
// Payment persists orchestrated payment lifecycle.
|
||||
type Payment struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
@@ -171,6 +205,7 @@ type Payment struct {
|
||||
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
|
||||
LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"`
|
||||
Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"`
|
||||
ExecutionPlan *ExecutionPlan `bson:"executionPlan,omitempty" json:"executionPlan,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
CardPayout *CardPayout `bson:"cardPayout,omitempty" json:"cardPayout,omitempty"`
|
||||
}
|
||||
@@ -212,12 +247,41 @@ func (p *Payment) Normalize() {
|
||||
p.Intent.Attributes[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if p.Intent.Customer != nil {
|
||||
p.Intent.Customer.ID = strings.TrimSpace(p.Intent.Customer.ID)
|
||||
p.Intent.Customer.FirstName = strings.TrimSpace(p.Intent.Customer.FirstName)
|
||||
p.Intent.Customer.MiddleName = strings.TrimSpace(p.Intent.Customer.MiddleName)
|
||||
p.Intent.Customer.LastName = strings.TrimSpace(p.Intent.Customer.LastName)
|
||||
p.Intent.Customer.IP = strings.TrimSpace(p.Intent.Customer.IP)
|
||||
p.Intent.Customer.Zip = strings.TrimSpace(p.Intent.Customer.Zip)
|
||||
p.Intent.Customer.Country = strings.TrimSpace(p.Intent.Customer.Country)
|
||||
p.Intent.Customer.State = strings.TrimSpace(p.Intent.Customer.State)
|
||||
p.Intent.Customer.City = strings.TrimSpace(p.Intent.Customer.City)
|
||||
p.Intent.Customer.Address = strings.TrimSpace(p.Intent.Customer.Address)
|
||||
}
|
||||
if p.Execution != nil {
|
||||
p.Execution.DebitEntryRef = strings.TrimSpace(p.Execution.DebitEntryRef)
|
||||
p.Execution.CreditEntryRef = strings.TrimSpace(p.Execution.CreditEntryRef)
|
||||
p.Execution.FXEntryRef = strings.TrimSpace(p.Execution.FXEntryRef)
|
||||
p.Execution.ChainTransferRef = strings.TrimSpace(p.Execution.ChainTransferRef)
|
||||
}
|
||||
if p.ExecutionPlan != nil {
|
||||
for _, step := range p.ExecutionPlan.Steps {
|
||||
if step == nil {
|
||||
continue
|
||||
}
|
||||
step.Code = strings.TrimSpace(step.Code)
|
||||
step.Description = strings.TrimSpace(step.Description)
|
||||
step.SourceWalletRef = strings.TrimSpace(step.SourceWalletRef)
|
||||
step.DestinationRef = strings.TrimSpace(step.DestinationRef)
|
||||
step.TransferRef = strings.TrimSpace(step.TransferRef)
|
||||
if step.Metadata != nil {
|
||||
for k, v := range step.Metadata {
|
||||
step.Metadata[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEndpoint(ep *PaymentEndpoint) {
|
||||
@@ -257,6 +321,7 @@ func normalizeEndpoint(ep *PaymentEndpoint) {
|
||||
ep.Card.Pan = strings.TrimSpace(ep.Card.Pan)
|
||||
ep.Card.Token = strings.TrimSpace(ep.Card.Token)
|
||||
ep.Card.Cardholder = strings.TrimSpace(ep.Card.Cardholder)
|
||||
ep.Card.CardholderSurname = strings.TrimSpace(ep.Card.CardholderSurname)
|
||||
ep.Card.Country = strings.TrimSpace(ep.Card.Country)
|
||||
ep.Card.MaskedPan = strings.TrimSpace(ep.Card.MaskedPan)
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ message WalletBalance {
|
||||
common.money.v1.Money pending_inbound = 2;
|
||||
common.money.v1.Money pending_outbound = 3;
|
||||
google.protobuf.Timestamp calculated_at = 4;
|
||||
common.money.v1.Money native_available = 5;
|
||||
}
|
||||
|
||||
message GetWalletBalanceRequest {
|
||||
@@ -188,6 +189,32 @@ message EstimateTransferFeeResponse {
|
||||
string estimation_context = 2;
|
||||
}
|
||||
|
||||
message ComputeGasTopUpRequest {
|
||||
string wallet_ref = 1;
|
||||
common.money.v1.Money estimated_total_fee = 2;
|
||||
}
|
||||
|
||||
message ComputeGasTopUpResponse {
|
||||
common.money.v1.Money topup_amount = 1;
|
||||
bool cap_hit = 2;
|
||||
}
|
||||
|
||||
message EnsureGasTopUpRequest {
|
||||
string idempotency_key = 1;
|
||||
string organization_ref = 2;
|
||||
string source_wallet_ref = 3;
|
||||
string target_wallet_ref = 4;
|
||||
common.money.v1.Money estimated_total_fee = 5;
|
||||
map<string, string> metadata = 6;
|
||||
string client_reference = 7;
|
||||
}
|
||||
|
||||
message EnsureGasTopUpResponse {
|
||||
common.money.v1.Money topup_amount = 1;
|
||||
bool cap_hit = 2;
|
||||
Transfer transfer = 3;
|
||||
}
|
||||
|
||||
message WalletDepositObservedEvent {
|
||||
string deposit_ref = 1;
|
||||
string wallet_ref = 2;
|
||||
@@ -217,4 +244,6 @@ service ChainGatewayService {
|
||||
rpc ListTransfers(ListTransfersRequest) returns (ListTransfersResponse);
|
||||
|
||||
rpc EstimateTransferFee(EstimateTransferFeeRequest) returns (EstimateTransferFeeResponse);
|
||||
rpc ComputeGasTopUp(ComputeGasTopUpRequest) returns (ComputeGasTopUpResponse);
|
||||
rpc EnsureGasTopUp(EnsureGasTopUpRequest) returns (EnsureGasTopUpResponse);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,20 @@ message PaymentIntent {
|
||||
fees.v1.PolicyOverrides fee_policy = 7;
|
||||
map<string, string> attributes = 8;
|
||||
SettlementMode settlement_mode = 9;
|
||||
Customer customer = 10;
|
||||
}
|
||||
|
||||
message Customer {
|
||||
string id = 1;
|
||||
string first_name = 2;
|
||||
string middle_name = 3;
|
||||
string last_name = 4;
|
||||
string ip = 5;
|
||||
string zip = 6;
|
||||
string country = 7;
|
||||
string state = 8;
|
||||
string city = 9;
|
||||
string address = 10;
|
||||
}
|
||||
|
||||
message PaymentQuote {
|
||||
@@ -141,6 +155,22 @@ message ExecutionRefs {
|
||||
string fee_transfer_ref = 6;
|
||||
}
|
||||
|
||||
message ExecutionStep {
|
||||
string code = 1;
|
||||
string description = 2;
|
||||
common.money.v1.Money amount = 3;
|
||||
common.money.v1.Money network_fee = 4;
|
||||
string source_wallet_ref = 5;
|
||||
string destination_ref = 6;
|
||||
string transfer_ref = 7;
|
||||
map<string, string> metadata = 8;
|
||||
}
|
||||
|
||||
message ExecutionPlan {
|
||||
repeated ExecutionStep steps = 1;
|
||||
common.money.v1.Money total_network_fee = 2;
|
||||
}
|
||||
|
||||
// Card payout gateway tracking info.
|
||||
message CardPayout {
|
||||
string payout_ref = 1;
|
||||
@@ -166,6 +196,7 @@ message Payment {
|
||||
google.protobuf.Timestamp created_at = 10;
|
||||
google.protobuf.Timestamp updated_at = 11;
|
||||
CardPayout card_payout = 12;
|
||||
ExecutionPlan execution_plan = 13;
|
||||
}
|
||||
|
||||
message QuotePaymentRequest {
|
||||
|
||||
15
api/server/interface/api/srequest/customer.go
Normal file
15
api/server/interface/api/srequest/customer.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package srequest
|
||||
|
||||
// Customer captures payer/recipient identity details for downstream processing.
|
||||
type Customer struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
FirstName string `json:"first_name,omitempty"`
|
||||
MiddleName string `json:"middle_name,omitempty"`
|
||||
LastName string `json:"last_name,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Zip string `json:"zip,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type PaymentIntent struct {
|
||||
FX *FXIntent `json:"fx,omitempty"`
|
||||
SettlementMode SettlementMode `json:"settlement_mode,omitempty"`
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
Customer *Customer `json:"customer,omitempty"`
|
||||
}
|
||||
|
||||
type AssetResolverStub struct{}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
@@ -20,11 +20,6 @@ type FeeLine struct {
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type NetworkFee struct {
|
||||
NetworkFee *model.Money `json:"networkFee,omitempty"`
|
||||
EstimationContext string `json:"estimationContext,omitempty"`
|
||||
}
|
||||
|
||||
type FxQuote struct {
|
||||
QuoteRef string `json:"quoteRef,omitempty"`
|
||||
BaseCurrency string `json:"baseCurrency,omitempty"`
|
||||
@@ -45,7 +40,6 @@ type PaymentQuote struct {
|
||||
ExpectedSettlementAmount *model.Money `json:"expectedSettlementAmount,omitempty"`
|
||||
ExpectedFeeTotal *model.Money `json:"expectedFeeTotal,omitempty"`
|
||||
FeeLines []FeeLine `json:"feeLines,omitempty"`
|
||||
NetworkFee *NetworkFee `json:"networkFee,omitempty"`
|
||||
FxQuote *FxQuote `json:"fxQuote,omitempty"`
|
||||
}
|
||||
|
||||
@@ -53,7 +47,6 @@ type PaymentQuoteAggregate struct {
|
||||
DebitAmounts []*model.Money `json:"debitAmounts,omitempty"`
|
||||
ExpectedSettlementAmounts []*model.Money `json:"expectedSettlementAmounts,omitempty"`
|
||||
ExpectedFeeTotals []*model.Money `json:"expectedFeeTotals,omitempty"`
|
||||
NetworkFeeTotals []*model.Money `json:"networkFeeTotals,omitempty"`
|
||||
}
|
||||
|
||||
type PaymentQuotes struct {
|
||||
@@ -83,7 +76,8 @@ type paymentQuotesResponse struct {
|
||||
|
||||
type paymentsResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Payments []Payment `json:"payments"`
|
||||
Payments []Payment `json:"payments"`
|
||||
Page *paginationv1.CursorPageResponse `json:"page,omitempty"`
|
||||
}
|
||||
|
||||
type paymentResponse struct {
|
||||
@@ -115,6 +109,15 @@ func PaymentsResponse(logger mlogger.Logger, payments []*orchestratorv1.Payment,
|
||||
})
|
||||
}
|
||||
|
||||
// PaymentsList wraps a list of payments with refreshed access token and pagination data.
|
||||
func PaymentsListResponse(logger mlogger.Logger, resp *orchestratorv1.ListPaymentsResponse, token *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, paymentsResponse{
|
||||
Payments: toPayments(resp.GetPayments()),
|
||||
Page: resp.GetPage(),
|
||||
authResponse: authResponse{AccessToken: *token},
|
||||
})
|
||||
}
|
||||
|
||||
// Payment wraps a payment with refreshed access token.
|
||||
func PaymentResponse(logger mlogger.Logger, payment *orchestratorv1.Payment, token *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, paymentResponse{
|
||||
@@ -146,16 +149,6 @@ func toFeeLines(lines []*feesv1.DerivedPostingLine) []FeeLine {
|
||||
return result
|
||||
}
|
||||
|
||||
func toNetworkFee(n *chainv1.EstimateTransferFeeResponse) *NetworkFee {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return &NetworkFee{
|
||||
NetworkFee: toMoney(n.GetNetworkFee()),
|
||||
EstimationContext: n.GetEstimationContext(),
|
||||
}
|
||||
}
|
||||
|
||||
func toFxQuote(q *oraclev1.Quote) *FxQuote {
|
||||
if q == nil {
|
||||
return nil
|
||||
@@ -192,7 +185,6 @@ func toPaymentQuote(q *orchestratorv1.PaymentQuote) *PaymentQuote {
|
||||
ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()),
|
||||
ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()),
|
||||
FeeLines: toFeeLines(q.GetFeeLines()),
|
||||
NetworkFee: toNetworkFee(q.GetNetworkFee()),
|
||||
FxQuote: toFxQuote(q.GetFxQuote()),
|
||||
}
|
||||
}
|
||||
@@ -205,7 +197,6 @@ func toPaymentQuoteAggregate(q *orchestratorv1.PaymentQuoteAggregate) *PaymentQu
|
||||
DebitAmounts: toMoneyList(q.GetDebitAmounts()),
|
||||
ExpectedSettlementAmounts: toMoneyList(q.GetExpectedSettlementAmounts()),
|
||||
ExpectedFeeTotals: toMoneyList(q.GetExpectedFeeTotals()),
|
||||
NetworkFeeTotals: toMoneyList(q.GetNetworkFeeTotals()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
25
api/server/internal/server/paymentapiimp/customer.go
Normal file
25
api/server/internal/server/paymentapiimp/customer.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
)
|
||||
|
||||
func applyCustomerIP(intent *srequest.PaymentIntent, remoteAddr string) {
|
||||
if intent == nil {
|
||||
return
|
||||
}
|
||||
ip := strings.TrimSpace(remoteAddr)
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(ip); err == nil && host != "" {
|
||||
ip = host
|
||||
}
|
||||
if intent.Customer == nil {
|
||||
intent.Customer = &srequest.Customer{}
|
||||
}
|
||||
intent.Customer.IP = strings.TrimSpace(ip)
|
||||
}
|
||||
153
api/server/internal/server/paymentapiimp/list.go
Normal file
153
api/server/internal/server/paymentapiimp/list.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const maxInt32 = int64(1<<31 - 1)
|
||||
|
||||
func (a *PaymentAPI) listPayments(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for payments list", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
if !allowed {
|
||||
a.logger.Debug("Access denied when listing payments", mutil.PLog(a.oph, r))
|
||||
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
|
||||
}
|
||||
|
||||
req := &orchestratorv1.ListPaymentsRequest{
|
||||
Meta: &orchestratorv1.RequestMeta{
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
},
|
||||
}
|
||||
|
||||
if page, err := listPaymentsPage(r); err != nil {
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
} else if page != nil {
|
||||
req.Page = page
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
if sourceRef := strings.TrimSpace(query.Get("source_ref")); sourceRef != "" {
|
||||
req.SourceRef = sourceRef
|
||||
}
|
||||
if destinationRef := strings.TrimSpace(query.Get("destination_ref")); destinationRef != "" {
|
||||
req.DestinationRef = destinationRef
|
||||
}
|
||||
|
||||
if states, err := parsePaymentStateFilters(r); err != nil {
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
} else if len(states) > 0 {
|
||||
req.FilterStates = states
|
||||
}
|
||||
|
||||
resp, err := a.client.ListPayments(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to list payments", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
return sresponse.PaymentsListResponse(a.logger, resp, token)
|
||||
}
|
||||
|
||||
func listPaymentsPage(r *http.Request) (*paginationv1.CursorPageRequest, error) {
|
||||
query := r.URL.Query()
|
||||
cursor := strings.TrimSpace(query.Get("cursor"))
|
||||
limitRaw := strings.TrimSpace(query.Get("limit"))
|
||||
|
||||
var limit int64
|
||||
hasLimit := false
|
||||
if limitRaw != "" {
|
||||
parsed, err := strconv.ParseInt(limitRaw, 10, 32)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid limit", "limit")
|
||||
}
|
||||
limit = parsed
|
||||
hasLimit = true
|
||||
}
|
||||
|
||||
if cursor == "" && !hasLimit {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
page := &paginationv1.CursorPageRequest{
|
||||
Cursor: cursor,
|
||||
}
|
||||
if hasLimit {
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
} else if limit > maxInt32 {
|
||||
limit = maxInt32
|
||||
}
|
||||
page.Limit = int32(limit)
|
||||
}
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func parsePaymentStateFilters(r *http.Request) ([]orchestratorv1.PaymentState, error) {
|
||||
query := r.URL.Query()
|
||||
values := append([]string{}, query["state"]...)
|
||||
values = append(values, query["states"]...)
|
||||
values = append(values, query["filter_states"]...)
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
states := make([]orchestratorv1.PaymentState, 0, len(values))
|
||||
for _, raw := range values {
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
state, ok := paymentStateFromString(trimmed)
|
||||
if !ok {
|
||||
return nil, merrors.InvalidArgument("unsupported payment state: "+trimmed, "state")
|
||||
}
|
||||
states = append(states, state)
|
||||
}
|
||||
}
|
||||
|
||||
if len(states) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func paymentStateFromString(value string) (orchestratorv1.PaymentState, bool) {
|
||||
upper := strings.ToUpper(strings.TrimSpace(value))
|
||||
if upper == "" {
|
||||
return 0, false
|
||||
}
|
||||
if !strings.HasPrefix(upper, "PAYMENT_STATE_") {
|
||||
upper = "PAYMENT_STATE_" + upper
|
||||
}
|
||||
enumValue, ok := orchestratorv1.PaymentState_value[upper]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return orchestratorv1.PaymentState(enumValue), true
|
||||
}
|
||||
@@ -50,6 +50,7 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn
|
||||
Fx: fx,
|
||||
SettlementMode: settlementMode,
|
||||
Attributes: copyStringMap(intent.Attributes),
|
||||
Customer: mapCustomer(intent.Customer),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -200,6 +201,24 @@ func mapFXIntent(fx *srequest.FXIntent) (*orchestratorv1.FXIntent, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mapCustomer(customer *srequest.Customer) *orchestratorv1.Customer {
|
||||
if customer == nil {
|
||||
return nil
|
||||
}
|
||||
return &orchestratorv1.Customer{
|
||||
Id: strings.TrimSpace(customer.ID),
|
||||
FirstName: strings.TrimSpace(customer.FirstName),
|
||||
MiddleName: strings.TrimSpace(customer.MiddleName),
|
||||
LastName: strings.TrimSpace(customer.LastName),
|
||||
Ip: strings.TrimSpace(customer.IP),
|
||||
Zip: strings.TrimSpace(customer.Zip),
|
||||
Country: strings.TrimSpace(customer.Country),
|
||||
State: strings.TrimSpace(customer.State),
|
||||
City: strings.TrimSpace(customer.City),
|
||||
Address: strings.TrimSpace(customer.Address),
|
||||
}
|
||||
}
|
||||
|
||||
func mapCurrencyPair(pair *srequest.CurrencyPair) *fxv1.CurrencyPair {
|
||||
if pair == nil {
|
||||
return nil
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/api/http/response"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
@@ -20,7 +21,7 @@ import (
|
||||
func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, token *sresponse.TokenData, expectQuote bool) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
|
||||
a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
|
||||
}
|
||||
|
||||
@@ -58,6 +59,7 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
|
||||
|
||||
var intent *orchestratorv1.PaymentIntent
|
||||
if payload.Intent != nil {
|
||||
applyCustomerIP(payload.Intent, r.RemoteAddr)
|
||||
intent, err = mapPaymentIntent(payload.Intent)
|
||||
if err != nil {
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
@@ -76,7 +78,7 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
|
||||
|
||||
resp, err := a.client.InitiatePayment(ctx, req)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to initiate payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||
a.logger.Warn("Failed to initiate payment", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token
|
||||
return response.Auto(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
applyCustomerIP(&payload.Intent, r.RemoteAddr)
|
||||
intent, err := mapPaymentIntent(&payload.Intent)
|
||||
if err != nil {
|
||||
a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
@@ -97,6 +98,7 @@ func (a *PaymentAPI) quotePayments(r *http.Request, account *model.Account, toke
|
||||
|
||||
intents := make([]*orchestratorv1.PaymentIntent, 0, len(payload.Intents))
|
||||
for i := range payload.Intents {
|
||||
applyCustomerIP(&payload.Intents[i], r.RemoteAddr)
|
||||
intent, err := mapPaymentIntent(&payload.Intents[i])
|
||||
if err != nil {
|
||||
a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r))
|
||||
|
||||
@@ -25,6 +25,7 @@ type paymentClient interface {
|
||||
QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
|
||||
InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
|
||||
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
||||
ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -72,6 +73,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/immediate"), api.Post, p.initiateImmediate)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-multiquote"), api.Post, p.initiatePaymentsByQuote)
|
||||
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listPayments)
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
20
frontend/pshared/lib/api/responses/payment/payment.dart
Normal file
20
frontend/pshared/lib/api/responses/payment/payment.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'package:pshared/api/responses/base.dart';
|
||||
import 'package:pshared/api/responses/token.dart';
|
||||
import 'package:pshared/data/dto/payment/payment.dart';
|
||||
|
||||
part 'payment.g.dart';
|
||||
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class PaymentResponse extends BaseAuthorizedResponse {
|
||||
|
||||
final PaymentDTO payment;
|
||||
|
||||
const PaymentResponse({required super.accessToken, required this.payment});
|
||||
|
||||
factory PaymentResponse.fromJson(Map<String, dynamic> json) => _$PaymentResponseFromJson(json);
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$PaymentResponseToJson(this);
|
||||
}
|
||||
20
frontend/pshared/lib/api/responses/payment/payments.dart
Normal file
20
frontend/pshared/lib/api/responses/payment/payments.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'package:pshared/api/responses/base.dart';
|
||||
import 'package:pshared/api/responses/token.dart';
|
||||
import 'package:pshared/data/dto/payment/payment.dart';
|
||||
|
||||
part 'payments.g.dart';
|
||||
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class PaymentsResponse extends BaseAuthorizedResponse {
|
||||
|
||||
final List<PaymentDTO> payments;
|
||||
|
||||
const PaymentsResponse({required super.accessToken, required this.payments});
|
||||
|
||||
factory PaymentsResponse.fromJson(Map<String, dynamic> json) => _$PaymentsResponseFromJson(json);
|
||||
@override
|
||||
Map<String, dynamic> toJson() => _$PaymentsResponseToJson(this);
|
||||
}
|
||||
41
frontend/pshared/lib/data/dto/payment/intent/customer.dart
Normal file
41
frontend/pshared/lib/data/dto/payment/intent/customer.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'customer.g.dart';
|
||||
|
||||
|
||||
@JsonSerializable()
|
||||
class CustomerDTO {
|
||||
final String id;
|
||||
|
||||
@JsonKey(name: 'first_name')
|
||||
final String? firstName;
|
||||
|
||||
@JsonKey(name: 'middle_name')
|
||||
final String? middleName;
|
||||
|
||||
@JsonKey(name: 'last_name')
|
||||
final String? lastName;
|
||||
|
||||
final String? ip;
|
||||
final String? zip;
|
||||
final String? country;
|
||||
final String? state;
|
||||
final String? city;
|
||||
final String? address;
|
||||
|
||||
const CustomerDTO({
|
||||
required this.id,
|
||||
this.firstName,
|
||||
this.middleName,
|
||||
this.lastName,
|
||||
this.ip,
|
||||
this.zip,
|
||||
this.country,
|
||||
this.state,
|
||||
this.city,
|
||||
this.address,
|
||||
});
|
||||
|
||||
factory CustomerDTO.fromJson(Map<String, dynamic> json) => _$CustomerDTOFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$CustomerDTOToJson(this);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user