refactored payment orchestration

This commit is contained in:
Stephan D
2026-02-03 00:40:46 +01:00
parent 05d998e0f7
commit 5e87e2f2f9
184 changed files with 3920 additions and 2219 deletions

View File

@@ -9,7 +9,7 @@ import (
chainasset "github.com/tech/sendico/pkg/chain"
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/model/account_role"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
@@ -426,7 +426,7 @@ func operationFromTransfer(req *chainv1.SubmitTransferRequest) (*connectorv1.Ope
params := map[string]interface{}{
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
"client_reference": strings.TrimSpace(req.GetClientReference()),
"payment_ref": strings.TrimSpace(req.GetPaymentRef()),
}
if memo := strings.TrimSpace(req.GetDestination().GetMemo()); memo != "" {
params["destination_memo"] = memo
@@ -444,6 +444,8 @@ func operationFromTransfer(req *chainv1.SubmitTransferRequest) (*connectorv1.Ope
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}},
Money: req.GetAmount(),
Params: structFromMap(params),
IntentRef: strings.TrimSpace(req.GetIntentRef()),
OperationRef: strings.TrimSpace(req.GetOperationRef()),
}
to, err := destinationToParty(req.GetDestination())
if err != nil {
@@ -472,14 +474,14 @@ func setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[strin
if op == nil || len(metadata) == 0 {
return
}
if raw := strings.TrimSpace(metadata[pmodel.MetadataKeyFromRole]); raw != "" {
if role, ok := pmodel.Parse(raw); ok && role != "" {
op.FromRole = pmodel.ToProto(role)
if raw := strings.TrimSpace(metadata[account_role.MetadataKeyFromRole]); raw != "" {
if role, ok := account_role.Parse(raw); ok && role != "" {
op.FromRole = account_role.ToProto(role)
}
}
if raw := strings.TrimSpace(metadata[pmodel.MetadataKeyToRole]); raw != "" {
if role, ok := pmodel.Parse(raw); ok && role != "" {
op.ToRole = pmodel.ToProto(role)
if raw := strings.TrimSpace(metadata[account_role.MetadataKeyToRole]); raw != "" {
if role, ok := account_role.Parse(raw); ok && role != "" {
op.ToRole = account_role.ToProto(role)
}
}
}
@@ -619,7 +621,7 @@ func gasTopUpEnsureOperation(req *chainv1.EnsureGasTopUpRequest) (*connectorv1.O
"mode": "ensure",
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
"target_wallet_ref": strings.TrimSpace(req.GetTargetWalletRef()),
"client_reference": strings.TrimSpace(req.GetClientReference()),
"payment_ref": strings.TrimSpace(req.GetPaymentRef()),
"estimated_total_fee": map[string]interface{}{"amount": fee.GetAmount(), "currency": fee.GetCurrency()},
}
if len(req.GetMetadata()) > 0 {
@@ -765,28 +767,54 @@ func managedWalletStatusFromAccount(state connectorv1.AccountState) chainv1.Mana
}
}
func transferStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus {
switch status {
case connectorv1.OperationStatus_CONFIRMED:
return chainv1.TransferStatus_TRANSFER_CONFIRMED
case connectorv1.OperationStatus_FAILED:
return chainv1.TransferStatus_TRANSFER_FAILED
case connectorv1.OperationStatus_CANCELED:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.TransferStatus_TRANSFER_PENDING
}
}
func operationStatusFromTransfer(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return connectorv1.OperationStatus_CONFIRMED
case chainv1.TransferStatus_TRANSFER_CREATED:
return connectorv1.OperationStatus_OPERATION_CREATED
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return connectorv1.OperationStatus_OPERATION_PROCESSING
case chainv1.TransferStatus_TRANSFER_WAITING:
return connectorv1.OperationStatus_OPERATION_WAITING
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return connectorv1.OperationStatus_OPERATION_SUCCESS
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_FAILED
return connectorv1.OperationStatus_OPERATION_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_CANCELED
return connectorv1.OperationStatus_OPERATION_CANCELLED
default:
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}
func transferStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus {
switch status {
case connectorv1.OperationStatus_OPERATION_CREATED:
return chainv1.TransferStatus_TRANSFER_CREATED
case connectorv1.OperationStatus_OPERATION_PROCESSING:
return chainv1.TransferStatus_TRANSFER_PROCESSING
case connectorv1.OperationStatus_OPERATION_WAITING:
return chainv1.TransferStatus_TRANSFER_WAITING
case connectorv1.OperationStatus_OPERATION_SUCCESS:
return chainv1.TransferStatus_TRANSFER_SUCCESS
case connectorv1.OperationStatus_OPERATION_FAILED:
return chainv1.TransferStatus_TRANSFER_FAILED
case connectorv1.OperationStatus_OPERATION_CANCELLED:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
}
}

View File

@@ -5,7 +5,7 @@ import (
"strings"
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/model/account_role"
"github.com/tech/sendico/pkg/payments/rail"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
@@ -102,13 +102,15 @@ func (g *chainRailGateway) Send(ctx context.Context, req rail.TransferRequest) (
OrganizationRef: orgRef,
SourceWalletRef: source,
Destination: dest,
IntentRef: strings.TrimSpace(req.IntentRef),
OperationRef: strings.TrimSpace(req.OperationRef),
PaymentRef: strings.TrimSpace(req.PaymentRef),
Amount: &moneyv1.Money{
Currency: currency,
Amount: amountValue,
},
Fees: fees,
Metadata: transferMetadataWithRoles(req.Metadata, req.FromRole, req.ToRole),
ClientReference: strings.TrimSpace(req.ClientReference),
Fees: fees,
Metadata: transferMetadataWithRoles(req.Metadata, req.FromRole, req.ToRole),
})
if err != nil {
return rail.RailResult{}, err
@@ -186,20 +188,29 @@ func (g *chainRailGateway) isManagedWallet(ctx context.Context, walletRef string
return true, nil
}
func statusFromTransfer(status chainv1.TransferStatus) string {
func statusFromTransfer(status chainv1.TransferStatus) rail.TransferStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
case chainv1.TransferStatus_TRANSFER_CREATED:
return rail.TransferStatusCreated
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return rail.TransferStatusProcessing
case chainv1.TransferStatus_TRANSFER_WAITING:
return rail.TransferStatusProcessing
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return rail.TransferStatusSuccess
case chainv1.TransferStatus_TRANSFER_FAILED:
return rail.TransferStatusFailed
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return rail.TransferStatusRejected
case chainv1.TransferStatus_TRANSFER_SIGNING,
chainv1.TransferStatus_TRANSFER_PENDING,
chainv1.TransferStatus_TRANSFER_SUBMITTED:
return rail.TransferStatusPending
return rail.TransferStatusCancelled
default:
return rail.TransferStatusPending
return rail.TransferStatusUnspecified
}
}
@@ -255,19 +266,19 @@ func railMoneyFromProto(m *moneyv1.Money) *rail.Money {
}
}
func transferMetadataWithRoles(metadata map[string]string, fromRole, toRole pmodel.AccountRole) map[string]string {
func transferMetadataWithRoles(metadata map[string]string, fromRole, toRole account_role.AccountRole) map[string]string {
result := cloneMetadata(metadata)
if strings.TrimSpace(string(fromRole)) != "" {
if result == nil {
result = map[string]string{}
}
result[pmodel.MetadataKeyFromRole] = strings.TrimSpace(string(fromRole))
result[account_role.MetadataKeyFromRole] = strings.TrimSpace(string(fromRole))
}
if strings.TrimSpace(string(toRole)) != "" {
if result == nil {
result = map[string]string{}
}
result[pmodel.MetadataKeyToRole] = strings.TrimSpace(string(toRole))
result[account_role.MetadataKeyToRole] = strings.TrimSpace(string(toRole))
}
if len(result) == 0 {
return nil

View File

@@ -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-20260131145833-e3fabd62fc61 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260201044653-ee82dce4af02 // 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.10.0 // indirect
@@ -84,5 +84,5 @@ require (
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
)

View File

@@ -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-20260131145833-e3fabd62fc61 h1:iLc9NjmJ3AdAl5VoiRSDXzEmmW8kvHp3E2vJ2eKKc7s=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260131145833-e3fabd62fc61/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260201044653-ee82dce4af02 h1:0uY5Ooun4eqGmP0IrQhiKVqeeEXoeEcL8KVRtug8+r8=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260201044653-ee82dce4af02/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=
@@ -360,8 +360,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -5,6 +5,7 @@ import (
"math/big"
"github.com/ethereum/go-ethereum/core/types"
pmodel "github.com/tech/sendico/pkg/model"
)
// ManagedWalletKey captures information returned after provisioning a managed wallet key.
@@ -17,7 +18,7 @@ type ManagedWalletKey struct {
// Manager defines the contract for managing managed wallet keys.
type Manager interface {
// CreateManagedWalletKey provisions a new managed wallet key for the provided wallet reference and network.
CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*ManagedWalletKey, error)
CreateManagedWalletKey(ctx context.Context, walletRef string, network pmodel.ChainNetwork) (*ManagedWalletKey, error)
// SignTransaction signs the provided transaction using the identified key material.
SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error)
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/tech/sendico/gateway/chain/internal/keymanager"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
pmodel "github.com/tech/sendico/pkg/model"
)
// Config describes how to connect to Vault for managed wallet keys.
@@ -92,19 +93,19 @@ 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) {
func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network pmodel.ChainNetwork) (*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", string(network)))
return nil, merrors.InvalidArgument("vault key manager: walletRef is required")
}
if strings.TrimSpace(network) == "" {
if network == pmodel.ChainNetworkUnspecified {
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", string(network)), zap.Error(err))
return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error())
}
privateKeyBytes := crypto.FromECDSA(privateKey)
@@ -113,9 +114,9 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string,
publicKeyHex := hex.EncodeToString(publicKeyBytes)
address := crypto.PubkeyToAddress(publicKey).Hex()
err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address)
err = m.persistKey(ctx, walletRef, string(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", string(network)), zap.Error(err))
zeroBytes(privateKeyBytes)
zeroBytes(publicKeyBytes)
return nil, err
@@ -125,12 +126,12 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string,
m.logger.Info("Managed wallet key created",
zap.String("wallet_ref", walletRef),
zap.String("network", network),
zap.String("network", string(network)),
zap.String("address", strings.ToLower(address)),
)
return &keymanager.ManagedWalletKey{
KeyID: m.buildKeyID(network, walletRef),
KeyID: m.buildKeyID(string(network), walletRef),
Address: strings.ToLower(address),
PublicKey: publicKeyHex,
}, nil

View File

@@ -22,6 +22,7 @@ import (
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
@@ -48,7 +49,7 @@ type config struct {
}
type chainConfig struct {
Name string `yaml:"name"`
Name pmodel.ChainNetwork `yaml:"name"`
RPCURLEnv string `yaml:"rpc_url_env"`
ChainID uint64 `yaml:"chain_id"`
NativeToken string `yaml:"native_token"`
@@ -57,10 +58,10 @@ type chainConfig struct {
}
type serviceWalletConfig struct {
Chain string `yaml:"chain"`
Address string `yaml:"address"`
AddressEnv string `yaml:"address_env"`
PrivateKeyEnv string `yaml:"private_key_env"`
Chain pmodel.ChainNetwork `yaml:"chain"`
Address string `yaml:"address"`
AddressEnv string `yaml:"address_env"`
PrivateKeyEnv string `yaml:"private_key_env"`
}
type tokenConfig struct {
@@ -209,20 +210,16 @@ func (i *Imp) loadConfig() (*config, error) {
func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatewayshared.Network, error) {
result := make([]gatewayshared.Network, 0, len(chains))
for _, chain := range chains {
if strings.TrimSpace(chain.Name) == "" {
logger.Warn("Skipping unnamed chain configuration")
continue
}
rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv))
if rpcURL == "" {
logger.Error("RPC url not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv))
logger.Error("RPC url not configured", zap.String("chain", string(chain.Name)), zap.String("env", chain.RPCURLEnv))
return nil, merrors.InvalidArgument(fmt.Sprintf("chain RPC endpoint not configured (chain=%s env=%s)", chain.Name, chain.RPCURLEnv))
}
contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens))
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", string(chain.Name)))
continue
}
addr := strings.TrimSpace(token.Contract)
@@ -232,9 +229,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", string(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", string(chain.Name)))
}
continue
}
@@ -246,7 +243,7 @@ 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))
logger.Error("Invalid gas top-up policy", zap.String("chain", string(chain.Name)), zap.Error(err))
return nil, err
}
@@ -262,7 +259,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew
return result, nil
}
func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) {
func buildGasTopUpPolicy(chainName pmodel.ChainNetwork, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) {
if cfg == nil {
return nil, nil
}
@@ -300,7 +297,7 @@ func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gateways
return policy, nil
}
func parseGasTopUpRule(chainName, label string, cfg gasTopUpRuleConfig) (gatewayshared.GasTopUpRule, bool, error) {
func parseGasTopUpRule(chainName pmodel.ChainNetwork, 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
}
@@ -336,7 +333,7 @@ func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewa
if 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", string(cfg.Chain)))
}
}
if privateKey == "" {

View File

@@ -26,8 +26,8 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
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))
if wallet.Network != source.Network {
deps.Logger.Warn("Destination wallet network mismatch", zap.String("source_network", string(source.Network)), zap.String("dest_network", string(wallet.Network)))
return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch")
}
if strings.TrimSpace(wallet.DepositAddress) == "" {
@@ -44,12 +44,12 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe
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", string(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", string(source.Network)), zap.Error(err))
return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet")
}
normalized, err := chainDriver.NormalizeAddress(external)

View File

@@ -53,19 +53,18 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E
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)
networkCfg, ok := c.deps.Networks.Network(sourceWallet.Network)
if !ok {
c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey))
c.deps.Logger.Warn("Unsupported chain", zap.String("network", string(sourceWallet.Network)))
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", string(sourceWallet.Network)))
return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured"))
}
chainDriver, err := c.deps.Drivers.Driver(networkKey)
chainDriver, err := c.deps.Drivers.Driver(sourceWallet.Network)
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", string(sourceWallet.Network)), zap.Error(err))
return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
}

View File

@@ -125,9 +125,9 @@ func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.Ensure
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef},
},
Amount: topUp,
Metadata: shared.CloneMetadata(req.GetMetadata()),
ClientReference: strings.TrimSpace(req.GetClientReference()),
Amount: topUp,
Metadata: shared.CloneMetadata(req.GetMetadata()),
PaymentRef: strings.TrimSpace(req.GetPaymentRef()),
}
submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq)
@@ -152,12 +152,7 @@ func computeGasTopUp(ctx context.Context, deps Deps, walletRef string, estimated
return nil, false, nil, nil, err
}
networkKey := strings.ToLower(strings.TrimSpace(walletModel.Network))
if strings.HasPrefix(networkKey, "tron") {
return nil, false, nil, nil, merrors.InvalidArgument("tron networks must use the tron gateway")
}
networkCfg, ok := deps.Networks.Network(networkKey)
networkCfg, ok := deps.Networks.Network(walletModel.Network)
if !ok {
return nil, false, nil, nil, merrors.InvalidArgument("unsupported chain for wallet")
}
@@ -248,7 +243,7 @@ func logDecision(logger mlogger.Logger, walletRef string, estimatedFee *moneyv1.
zap.Bool("cap_hit", capHit),
}
if walletModel != nil {
fields = append(fields, zap.String("network", strings.TrimSpace(walletModel.Network)))
fields = append(fields, zap.String("network", string(walletModel.Network)))
}
logger.Info("Gas top-up decision", fields...)
}

View File

@@ -41,8 +41,8 @@ func toProtoTransfer(transfer *model.Transfer) *chainv1.Transfer {
SourceWalletRef: transfer.SourceWalletRef,
Destination: destination,
Asset: asset,
RequestedAmount: shared.CloneMoney(transfer.RequestedAmount),
NetAmount: shared.CloneMoney(transfer.NetAmount),
RequestedAmount: shared.MonenyToProto(transfer.RequestedAmount),
NetAmount: shared.MonenyToProto(transfer.NetAmount),
Fees: protoFees,
Status: shared.TransferStatusToProto(transfer.Status),
TransactionHash: transfer.TxHash,

View File

@@ -77,10 +77,9 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
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)
networkCfg, ok := c.deps.Networks.Network(sourceWallet.Network)
if !ok {
c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey))
c.deps.Logger.Warn("Unsupported chain", zap.String("network", string(sourceWallet.Network)))
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet"))
}
@@ -124,17 +123,19 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
transfer := &model.Transfer{
IdempotencyKey: idempotencyKey,
TransferRef: shared.GenerateTransferRef(),
IntentRef: req.IntentRef,
OperationRef: req.OperationRef,
OrganizationRef: organizationRef,
SourceWalletRef: sourceWalletRef,
Destination: destination,
Network: sourceWallet.Network,
TokenSymbol: effectiveTokenSymbol,
ContractAddress: effectiveContractAddress,
RequestedAmount: shared.CloneMoney(amount),
NetAmount: netAmount,
RequestedAmount: shared.ProtoToMoney(amount),
NetAmount: shared.ProtoToMoney(netAmount),
Fees: fees,
Status: model.TransferStatusPending,
ClientReference: strings.TrimSpace(req.GetClientReference()),
Status: model.TransferStatusCreated,
PaymentRef: strings.TrimSpace(req.GetPaymentRef()),
LastStatusAt: c.deps.Clock.Now().UTC(),
}

View File

@@ -51,23 +51,19 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
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()))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
}
chainKey := shared.ChainKeyFromEnum(asset.GetChain())
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", string(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", string(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", string(chainKey)), zap.Error(err))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain"))
}
@@ -81,7 +77,7 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
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))
c.deps.Logger.Warn("Unsupported token", zap.String("token", tokenSymbol), zap.String("chain", string(chainKey)))
return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain"))
}
}

View File

@@ -34,7 +34,7 @@ func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.Li
filter.OwnerRefFilter = &ownerRef
}
if asset := req.GetAsset(); asset != nil {
filter.Network, _ = shared.ChainKeyFromEnum(asset.GetChain())
filter.Network = shared.ChainKeyFromEnum(asset.GetChain())
filter.TokenSymbol = strings.TrimSpace(asset.GetTokenSymbol())
}
if page := req.GetPage(); page != nil {

View File

@@ -3,7 +3,6 @@ package wallet
import (
"context"
"fmt"
"strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/storage/model"
@@ -24,21 +23,20 @@ func OnChainWalletBalances(ctx context.Context, deps Deps, wallet *model.Managed
return nil, nil, merrors.Internal("chain drivers not configured")
}
networkKey := strings.ToLower(strings.TrimSpace(wallet.Network))
network, ok := deps.Networks.Network(networkKey)
network, ok := deps.Networks.Network(wallet.Network)
if !ok {
logger.Warn("Requested network is not configured",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", networkKey),
zap.String("network", string(wallet.Network)),
)
return nil, 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", wallet.Network))
}
chainDriver, err := deps.Drivers.Driver(networkKey)
chainDriver, err := deps.Drivers.Driver(wallet.Network)
if err != nil {
logger.Warn("Chain driver not configured",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", networkKey),
zap.String("network", string(wallet.Network)),
zap.Error(err),
)
return nil, nil, merrors.InvalidArgument("unsupported chain")

View File

@@ -165,11 +165,13 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
OrganizationRef: orgRef,
SourceWalletRef: source,
IntentRef: strings.TrimSpace(op.GetIntentRef()),
OperationRef: op.GetOperationRef(),
Destination: dest,
Amount: amount,
Fees: parseChainFees(reader),
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
ClientReference: strings.TrimSpace(reader.String("client_reference")),
PaymentRef: strings.TrimSpace(reader.String("payment_ref")),
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
@@ -208,7 +210,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Status: connectorv1.OperationStatus_OPERATION_SUCCESS,
Result: result,
},
}, nil
@@ -238,7 +240,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Status: connectorv1.OperationStatus_OPERATION_SUCCESS,
Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), ""),
},
}, nil
@@ -256,12 +258,14 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
}
resp, err := s.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
IntentRef: strings.TrimSpace(op.GetIntentRef()),
OperationRef: strings.TrimSpace(op.GetOperationRef()),
OrganizationRef: orgRef,
SourceWalletRef: source,
TargetWalletRef: target,
EstimatedTotalFee: fee,
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
ClientReference: strings.TrimSpace(reader.String("client_reference")),
PaymentRef: strings.TrimSpace(reader.String("payment_ref")),
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
@@ -273,7 +277,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Status: shared.СhainTransferStatusToOperation(resp.GetTransfer().GetStatus()),
Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), transferRef),
},
}, nil
@@ -544,25 +548,51 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation
func chainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return connectorv1.OperationStatus_CONFIRMED
case chainv1.TransferStatus_TRANSFER_CREATED:
return connectorv1.OperationStatus_OPERATION_CREATED
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return connectorv1.OperationStatus_OPERATION_PROCESSING
case chainv1.TransferStatus_TRANSFER_WAITING:
return connectorv1.OperationStatus_OPERATION_WAITING
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return connectorv1.OperationStatus_OPERATION_SUCCESS
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_FAILED
return connectorv1.OperationStatus_OPERATION_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_CANCELED
return connectorv1.OperationStatus_OPERATION_CANCELLED
default:
return connectorv1.OperationStatus_PENDING
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}
func chainStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus {
switch status {
case connectorv1.OperationStatus_CONFIRMED:
return chainv1.TransferStatus_TRANSFER_CONFIRMED
case connectorv1.OperationStatus_FAILED:
case connectorv1.OperationStatus_OPERATION_CREATED:
return chainv1.TransferStatus_TRANSFER_CREATED
case connectorv1.OperationStatus_OPERATION_PROCESSING:
return chainv1.TransferStatus_TRANSFER_PROCESSING
case connectorv1.OperationStatus_OPERATION_WAITING:
return chainv1.TransferStatus_TRANSFER_WAITING
case connectorv1.OperationStatus_OPERATION_SUCCESS:
return chainv1.TransferStatus_TRANSFER_SUCCESS
case connectorv1.OperationStatus_OPERATION_FAILED:
return chainv1.TransferStatus_TRANSFER_FAILED
case connectorv1.OperationStatus_CANCELED:
case connectorv1.OperationStatus_OPERATION_CANCELLED:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
}

View File

@@ -47,7 +47,7 @@ func (d *Driver) NormalizeAddress(address string) (string, error) {
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
d.logger.Debug("Balance request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
)
driverDeps := deps
driverDeps.Logger = d.logger
@@ -55,13 +55,13 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
if err != nil {
d.logger.Warn("Balance failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Error(err),
)
} else if result != nil {
d.logger.Debug("Balance result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
@@ -72,7 +72,7 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
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),
zap.String("network", string(network.Name)),
)
driverDeps := deps
driverDeps.Logger = d.logger
@@ -80,13 +80,13 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
if err != nil {
d.logger.Warn("Native balance failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(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("network", string(network.Name)),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
@@ -97,7 +97,7 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
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",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.String("destination", destination),
)
driverDeps := deps
@@ -106,13 +106,13 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
if err != nil {
d.logger.Warn("Estimate fee failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Error(err),
)
} else if result != nil {
d.logger.Debug("Estimate fee result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
@@ -123,7 +123,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",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.String("destination", destination),
)
driverDeps := deps
@@ -132,13 +132,13 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s
if err != nil {
d.logger.Warn("Submit transfer failed",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Error(err),
)
} else {
d.logger.Debug("Submit transfer result",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.String("tx_hash", txHash),
)
}
@@ -148,7 +148,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",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
)
driverDeps := deps
driverDeps.Logger = d.logger
@@ -156,13 +156,13 @@ func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, networ
if err != nil {
d.logger.Warn("Await confirmation failed",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Error(err),
)
} else if receipt != nil {
d.logger.Debug("Await confirmation result",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
zap.Uint64("status", receipt.Status),
)

View File

@@ -47,7 +47,7 @@ func (d *Driver) NormalizeAddress(address string) (string, error) {
func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) {
d.logger.Debug("Balance request",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
)
driverDeps := deps
driverDeps.Logger = d.logger
@@ -55,13 +55,13 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
if err != nil {
d.logger.Warn("Balance failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Error(err),
)
} else if result != nil {
d.logger.Debug("Balance result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
@@ -72,7 +72,7 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N
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),
zap.String("network", string(network.Name)),
)
driverDeps := deps
driverDeps.Logger = d.logger
@@ -80,13 +80,13 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
if err != nil {
d.logger.Warn("Native balance failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(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("network", string(network.Name)),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
@@ -97,7 +97,7 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh
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",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.String("destination", destination),
)
driverDeps := deps
@@ -106,13 +106,13 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar
if err != nil {
d.logger.Warn("Estimate fee failed",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Error(err),
)
} else if result != nil {
d.logger.Debug("Estimate fee result",
zap.String("wallet_ref", wallet.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.String("amount", result.Amount),
zap.String("currency", result.Currency),
)
@@ -123,7 +123,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",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.String("destination", destination),
)
driverDeps := deps
@@ -132,13 +132,13 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s
if err != nil {
d.logger.Warn("Submit transfer failed",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Error(err),
)
} else {
d.logger.Debug("Submit transfer result",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.String("tx_hash", txHash),
)
}
@@ -148,7 +148,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",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
)
driverDeps := deps
driverDeps.Logger = d.logger
@@ -156,13 +156,13 @@ func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, networ
if err != nil {
d.logger.Warn("Await confirmation failed",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Error(err),
)
} else if receipt != nil {
d.logger.Debug("Await confirmation result",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
zap.Uint64("status", receipt.Status),
)

View File

@@ -74,7 +74,7 @@ func NormalizeAddress(address string) (string, error) {
func nativeCurrency(network shared.Network) string {
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
if currency == "" {
currency = strings.ToUpper(network.Name)
currency = strings.ToUpper(string(network.Name))
}
return currency
}
@@ -114,7 +114,7 @@ func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wall
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("network", strings.ToLower(strings.TrimSpace(string(network.Name)))),
zap.String("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))),
zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))),
zap.String("wallet_address", normalizedAddress),
@@ -194,7 +194,7 @@ func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network
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("network", string(network.Name)),
zap.String("wallet_address", normalizedAddress),
}
if rpcURL == "" {
@@ -260,12 +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))
logger.Warn("Failed to resolve client", zap.Error(err), zap.String("network_name", string(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))
logger.Warn("Failed to resolve RPC client", zap.Error(err), zap.String("network_name", string(network.Name)))
return nil, err
}
@@ -374,7 +374,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
}
rpcURL := strings.TrimSpace(network.RPCURL)
if rpcURL == "" {
logger.Warn("Network rpc url missing", zap.String("network", network.Name))
logger.Warn("Network rpc url missing", zap.String("network", string(network.Name)))
return "", executorInvalid("network rpc url is not configured")
}
if source == nil || transfer == nil {
@@ -397,18 +397,18 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
logger.Info("Submitting transfer",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("source_wallet_ref", source.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.String("destination", strings.ToLower(destination)),
)
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", string(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", string(network.Name)))
return "", err
}
@@ -429,7 +429,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
gasPrice, err := client.SuggestGasPrice(ctx)
if err != nil {
logger.Warn("Failed to suggest gas price", zap.Error(err),
zap.String("transfer_ref", transfer.TransferRef), zap.String("network", network.Name),
zap.String("transfer_ref", transfer.TransferRef), zap.String("network", string(network.Name)),
)
return "", executorInternal("failed to suggest gas price", err)
}
@@ -532,7 +532,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ
txHash := signedTx.Hash().Hex()
logger.Info("Transaction submitted", zap.String("transfer_ref", transfer.TransferRef),
zap.String("tx_hash", txHash), zap.String("network", network.Name),
zap.String("tx_hash", txHash), zap.String("network", string(network.Name)),
)
return txHash, nil
@@ -544,7 +544,7 @@ func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Net
registry := deps.Registry
if strings.TrimSpace(txHash) == "" {
logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name))
logger.Warn("Missing transaction hash for confirmation", zap.String("network", string(network.Name)))
return nil, executorInvalid("tx hash is required")
}
rpcURL := strings.TrimSpace(network.RPCURL)
@@ -572,23 +572,23 @@ func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Net
select {
case <-ticker.C:
logger.Debug("Transaction not yet mined", zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
)
continue
case <-ctx.Done():
logger.Warn("Context cancelled while awaiting confirmation", zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
)
return nil, ctx.Err()
}
}
logger.Warn("Failed to fetch transaction receipt", zap.Error(err),
zap.String("tx_hash", txHash), zap.String("network", network.Name),
zap.String("tx_hash", txHash), zap.String("network", string(network.Name)),
)
return nil, executorInternal("failed to fetch transaction receipt", err)
}
logger.Info("Transaction confirmed", zap.String("tx_hash", txHash),
zap.String("network", network.Name), zap.Uint64("status", receipt.Status),
zap.String("network", string(network.Name)), zap.Uint64("status", receipt.Status),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
)
return receipt, nil
@@ -654,12 +654,6 @@ type gasEstimator interface {
}
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)
}
@@ -702,10 +696,6 @@ func tronEstimateCall(callMsg ethereum.CallMsg) map[string]string {
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 {

View File

@@ -29,9 +29,6 @@ func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estima
}
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))
}

View File

@@ -2,7 +2,6 @@ package drivers
import (
"fmt"
"strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/arbitrum"
@@ -10,12 +9,13 @@ import (
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
pmodel "github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
// Registry maps configured network keys to chain drivers.
type Registry struct {
byNetwork map[string]driver.Driver
byNetwork map[pmodel.ChainNetwork]driver.Driver
}
// NewRegistry selects drivers for the configured networks.
@@ -23,18 +23,14 @@ func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, e
if logger == nil {
return nil, merrors.InvalidArgument("driver registry: logger is required")
}
result := &Registry{byNetwork: map[string]driver.Driver{}}
result := &Registry{byNetwork: map[pmodel.ChainNetwork]driver.Driver{}}
for _, network := range networks {
name := strings.ToLower(strings.TrimSpace(network.Name))
if name == "" {
continue
}
chainDriver, err := resolveDriver(logger, name)
chainDriver, err := resolveDriver(logger, network.Name)
if err != nil {
logger.Error("Unsupported chain driver", zap.String("network", name), zap.Error(err))
logger.Error("Unsupported chain driver", zap.String("network", string(network.Name)), zap.Error(err))
return nil, err
}
result.byNetwork[name] = chainDriver
result.byNetwork[network.Name] = chainDriver
}
if len(result.byNetwork) == 0 {
return nil, merrors.InvalidArgument("driver registry: no supported networks configured")
@@ -44,30 +40,25 @@ func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, e
}
// Driver resolves a driver for the provided network key.
func (r *Registry) Driver(network string) (driver.Driver, error) {
func (r *Registry) Driver(network pmodel.ChainNetwork) (driver.Driver, error) {
if r == nil || len(r.byNetwork) == 0 {
return nil, merrors.Internal("driver registry is not configured")
}
key := strings.ToLower(strings.TrimSpace(network))
if key == "" {
return nil, merrors.InvalidArgument("network is required")
}
chainDriver, ok := r.byNetwork[key]
chainDriver, ok := r.byNetwork[network]
if !ok {
return nil, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", key))
return nil, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", network))
}
return chainDriver, nil
}
func resolveDriver(logger mlogger.Logger, network string) (driver.Driver, error) {
switch {
case strings.HasPrefix(network, "tron"):
return nil, merrors.InvalidArgument("tron networks must use the tron gateway, not chain gateway")
case strings.HasPrefix(network, "arbitrum"):
func resolveDriver(logger mlogger.Logger, network pmodel.ChainNetwork) (driver.Driver, error) {
switch network {
case pmodel.ChainNetworkArbitrumOne:
case pmodel.ChainNetworkArbitrumSepolia:
return arbitrum.New(logger), nil
case strings.HasPrefix(network, "ethereum"):
case pmodel.ChainNetworkEthereumMainnet:
return ethereum.New(logger), nil
default:
return nil, merrors.InvalidArgument("unsupported chain network " + network)
}
return nil, merrors.InvalidArgument("unsupported chain network " + string(network))
}

View File

@@ -52,7 +52,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
}
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", string(network.Name)))
return "", executorInvalid("network rpc url is not configured")
}
if source == nil || transfer == nil {
@@ -75,19 +75,19 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
o.logger.Info("Submitting transfer",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("source_wallet_ref", source.WalletRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.String("destination", strings.ToLower(destinationAddress)),
)
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", string(network.Name)))
return "", err
}
rpcClient, err := o.clients.RPCClient(network.Name)
if err != nil {
o.logger.Warn("Failed to initialise RPC client",
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Error(err),
)
return "", err
@@ -112,7 +112,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
if err != nil {
o.logger.Warn("Failed to suggest gas price",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Error(err),
)
return "", executorInternal("failed to suggest gas price", err)
@@ -206,7 +206,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr
o.logger.Info("Transaction submitted",
zap.String("transfer_ref", transfer.TransferRef),
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
)
return txHash, nil
@@ -214,7 +214,7 @@ 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", string(network.Name)))
return nil, executorInvalid("tx hash is required")
}
rpcURL := strings.TrimSpace(network.RPCURL)
@@ -240,27 +240,27 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.
case <-ticker.C:
o.logger.Debug("Transaction not yet mined",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
)
continue
case <-ctx.Done():
o.logger.Warn("Context cancelled while awaiting confirmation",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
)
return nil, ctx.Err()
}
}
o.logger.Warn("Failed to fetch transaction receipt",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Error(err),
)
return nil, executorInternal("failed to fetch transaction receipt", err)
}
o.logger.Info("Transaction confirmed",
zap.String("tx_hash", txHash),
zap.String("network", network.Name),
zap.String("network", string(network.Name)),
zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
zap.Uint64("status", receipt.Status),
)

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
clockpkg "github.com/tech/sendico/pkg/clock"
pmodel "github.com/tech/sendico/pkg/model"
)
// Option configures the Service.
@@ -34,7 +35,7 @@ func WithNetworks(networks []shared.Network) Option {
return
}
if s.networks == nil {
s.networks = make(map[string]shared.Network, len(networks))
s.networks = make(map[pmodel.ChainNetwork]shared.Network, len(networks))
}
for _, network := range networks {
if network.Name == "" {
@@ -48,7 +49,7 @@ func WithNetworks(networks []shared.Network) Option {
clone.TokenConfigs[i].Symbol = strings.ToUpper(strings.TrimSpace(clone.TokenConfigs[i].Symbol))
clone.TokenConfigs[i].ContractAddress = strings.ToLower(strings.TrimSpace(clone.TokenConfigs[i].ContractAddress))
}
clone.Name = strings.ToLower(strings.TrimSpace(clone.Name))
clone.Name = clone.Name
s.networks[clone.Name] = clone
}
}

View File

@@ -14,13 +14,14 @@ import (
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
pmodel "github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
// Clients holds pre-initialised RPC clients keyed by network name.
type Clients struct {
logger mlogger.Logger
clients map[string]clientEntry
clients map[pmodel.ChainNetwork]clientEntry
}
type clientEntry struct {
@@ -36,25 +37,20 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
clientLogger := logger.Named("rpc_client")
result := &Clients{
logger: clientLogger,
clients: make(map[string]clientEntry),
clients: make(map[pmodel.ChainNetwork]clientEntry),
}
for _, network := range networks {
name := strings.ToLower(strings.TrimSpace(network.Name))
rpcURL := strings.TrimSpace(network.RPCURL)
if name == "" {
clientLogger.Warn("Skipping network with empty name during rpc client preparation")
continue
}
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))
err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", network.Name))
clientLogger.Warn("Rpc url missing", zap.String("network", string(network.Name)))
return nil, err
}
fields := []zap.Field{
zap.String("network", name),
zap.String("network", string(network.Name)),
}
clientLogger.Info("Initialising rpc client", fields...)
@@ -62,7 +58,7 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
httpClient := &http.Client{
Transport: &loggingRoundTripper{
logger: clientLogger,
network: name,
network: network.Name,
endpoint: rpcURL,
base: http.DefaultTransport,
},
@@ -72,10 +68,10 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo
if err != nil {
result.Close()
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()))
return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", network.Name, err.Error()))
}
client := ethclient.NewClient(rpcCli)
result.clients[name] = clientEntry{
result.clients[network.Name] = clientEntry{
eth: client,
rpc: rpcCli,
}
@@ -93,27 +89,25 @@ 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) {
func (c *Clients) Client(network pmodel.ChainNetwork) (*ethclient.Client, error) {
if c == nil {
return nil, merrors.Internal("RPC clients not initialised")
}
name := strings.ToLower(strings.TrimSpace(network))
entry, ok := c.clients[name]
entry, ok := c.clients[network]
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", network))
}
return entry.eth, nil
}
// RPCClient returns the raw RPC client for low-level calls.
func (c *Clients) RPCClient(network string) (*rpc.Client, error) {
func (c *Clients) RPCClient(network pmodel.ChainNetwork) (*rpc.Client, error) {
if c == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
name := strings.ToLower(strings.TrimSpace(network))
entry, ok := c.clients[name]
entry, ok := c.clients[network]
if !ok || entry.rpc == 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", network))
}
return entry.rpc, nil
}
@@ -130,14 +124,14 @@ 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", string(name)))
}
}
}
type loggingRoundTripper struct {
logger mlogger.Logger
network string
network pmodel.ChainNetwork
endpoint string
base http.RoundTripper
}
@@ -155,7 +149,7 @@ func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
}
fields := []zap.Field{
zap.String("network", l.network),
zap.String("network", string(l.network)),
}
if len(reqBody) > 0 {
fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048)))

View File

@@ -1,22 +1,21 @@
package rpcclient
import (
"strings"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/ethereum/go-ethereum/rpc"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
)
// Registry binds static network metadata with prepared RPC clients.
type Registry struct {
networks map[string]shared.Network
networks map[pmodel.ChainNetwork]shared.Network
clients *Clients
}
// NewRegistry constructs a registry keyed by lower-cased network name.
func NewRegistry(networks map[string]shared.Network, clients *Clients) *Registry {
func NewRegistry(networks map[pmodel.ChainNetwork]shared.Network, clients *Clients) *Registry {
return &Registry{
networks: networks,
clients: clients,
@@ -24,31 +23,31 @@ func NewRegistry(networks map[string]shared.Network, clients *Clients) *Registry
}
// Network fetches network metadata by key (case-insensitive).
func (r *Registry) Network(key string) (shared.Network, bool) {
func (r *Registry) Network(key pmodel.ChainNetwork) (shared.Network, bool) {
if r == nil || len(r.networks) == 0 {
return shared.Network{}, false
}
n, ok := r.networks[strings.ToLower(strings.TrimSpace(key))]
n, ok := r.networks[key]
return n, ok
}
// Client returns the prepared RPC client for the given network name.
func (r *Registry) Client(key string) (*ethclient.Client, error) {
func (r *Registry) Client(key pmodel.ChainNetwork) (*ethclient.Client, error) {
if r == nil || r.clients == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
return r.clients.Client(strings.ToLower(strings.TrimSpace(key)))
return r.clients.Client(key)
}
// RPCClient returns the raw RPC client for low-level calls.
func (r *Registry) RPCClient(key string) (*rpc.Client, error) {
func (r *Registry) RPCClient(key pmodel.ChainNetwork) (*rpc.Client, error) {
if r == nil || r.clients == nil {
return nil, merrors.Internal("rpc clients not initialised")
}
return r.clients.RPCClient(strings.ToLower(strings.TrimSpace(key)))
return r.clients.RPCClient(key)
}
// Networks exposes the registry map for iteration when needed.
func (r *Registry) Networks() map[string]shared.Network {
func (r *Registry) Networks() map[pmodel.ChainNetwork]shared.Network {
return r.networks
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/tech/sendico/pkg/discovery"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
@@ -43,7 +44,7 @@ type Service struct {
settings CacheSettings
networks map[string]shared.Network
networks map[pmodel.ChainNetwork]shared.Network
serviceWallet shared.ServiceWallet
keyManager keymanager.Manager
rpcClients *rpcclient.Clients
@@ -64,7 +65,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
producer: producer,
clock: clockpkg.System{},
settings: defaultSettings(),
networks: map[string]shared.Network{},
networks: map[pmodel.ChainNetwork]shared.Network{},
}
initMetrics()
@@ -79,7 +80,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
svc.clock = clockpkg.System{}
}
if svc.networks == nil {
svc.networks = map[string]shared.Network{}
svc.networks = map[pmodel.ChainNetwork]shared.Network{}
}
svc.settings = svc.settings.withDefaults()
svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients)
@@ -207,7 +208,7 @@ func (s *Service) startDiscoveryAnnouncers() {
announce := discovery.Announcement{
Service: "CRYPTO_RAIL_GATEWAY",
Rail: "CRYPTO",
Network: network.Name,
Network: string(network.Name),
Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"},
Currencies: currencies,
InvokeURI: s.invokeURI,

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/stretchr/testify/require"
pmodel "github.com/tech/sendico/pkg/model"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
ichainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -170,6 +171,7 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) {
Amount: &moneyv1.Money{Currency: "USDC", Amount: "5"},
},
},
IntentRef: "intent-1",
})
require.NoError(t, err)
require.NotNil(t, transferResp.GetTransfer())
@@ -177,7 +179,7 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) {
stored := repo.transfers.get(transferResp.GetTransfer().GetTransferRef())
require.NotNil(t, stored)
require.Equal(t, model.TransferStatusPending, stored.Status)
require.Equal(t, model.TransferStatusCreated, stored.Status)
// GetTransfer
getResp, err := svc.GetTransfer(ctx, &ichainv1.GetTransferRequest{TransferRef: stored.TransferRef})
@@ -335,7 +337,7 @@ func (w *inMemoryWallets) List(ctx context.Context, filter model.ManagedWalletFi
continue
}
}
if filter.Network != "" && !strings.EqualFold(wallet.Network, filter.Network) {
if wallet.Network != filter.Network {
continue
}
if filter.TokenSymbol != "" && !strings.EqualFold(wallet.TokenSymbol, filter.TokenSymbol) {
@@ -644,9 +646,9 @@ func newTestService(t *testing.T) (*Service, *inMemoryRepository) {
type fakeKeyManager struct{}
func (f *fakeKeyManager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) {
func (f *fakeKeyManager) CreateManagedWalletKey(ctx context.Context, walletRef string, network pmodel.ChainNetwork) (*keymanager.ManagedWalletKey, error) {
return &keymanager.ManagedWalletKey{
KeyID: fmt.Sprintf("%s/%s", strings.ToLower(network), walletRef),
KeyID: fmt.Sprintf("%s/%s", network, walletRef),
Address: "0x" + strings.Repeat("a", 40),
PublicKey: strings.Repeat("b", 128),
}, nil

View File

@@ -1,6 +1,10 @@
package shared
import "github.com/shopspring/decimal"
import (
"github.com/shopspring/decimal"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
// GasTopUpRule defines buffer, minimum, rounding, and cap behavior for native gas top-ups.
type GasTopUpRule struct {
@@ -30,3 +34,20 @@ func (p *GasTopUpPolicy) Rule(contractTransfer bool) (GasTopUpRule, bool) {
}
return p.Default, true
}
func СhainTransferStatusToOperation(ts chainv1.TransferStatus) connectorv1.OperationStatus {
switch ts {
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_OPERATION_CANCELLED
case chainv1.TransferStatus_TRANSFER_CREATED:
return connectorv1.OperationStatus_OPERATION_CREATED
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_OPERATION_FAILED
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return connectorv1.OperationStatus_OPERATION_SUCCESS
case chainv1.TransferStatus_TRANSFER_WAITING:
return connectorv1.OperationStatus_OPERATION_WAITING
default:
}
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}

View File

@@ -4,8 +4,10 @@ import (
"strings"
"github.com/tech/sendico/gateway/chain/storage/model"
chainasset "github.com/tech/sendico/pkg/chain"
pmodel "github.com/tech/sendico/pkg/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -49,16 +51,38 @@ func GenerateTransferRef() string {
return bson.NewObjectID().Hex()
}
func ChainKeyFromEnum(chain chainv1.ChainNetwork) (string, chainv1.ChainNetwork) {
if name, ok := chainv1.ChainNetwork_name[int32(chain)]; ok {
key := strings.ToLower(strings.TrimPrefix(name, "CHAIN_NETWORK_"))
return key, chain
func ChainKeyFromEnum(chain chainv1.ChainNetwork) pmodel.ChainNetwork {
switch chain {
case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE:
return pmodel.ChainNetworkArbitrumOne
case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_SEPOLIA:
return pmodel.ChainNetworkArbitrumSepolia
case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET:
return pmodel.ChainNetworkEthereumMainnet
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET:
return pmodel.ChainNetworkTronMainnet
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE:
return pmodel.ChainNetworkTronNile
default:
return pmodel.ChainNetworkUnspecified
}
return "", chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
func ChainEnumFromName(name string) chainv1.ChainNetwork {
return chainasset.NetworkFromString(name)
func ChainEnumFromName(name pmodel.ChainNetwork) chainv1.ChainNetwork {
switch name {
case pmodel.ChainNetworkArbitrumOne:
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE
case pmodel.ChainNetworkArbitrumSepolia:
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_SEPOLIA
case pmodel.ChainNetworkEthereumMainnet:
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET
case pmodel.ChainNetworkTronMainnet:
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET
case pmodel.ChainNetworkTronNile:
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE
default:
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
}
func ManagedWalletStatusToProto(status model.ManagedWalletStatus) chainv1.ManagedWalletStatus {
@@ -76,54 +100,94 @@ func ManagedWalletStatusToProto(status model.ManagedWalletStatus) chainv1.Manage
func TransferStatusToModel(status chainv1.TransferStatus) model.TransferStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_PENDING:
return model.TransferStatusPending
case chainv1.TransferStatus_TRANSFER_SIGNING:
return model.TransferStatusSigning
case chainv1.TransferStatus_TRANSFER_SUBMITTED:
return model.TransferStatusSubmitted
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return model.TransferStatusConfirmed
case chainv1.TransferStatus_TRANSFER_CREATED:
return model.TransferStatusCreated
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return model.TransferStatusProcessing
case chainv1.TransferStatus_TRANSFER_WAITING:
return model.TransferStatusWaiting
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return model.TransferStatusSuccess
case chainv1.TransferStatus_TRANSFER_FAILED:
return model.TransferStatusFailed
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return model.TransferStatusCancelled
default:
return ""
return model.TransferStatus("")
}
}
func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
switch status {
case model.TransferStatusPending:
return chainv1.TransferStatus_TRANSFER_PENDING
case model.TransferStatusSigning:
return chainv1.TransferStatus_TRANSFER_SIGNING
case model.TransferStatusSubmitted:
return chainv1.TransferStatus_TRANSFER_SUBMITTED
case model.TransferStatusConfirmed:
return chainv1.TransferStatus_TRANSFER_CONFIRMED
case model.TransferStatusCreated:
return chainv1.TransferStatus_TRANSFER_CREATED
case model.TransferStatusProcessing:
return chainv1.TransferStatus_TRANSFER_PROCESSING
case model.TransferStatusWaiting:
return chainv1.TransferStatus_TRANSFER_WAITING
case model.TransferStatusSuccess:
return chainv1.TransferStatus_TRANSFER_SUCCESS
case model.TransferStatusFailed:
return chainv1.TransferStatus_TRANSFER_FAILED
case model.TransferStatusCancelled:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
}
}
func ChainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CREATED:
return connectorv1.OperationStatus_OPERATION_CREATED
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return connectorv1.OperationStatus_OPERATION_PROCESSING
case chainv1.TransferStatus_TRANSFER_WAITING:
return connectorv1.OperationStatus_OPERATION_WAITING
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return connectorv1.OperationStatus_OPERATION_SUCCESS
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_OPERATION_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_OPERATION_CANCELLED
default:
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}
// 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))
currency = strings.ToUpper(network.Name.String())
}
return currency
}
// Network describes a supported blockchain network and known token contracts.
type Network struct {
Name string
Name pmodel.ChainNetwork
RPCURL string
ChainID uint64
NativeToken string
@@ -139,7 +203,27 @@ type TokenContract struct {
// ServiceWallet captures the managed service wallet configuration.
type ServiceWallet struct {
Network string
Network pmodel.ChainNetwork
Address string
PrivateKey string
}
func ProtoToMoney(money *moneyv1.Money) *paymenttypes.Money {
if money == nil {
return &paymenttypes.Money{}
}
return &paymenttypes.Money{
Amount: money.GetAmount(),
Currency: money.GetCurrency(),
}
}
func MonenyToProto(money *paymenttypes.Money) *moneyv1.Money {
if money == nil {
return &moneyv1.Money{}
}
return &moneyv1.Money{
Amount: money.Amount,
Currency: money.Currency,
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
@@ -40,35 +41,35 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
return err
}
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil {
if _, err := s.updateTransferStatus(ctx, transferRef, model.TransferStatusProcessing, "", ""); err != nil {
s.logger.Warn("Failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
}
driverDeps := s.driverDeps()
chainDriver, err := s.driverForNetwork(network.Name)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
_, _ = s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
_, _ = s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
sourceAddress, err := chainDriver.NormalizeAddress(sourceWallet.DepositAddress)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
_, _ = s.updateTransferStatus(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),
zap.String("network", string(network.Name)),
)
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", ""); err != nil {
if _, err := s.updateTransferStatus(ctx, transferRef, model.TransferStatusSuccess, "", ""); err != nil {
s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
}
return nil
@@ -76,11 +77,14 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
s.logger.Warn("Failed to submit transfer", zap.String("transfer_ref", transferRef), zap.Error(err))
if _, e := s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), ""); e != nil {
s.logger.Warn("Failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(e))
}
return err
}
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil {
if _, err := s.updateTransferStatus(ctx, transferRef, model.TransferStatusWaiting, "", txHash); err != nil {
s.logger.Warn("Failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err))
}
@@ -94,15 +98,15 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
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))
}
return nil
failureReason := ""
pStatus := model.TransferStatusSuccess
if receipt != nil && receipt.Status != types.ReceiptStatusSuccessful {
failureReason = "transaction reverted"
pStatus = model.TransferStatusFailed
}
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))
if _, err := s.updateTransferStatus(ctx, transferRef, pStatus, failureReason, txHash); err != nil {
s.logger.Warn("Failed to update transfer status", zap.Error(err),
zap.String("transfer_ref", transferRef), zap.String("status", string(pStatus)))
}
return nil
}
@@ -133,7 +137,7 @@ func (s *Service) driverDeps() driver.Deps {
}
}
func (s *Service) driverForNetwork(network string) (driver.Driver, error) {
func (s *Service) driverForNetwork(network pmodel.ChainNetwork) (driver.Driver, error) {
if s.drivers == nil {
return nil, merrors.Internal("chain drivers not configured")
}

View File

@@ -0,0 +1,77 @@
package gateway
import (
"context"
"fmt"
"github.com/tech/sendico/gateway/chain/storage/model"
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/payments/rail"
"go.uber.org/zap"
)
func isFinalStatus(t *model.Transfer) bool {
switch t.Status {
case model.TransferStatusFailed, model.TransferStatusSuccess, model.TransferStatusCancelled:
return true
default:
return false
}
}
func toOpStatus(t *model.Transfer) rail.OperationResult {
switch t.Status {
case model.TransferStatusFailed:
return rail.OperationResultFailed
case model.TransferStatusSuccess:
return rail.OperationResultSuccess
case model.TransferStatusCancelled:
return rail.OperationResultCancelled
default:
panic(fmt.Sprintf("toOpStatus: unexpected transfer status: %s", t.Status))
}
}
func toError(t *model.Transfer) string {
if t == nil {
return ""
}
if t.Status == model.TransferStatusSuccess {
return ""
}
return t.FailureReason
}
func (s *Service) updateTransferStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason, txHash string) (*model.Transfer, error) {
transfer, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, status, failureReason, txHash)
if err != nil {
s.logger.Warn("Failed to update transfer status", zap.String("transfer_ref", transferRef), zap.String("status", string(status)), zap.Error(err))
}
if isFinalStatus(transfer) {
s.emitTransferStatusEvent(transfer)
}
return transfer, err
}
func (s *Service) emitTransferStatusEvent(transfer *model.Transfer) {
if s == nil || s.producer == nil || transfer == nil {
return
}
exec := pmodel.PaymentGatewayExecution{
PaymentIntentID: transfer.IntentRef,
IdempotencyKey: transfer.IdempotencyKey,
ExecutedMoney: transfer.NetAmount,
PaymentRef: transfer.PaymentRef,
Status: toOpStatus(transfer),
OperationRef: transfer.OperationRef,
Error: toError(transfer),
TransferRef: transfer.TransferRef,
}
env := paymentgateway.PaymentGatewayExecution(mservice.ChainGateway, &exec)
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish transfer status event", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
}
}

View File

@@ -5,19 +5,22 @@ import (
"time"
"github.com/tech/sendico/pkg/db/storable"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
paytypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
type TransferStatus string
const (
TransferStatusPending TransferStatus = "pending"
TransferStatusSigning TransferStatus = "signing"
TransferStatusSubmitted TransferStatus = "submitted"
TransferStatusConfirmed TransferStatus = "confirmed"
TransferStatusFailed TransferStatus = "failed"
TransferStatusCancelled TransferStatus = "cancelled"
TransferStatusCreated TransferStatus = "created" // record exists, not started
TransferStatusProcessing TransferStatus = "processing" // we are working on it
TransferStatusWaiting TransferStatus = "waiting" // waiting external world
TransferStatusSuccess TransferStatus = "success" // final success
TransferStatusFailed TransferStatus = "failed" // final failure
TransferStatusCancelled TransferStatus = "cancelled" // final cancelled
)
// ServiceFee represents a fee component applied to a transfer.
@@ -38,21 +41,23 @@ type TransferDestination struct {
type Transfer struct {
storable.Base `bson:",inline" json:",inline"`
OperationRef string `bson:"operationRef" json:"operationRef"`
TransferRef string `bson:"transferRef" json:"transferRef"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
IdempotencyKey string `bson:"intentRef" json:"intentRef"`
IntentRef string `bson:"idempotencyKey" json:"idempotencyKey"`
OrganizationRef string `bson:"organizationRef" json:"organizationRef"`
SourceWalletRef string `bson:"sourceWalletRef" json:"sourceWalletRef"`
Destination TransferDestination `bson:"destination" json:"destination"`
Network string `bson:"network" json:"network"`
Network pmodel.ChainNetwork `bson:"network" json:"network"`
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"`
ContractAddress string `bson:"contractAddress" json:"contractAddress"`
RequestedAmount *moneyv1.Money `bson:"requestedAmount" json:"requestedAmount"`
NetAmount *moneyv1.Money `bson:"netAmount" json:"netAmount"`
RequestedAmount *paytypes.Money `bson:"requestedAmount" json:"requestedAmount"`
NetAmount *paytypes.Money `bson:"netAmount" json:"netAmount"`
Fees []ServiceFee `bson:"fees,omitempty" json:"fees,omitempty"`
Status TransferStatus `bson:"status" json:"status"`
TxHash string `bson:"txHash,omitempty" json:"txHash,omitempty"`
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
ClientReference string `bson:"clientReference,omitempty" json:"clientReference,omitempty"`
PaymentRef string `bson:"paymentRef,omitempty" json:"paymentRef,omitempty"`
LastStatusAt time.Time `bson:"lastStatusAt" json:"lastStatusAt"`
}
@@ -82,12 +87,11 @@ func (t *Transfer) Normalize() {
t.IdempotencyKey = strings.TrimSpace(t.IdempotencyKey)
t.OrganizationRef = strings.TrimSpace(t.OrganizationRef)
t.SourceWalletRef = strings.TrimSpace(t.SourceWalletRef)
t.Network = strings.TrimSpace(strings.ToLower(t.Network))
t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol))
t.ContractAddress = strings.TrimSpace(strings.ToLower(t.ContractAddress))
t.Destination.ManagedWalletRef = strings.TrimSpace(t.Destination.ManagedWalletRef)
t.Destination.ExternalAddress = 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)
t.PaymentRef = strings.TrimSpace(t.PaymentRef)
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/pkg/db/storable"
pkgmodel "github.com/tech/sendico/pkg/model"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
@@ -23,17 +24,17 @@ type ManagedWallet struct {
storable.Base `bson:",inline" json:",inline"`
pkgmodel.Describable `bson:",inline" json:",inline"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
WalletRef string `bson:"walletRef" json:"walletRef"`
OrganizationRef string `bson:"organizationRef" json:"organizationRef"`
OwnerRef string `bson:"ownerRef" json:"ownerRef"`
Network string `bson:"network" json:"network"`
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"`
ContractAddress string `bson:"contractAddress" json:"contractAddress"`
DepositAddress string `bson:"depositAddress" json:"depositAddress"`
KeyReference string `bson:"keyReference,omitempty" json:"keyReference,omitempty"`
Status ManagedWalletStatus `bson:"status" json:"status"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
WalletRef string `bson:"walletRef" json:"walletRef"`
OrganizationRef string `bson:"organizationRef" json:"organizationRef"`
OwnerRef string `bson:"ownerRef" json:"ownerRef"`
Network pkgmodel.ChainNetwork `bson:"network" json:"network"`
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"`
ContractAddress string `bson:"contractAddress" json:"contractAddress"`
DepositAddress string `bson:"depositAddress" json:"depositAddress"`
KeyReference string `bson:"keyReference,omitempty" json:"keyReference,omitempty"`
Status ManagedWalletStatus `bson:"status" json:"status"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
}
// Collection implements storable.Storable.
@@ -66,7 +67,7 @@ type ManagedWalletFilter struct {
// - pointer to empty string: filter for wallets where owner_ref is empty
// - pointer to a value: filter for wallets where owner_ref matches
OwnerRefFilter *string
Network string
Network pmodel.ChainNetwork
TokenSymbol string
Cursor string
Limit int32
@@ -93,7 +94,6 @@ func (m *ManagedWallet) Normalize() {
m.Description = &desc
}
}
m.Network = strings.TrimSpace(strings.ToLower(m.Network))
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))
m.DepositAddress = normalizeWalletAddress(m.DepositAddress)

View File

@@ -79,7 +79,7 @@ func (t *Transfers) Create(ctx context.Context, transfer *model.Transfer) (*mode
return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey")
}
if transfer.Status == "" {
transfer.Status = model.TransferStatusPending
transfer.Status = model.TransferStatusCreated
}
if transfer.LastStatusAt.IsZero() {
transfer.LastStatusAt = time.Now().UTC()

View File

@@ -111,7 +111,7 @@ func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*mod
fields = append(fields, zap.String("owner_ref", wallet.OwnerRef))
}
if wallet.Network != "" {
fields = append(fields, zap.String("network", wallet.Network))
fields = append(fields, zap.String("network", string(wallet.Network)))
}
if wallet.TokenSymbol != "" {
fields = append(fields, zap.String("token_symbol", wallet.TokenSymbol))
@@ -161,11 +161,7 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*
query = query.Filter(repository.Field("ownerRef"), ownerRef)
fields = append(fields, zap.String("owner_ref_filter", ownerRef))
}
if network := strings.TrimSpace(filter.Network); network != "" {
normalized := strings.ToLower(network)
query = query.Filter(repository.Field("network"), normalized)
fields = append(fields, zap.String("network", normalized))
}
fields = append(fields, zap.String("network", string(filter.Network)))
if token := strings.TrimSpace(filter.TokenSymbol); token != "" {
normalized := strings.ToUpper(token)
query = query.Filter(repository.Field("tokenSymbol"), normalized)

View File

@@ -7,7 +7,7 @@ import (
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/model/account_role"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
@@ -179,14 +179,14 @@ func setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[strin
if op == nil || len(metadata) == 0 {
return
}
if raw := strings.TrimSpace(metadata[pmodel.MetadataKeyFromRole]); raw != "" {
if role, ok := pmodel.Parse(raw); ok && role != "" {
op.FromRole = pmodel.ToProto(role)
if raw := strings.TrimSpace(metadata[account_role.MetadataKeyFromRole]); raw != "" {
if role, ok := account_role.Parse(raw); ok && role != "" {
op.FromRole = account_role.ToProto(role)
}
}
if raw := strings.TrimSpace(metadata[pmodel.MetadataKeyToRole]); raw != "" {
if role, ok := pmodel.Parse(raw); ok && role != "" {
op.ToRole = pmodel.ToProto(role)
if raw := strings.TrimSpace(metadata[account_role.MetadataKeyToRole]); raw != "" {
if role, ok := account_role.Parse(raw); ok && role != "" {
op.ToRole = account_role.ToProto(role)
}
}
}
@@ -298,12 +298,22 @@ func minorFromMoney(m *moneyv1.Money) int64 {
func payoutStatusFromOperation(status connectorv1.OperationStatus) mntxv1.PayoutStatus {
switch status {
case connectorv1.OperationStatus_CONFIRMED:
return mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED
case connectorv1.OperationStatus_FAILED:
case connectorv1.OperationStatus_OPERATION_CREATED:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED
case connectorv1.OperationStatus_OPERATION_WAITING:
return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING
case connectorv1.OperationStatus_OPERATION_SUCCESS:
return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
case connectorv1.OperationStatus_OPERATION_FAILED:
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
case connectorv1.OperationStatus_PENDING, connectorv1.OperationStatus_SUBMITTED:
return mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
case connectorv1.OperationStatus_OPERATION_CANCELLED:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED
default:
return mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED
}

View File

@@ -11,6 +11,17 @@ grpc:
metrics:
address: ":9404"
database:
driver: mongodb
settings:
host_env: MNTX_GATEWAY_MONGO_HOST
port_env: MNTX_GATEWAY_MONGO_PORT
database_env: MNTX_GATEWAY_MONGO_DATABASE
user_env: MNTX_GATEWAY_MONGO_USER
password_env: MNTX_GATEWAY_MONGO_PASSWORD
auth_source_env: MNTX_GATEWAY_MONGO_AUTH_SOURCE
replica_set_env: MNTX_GATEWAY_MONGO_REPLICA_SET
messaging:
driver: NATS
settings:

View File

@@ -11,6 +11,17 @@ grpc:
metrics:
address: ":9404"
database:
driver: mongodb
settings:
host_env: MNTX_GATEWAY_MONGO_HOST
port_env: MNTX_GATEWAY_MONGO_PORT
database_env: MNTX_GATEWAY_MONGO_DATABASE
user_env: MNTX_GATEWAY_MONGO_USER
password_env: MNTX_GATEWAY_MONGO_PASSWORD
auth_source_env: MNTX_GATEWAY_MONGO_AUTH_SOURCE
replica_set_env: MNTX_GATEWAY_MONGO_REPLICA_SET
messaging:
driver: NATS
settings:

View File

@@ -9,6 +9,7 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver/v2 v2.5.0
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.78.0
google.golang.org/protobuf v1.36.11
@@ -40,7 +41,6 @@ require (
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.47.0 // indirect
@@ -48,5 +48,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
)

View File

@@ -210,8 +210,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -15,7 +15,10 @@ import (
"github.com/tech/sendico/gateway/mntx/internal/appversion"
mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/gateway/mntx/storage"
gatewaymongo "github.com/tech/sendico/gateway/mntx/storage/mongo"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
@@ -31,7 +34,7 @@ type Imp struct {
debug bool
config *config
app *grpcapp.App[struct{}]
app *grpcapp.App[storage.Repository]
http *http.Server
service *mntxservice.Service
}
@@ -183,7 +186,7 @@ func (i *Imp) Start() error {
zap.Int64("max_body_bytes", callbackCfg.MaxBodyBytes),
)
serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) {
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
invokeURI := ""
if cfg.GRPC != nil {
invokeURI = cfg.GRPC.DiscoveryInvokeURI()
@@ -194,6 +197,7 @@ func (i *Imp) Start() error {
mntxservice.WithMonetixConfig(monetixCfg),
mntxservice.WithGatewayDescriptor(gatewayDescriptor),
mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}),
mntxservice.WithStorage(repo),
)
i.service = svc
@@ -204,7 +208,11 @@ func (i *Imp) Start() error {
return svc, nil
}
app, err := grpcapp.NewApp(i.logger, "monetix", cfg.Config, i.debug, nil, serviceFactory)
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
return gatewaymongo.New(logger, conn)
}
app, err := grpcapp.NewApp(i.logger, "monetix", cfg.Config, i.debug, repoFactory, serviceFactory)
if err != nil {
return err
}

View File

@@ -92,10 +92,10 @@ func mapCallbackToState(clock clockpkg.Clock, cfg monetix.Config, cb monetixCall
internalStatus := mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
if status == cfg.SuccessStatus() && opStatus == cfg.SuccessStatus() && (code == "" || code == "0") {
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
outcome = monetix.OutcomeSuccess
} else if status == cfg.ProcessingStatus() || opStatus == cfg.ProcessingStatus() {
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING
outcome = monetix.OutcomeProcessing
}

View File

@@ -51,7 +51,7 @@ func TestMapCallbackToState_StatusMapping(t *testing.T) {
paymentStatus: "success",
operationStatus: "success",
code: "0",
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED,
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS,
expectedOutcome: monetix.OutcomeSuccess,
},
{
@@ -59,7 +59,7 @@ func TestMapCallbackToState_StatusMapping(t *testing.T) {
paymentStatus: "processing",
operationStatus: "success",
code: "",
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING,
expectedOutcome: monetix.OutcomeProcessing,
},
{

View File

@@ -1,55 +0,0 @@
package gateway
import (
"strings"
"sync"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/proto"
)
type cardPayoutStore struct {
mu sync.RWMutex
payouts map[string]*mntxv1.CardPayoutState
}
func newCardPayoutStore() *cardPayoutStore {
return &cardPayoutStore{
payouts: make(map[string]*mntxv1.CardPayoutState),
}
}
func (s *cardPayoutStore) Save(p *mntxv1.CardPayoutState) {
if p == nil {
return
}
key := strings.TrimSpace(p.GetPayoutId())
if key == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.payouts[key] = cloneCardPayoutState(p)
}
func (s *cardPayoutStore) Get(payoutID string) (*mntxv1.CardPayoutState, bool) {
id := strings.TrimSpace(payoutID)
if id == "" {
return nil, false
}
s.mu.RLock()
defer s.mu.RUnlock()
val, ok := s.payouts[id]
return cloneCardPayoutState(val), ok
}
func cloneCardPayoutState(p *mntxv1.CardPayoutState) *mntxv1.CardPayoutState {
if p == nil {
return nil
}
cloned := proto.Clone(p)
if cp, ok := cloned.(*mntxv1.CardPayoutState); ok {
return cp
}
return nil
}

View File

@@ -0,0 +1,67 @@
package gateway
import (
"context"
"github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/gateway/mntx/storage/model"
)
// mockRepository implements storage.Repository for tests.
type mockRepository struct {
payouts *cardPayoutStore
}
func newMockRepository() *mockRepository {
return &mockRepository{
payouts: newCardPayoutStore(),
}
}
func (r *mockRepository) Payouts() storage.PayoutsStore {
return r.payouts
}
// cardPayoutStore implements storage.PayoutsStore for tests.
type cardPayoutStore struct {
data map[string]*model.CardPayout
}
func newCardPayoutStore() *cardPayoutStore {
return &cardPayoutStore{
data: make(map[string]*model.CardPayout),
}
}
func (s *cardPayoutStore) FindByIdempotencyKey(_ context.Context, key string) (*model.CardPayout, error) {
for _, v := range s.data {
if v.IdempotencyKey == key {
return v, nil
}
}
return nil, nil
}
func (s *cardPayoutStore) FindByPaymentID(_ context.Context, id string) (*model.CardPayout, error) {
v, ok := s.data[id]
if !ok {
return nil, nil
}
return v, nil
}
func (s *cardPayoutStore) Upsert(_ context.Context, record *model.CardPayout) error {
s.data[record.PayoutID] = record
return nil
}
// Save is a helper for tests to pre-populate data.
func (s *cardPayoutStore) Save(state *model.CardPayout) {
s.data[state.PayoutID] = state
}
// Get is a helper for tests to retrieve data.
func (s *cardPayoutStore) Get(id string) (*model.CardPayout, bool) {
v, ok := s.data[id]
return v, ok
}

View File

@@ -8,30 +8,33 @@ import (
"strings"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/gateway/mntx/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
messaging "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
nm "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/timestamppb"
)
type cardPayoutProcessor struct {
logger mlogger.Logger
config monetix.Config
clock clockpkg.Clock
store *cardPayoutStore
store storage.Repository
httpClient *http.Client
producer msg.Producer
}
func newCardPayoutProcessor(logger mlogger.Logger, cfg monetix.Config, clock clockpkg.Clock, store *cardPayoutStore, client *http.Client, producer msg.Producer) *cardPayoutProcessor {
func newCardPayoutProcessor(
logger mlogger.Logger,
cfg monetix.Config,
clock clockpkg.Clock,
store storage.Repository,
client *http.Client,
producer msg.Producer,
) *cardPayoutProcessor {
return &cardPayoutProcessor{
logger: logger.Named("card_payout_processor"),
config: cfg,
@@ -46,18 +49,23 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
if p == nil {
return nil, merrors.Internal("card payout processor not initialised")
}
req = sanitizeCardPayoutRequest(req)
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()))),
zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())),
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
)
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
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",
zap.String("payout_id", req.GetPayoutId()),
@@ -76,53 +84,88 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
return nil, merrors.Internal("monetix project_id is not configured")
}
now := timestamppb.New(p.clock.Now())
state := &mntxv1.CardPayoutState{
PayoutId: req.GetPayoutId(),
ProjectId: projectID,
CustomerId: req.GetCustomerId(),
AmountMinor: req.GetAmountMinor(),
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
CreatedAt: now,
UpdatedAt: now,
now := p.clock.Now()
state := &model.CardPayout{
PayoutID: strings.TrimSpace(req.GetPayoutId()),
OperationRef: strings.TrimSpace(req.GetOperationRef()),
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
ProjectID: projectID,
CustomerID: strings.TrimSpace(req.GetCustomerId()),
AmountMinor: req.GetAmountMinor(),
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
Status: model.PayoutStatusWaiting,
CreatedAt: now,
UpdatedAt: now,
}
if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil {
if existing.GetCreatedAt() != nil {
state.CreatedAt = existing.GetCreatedAt()
// Keep CreatedAt/refs if record already exists.
if existing, err := p.store.Payouts().FindByPaymentID(ctx, state.PayoutID); err == nil && existing != nil {
if !existing.CreatedAt.IsZero() {
state.CreatedAt = existing.CreatedAt
}
if state.OperationRef == "" {
state.OperationRef = existing.OperationRef
}
if state.IdempotencyKey == "" {
state.IdempotencyKey = existing.IdempotencyKey
}
}
client := monetix.NewClient(p.config, p.httpClient, p.logger)
apiReq := buildCardPayoutRequest(projectID, req)
result, err := client.CreateCardPayout(ctx, apiReq)
if err != nil {
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
state.Status = model.PayoutStatusFailed
state.ProviderMessage = err.Error()
state.UpdatedAt = timestamppb.New(p.clock.Now())
p.store.Save(state)
state.UpdatedAt = p.clock.Now()
if e := p.updatePayoutStatus(ctx, state); e != nil {
p.logger.Warn("Failed to update payout status",
zap.Error(e),
zap.String("payout_id", state.PayoutID),
zap.String("customer_id", state.CustomerID),
zap.String("operation_ref", state.OperationRef),
zap.String("idempotency_key", state.IdempotencyKey),
)
}
p.logger.Warn("Monetix payout submission failed",
zap.String("payout_id", req.GetPayoutId()),
zap.String("customer_id", req.GetCustomerId()),
zap.Error(err),
zap.String("payout_id", state.PayoutID),
zap.String("customer_id", state.CustomerID),
zap.String("operation_ref", state.OperationRef),
zap.String("idempotency_key", state.IdempotencyKey),
)
return nil, err
}
state.ProviderPaymentId = result.ProviderRequestID
// Provider request id is the provider-side payment id in your model.
state.ProviderPaymentID = strings.TrimSpace(result.ProviderRequestID)
if result.Accepted {
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
state.Status = model.PayoutStatusWaiting
} else {
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
state.ProviderCode = result.ErrorCode
state.ProviderMessage = result.ErrorMessage
state.Status = model.PayoutStatusFailed
state.ProviderCode = strings.TrimSpace(result.ErrorCode)
state.ProviderMessage = strings.TrimSpace(result.ErrorMessage)
}
state.UpdatedAt = p.clock.Now()
if err := p.updatePayoutStatus(ctx, state); err != nil {
p.logger.Warn("Failed to store payout",
zap.Error(err),
zap.String("payout_id", state.PayoutID),
zap.String("customer_id", state.CustomerID),
zap.String("operation_ref", state.OperationRef),
zap.String("idempotency_key", state.IdempotencyKey),
)
// do not fail request here: provider already answered and client expects response
}
state.UpdatedAt = timestamppb.New(p.clock.Now())
p.store.Save(state)
resp := &mntxv1.CardPayoutResponse{
Payout: state,
Payout: StateToProto(state),
Accepted: result.Accepted,
ProviderRequestId: result.ProviderRequestID,
ErrorCode: result.ErrorCode,
@@ -130,8 +173,8 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout
}
p.logger.Info("Card payout submission stored",
zap.String("payout_id", state.GetPayoutId()),
zap.String("status", state.GetStatus().String()),
zap.String("payout_id", state.PayoutID),
zap.String("status", string(state.Status)),
zap.Bool("accepted", result.Accepted),
zap.String("provider_request_id", result.ProviderRequestID),
)
@@ -143,18 +186,23 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
if p == nil {
return nil, merrors.Internal("card payout processor not initialised")
}
req = sanitizeCardTokenPayoutRequest(req)
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()))),
zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())),
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
)
if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" {
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",
zap.String("payout_id", req.GetPayoutId()),
@@ -173,53 +221,69 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
return nil, merrors.Internal("monetix project_id is not configured")
}
now := timestamppb.New(p.clock.Now())
state := &mntxv1.CardPayoutState{
PayoutId: req.GetPayoutId(),
ProjectId: projectID,
CustomerId: req.GetCustomerId(),
AmountMinor: req.GetAmountMinor(),
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING,
CreatedAt: now,
UpdatedAt: now,
now := p.clock.Now()
state := &model.CardPayout{
PayoutID: strings.TrimSpace(req.GetPayoutId()),
OperationRef: strings.TrimSpace(req.GetOperationRef()),
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
ProjectID: projectID,
CustomerID: strings.TrimSpace(req.GetCustomerId()),
AmountMinor: req.GetAmountMinor(),
Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())),
Status: model.PayoutStatusWaiting,
CreatedAt: now,
UpdatedAt: now,
}
if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil {
if existing.GetCreatedAt() != nil {
state.CreatedAt = existing.GetCreatedAt()
if existing, err := p.store.Payouts().FindByPaymentID(ctx, state.PayoutID); err == nil && existing != nil {
if !existing.CreatedAt.IsZero() {
state.CreatedAt = existing.CreatedAt
}
if state.OperationRef == "" {
state.OperationRef = existing.OperationRef
}
if state.IdempotencyKey == "" {
state.IdempotencyKey = existing.IdempotencyKey
}
}
client := monetix.NewClient(p.config, p.httpClient, p.logger)
apiReq := buildCardTokenPayoutRequest(projectID, req)
result, err := client.CreateCardTokenPayout(ctx, apiReq)
if err != nil {
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
state.Status = model.PayoutStatusFailed
state.ProviderMessage = err.Error()
state.UpdatedAt = timestamppb.New(p.clock.Now())
p.store.Save(state)
state.UpdatedAt = p.clock.Now()
_ = p.updatePayoutStatus(ctx, state)
p.logger.Warn("Monetix token payout submission failed",
zap.String("payout_id", req.GetPayoutId()),
zap.String("customer_id", req.GetCustomerId()),
zap.String("payout_id", state.PayoutID),
zap.String("customer_id", state.CustomerID),
zap.Error(err),
)
return nil, err
}
state.ProviderPaymentId = result.ProviderRequestID
state.ProviderPaymentID = strings.TrimSpace(result.ProviderRequestID)
if result.Accepted {
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
state.Status = model.PayoutStatusWaiting
} else {
state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
state.ProviderCode = result.ErrorCode
state.ProviderMessage = result.ErrorMessage
state.Status = model.PayoutStatusFailed
state.ProviderCode = strings.TrimSpace(result.ErrorCode)
state.ProviderMessage = strings.TrimSpace(result.ErrorMessage)
}
state.UpdatedAt = p.clock.Now()
if err := p.updatePayoutStatus(ctx, state); err != nil {
p.logger.Warn("Failed to update payout status", zap.Error(err))
return nil, err
}
state.UpdatedAt = timestamppb.New(p.clock.Now())
p.store.Save(state)
resp := &mntxv1.CardTokenPayoutResponse{
Payout: state,
Payout: StateToProto(state),
Accepted: result.Accepted,
ProviderRequestId: result.ProviderRequestID,
ErrorCode: result.ErrorCode,
@@ -227,8 +291,8 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT
}
p.logger.Info("Card token payout submission stored",
zap.String("payout_id", state.GetPayoutId()),
zap.String("status", state.GetStatus().String()),
zap.String("payout_id", state.PayoutID),
zap.String("status", string(state.Status)),
zap.Bool("accepted", result.Accepted),
zap.String("provider_request_id", result.ProviderRequestID),
)
@@ -240,10 +304,12 @@ 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",
@@ -265,8 +331,10 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
req = sanitizeCardTokenizeRequest(req)
cardInput = extractTokenizeCard(req)
client := monetix.NewClient(p.config, p.httpClient, p.logger)
apiReq := buildCardTokenizeRequest(projectID, req, cardInput)
result, err := client.CreateCardTokenization(ctx, apiReq)
if err != nil {
p.logger.Warn("Monetix tokenization request failed",
@@ -298,36 +366,45 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke
return resp, nil
}
func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv1.CardPayoutState, error) {
func (p *cardPayoutProcessor) Status(ctx context.Context, payoutID string) (*mntxv1.CardPayoutState, error) {
if p == nil {
return nil, merrors.Internal("card payout processor not initialised")
}
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")
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))
state, err := p.store.Payouts().FindByPaymentID(ctx, id)
if err != nil || state == nil {
p.logger.Warn("Payout status not found", zap.String("payout_id", id), zap.Error(err))
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
p.logger.Info("Card payout status resolved",
zap.String("payout_id", state.PayoutID),
zap.String("status", string(state.Status)),
)
return StateToProto(state), nil
}
func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byte) (int, error) {
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")
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")
return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured")
@@ -354,45 +431,48 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt
return status, err
}
state, statusLabel := mapCallbackToState(p.clock, p.config, cb)
if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil {
if existing.GetCreatedAt() != nil {
state.CreatedAt = existing.GetCreatedAt()
// mapCallbackToState currently returns proto-state in your code.
// Convert it to mongo model and preserve internal refs if record exists.
pbState, statusLabel := mapCallbackToState(p.clock, p.config, cb)
// Convert proto -> mongo (operationRef/idempotencyKey are internal; keep empty for now)
state := CardPayoutStateFromProto(p.clock, pbState)
// Preserve CreatedAt + internal keys from existing record if present.
if existing, err := p.store.Payouts().FindByPaymentID(ctx, state.PayoutID); err != nil {
p.logger.Warn("Failed to fetch payout state while processing callback",
zap.Error(err),
zap.String("payout_id", state.PayoutID),
)
return http.StatusInternalServerError, err
} else if existing != nil {
if !existing.CreatedAt.IsZero() {
state.CreatedAt = existing.CreatedAt
}
if state.OperationRef == "" {
state.OperationRef = existing.OperationRef
}
if state.IdempotencyKey == "" {
state.IdempotencyKey = existing.IdempotencyKey
}
// keep failure reason if you want, or override depending on callback semantics
if state.FailureReason == "" {
state.FailureReason = existing.FailureReason
}
}
p.store.Save(state)
p.emitCardPayoutEvent(state)
if err := p.updatePayoutStatus(ctx, state); err != nil {
p.logger.Warn("Failed to update payout state while processing callback", zap.Error(err))
}
monetix.ObserveCallback(statusLabel)
p.logger.Info("Monetix payout callback processed",
zap.String("payout_id", state.GetPayoutId()),
zap.String("payout_id", state.PayoutID),
zap.String("status", statusLabel),
zap.String("provider_code", state.GetProviderCode()),
zap.String("provider_message", state.GetProviderMessage()),
zap.String("provider_code", state.ProviderCode),
zap.String("provider_message", state.ProviderMessage),
zap.String("masked_account", cb.Account.Number),
)
return http.StatusOK, nil
}
func (p *cardPayoutProcessor) emitCardPayoutEvent(state *mntxv1.CardPayoutState) {
if state == nil || p.producer == nil {
return
}
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))
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))
return
}
if err := p.producer.SendMessage(env); err != nil {
p.logger.Warn("Failed to publish payout callback event", zap.Error(err))
}
}

View File

@@ -11,11 +11,11 @@ import (
"time"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/gateway/mntx/storage/model"
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)
@@ -40,10 +40,11 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
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",
existingCreated := time.Date(2020, 2, 3, 0, 0, 0, 0, time.UTC)
repo := newMockRepository()
repo.payouts.Save(&model.CardPayout{
PayoutID: "payout-1",
CreatedAt: existingCreated,
})
@@ -61,7 +62,7 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
}
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, store, httpClient, nil)
processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, repo, httpClient, nil)
req := validCardPayoutRequest()
req.ProjectId = 0
@@ -76,27 +77,38 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) {
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().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING {
t.Fatalf("expected waiting status, got %v", resp.GetPayout().GetStatus())
}
if !resp.GetPayout().GetCreatedAt().AsTime().Equal(existingCreated.AsTime()) {
if !resp.GetPayout().GetCreatedAt().AsTime().Equal(existingCreated) {
t.Fatalf("expected created_at preserved, got %v", resp.GetPayout().GetCreatedAt().AsTime())
}
stored, ok := store.Get(req.GetPayoutId())
stored, ok := repo.payouts.Get(req.GetPayoutId())
if !ok || stored == nil {
t.Fatalf("expected payout state stored")
}
if stored.GetProviderPaymentId() == "" {
if stored.ProviderPaymentID == "" {
t.Fatalf("expected provider payment id")
}
if !stored.CreatedAt.Equal(existingCreated) {
t.Fatalf("expected created_at preserved in model, got %v", stored.CreatedAt)
}
}
func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) {
cfg := monetix.Config{
AllowedCurrencies: []string{"RUB"},
}
processor := newCardPayoutProcessor(zap.NewNop(), cfg, clockpkg.NewSystem(), newCardPayoutStore(), &http.Client{}, nil)
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
clockpkg.NewSystem(),
newMockRepository(),
&http.Client{},
nil,
)
_, err := processor.Submit(context.Background(), validCardPayoutRequest())
if err == nil {
@@ -114,12 +126,21 @@ func TestCardPayoutProcessor_ProcessCallback(t *testing.T) {
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)
repo := newMockRepository()
processor := newCardPayoutProcessor(
zap.NewNop(),
cfg,
staticClock{now: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)},
repo,
&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)
@@ -139,11 +160,12 @@ func TestCardPayoutProcessor_ProcessCallback(t *testing.T) {
t.Fatalf("expected status ok, got %d", status)
}
state, ok := store.Get(cb.Payment.ID)
state, ok := repo.payouts.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())
if state.Status != model.PayoutStatusSuccess {
t.Fatalf("expected success status in model, got %v", state.Status)
}
}

View File

@@ -9,8 +9,8 @@ import (
"github.com/tech/sendico/gateway/mntx/internal/appversion"
"github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/merrors"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
@@ -19,9 +19,9 @@ const mntxConnectorID = "mntx"
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
return &connectorv1.GetCapabilitiesResponse{
Capabilities: &connectorv1.ConnectorCapabilities{
ConnectorType: mntxConnectorID,
Version: appversion.Create().Short(),
SupportedAccountKinds: nil,
ConnectorType: mntxConnectorID,
Version: appversion.Create().Short(),
SupportedAccountKinds: nil,
SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_PAYOUT},
OperationParams: mntxOperationParams(),
},
@@ -161,49 +161,49 @@ func currencyFromOperation(op *connectorv1.Operation) string {
func buildCardTokenPayoutRequestFromParams(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest {
req := &mntxv1.CardTokenPayoutRequest{
PayoutId: payoutID,
ProjectId: readerInt64(reader, "project_id"),
CustomerId: strings.TrimSpace(reader.String("customer_id")),
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
PayoutId: payoutID,
ProjectId: readerInt64(reader, "project_id"),
CustomerId: strings.TrimSpace(reader.String("customer_id")),
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")),
CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")),
CustomerIp: strings.TrimSpace(reader.String("customer_ip")),
CustomerZip: strings.TrimSpace(reader.String("customer_zip")),
CustomerCountry: strings.TrimSpace(reader.String("customer_country")),
CustomerState: strings.TrimSpace(reader.String("customer_state")),
CustomerCity: strings.TrimSpace(reader.String("customer_city")),
CustomerAddress: strings.TrimSpace(reader.String("customer_address")),
AmountMinor: amountMinor,
Currency: currency,
CardToken: strings.TrimSpace(reader.String("card_token")),
CardHolder: strings.TrimSpace(reader.String("card_holder")),
MaskedPan: strings.TrimSpace(reader.String("masked_pan")),
Metadata: reader.StringMap("metadata"),
CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")),
CustomerIp: strings.TrimSpace(reader.String("customer_ip")),
CustomerZip: strings.TrimSpace(reader.String("customer_zip")),
CustomerCountry: strings.TrimSpace(reader.String("customer_country")),
CustomerState: strings.TrimSpace(reader.String("customer_state")),
CustomerCity: strings.TrimSpace(reader.String("customer_city")),
CustomerAddress: strings.TrimSpace(reader.String("customer_address")),
AmountMinor: amountMinor,
Currency: currency,
CardToken: strings.TrimSpace(reader.String("card_token")),
CardHolder: strings.TrimSpace(reader.String("card_holder")),
MaskedPan: strings.TrimSpace(reader.String("masked_pan")),
Metadata: reader.StringMap("metadata"),
}
return req
}
func buildCardPayoutRequestFromParams(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardPayoutRequest {
return &mntxv1.CardPayoutRequest{
PayoutId: payoutID,
ProjectId: readerInt64(reader, "project_id"),
CustomerId: strings.TrimSpace(reader.String("customer_id")),
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
PayoutId: payoutID,
ProjectId: readerInt64(reader, "project_id"),
CustomerId: strings.TrimSpace(reader.String("customer_id")),
CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")),
CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")),
CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")),
CustomerIp: strings.TrimSpace(reader.String("customer_ip")),
CustomerZip: strings.TrimSpace(reader.String("customer_zip")),
CustomerCountry: strings.TrimSpace(reader.String("customer_country")),
CustomerState: strings.TrimSpace(reader.String("customer_state")),
CustomerCity: strings.TrimSpace(reader.String("customer_city")),
CustomerAddress: strings.TrimSpace(reader.String("customer_address")),
AmountMinor: amountMinor,
Currency: currency,
CardPan: strings.TrimSpace(reader.String("card_pan")),
CardExpYear: uint32(readerInt64(reader, "card_exp_year")),
CardExpMonth: uint32(readerInt64(reader, "card_exp_month")),
CardHolder: strings.TrimSpace(reader.String("card_holder")),
Metadata: reader.StringMap("metadata"),
CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")),
CustomerIp: strings.TrimSpace(reader.String("customer_ip")),
CustomerZip: strings.TrimSpace(reader.String("customer_zip")),
CustomerCountry: strings.TrimSpace(reader.String("customer_country")),
CustomerState: strings.TrimSpace(reader.String("customer_state")),
CustomerCity: strings.TrimSpace(reader.String("customer_city")),
CustomerAddress: strings.TrimSpace(reader.String("customer_address")),
AmountMinor: amountMinor,
Currency: currency,
CardPan: strings.TrimSpace(reader.String("card_pan")),
CardExpYear: uint32(readerInt64(reader, "card_exp_year")),
CardExpMonth: uint32(readerInt64(reader, "card_exp_month")),
CardHolder: strings.TrimSpace(reader.String("card_holder")),
Metadata: reader.StringMap("metadata"),
}
}
@@ -217,7 +217,7 @@ func readerInt64(reader params.Reader, key string) int64 {
func payoutReceipt(state *mntxv1.CardPayoutState) *connectorv1.OperationReceipt {
if state == nil {
return &connectorv1.OperationReceipt{
Status: connectorv1.OperationStatus_PENDING,
Status: connectorv1.OperationStatus_OPERATION_PROCESSING,
}
}
return &connectorv1.OperationReceipt{
@@ -252,14 +252,24 @@ func minorToDecimal(amount int64) string {
func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationStatus {
switch status {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
return connectorv1.OperationStatus_CONFIRMED
case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED:
return connectorv1.OperationStatus_OPERATION_CREATED
case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING:
return connectorv1.OperationStatus_OPERATION_WAITING
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
return connectorv1.OperationStatus_OPERATION_SUCCESS
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
return connectorv1.OperationStatus_FAILED
case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING:
return connectorv1.OperationStatus_PENDING
return connectorv1.OperationStatus_OPERATION_FAILED
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
return connectorv1.OperationStatus_OPERATION_CANCELLED
default:
return connectorv1.OperationStatus_PENDING
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}

View File

@@ -0,0 +1,97 @@
package gateway
import (
"time"
"github.com/tech/sendico/gateway/mntx/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
func tsOrNow(clock clockpkg.Clock, ts *timestamppb.Timestamp) time.Time {
if ts == nil {
return clock.Now()
}
return ts.AsTime()
}
func CardPayoutStateFromProto(clock clockpkg.Clock, p *mntxv1.CardPayoutState) *model.CardPayout {
if p == nil {
return nil
}
return &model.CardPayout{
PayoutID: p.PayoutId,
OperationRef: p.GetOperationRef(),
IntentRef: p.GetIntentRef(),
IdempotencyKey: p.GetIdempotencyKey(),
ProjectID: p.ProjectId,
CustomerID: p.CustomerId,
AmountMinor: p.AmountMinor,
Currency: p.Currency,
Status: payoutStatusFromProto(p.Status),
ProviderCode: p.ProviderCode,
ProviderMessage: p.ProviderMessage,
ProviderPaymentID: p.ProviderPaymentId,
CreatedAt: tsOrNow(clock, p.CreatedAt),
UpdatedAt: tsOrNow(clock, p.UpdatedAt),
}
}
func StateToProto(m *model.CardPayout) *mntxv1.CardPayoutState {
return &mntxv1.CardPayoutState{
PayoutId: m.PayoutID,
ProjectId: m.ProjectID,
CustomerId: m.CustomerID,
AmountMinor: m.AmountMinor,
Currency: m.Currency,
Status: payoutStatusToProto(m.Status),
ProviderCode: m.ProviderCode,
ProviderMessage: m.ProviderMessage,
ProviderPaymentId: m.ProviderPaymentID,
CreatedAt: timestamppb.New(m.CreatedAt),
UpdatedAt: timestamppb.New(m.UpdatedAt),
}
}
func payoutStatusToProto(s model.PayoutStatus) mntxv1.PayoutStatus {
switch s {
case model.PayoutStatusCreated:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED
case model.PayoutStatusProcessing:
return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING
case model.PayoutStatusWaiting:
return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING
case model.PayoutStatusSuccess:
return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS
case model.PayoutStatusFailed:
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
case model.PayoutStatusCancelled:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED
default:
return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED
}
}
func payoutStatusFromProto(s mntxv1.PayoutStatus) model.PayoutStatus {
switch s {
case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED:
return model.PayoutStatusCreated
case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING:
return model.PayoutStatusWaiting
case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS:
return model.PayoutStatusSuccess
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
return model.PayoutStatusFailed
case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED:
return model.PayoutStatusCancelled
default:
return model.PayoutStatusCreated
}
}

View File

@@ -5,6 +5,7 @@ import (
"strings"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/pkg/clock"
msg "github.com/tech/sendico/pkg/messaging"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
@@ -29,6 +30,12 @@ func WithProducer(p msg.Producer) Option {
}
}
func WithStorage(storage storage.Repository) Option {
return func(s *Service) {
s.storage = storage
}
}
// WithHTTPClient injects a custom HTTP client (useful for tests).
func WithHTTPClient(client *http.Client) Option {
return func(s *Service) {

View File

@@ -7,6 +7,7 @@ import (
"github.com/tech/sendico/gateway/mntx/internal/appversion"
"github.com/tech/sendico/gateway/mntx/internal/service/monetix"
"github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock"
@@ -24,7 +25,7 @@ type Service struct {
logger mlogger.Logger
clock clockpkg.Clock
producer msg.Producer
cardStore *cardPayoutStore
storage storage.Repository
config monetix.Config
httpClient *http.Client
card *cardPayoutProcessor
@@ -60,10 +61,9 @@ func (r reasonedError) Reason() string {
// NewService constructs the Monetix gateway service skeleton.
func NewService(logger mlogger.Logger, opts ...Option) *Service {
svc := &Service{
logger: logger.Named("service"),
clock: clockpkg.NewSystem(),
cardStore: newCardPayoutStore(),
config: monetix.DefaultConfig(),
logger: logger.Named("service"),
clock: clockpkg.NewSystem(),
config: monetix.DefaultConfig(),
}
initMetrics()
@@ -84,11 +84,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
svc.httpClient.Timeout = svc.config.Timeout()
}
if svc.cardStore == nil {
svc.cardStore = newCardPayoutStore()
}
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.cardStore, svc.httpClient, svc.producer)
svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.storage, svc.httpClient, svc.producer)
svc.startDiscoveryAnnouncer()
return svc

View File

@@ -0,0 +1,81 @@
package gateway
import (
"context"
"fmt"
"github.com/tech/sendico/gateway/mntx/storage/model"
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/pkg/payments/rail"
paytypes "github.com/tech/sendico/pkg/payments/types"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
"go.uber.org/zap"
)
func isFinalStatus(t *model.CardPayout) bool {
switch t.Status {
case model.PayoutStatusFailed, model.PayoutStatusSuccess, model.PayoutStatusCancelled:
return true
default:
return false
}
}
func toOpStatus(t *model.CardPayout) rail.OperationResult {
switch t.Status {
case model.PayoutStatusFailed:
return rail.OperationResultFailed
case model.PayoutStatusSuccess:
return rail.OperationResultSuccess
case model.PayoutStatusCancelled:
return rail.OperationResultCancelled
default:
panic(fmt.Sprintf("unexpected transfer status, %s", t.Status))
}
}
func toError(t *model.CardPayout) *gatewayv1.OperationError {
if t.Status == model.PayoutStatusSuccess {
return nil
}
return &gatewayv1.OperationError{
Message: t.FailureReason,
}
}
func (p *cardPayoutProcessor) updatePayoutStatus(ctx context.Context, state *model.CardPayout) error {
if err := p.store.Payouts().Upsert(ctx, state); err != nil {
p.logger.Warn("Failed to update transfer status", zap.String("transfer_ref", state.PayoutID), zap.String("status", string(state.Status)), zap.Error(err))
}
if isFinalStatus(state) {
p.emitTransferStatusEvent(state)
}
return nil
}
func (p *cardPayoutProcessor) emitTransferStatusEvent(payout *model.CardPayout) {
if p == nil || p.producer == nil || payout == nil {
return
}
exec := pmodel.PaymentGatewayExecution{
PaymentIntentID: payout.IntentRef,
IdempotencyKey: payout.IdempotencyKey,
ExecutedMoney: &paytypes.Money{
Amount: fmt.Sprintf("%d", payout.AmountMinor),
Currency: payout.Currency,
},
PaymentRef: payout.PaymentRef,
Status: toOpStatus(payout),
OperationRef: payout.OperationRef,
Error: payout.FailureReason,
TransferRef: payout.GetID().Hex(),
}
env := paymentgateway.PaymentGatewayExecution(mservice.MntxGateway, &exec)
if err := p.producer.SendMessage(env); err != nil {
p.logger.Warn("Failed to publish transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", payout.ID))
}
}

View File

@@ -0,0 +1,29 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
)
// CardPayout is a Mongo/JSON representation of proto CardPayout
type CardPayout struct {
storable.Base `bson:",inline" json:",inline"`
PayoutID string `bson:"payoutId" json:"payout_id"`
PaymentRef string `bson:"paymentRef" json:"payment_ref"`
OperationRef string `bson:"operationRef" json:"operation_ref"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotency_key"`
IntentRef string `bson:"intentRef" json:"intentRef"`
ProjectID int64 `bson:"projectId" json:"project_id"`
CustomerID string `bson:"customerId" json:"customer_id"`
AmountMinor int64 `bson:"amountMinor" json:"amount_minor"`
Currency string `bson:"currency" json:"currency"`
Status PayoutStatus `bson:"status" json:"status"`
ProviderCode string `bson:"providerCode,omitempty" json:"provider_code,omitempty"`
ProviderMessage string `bson:"providerMessage,omitempty" json:"provider_message,omitempty"`
ProviderPaymentID string `bson:"providerPaymentId,omitempty" json:"provider_payment_id,omitempty"`
FailureReason string `bson:"failureReason,omitempty" json:"failure_reason,omitempty"`
CreatedAt time.Time `bson:"createdAt" json:"created_at"`
UpdatedAt time.Time `bson:"updatedAt" json:"updated_at"`
}

View File

@@ -0,0 +1,13 @@
package model
type PayoutStatus string
const (
PayoutStatusCreated PayoutStatus = "created" // record exists, not started
PayoutStatusProcessing PayoutStatus = "processing" // we are working on it
PayoutStatusWaiting PayoutStatus = "waiting" // waiting external world
PayoutStatusSuccess PayoutStatus = "success" // final success
PayoutStatusFailed PayoutStatus = "failed" // final failure
PayoutStatusCancelled PayoutStatus = "cancelled" // final cancelled
)

View File

@@ -0,0 +1,69 @@
package mongo
import (
"context"
"time"
"github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/gateway/mntx/storage/mongo/store"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
type Repository struct {
logger mlogger.Logger
conn *db.MongoConnection
db *mongo.Database
payouts storage.PayoutsStore
}
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) {
if logger == nil {
logger = zap.NewNop()
}
if conn == nil {
return nil, merrors.InvalidArgument("mongo connection is nil")
}
client := conn.Client()
if client == nil {
return nil, merrors.Internal("mongo client is not initialised")
}
db := conn.Database()
if db == nil {
return nil, merrors.Internal("mongo database is not initialised")
}
dbName := db.Name()
logger = logger.Named("storage").Named("mongo")
if dbName != "" {
logger = logger.With(zap.String("database", dbName))
}
result := &Repository{
logger: logger,
conn: conn,
db: db,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := result.conn.Ping(ctx); err != nil {
result.logger.Error("Mongo ping failed during repository initialisation", zap.Error(err))
return nil, err
}
payoutsStore, err := store.NewPayouts(result.logger, result.db)
if err != nil {
result.logger.Error("Failed to initialise payouts store", zap.Error(err), zap.String("store", "payments"))
return nil, err
}
result.payouts = payoutsStore
result.logger.Info("Payouts gateway MongoDB storage initialised")
return result, nil
}
func (r *Repository) Payouts() storage.PayoutsStore {
return r.payouts
}
var _ storage.Repository = (*Repository)(nil)

View File

@@ -0,0 +1,137 @@
package store
import (
"context"
"errors"
"strings"
"time"
storage "github.com/tech/sendico/gateway/mntx/storage"
"github.com/tech/sendico/gateway/mntx/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.uber.org/zap"
)
const (
payoutsCollection = "card_payouts"
payoutIdemField = "idempotencyKey"
payoutIdField = "payoutId"
)
type Payouts struct {
logger mlogger.Logger
coll *mongo.Collection
}
func NewPayouts(logger mlogger.Logger, db *mongo.Database) (*Payouts, error) {
if db == nil {
return nil, merrors.InvalidArgument("mongo database is nil")
}
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("payouts").With(zap.String("collection", payoutsCollection))
repo := repository.CreateMongoRepository(db, payoutsCollection)
if err := repo.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: payoutIdemField, Sort: ri.Asc}},
Unique: true,
}); err != nil {
logger.Error("Failed to create payouts idempotency index",
zap.Error(err),
zap.String("index_field", payoutIdemField))
return nil, err
}
p := &Payouts{
logger: logger,
coll: db.Collection(payoutsCollection),
}
p.logger.Debug("Payouts store initialised")
return p, nil
}
func (p *Payouts) findOneByField(ctx context.Context, field, value string) (*model.CardPayout, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, merrors.InvalidArgument("lookup key is required", field)
}
var result model.CardPayout
err := p.coll.FindOne(ctx, bson.M{field: value}).Decode(&result)
if err == mongo.ErrNoDocuments {
return nil, nil
}
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
p.logger.Warn("Payout record lookup failed",
zap.String("field", field),
zap.String("value", value),
zap.Error(err))
}
return nil, err
}
return &result, nil
}
func (p *Payouts) FindByIdempotencyKey(ctx context.Context, key string) (*model.CardPayout, error) {
return p.findOneByField(ctx, payoutIdemField, key) // operationRef
}
func (p *Payouts) FindByPaymentID(ctx context.Context, paymentID string) (*model.CardPayout, error) {
return p.findOneByField(ctx, payoutIdField, paymentID)
}
func (p *Payouts) Upsert(ctx context.Context, record *model.CardPayout) error {
if record == nil {
return merrors.InvalidArgument("payout record is nil", "record")
}
record.OperationRef = strings.TrimSpace(record.OperationRef)
record.PayoutID = strings.TrimSpace(record.PayoutID)
record.CustomerID = strings.TrimSpace(record.CustomerID)
record.ProviderCode = strings.TrimSpace(record.ProviderCode)
record.ProviderPaymentID = strings.TrimSpace(record.ProviderPaymentID)
if record.OperationRef == "" {
return merrors.InvalidArgument("operation ref is required", "operation_ref")
}
now := time.Now()
if record.CreatedAt.IsZero() {
record.CreatedAt = now
}
record.UpdatedAt = now
// critical: never let caller override _id
record.ID = bson.NilObjectID
update := bson.M{
"$set": record,
}
_, err := p.coll.UpdateOne(
ctx,
bson.M{payoutIdemField: record.OperationRef},
update,
options.UpdateOne().SetUpsert(true),
)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
p.logger.Warn("Failed to upsert payout record",
zap.String("operation_ref", record.OperationRef),
zap.String("payout_id", record.PayoutID),
zap.Error(err))
}
return err
}
return nil
}
var _ storage.PayoutsStore = (*Payouts)(nil)

View File

@@ -0,0 +1,20 @@
package storage
import (
"context"
"github.com/tech/sendico/gateway/mntx/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
var ErrDuplicate = merrors.DataConflict("payment gateway storage: duplicate record")
type Repository interface {
Payouts() PayoutsStore
}
type PayoutsStore interface {
FindByIdempotencyKey(ctx context.Context, key string) (*model.CardPayout, error)
FindByPaymentID(ctx context.Context, key string) (*model.CardPayout, error)
Upsert(ctx context.Context, record *model.CardPayout) error
}

View File

@@ -45,5 +45,5 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
)

View File

@@ -208,8 +208,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -7,8 +7,8 @@ import (
"github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/merrors"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap"
)
@@ -18,9 +18,9 @@ const tgsettleConnectorID = "tgsettle"
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
return &connectorv1.GetCapabilitiesResponse{
Capabilities: &connectorv1.ConnectorCapabilities{
ConnectorType: tgsettleConnectorID,
Version: "",
SupportedAccountKinds: nil,
ConnectorType: tgsettleConnectorID,
Version: "",
SupportedAccountKinds: nil,
SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_TRANSFER},
OperationParams: tgsettleOperationParams(),
},
@@ -64,7 +64,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
}
paymentIntentID := strings.TrimSpace(reader.String("payment_intent_id"))
if paymentIntentID == "" {
paymentIntentID = strings.TrimSpace(reader.String("client_reference"))
paymentIntentID = strings.TrimSpace(reader.String("payment_ref"))
}
if paymentIntentID == "" {
paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID])
@@ -122,7 +122,9 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
Destination: dest,
Amount: normalizedAmount,
Metadata: metadata,
ClientReference: paymentIntentID,
PaymentRef: paymentIntentID,
IntentRef: strings.TrimSpace(op.GetIntentRef()),
OperationRef: strings.TrimSpace(op.GetOperationRef()),
})
if err != nil {
s.logger.Warn("Submit operation transfer failed", append(logFields, zap.Error(err))...)
@@ -239,14 +241,29 @@ func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
func transferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return connectorv1.OperationStatus_CONFIRMED
case chainv1.TransferStatus_TRANSFER_CREATED:
return connectorv1.OperationStatus_OPERATION_CREATED
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return connectorv1.OperationStatus_OPERATION_PROCESSING
case chainv1.TransferStatus_TRANSFER_WAITING:
return connectorv1.OperationStatus_OPERATION_WAITING
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return connectorv1.OperationStatus_OPERATION_SUCCESS
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_FAILED
return connectorv1.OperationStatus_OPERATION_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_CANCELED
return connectorv1.OperationStatus_OPERATION_CANCELLED
case chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED:
fallthrough
default:
return connectorv1.OperationStatus_PENDING
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}

View File

@@ -16,7 +16,6 @@ import (
mb "github.com/tech/sendico/pkg/messaging/broker"
cons "github.com/tech/sendico/pkg/messaging/consumer"
confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations"
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
np "github.com/tech/sendico/pkg/messaging/notifications/processor"
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
"github.com/tech/sendico/pkg/mlogger"
@@ -188,12 +187,7 @@ func (s *Service) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransfe
return nil, err
}
if existing != nil {
existing, err = s.expirePaymentIfNeeded(ctx, existing)
if err != nil {
s.logger.Warn("Submit transfer status refresh failed", append(logFields, zap.Error(err))...)
return nil, err
}
s.logger.Info("Submit transfer idempotent hit", append(logFields, zap.String("status", string(paymentStatus(existing))))...)
s.logger.Info("Submit transfer idempotent hit", append(logFields, zap.String("status", string(existing.Status)))...)
return &chainv1.SubmitTransferResponse{Transfer: transferFromPayment(existing, req)}, nil
}
if err := s.onIntent(ctx, intent); err != nil {
@@ -225,14 +219,9 @@ func (s *Service) GetTransfer(ctx context.Context, req *chainv1.GetTransferReque
return nil, err
}
if existing != nil {
existing, err = s.expirePaymentIfNeeded(ctx, existing)
if err != nil {
s.logger.Warn("Get transfer status refresh failed", append(logFields, zap.Error(err))...)
return nil, err
}
s.logger.Info("Get transfer resolved from execution", append(logFields,
zap.String("payment_intent_id", strings.TrimSpace(existing.PaymentIntentID)),
zap.String("status", string(paymentStatus(existing))),
zap.String("status", string(existing.Status)),
)...)
return &chainv1.GetTransferResponse{Transfer: transferFromPayment(existing, nil)}, nil
}
@@ -274,27 +263,30 @@ func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayInte
return err
}
if existing != nil {
existing, err = s.expirePaymentIfNeeded(ctx, existing)
if err != nil {
return err
}
s.logger.Info("Payment gateway intent already recorded",
zap.String("idempotency_key", confirmReq.RequestID),
zap.String("payment_intent_id", confirmReq.PaymentIntentID),
zap.String("quote_ref", confirmReq.QuoteRef),
zap.String("rail", confirmReq.Rail),
zap.String("status", string(paymentStatus(existing))))
zap.String("status", string(existing.Status)))
return nil
}
record := paymentRecordFromIntent(intent, confirmReq)
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
s.logger.Warn("Failed to persist pending payment", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID))
if err := s.updateTransferStatus(ctx, record); err != nil {
s.logger.Warn("Failed to persist payment record", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID))
return err
}
if err := s.sendConfirmationRequest(confirmReq); err != nil {
s.logger.Warn("Failed to publish confirmation request", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID))
s.markPaymentExpired(ctx, record, time.Now())
// If the confirmation request was not sent, we keep the record in waiting
// (or it can be marked as failed — depending on your semantics).
// Here, failed is chosen to avoid it hanging indefinitely.
record.Status = storagemodel.PaymentStatusFailed
record.UpdatedAt = time.Now()
if e := s.updateTransferStatus(ctx, record); e != nil {
s.logger.Warn("Failed to update payment status change", zap.Error(e), zap.String("idempotency_key", confirmReq.RequestID))
}
return err
}
return nil
@@ -302,65 +294,88 @@ func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayInte
func (s *Service) onConfirmationResult(ctx context.Context, result *model.ConfirmationResult) error {
if result == nil {
s.logger.Warn("Confirmation result rejected", zap.String("reason", "result is nil"))
return merrors.InvalidArgument("confirmation result is nil", "result")
}
requestID := strings.TrimSpace(result.RequestID)
if requestID == "" {
s.logger.Warn("Confirmation result rejected", zap.String("reason", "request_id is required"))
return merrors.InvalidArgument("confirmation request_id is required", "request_id")
}
record, err := s.loadPayment(ctx, requestID)
if err != nil {
s.logger.Warn("Confirmation result lookup failed", zap.Error(err), zap.String("request_id", requestID))
return err
}
if record == nil {
s.logger.Warn("Confirmation result ignored: payment not found", zap.String("request_id", requestID))
return nil
}
// Store raw reply for audit/debug purposes. This does NOT affect payment state.
if result.RawReply != nil && s.repo != nil && s.repo.TelegramConfirmations() != nil {
if err := s.repo.TelegramConfirmations().Upsert(ctx, &storagemodel.TelegramConfirmation{
if e := s.repo.TelegramConfirmations().Upsert(ctx, &storagemodel.TelegramConfirmation{
RequestID: requestID,
PaymentIntentID: record.PaymentIntentID,
QuoteRef: record.QuoteRef,
RawReply: result.RawReply,
}); err != nil {
s.logger.Warn("Failed to store telegram confirmation", zap.Error(err), zap.String("request_id", requestID))
} else {
s.logger.Info("Stored telegram confirmation", zap.String("request_id", requestID),
zap.String("payment_intent_id", record.PaymentIntentID),
zap.String("reply_text", result.RawReply.Text), zap.String("reply_user_id", result.RawReply.FromUserID),
zap.String("reply_user", result.RawReply.FromUsername))
}); e != nil {
s.logger.Warn("Failed to store confirmation error", zap.Error(e),
zap.String("request_id", requestID),
zap.String("status", string(result.Status)))
}
}
nextStatus := paymentStatusFromResult(result)
currentStatus := paymentStatus(record)
if currentStatus == storagemodel.PaymentStatusExecuted || currentStatus == storagemodel.PaymentStatusExpired {
s.logger.Info("Confirmation result ignored: payment already finalized",
zap.String("request_id", requestID),
zap.String("status", string(currentStatus)))
// If the payment is already finalized — ignore the result.
switch record.Status {
case storagemodel.PaymentStatusSuccess,
storagemodel.PaymentStatusFailed,
storagemodel.PaymentStatusCancelled:
return nil
}
s.applyPaymentResult(record, nextStatus, result)
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
s.logger.Warn("Failed to persist payment status", zap.Error(err), zap.String("request_id", requestID))
now := time.Now()
switch result.Status {
// FINAL: confirmation succeeded
case model.ConfirmationStatusConfirmed:
record.Status = storagemodel.PaymentStatusSuccess
record.ExecutedMoney = result.Money
if record.ExecutedAt.IsZero() {
record.ExecutedAt = now
}
record.UpdatedAt = now
// FINAL: confirmation rejected or timed out
case model.ConfirmationStatusRejected,
model.ConfirmationStatusTimeout:
record.Status = storagemodel.PaymentStatusFailed
record.UpdatedAt = now
// NOT FINAL: do absolutely nothing
case model.ConfirmationStatusClarified:
s.logger.Debug("Confirmation clarified — no state change",
zap.String("request_id", requestID))
return nil
default:
s.logger.Debug("Non-final confirmation status — ignored",
zap.String("request_id", requestID),
zap.String("status", string(result.Status)))
return nil
}
// The ONLY place where state is persisted and rail event may be emitted
if err := s.updateTransferStatus(ctx, record); err != nil {
return err
}
intent := intentFromPayment(record)
s.publishExecution(intent, result)
s.publishTelegramReaction(result)
return nil
}
func (s *Service) buildConfirmationRequest(intent *model.PaymentGatewayIntent) (*model.ConfirmationRequest, error) {
targetChatID := strings.TrimSpace(intent.TargetChatID)
if targetChatID == "" {
targetChatID = s.chatID
}
targetChatID := s.chatID
if targetChatID == "" {
return nil, merrors.InvalidArgument("target_chat_id is required", "target_chat_id")
}
@@ -414,38 +429,6 @@ func (s *Service) sendConfirmationRequest(request *model.ConfirmationRequest) er
return nil
}
func (s *Service) publishExecution(intent *model.PaymentGatewayIntent, result *model.ConfirmationResult) {
if s == nil || intent == nil || result == nil || s.producer == nil {
return
}
exec := &model.PaymentGatewayExecution{
PaymentIntentID: intent.PaymentIntentID,
IdempotencyKey: intent.IdempotencyKey,
QuoteRef: intent.QuoteRef,
ExecutedMoney: result.Money,
Status: result.Status,
RequestID: result.RequestID,
RawReply: result.RawReply,
}
env := paymentgateway.PaymentGatewayExecution(string(mservice.PaymentGateway), exec)
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish gateway execution result",
zap.Error(err),
zap.String("request_id", result.RequestID),
zap.String("idempotency_key", intent.IdempotencyKey),
zap.String("payment_intent_id", intent.PaymentIntentID),
zap.String("quote_ref", intent.QuoteRef),
zap.String("status", string(result.Status)))
return
}
s.logger.Info("Published gateway execution result",
zap.String("request_id", result.RequestID),
zap.String("idempotency_key", intent.IdempotencyKey),
zap.String("payment_intent_id", intent.PaymentIntentID),
zap.String("quote_ref", intent.QuoteRef),
zap.String("status", string(result.Status)))
}
func (s *Service) publishTelegramReaction(result *model.ConfirmationResult) {
if s == nil || s.producer == nil || result == nil || result.RawReply == nil {
return
@@ -490,46 +473,6 @@ func (s *Service) loadPayment(ctx context.Context, requestID string) (*storagemo
return s.repo.Payments().FindByIdempotencyKey(ctx, requestID)
}
func (s *Service) expirePaymentIfNeeded(ctx context.Context, record *storagemodel.PaymentRecord) (*storagemodel.PaymentRecord, error) {
if record == nil {
return nil, nil
}
status := paymentStatus(record)
if status != storagemodel.PaymentStatusPending {
return record, nil
}
if record.ExpiresAt.IsZero() {
return record, nil
}
if time.Now().Before(record.ExpiresAt) {
return record, nil
}
record.Status = storagemodel.PaymentStatusExpired
if record.ExpiredAt.IsZero() {
record.ExpiredAt = time.Now()
}
if s != nil && s.repo != nil && s.repo.Payments() != nil {
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
return record, err
}
}
return record, nil
}
func (s *Service) markPaymentExpired(ctx context.Context, record *storagemodel.PaymentRecord, when time.Time) {
if record == nil || s == nil || s.repo == nil || s.repo.Payments() == nil {
return
}
if when.IsZero() {
when = time.Now()
}
record.Status = storagemodel.PaymentStatusExpired
record.ExpiredAt = when
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
s.logger.Warn("Failed to mark payment as expired", zap.Error(err), zap.String("request_id", record.IdempotencyKey))
}
}
func (s *Service) startAnnouncer() {
if s == nil || s.producer == nil {
return
@@ -557,78 +500,37 @@ func normalizeIntent(intent *model.PaymentGatewayIntent) *model.PaymentGatewayIn
cp.IdempotencyKey = strings.TrimSpace(cp.IdempotencyKey)
cp.OutgoingLeg = strings.TrimSpace(cp.OutgoingLeg)
cp.QuoteRef = strings.TrimSpace(cp.QuoteRef)
cp.TargetChatID = strings.TrimSpace(cp.TargetChatID)
if cp.RequestedMoney != nil {
cp.RequestedMoney.Amount = strings.TrimSpace(cp.RequestedMoney.Amount)
cp.RequestedMoney.Currency = strings.TrimSpace(cp.RequestedMoney.Currency)
}
cp.IntentRef = strings.TrimSpace(cp.IntentRef)
cp.OperationRef = strings.TrimSpace(cp.OperationRef)
return &cp
}
func paymentStatus(record *storagemodel.PaymentRecord) storagemodel.PaymentStatus {
if record == nil {
return storagemodel.PaymentStatusPending
}
if record.Status != "" {
return record.Status
}
if record.ExecutedMoney != nil || !record.ExecutedAt.IsZero() {
return storagemodel.PaymentStatusExecuted
}
return storagemodel.PaymentStatusPending
}
func paymentStatusFromResult(result *model.ConfirmationResult) storagemodel.PaymentStatus {
if result == nil {
return storagemodel.PaymentStatusPending
}
switch result.Status {
case model.ConfirmationStatusConfirmed, model.ConfirmationStatusClarified:
return storagemodel.PaymentStatusExecuted
case model.ConfirmationStatusTimeout, model.ConfirmationStatusRejected:
return storagemodel.PaymentStatusExpired
default:
return storagemodel.PaymentStatusPending
}
}
func (s *Service) applyPaymentResult(record *storagemodel.PaymentRecord, status storagemodel.PaymentStatus, result *model.ConfirmationResult) {
if record == nil {
return
}
record.Status = status
switch status {
case storagemodel.PaymentStatusExecuted:
record.ExecutedMoney = result.Money
if record.ExecutedAt.IsZero() {
record.ExecutedAt = time.Now()
}
case storagemodel.PaymentStatusExpired:
if record.ExpiredAt.IsZero() {
record.ExpiredAt = time.Now()
}
}
}
func paymentRecordFromIntent(intent *model.PaymentGatewayIntent, confirmReq *model.ConfirmationRequest) *storagemodel.PaymentRecord {
record := &storagemodel.PaymentRecord{
Status: storagemodel.PaymentStatusPending,
Status: storagemodel.PaymentStatusWaiting,
}
if intent != nil {
record.IdempotencyKey = strings.TrimSpace(intent.IdempotencyKey)
record.PaymentIntentID = strings.TrimSpace(intent.PaymentIntentID)
record.QuoteRef = strings.TrimSpace(intent.QuoteRef)
record.OutgoingLeg = strings.TrimSpace(intent.OutgoingLeg)
record.TargetChatID = strings.TrimSpace(intent.TargetChatID)
record.RequestedMoney = intent.RequestedMoney
record.IntentRef = intent.IntentRef
record.OperationRef = intent.OperationRef
}
if confirmReq != nil {
record.IdempotencyKey = strings.TrimSpace(confirmReq.RequestID)
record.PaymentIntentID = strings.TrimSpace(confirmReq.PaymentIntentID)
record.QuoteRef = strings.TrimSpace(confirmReq.QuoteRef)
record.OutgoingLeg = strings.TrimSpace(confirmReq.Rail)
record.TargetChatID = strings.TrimSpace(confirmReq.TargetChatID)
record.RequestedMoney = confirmReq.RequestedMoney
record.IntentRef = strings.TrimSpace(confirmReq.IntentRef)
record.OperationRef = strings.TrimSpace(confirmReq.OperationRef)
// ExpiresAt is not used to derive an "expired" status — it can be kept for informational purposes only.
if confirmReq.TimeoutSeconds > 0 {
record.ExpiresAt = time.Now().Add(time.Duration(confirmReq.TimeoutSeconds) * time.Second)
}
@@ -641,12 +543,14 @@ func intentFromPayment(record *storagemodel.PaymentRecord) *model.PaymentGateway
return nil
}
return &model.PaymentGatewayIntent{
PaymentRef: strings.TrimSpace(record.PaymentRef),
PaymentIntentID: strings.TrimSpace(record.PaymentIntentID),
IdempotencyKey: strings.TrimSpace(record.IdempotencyKey),
OutgoingLeg: strings.TrimSpace(record.OutgoingLeg),
QuoteRef: strings.TrimSpace(record.QuoteRef),
IntentRef: strings.TrimSpace(record.IntentRef),
OperationRef: strings.TrimSpace(record.OperationRef),
RequestedMoney: record.RequestedMoney,
TargetChatID: strings.TrimSpace(record.TargetChatID),
}
}
@@ -678,13 +582,17 @@ func intentFromSubmitTransfer(req *chainv1.SubmitTransferRequest, defaultRail, d
Currency: sourceCurrency,
}
}
paymentIntentID := strings.TrimSpace(req.GetClientReference())
paymentIntentID := strings.TrimSpace(req.GetIntentRef())
if paymentIntentID == "" {
paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID])
}
if paymentIntentID == "" {
return nil, merrors.InvalidArgument("submit_transfer: payment_intent_id is required")
}
paymentRef := strings.TrimSpace(req.PaymentRef)
if paymentRef == "" {
return nil, merrors.InvalidArgument("submit_transfer: payment_ref is required")
}
quoteRef := strings.TrimSpace(metadata[metadataQuoteRef])
targetChatID := strings.TrimSpace(metadata[metadataTargetChatID])
outgoingLeg := strings.TrimSpace(metadata[metadataOutgoingLeg])
@@ -695,12 +603,12 @@ func intentFromSubmitTransfer(req *chainv1.SubmitTransferRequest, defaultRail, d
targetChatID = strings.TrimSpace(defaultChatID)
}
return &model.PaymentGatewayIntent{
PaymentRef: paymentRef,
PaymentIntentID: paymentIntentID,
IdempotencyKey: idempotencyKey,
OutgoingLeg: outgoingLeg,
QuoteRef: quoteRef,
RequestedMoney: requestedMoney,
TargetChatID: targetChatID,
}, nil
}
@@ -708,15 +616,14 @@ func transferFromRequest(req *chainv1.SubmitTransferRequest) *chainv1.Transfer {
if req == nil {
return nil
}
amount := req.GetAmount()
return &chainv1.Transfer{
TransferRef: strings.TrimSpace(req.GetIdempotencyKey()),
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
SourceWalletRef: strings.TrimSpace(req.GetSourceWalletRef()),
Destination: req.GetDestination(),
RequestedAmount: amount,
Status: chainv1.TransferStatus_TRANSFER_SUBMITTED,
RequestedAmount: req.GetAmount(),
Status: chainv1.TransferStatus_TRANSFER_CREATED,
}
}
@@ -724,20 +631,32 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit
if record == nil {
return nil
}
var requested *moneyv1.Money
if req != nil && req.GetAmount() != nil {
requested = req.GetAmount()
} else {
requested = moneyFromPayment(record.RequestedMoney)
}
net := moneyFromPayment(record.ExecutedMoney)
status := chainv1.TransferStatus_TRANSFER_SUBMITTED
switch paymentStatus(record) {
case storagemodel.PaymentStatusExecuted:
status = chainv1.TransferStatus_TRANSFER_CONFIRMED
case storagemodel.PaymentStatusExpired:
net := moneyFromPayment(record.RequestedMoney)
var status chainv1.TransferStatus
switch record.Status {
case storagemodel.PaymentStatusSuccess:
status = chainv1.TransferStatus_TRANSFER_SUCCESS
case storagemodel.PaymentStatusCancelled:
status = chainv1.TransferStatus_TRANSFER_CANCELLED
case storagemodel.PaymentStatusFailed:
status = chainv1.TransferStatus_TRANSFER_FAILED
case storagemodel.PaymentStatusProcessing:
status = chainv1.TransferStatus_TRANSFER_PROCESSING
case storagemodel.PaymentStatusWaiting:
status = chainv1.TransferStatus_TRANSFER_WAITING
default:
status = chainv1.TransferStatus_TRANSFER_CREATED
}
transfer := &chainv1.Transfer{
TransferRef: strings.TrimSpace(record.IdempotencyKey),
IdempotencyKey: strings.TrimSpace(record.IdempotencyKey),
@@ -745,11 +664,13 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit
NetAmount: net,
Status: status,
}
if req != nil {
transfer.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef())
transfer.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef())
transfer.Destination = req.GetDestination()
}
if !record.ExecutedAt.IsZero() {
ts := timestamppb.New(record.ExecutedAt)
transfer.CreatedAt = ts
@@ -761,6 +682,7 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit
transfer.CreatedAt = timestamppb.New(record.CreatedAt)
}
}
return transfer
}
@@ -787,3 +709,27 @@ func readEnv(env string) string {
}
var _ grpcapp.Service = (*Service)(nil)
func statusFromConfirmationResult(r *model.ConfirmationResult) storagemodel.PaymentStatus {
if r == nil {
return storagemodel.PaymentStatusWaiting
}
switch r.Status {
case model.ConfirmationStatusConfirmed:
return storagemodel.PaymentStatusProcessing
case model.ConfirmationStatusClarified:
return storagemodel.PaymentStatusWaiting
case model.ConfirmationStatusRejected:
return storagemodel.PaymentStatusFailed
case model.ConfirmationStatusTimeout:
return storagemodel.PaymentStatusFailed
default:
return storagemodel.PaymentStatusFailed
}
}

View File

@@ -2,22 +2,23 @@ package gateway
import (
"context"
"encoding/json"
"sync"
"testing"
"github.com/tech/sendico/gateway/tgsettle/storage"
storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model"
envelope "github.com/tech/sendico/pkg/messaging/envelope"
tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
"github.com/tech/sendico/pkg/model"
notification "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
//
// FAKE STORES
//
type fakePaymentsStore struct {
mu sync.Mutex
records map[string]*storagemodel.PaymentRecord
@@ -26,6 +27,9 @@ type fakePaymentsStore struct {
func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string) (*storagemodel.PaymentRecord, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.records == nil {
return nil, nil
}
return f.records[key], nil
}
@@ -67,286 +71,212 @@ func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore {
return f.tg
}
type captureProducer struct {
mu sync.Mutex
confirmationRequests []*model.ConfirmationRequest
executions []*model.PaymentGatewayExecution
}
//
// FAKE BROKER (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА)
//
func (c *captureProducer) SendMessage(env envelope.Envelope) error {
_, _ = env.Serialize()
switch env.GetSignature().ToString() {
case model.NewNotification(mservice.Notifications, notification.NAConfirmationRequest).ToString():
var req model.ConfirmationRequest
if err := json.Unmarshal(env.GetData(), &req); err == nil {
c.mu.Lock()
c.confirmationRequests = append(c.confirmationRequests, &req)
c.mu.Unlock()
}
case model.NewNotification(mservice.PaymentGateway, notification.NAPaymentGatewayExecution).ToString():
var exec model.PaymentGatewayExecution
if err := json.Unmarshal(env.GetData(), &exec); err == nil {
c.mu.Lock()
c.executions = append(c.executions, &exec)
c.mu.Unlock()
}
}
type fakeBroker struct{}
func (f *fakeBroker) Publish(_ envelope.Envelope) error {
return nil
}
func (c *captureProducer) Reset() {
func (f *fakeBroker) Subscribe(event model.NotificationEvent) (<-chan envelope.Envelope, error) {
return nil, nil
}
func (f *fakeBroker) Unsubscribe(event model.NotificationEvent, subChan <-chan envelope.Envelope) error {
return nil
}
//
// CAPTURE ONLY TELEGRAM REACTIONS
//
type captureProducer struct {
mu sync.Mutex
reactions []envelope.Envelope
sig string
}
func (c *captureProducer) SendMessage(env envelope.Envelope) error {
if env.GetSignature().ToString() != c.sig {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
c.confirmationRequests = nil
c.executions = nil
c.reactions = append(c.reactions, env)
c.mu.Unlock()
return nil
}
func TestOnIntentCreatesConfirmationRequest(t *testing.T) {
//
// TESTS
//
func newTestService(_ *testing.T) (*Service, *fakeRepo, *captureProducer) {
logger := mloggerfactory.NewLogger(false)
repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}}
prod := &captureProducer{}
t.Setenv("PGS_CHAT_ID", "-100")
svc := NewService(logger, repo, prod, nil, Config{
Rail: "card",
TargetChatIDEnv: "PGS_CHAT_ID",
TimeoutSeconds: 90,
AcceptedUserIDs: []string{"42"},
repo := &fakeRepo{
payments: &fakePaymentsStore{},
tg: &fakeTelegramStore{},
}
sigEnv := tnotifications.TelegramReaction(string(mservice.PaymentGateway), &model.TelegramReactionRequest{
RequestID: "x",
ChatID: "1",
MessageID: "2",
Emoji: "ok",
})
prod.Reset()
intent := &model.PaymentGatewayIntent{
prod := &captureProducer{
sig: sigEnv.GetSignature().ToString(),
}
svc := NewService(logger, repo, prod, &fakeBroker{}, Config{
Rail: "card",
SuccessReaction: "👍",
})
return svc, repo, prod
}
func TestConfirmed(t *testing.T) {
svc, repo, prod := newTestService(t)
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
IdempotencyKey: "idem-1",
PaymentIntentID: "pi-1",
IdempotencyKey: "idem-1",
OutgoingLeg: "card",
QuoteRef: "quote-1",
RequestedMoney: &paymenttypes.Money{Amount: "10.50", Currency: "USD"},
TargetChatID: "",
}
if err := svc.onIntent(context.Background(), intent); err != nil {
t.Fatalf("onIntent error: %v", err)
}
if len(prod.confirmationRequests) != 1 {
t.Fatalf("expected 1 confirmation request, got %d", len(prod.confirmationRequests))
}
req := prod.confirmationRequests[0]
if req.RequestID != "idem-1" || req.PaymentIntentID != "pi-1" || req.QuoteRef != "quote-1" {
t.Fatalf("unexpected confirmation request fields: %#v", req)
}
if req.TargetChatID != "-100" {
t.Fatalf("expected target chat id -100, got %q", req.TargetChatID)
}
if req.RequestedMoney == nil || req.RequestedMoney.Amount != "10.50" || req.RequestedMoney.Currency != "USD" {
t.Fatalf("requested money mismatch: %#v", req.RequestedMoney)
}
if req.TimeoutSeconds != 90 {
t.Fatalf("expected timeout 90, got %d", req.TimeoutSeconds)
}
if req.SourceService != string(mservice.PaymentGateway) || req.Rail != "card" {
t.Fatalf("unexpected source/rail: %#v", req)
}
record := repo.payments.records["idem-1"]
if record == nil {
t.Fatalf("expected pending payment to be stored")
}
if record.Status != storagemodel.PaymentStatusPending {
t.Fatalf("expected pending status, got %q", record.Status)
}
if record.RequestedMoney == nil || record.RequestedMoney.Amount != "10.50" {
t.Fatalf("requested money not stored correctly: %#v", record.RequestedMoney)
}
}
func TestIntentFromSubmitTransferUsesSourceMoney(t *testing.T) {
req := &chainv1.SubmitTransferRequest{
IdempotencyKey: "idem-1",
ClientReference: "pi-1",
Amount: &moneyv1.Money{Amount: "10.00", Currency: "EUR"},
Metadata: map[string]string{
metadataSourceAmount: "12.34",
metadataSourceCurrency: "USD",
},
}
intent, err := intentFromSubmitTransfer(req, "provider_settlement", "")
if err != nil {
t.Fatalf("intentFromSubmitTransfer error: %v", err)
}
if intent.RequestedMoney == nil || intent.RequestedMoney.Amount != "12.34" || intent.RequestedMoney.Currency != "USD" {
t.Fatalf("expected source money override, got: %#v", intent.RequestedMoney)
}
}
func TestConfirmationResultPersistsExecutionAndReply(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}}
prod := &captureProducer{}
svc := NewService(logger, repo, prod, nil, Config{Rail: "card"})
intent := &model.PaymentGatewayIntent{
PaymentIntentID: "pi-2",
IdempotencyKey: "idem-2",
QuoteRef: "quote-2",
OutgoingLeg: "card",
RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
Status: storagemodel.PaymentStatusWaiting,
})
result := &model.ConfirmationResult{
RequestID: "idem-1",
Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
Status: model.ConfirmationStatusConfirmed,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
}
_ = svc.onConfirmationResult(context.Background(), result)
rec := repo.payments.records["idem-1"]
if rec.Status != storagemodel.PaymentStatusSuccess {
t.Fatalf("expected success, got %s", rec.Status)
}
if rec.RequestedMoney == nil {
t.Fatalf("requested money not set")
}
if rec.ExecutedAt.IsZero() {
t.Fatalf("executedAt not set")
}
if repo.tg.records["idem-1"] == nil {
t.Fatalf("telegram confirmation not stored")
}
if len(prod.reactions) != 1 {
t.Fatalf("reaction must be published")
}
}
func TestClarified(t *testing.T) {
svc, repo, prod := newTestService(t)
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
IdempotencyKey: "idem-2",
PaymentIntentID: intent.PaymentIntentID,
QuoteRef: intent.QuoteRef,
OutgoingLeg: intent.OutgoingLeg,
RequestedMoney: intent.RequestedMoney,
Status: storagemodel.PaymentStatusPending,
IdempotencyKey: "idem-2",
Status: storagemodel.PaymentStatusWaiting,
})
result := &model.ConfirmationResult{
RequestID: "idem-2",
Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
Status: model.ConfirmationStatusConfirmed,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2", Text: "5 EUR"},
Status: model.ConfirmationStatusClarified,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
}
if err := svc.onConfirmationResult(context.Background(), result); err != nil {
t.Fatalf("onConfirmationResult error: %v", err)
_ = svc.onConfirmationResult(context.Background(), result)
rec := repo.payments.records["idem-2"]
if rec.Status != storagemodel.PaymentStatusWaiting {
t.Fatalf("clarified must not change status")
}
record := repo.payments.records["idem-2"]
if record == nil {
t.Fatalf("expected payment record to be stored")
if repo.tg.records["idem-2"] == nil {
t.Fatalf("telegram confirmation must be stored")
}
if record.Status != storagemodel.PaymentStatusExecuted {
t.Fatalf("expected executed status, got %q", record.Status)
}
if record.ExecutedMoney == nil || record.ExecutedMoney.Amount != "5" {
t.Fatalf("executed money not stored correctly: %#v", record.ExecutedMoney)
}
if repo.tg.records["idem-2"] == nil || repo.tg.records["idem-2"].RawReply.Text != "5 EUR" {
t.Fatalf("telegram reply not stored correctly")
if len(prod.reactions) != 0 {
t.Fatalf("clarified must not publish reaction")
}
}
func TestClarifiedResultPersistsExecution(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}}
prod := &captureProducer{}
svc := NewService(logger, repo, prod, nil, Config{Rail: "card"})
intent := &model.PaymentGatewayIntent{
PaymentIntentID: "pi-clarified",
IdempotencyKey: "idem-clarified",
QuoteRef: "quote-clarified",
OutgoingLeg: "card",
RequestedMoney: &paymenttypes.Money{Amount: "12", Currency: "USD"},
}
func TestRejected(t *testing.T) {
svc, repo, prod := newTestService(t)
// ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil,
// даем минимально ожидаемые поля + non-nil ExecutedMoney.
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
IdempotencyKey: "idem-clarified",
PaymentIntentID: intent.PaymentIntentID,
QuoteRef: intent.QuoteRef,
OutgoingLeg: intent.OutgoingLeg,
RequestedMoney: intent.RequestedMoney,
Status: storagemodel.PaymentStatusPending,
IdempotencyKey: "idem-3",
PaymentIntentID: "pi-3",
QuoteRef: "quote-3",
OutgoingLeg: "card",
RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"},
Status: storagemodel.PaymentStatusWaiting,
})
result := &model.ConfirmationResult{
RequestID: "idem-clarified",
Money: &paymenttypes.Money{Amount: "12", Currency: "USD"},
Status: model.ConfirmationStatusClarified,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "3", Text: "12 USD"},
RequestID: "idem-3",
Status: model.ConfirmationStatusRejected,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
}
if err := svc.onConfirmationResult(context.Background(), result); err != nil {
t.Fatalf("onConfirmationResult error: %v", err)
_ = svc.onConfirmationResult(context.Background(), result)
rec := repo.payments.records["idem-3"]
if rec.Status != storagemodel.PaymentStatusFailed {
t.Fatalf("expected failed")
}
record := repo.payments.records["idem-clarified"]
if record == nil || record.Status != storagemodel.PaymentStatusExecuted {
t.Fatalf("expected payment executed status, got %#v", record)
if repo.tg.records["idem-3"] == nil {
t.Fatalf("telegram confirmation must be stored")
}
if len(prod.reactions) != 0 {
t.Fatalf("rejected must not publish reaction")
}
}
func TestIdempotencyPreventsDuplicateWrites(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
repo := &fakeRepo{payments: &fakePaymentsStore{records: map[string]*storagemodel.PaymentRecord{
"idem-3": {IdempotencyKey: "idem-3", Status: storagemodel.PaymentStatusPending},
}}, tg: &fakeTelegramStore{}}
prod := &captureProducer{}
svc := NewService(logger, repo, prod, nil, Config{Rail: "card"})
intent := &model.PaymentGatewayIntent{
PaymentIntentID: "pi-3",
IdempotencyKey: "idem-3",
OutgoingLeg: "card",
QuoteRef: "quote-3",
RequestedMoney: &paymenttypes.Money{Amount: "1", Currency: "USD"},
TargetChatID: "chat",
}
if err := svc.onIntent(context.Background(), intent); err != nil {
t.Fatalf("onIntent error: %v", err)
}
if len(prod.confirmationRequests) != 0 {
t.Fatalf("expected no confirmation request for duplicate intent")
}
}
func TestTimeout(t *testing.T) {
svc, repo, prod := newTestService(t)
func TestTimeoutDoesNotPersistExecution(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}}
prod := &captureProducer{}
svc := NewService(logger, repo, prod, nil, Config{Rail: "card"})
intent := &model.PaymentGatewayIntent{
PaymentIntentID: "pi-4",
IdempotencyKey: "idem-4",
QuoteRef: "quote-4",
OutgoingLeg: "card",
RequestedMoney: &paymenttypes.Money{Amount: "8", Currency: "USD"},
}
// ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil,
// даем минимально ожидаемые поля + non-nil ExecutedMoney.
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
IdempotencyKey: "idem-4",
PaymentIntentID: intent.PaymentIntentID,
QuoteRef: intent.QuoteRef,
OutgoingLeg: intent.OutgoingLeg,
RequestedMoney: intent.RequestedMoney,
Status: storagemodel.PaymentStatusPending,
PaymentIntentID: "pi-4",
QuoteRef: "quote-4",
OutgoingLeg: "card",
RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"},
ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"},
Status: storagemodel.PaymentStatusWaiting,
})
result := &model.ConfirmationResult{
RequestID: "idem-4",
Status: model.ConfirmationStatusTimeout,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"},
}
if err := svc.onConfirmationResult(context.Background(), result); err != nil {
t.Fatalf("onConfirmationResult error: %v", err)
_ = svc.onConfirmationResult(context.Background(), result)
rec := repo.payments.records["idem-4"]
if rec.Status != storagemodel.PaymentStatusFailed {
t.Fatalf("timeout must be failed")
}
record := repo.payments.records["idem-4"]
if record == nil || record.Status != storagemodel.PaymentStatusExpired {
t.Fatalf("expected expired status for timeout, got %#v", record)
}
}
func TestRejectedDoesNotPersistExecution(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}}
prod := &captureProducer{}
svc := NewService(logger, repo, prod, nil, Config{Rail: "card"})
intent := &model.PaymentGatewayIntent{
PaymentIntentID: "pi-reject",
IdempotencyKey: "idem-reject",
QuoteRef: "quote-reject",
OutgoingLeg: "card",
RequestedMoney: &paymenttypes.Money{Amount: "3", Currency: "USD"},
}
_ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{
IdempotencyKey: "idem-reject",
PaymentIntentID: intent.PaymentIntentID,
QuoteRef: intent.QuoteRef,
OutgoingLeg: intent.OutgoingLeg,
RequestedMoney: intent.RequestedMoney,
Status: storagemodel.PaymentStatusPending,
})
result := &model.ConfirmationResult{
RequestID: "idem-reject",
Status: model.ConfirmationStatusRejected,
RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "4", Text: "no"},
}
if err := svc.onConfirmationResult(context.Background(), result); err != nil {
t.Fatalf("onConfirmationResult error: %v", err)
}
record := repo.payments.records["idem-reject"]
if record == nil || record.Status != storagemodel.PaymentStatusExpired {
t.Fatalf("expected expired status for rejection, got %#v", record)
}
if repo.tg.records["idem-reject"] == nil {
t.Fatalf("expected raw reply to be stored for rejection")
if repo.tg.records["idem-4"] == nil {
t.Fatalf("telegram confirmation must be stored")
}
if len(prod.reactions) != 0 {
t.Fatalf("timeout must not publish reaction")
}
}

View File

@@ -0,0 +1,77 @@
package gateway
import (
"context"
"github.com/tech/sendico/gateway/tgsettle/storage/model"
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/pkg/payments/rail"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
"go.uber.org/zap"
)
func isFinalStatus(t *model.PaymentRecord) bool {
switch t.Status {
case model.PaymentStatusFailed, model.PaymentStatusSuccess, model.PaymentStatusCancelled:
return true
default:
return false
}
}
func toOpStatus(t *model.PaymentRecord) rail.OperationResult {
switch t.Status {
case model.PaymentStatusFailed:
return rail.OperationResultFailed
case model.PaymentStatusSuccess:
return rail.OperationResultSuccess
case model.PaymentStatusCancelled:
return rail.OperationResultCancelled
default:
panic("unexpected transfer status")
}
}
func toError(t *model.PaymentRecord) *gatewayv1.OperationError {
if t.Status == model.PaymentStatusSuccess {
return nil
}
return &gatewayv1.OperationError{
Message: t.FailureReason,
}
}
func (s *Service) updateTransferStatus(ctx context.Context, record *model.PaymentRecord) error {
if err := s.repo.Payments().Upsert(ctx, record); err != nil {
s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err))
return err
}
if isFinalStatus(record) {
s.emitTransferStatusEvent(ctx, record)
}
return nil
}
func (s *Service) emitTransferStatusEvent(_ context.Context, record *model.PaymentRecord) {
if s == nil || s.producer == nil || record == nil {
return
}
exec := pmodel.PaymentGatewayExecution{
PaymentIntentID: record.PaymentIntentID,
IdempotencyKey: record.IdempotencyKey,
ExecutedMoney: record.ExecutedMoney,
PaymentRef: record.PaymentRef,
Status: toOpStatus(record),
OperationRef: record.OperationRef,
Error: record.FailureReason,
TransferRef: record.ID.Hex(),
}
env := paymentgateway.PaymentGatewayExecution(mservice.MntxGateway, &exec)
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", record.ID))
}
}

View File

@@ -11,21 +11,28 @@ import (
type PaymentStatus string
const (
PaymentStatusPending PaymentStatus = "pending"
PaymentStatusExpired PaymentStatus = "expired"
PaymentStatusExecuted PaymentStatus = "executed"
PaymentStatusCreated PaymentStatus = "created" // created
PaymentStatusProcessing PaymentStatus = "processing" // processing
PaymentStatusWaiting PaymentStatus = "waiting" // waiting external action
PaymentStatusSuccess PaymentStatus = "success" // final success
PaymentStatusFailed PaymentStatus = "failed" // final failure
PaymentStatusCancelled PaymentStatus = "cancelled" // cancelled final
)
type PaymentRecord struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
OperationRef string `bson:"operationRef,omitempty" json:"operation_ref,omitempty"`
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"`
PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"`
QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"`
IntentRef string `bson:"intentRef,omitempty" json:"intent_ref,omitempty"`
PaymentRef string `bson:"paymentRef,omitempty" json:"payment_ref,omitempty"`
OutgoingLeg string `bson:"outgoingLeg,omitempty" json:"outgoing_leg,omitempty"`
TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"`
RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"`
ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"`
Status PaymentStatus `bson:"status,omitempty" json:"status,omitempty"`
FailureReason string `bson:"failureReason,omitempty" json:"Failure_reason,omitempty"`
CreatedAt time.Time `bson:"createdAt,omitempty" json:"created_at,omitempty"`
UpdatedAt time.Time `bson:"updatedAt,omitempty" json:"updated_at,omitempty"`
ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"`

View File

@@ -85,6 +85,9 @@ func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) erro
if record.IdempotencyKey == "" {
return merrors.InvalidArgument("idempotency key is required", "idempotency_key")
}
if record.IntentRef == "" {
return merrors.InvalidArgument("intention reference key is required", "intent_ref")
}
now := time.Now()
if record.CreatedAt.IsZero() {
record.CreatedAt = now

View File

@@ -9,7 +9,7 @@ import (
chainasset "github.com/tech/sendico/pkg/chain"
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/model/account_role"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
@@ -426,7 +426,7 @@ func operationFromTransfer(req *chainv1.SubmitTransferRequest) (*connectorv1.Ope
params := map[string]interface{}{
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
"client_reference": strings.TrimSpace(req.GetClientReference()),
"payment_ref": strings.TrimSpace(req.GetPaymentRef()),
}
if memo := strings.TrimSpace(req.GetDestination().GetMemo()); memo != "" {
params["destination_memo"] = memo
@@ -472,14 +472,14 @@ func setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[strin
if op == nil || len(metadata) == 0 {
return
}
if raw := strings.TrimSpace(metadata[pmodel.MetadataKeyFromRole]); raw != "" {
if role, ok := pmodel.Parse(raw); ok && role != "" {
op.FromRole = pmodel.ToProto(role)
if raw := strings.TrimSpace(metadata[account_role.MetadataKeyFromRole]); raw != "" {
if role, ok := account_role.Parse(raw); ok && role != "" {
op.FromRole = account_role.ToProto(role)
}
}
if raw := strings.TrimSpace(metadata[pmodel.MetadataKeyToRole]); raw != "" {
if role, ok := pmodel.Parse(raw); ok && role != "" {
op.ToRole = pmodel.ToProto(role)
if raw := strings.TrimSpace(metadata[account_role.MetadataKeyToRole]); raw != "" {
if role, ok := account_role.Parse(raw); ok && role != "" {
op.ToRole = account_role.ToProto(role)
}
}
}
@@ -619,7 +619,7 @@ func gasTopUpEnsureOperation(req *chainv1.EnsureGasTopUpRequest) (*connectorv1.O
"mode": "ensure",
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
"target_wallet_ref": strings.TrimSpace(req.GetTargetWalletRef()),
"client_reference": strings.TrimSpace(req.GetClientReference()),
"payment_ref": strings.TrimSpace(req.GetPaymentRef()),
"estimated_total_fee": map[string]interface{}{"amount": fee.GetAmount(), "currency": fee.GetCurrency()},
}
if len(req.GetMetadata()) > 0 {
@@ -765,28 +765,54 @@ func managedWalletStatusFromAccount(state connectorv1.AccountState) chainv1.Mana
}
}
func transferStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus {
switch status {
case connectorv1.OperationStatus_CONFIRMED:
return chainv1.TransferStatus_TRANSFER_CONFIRMED
case connectorv1.OperationStatus_FAILED:
return chainv1.TransferStatus_TRANSFER_FAILED
case connectorv1.OperationStatus_CANCELED:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.TransferStatus_TRANSFER_PENDING
}
}
func operationStatusFromTransfer(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return connectorv1.OperationStatus_CONFIRMED
case chainv1.TransferStatus_TRANSFER_CREATED:
return connectorv1.OperationStatus_OPERATION_CREATED
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return connectorv1.OperationStatus_OPERATION_PROCESSING
case chainv1.TransferStatus_TRANSFER_WAITING:
return connectorv1.OperationStatus_OPERATION_WAITING
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return connectorv1.OperationStatus_OPERATION_SUCCESS
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_FAILED
return connectorv1.OperationStatus_OPERATION_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_CANCELED
return connectorv1.OperationStatus_OPERATION_CANCELLED
default:
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}
func transferStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus {
switch status {
case connectorv1.OperationStatus_OPERATION_CREATED:
return chainv1.TransferStatus_TRANSFER_CREATED
case connectorv1.OperationStatus_OPERATION_PROCESSING:
return chainv1.TransferStatus_TRANSFER_PROCESSING
case connectorv1.OperationStatus_OPERATION_WAITING:
return chainv1.TransferStatus_TRANSFER_WAITING
case connectorv1.OperationStatus_OPERATION_SUCCESS:
return chainv1.TransferStatus_TRANSFER_SUCCESS
case connectorv1.OperationStatus_OPERATION_FAILED:
return chainv1.TransferStatus_TRANSFER_FAILED
case connectorv1.OperationStatus_OPERATION_CANCELLED:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
}
}

View File

@@ -5,7 +5,7 @@ import (
"strings"
"github.com/tech/sendico/pkg/merrors"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/model/account_role"
"github.com/tech/sendico/pkg/payments/rail"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
@@ -106,9 +106,11 @@ func (g *chainRailGateway) Send(ctx context.Context, req rail.TransferRequest) (
Currency: currency,
Amount: amountValue,
},
Fees: fees,
Metadata: transferMetadataWithRoles(req.Metadata, req.FromRole, req.ToRole),
ClientReference: strings.TrimSpace(req.ClientReference),
Fees: fees,
Metadata: transferMetadataWithRoles(req.Metadata, req.FromRole, req.ToRole),
PaymentRef: strings.TrimSpace(req.PaymentRef),
OperationRef: strings.TrimSpace(req.OperationRef),
IntentRef: strings.TrimSpace(req.OperationRef),
})
if err != nil {
return rail.RailResult{}, err
@@ -186,20 +188,29 @@ func (g *chainRailGateway) isManagedWallet(ctx context.Context, walletRef string
return true, nil
}
func statusFromTransfer(status chainv1.TransferStatus) string {
func statusFromTransfer(status chainv1.TransferStatus) rail.TransferStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
case chainv1.TransferStatus_TRANSFER_CREATED:
return rail.TransferStatusCreated
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return rail.TransferStatusProcessing
case chainv1.TransferStatus_TRANSFER_WAITING:
return rail.TransferStatusProcessing
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return rail.TransferStatusSuccess
case chainv1.TransferStatus_TRANSFER_FAILED:
return rail.TransferStatusFailed
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return rail.TransferStatusRejected
case chainv1.TransferStatus_TRANSFER_SIGNING,
chainv1.TransferStatus_TRANSFER_PENDING,
chainv1.TransferStatus_TRANSFER_SUBMITTED:
return rail.TransferStatusPending
return rail.TransferStatusCancelled
default:
return rail.TransferStatusPending
return rail.TransferStatusUnspecified
}
}
@@ -255,19 +266,19 @@ func railMoneyFromProto(m *moneyv1.Money) *rail.Money {
}
}
func transferMetadataWithRoles(metadata map[string]string, fromRole, toRole pmodel.AccountRole) map[string]string {
func transferMetadataWithRoles(metadata map[string]string, fromRole, toRole account_role.AccountRole) map[string]string {
result := cloneMetadata(metadata)
if strings.TrimSpace(string(fromRole)) != "" {
if result == nil {
result = map[string]string{}
}
result[pmodel.MetadataKeyFromRole] = strings.TrimSpace(string(fromRole))
result[account_role.MetadataKeyFromRole] = strings.TrimSpace(string(fromRole))
}
if strings.TrimSpace(string(toRole)) != "" {
if result == nil {
result = map[string]string{}
}
result[pmodel.MetadataKeyToRole] = strings.TrimSpace(string(toRole))
result[account_role.MetadataKeyToRole] = strings.TrimSpace(string(toRole))
}
if len(result) == 0 {
return nil

View File

@@ -23,7 +23,7 @@ require (
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260131145833-e3fabd62fc61 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260201044653-ee82dce4af02 // 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.10.0 // indirect
@@ -92,6 +92,6 @@ require (
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260202165425-ce8ad4cf556b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect
)

View File

@@ -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-20260131145833-e3fabd62fc61 h1:iLc9NjmJ3AdAl5VoiRSDXzEmmW8kvHp3E2vJ2eKKc7s=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260131145833-e3fabd62fc61/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260201044653-ee82dce4af02 h1:0uY5Ooun4eqGmP0IrQhiKVqeeEXoeEcL8KVRtug8+r8=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260201044653-ee82dce4af02/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=
@@ -379,10 +379,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/api v0.0.0-20260202165425-ce8ad4cf556b h1:SGYyueaEovpqmWmtTvwtVgo638V/QFE2zlTCnRrR3jg=
google.golang.org/genproto/googleapis/api v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

View File

@@ -126,9 +126,11 @@ func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.Ensure
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef},
},
Amount: topUp,
Metadata: shared.CloneMetadata(req.GetMetadata()),
ClientReference: strings.TrimSpace(req.GetClientReference()),
Amount: topUp,
Metadata: shared.CloneMetadata(req.GetMetadata()),
PaymentRef: strings.TrimSpace(req.GetPaymentRef()),
IntentRef: strings.TrimSpace(req.GetIntentRef()),
OperationRef: strings.TrimSpace(req.GetOperationRef()),
}
submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq)

View File

@@ -38,11 +38,12 @@ func toProtoTransfer(transfer *model.Transfer) *chainv1.Transfer {
TransferRef: transfer.TransferRef,
IdempotencyKey: transfer.IdempotencyKey,
OrganizationRef: transfer.OrganizationRef,
IntentRef: transfer.IntentRef,
SourceWalletRef: transfer.SourceWalletRef,
Destination: destination,
Asset: asset,
RequestedAmount: shared.CloneMoney(transfer.RequestedAmount),
NetAmount: shared.CloneMoney(transfer.NetAmount),
RequestedAmount: shared.MonenyToProto(transfer.RequestedAmount),
NetAmount: shared.MonenyToProto(transfer.NetAmount),
Fees: protoFees,
Status: shared.TransferStatusToProto(transfer.Status),
TransactionHash: transfer.TxHash,

View File

@@ -38,6 +38,17 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
c.deps.Logger.Warn("Missing idempotency key")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required"))
}
intentRef := strings.TrimSpace(req.GetIntentRef())
if intentRef == "" {
c.deps.Logger.Warn("Missing intent reference")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("intentRef is required"))
}
operationRef := strings.TrimSpace(req.GetOperationRef())
if operationRef == "" {
c.deps.Logger.Warn("Missing operation reference")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("operationRef is required"))
}
organizationRef := strings.TrimSpace(req.GetOrganizationRef())
if organizationRef == "" {
c.deps.Logger.Warn("Missing organization ref")
@@ -63,6 +74,11 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
c.deps.Logger.Warn("Missing amount value")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required"))
}
paymentRef := strings.TrimSpace(req.GetPaymentRef())
if paymentRef == "" {
c.deps.Logger.Warn("Missing payment reference")
return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("payment reference is required", "paymentRef"))
}
sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef)
if err != nil {
@@ -123,6 +139,8 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
transfer := &model.Transfer{
IdempotencyKey: idempotencyKey,
OperationRef: operationRef,
IntentRef: intentRef,
TransferRef: shared.GenerateTransferRef(),
OrganizationRef: organizationRef,
SourceWalletRef: sourceWalletRef,
@@ -130,11 +148,11 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit
Network: sourceWallet.Network,
TokenSymbol: effectiveTokenSymbol,
ContractAddress: effectiveContractAddress,
RequestedAmount: shared.CloneMoney(amount),
NetAmount: netAmount,
RequestedAmount: shared.ProtoToMoney(amount),
NetAmount: shared.ProtoToMoney(netAmount),
PaymentRef: paymentRef,
Fees: fees,
Status: model.TransferStatusPending,
ClientReference: strings.TrimSpace(req.GetClientReference()),
Status: model.TransferStatusCreated,
LastStatusAt: c.deps.Clock.Now().UTC(),
}

View File

@@ -169,7 +169,9 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
Amount: amount,
Fees: parseChainFees(reader),
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
ClientReference: strings.TrimSpace(reader.String("client_reference")),
PaymentRef: strings.TrimSpace(reader.String("payment_ref")),
IntentRef: strings.TrimSpace(op.GetIntentRef()),
OperationRef: strings.TrimSpace(op.GetOperationRef()),
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
@@ -208,7 +210,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Status: connectorv1.OperationStatus_OPERATION_SUCCESS,
Result: result,
},
}, nil
@@ -238,7 +240,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Status: connectorv1.OperationStatus_OPERATION_SUCCESS,
Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), ""),
},
}, nil
@@ -256,12 +258,14 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
}
resp, err := s.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
IntentRef: strings.TrimSpace(op.GetIntentRef()),
OperationRef: strings.TrimSpace(op.GetOperationRef()),
OrganizationRef: orgRef,
SourceWalletRef: source,
TargetWalletRef: target,
EstimatedTotalFee: fee,
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
ClientReference: strings.TrimSpace(reader.String("client_reference")),
PaymentRef: strings.TrimSpace(reader.String("payment_ref")),
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
@@ -273,7 +277,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Status: shared.ChainTransferStatusToOperation(resp.GetTransfer().GetStatus()),
Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), transferRef),
},
}, nil
@@ -544,25 +548,51 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation
func chainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return connectorv1.OperationStatus_CONFIRMED
case chainv1.TransferStatus_TRANSFER_CREATED:
return connectorv1.OperationStatus_OPERATION_CREATED
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return connectorv1.OperationStatus_OPERATION_PROCESSING
case chainv1.TransferStatus_TRANSFER_WAITING:
return connectorv1.OperationStatus_OPERATION_WAITING
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return connectorv1.OperationStatus_OPERATION_SUCCESS
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_FAILED
return connectorv1.OperationStatus_OPERATION_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_CANCELED
return connectorv1.OperationStatus_OPERATION_CANCELLED
default:
return connectorv1.OperationStatus_PENDING
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}
func chainStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus {
switch status {
case connectorv1.OperationStatus_CONFIRMED:
return chainv1.TransferStatus_TRANSFER_CONFIRMED
case connectorv1.OperationStatus_FAILED:
case connectorv1.OperationStatus_OPERATION_CREATED:
return chainv1.TransferStatus_TRANSFER_CREATED
case connectorv1.OperationStatus_OPERATION_PROCESSING:
return chainv1.TransferStatus_TRANSFER_PROCESSING
case connectorv1.OperationStatus_OPERATION_WAITING:
return chainv1.TransferStatus_TRANSFER_WAITING
case connectorv1.OperationStatus_OPERATION_SUCCESS:
return chainv1.TransferStatus_TRANSFER_SUCCESS
case connectorv1.OperationStatus_OPERATION_FAILED:
return chainv1.TransferStatus_TRANSFER_FAILED
case connectorv1.OperationStatus_CANCELED:
case connectorv1.OperationStatus_OPERATION_CANCELLED:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
}

View File

@@ -3,7 +3,6 @@ package tron
import (
"context"
"math/big"
"strings"
"time"
"github.com/ethereum/go-ethereum/core/types"
@@ -157,9 +156,3 @@ func GetTransactionStatus(
}
return 0, nil
}
// isTronNetwork checks if the network name indicates a TRON network.
func isTronNetwork(networkName string) bool {
name := strings.ToLower(strings.TrimSpace(networkName))
return strings.HasPrefix(name, "tron")
}

View File

@@ -162,6 +162,7 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) {
IdempotencyKey: "transfer-1",
OrganizationRef: "org-1",
SourceWalletRef: srcRef,
PaymentRef: "ref-1",
Destination: &ichainv1.TransferDestination{
Destination: &ichainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: dstRef},
},
@@ -172,6 +173,8 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) {
Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"},
},
},
OperationRef: "oper-1",
IntentRef: "intent-1",
})
require.NoError(t, err)
require.NotNil(t, transferResp.GetTransfer())
@@ -179,7 +182,7 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) {
stored := repo.transfers.get(transferResp.GetTransfer().GetTransferRef())
require.NotNil(t, stored)
require.Equal(t, model.TransferStatusPending, stored.Status)
require.Equal(t, model.TransferStatusCreated, stored.Status)
// GetTransfer
getResp, err := svc.GetTransfer(ctx, &ichainv1.GetTransferRequest{TransferRef: stored.TransferRef})

View File

@@ -6,7 +6,9 @@ import (
"github.com/tech/sendico/gateway/tron/storage/model"
chainasset "github.com/tech/sendico/pkg/chain"
pmodel "github.com/tech/sendico/pkg/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -77,42 +79,82 @@ func ManagedWalletStatusToProto(status model.ManagedWalletStatus) chainv1.Manage
func TransferStatusToModel(status chainv1.TransferStatus) model.TransferStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_PENDING:
return model.TransferStatusPending
case chainv1.TransferStatus_TRANSFER_SIGNING:
return model.TransferStatusSigning
case chainv1.TransferStatus_TRANSFER_SUBMITTED:
return model.TransferStatusSubmitted
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return model.TransferStatusConfirmed
case chainv1.TransferStatus_TRANSFER_CREATED:
return model.TransferStatusCreated
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return model.TransferStatusProcessing
case chainv1.TransferStatus_TRANSFER_WAITING:
return model.TransferStatusWaiting
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return model.TransferStatusSuccess
case chainv1.TransferStatus_TRANSFER_FAILED:
return model.TransferStatusFailed
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return model.TransferStatusCancelled
default:
return ""
return model.TransferStatus("")
}
}
func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus {
switch status {
case model.TransferStatusPending:
return chainv1.TransferStatus_TRANSFER_PENDING
case model.TransferStatusSigning:
return chainv1.TransferStatus_TRANSFER_SIGNING
case model.TransferStatusSubmitted:
return chainv1.TransferStatus_TRANSFER_SUBMITTED
case model.TransferStatusConfirmed:
return chainv1.TransferStatus_TRANSFER_CONFIRMED
case model.TransferStatusCreated:
return chainv1.TransferStatus_TRANSFER_CREATED
case model.TransferStatusProcessing:
return chainv1.TransferStatus_TRANSFER_PROCESSING
case model.TransferStatusWaiting:
return chainv1.TransferStatus_TRANSFER_WAITING
case model.TransferStatusSuccess:
return chainv1.TransferStatus_TRANSFER_SUCCESS
case model.TransferStatusFailed:
return chainv1.TransferStatus_TRANSFER_FAILED
case model.TransferStatusCancelled:
return chainv1.TransferStatus_TRANSFER_CANCELLED
default:
return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
}
}
func ChainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CREATED:
return connectorv1.OperationStatus_OPERATION_CREATED
case chainv1.TransferStatus_TRANSFER_PROCESSING:
return connectorv1.OperationStatus_OPERATION_PROCESSING
case chainv1.TransferStatus_TRANSFER_WAITING:
return connectorv1.OperationStatus_OPERATION_WAITING
case chainv1.TransferStatus_TRANSFER_SUCCESS:
return connectorv1.OperationStatus_OPERATION_SUCCESS
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_OPERATION_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_OPERATION_CANCELLED
default:
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}
// NativeCurrency returns the canonical native token symbol for a network.
func NativeCurrency(network Network) string {
currency := strings.ToUpper(strings.TrimSpace(network.NativeToken))
@@ -146,3 +188,23 @@ type ServiceWallet struct {
Address string
PrivateKey string
}
func ProtoToMoney(money *moneyv1.Money) *paymenttypes.Money {
if money == nil {
return &paymenttypes.Money{}
}
return &paymenttypes.Money{
Amount: money.GetAmount(),
Currency: money.GetCurrency(),
}
}
func MonenyToProto(money *paymenttypes.Money) *moneyv1.Money {
if money == nil {
return &moneyv1.Money{}
}
return &moneyv1.Money{
Amount: money.Amount,
Currency: money.Currency,
}
}

View File

@@ -40,26 +40,26 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
return err
}
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil {
if _, err := s.updateTransferStatus(ctx, transferRef, model.TransferStatusProcessing, "", ""); err != nil {
s.logger.Warn("Failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err))
}
driverDeps := s.driverDeps()
chainDriver, err := s.driverForNetwork(network.Name.String())
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
_, _ = s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
_, _ = s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
sourceAddress, err := chainDriver.NormalizeAddress(sourceWallet.DepositAddress)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
_, _ = s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
return err
}
if chainDriver.Name() == "tron" && sourceAddress == destinationAddress {
@@ -68,7 +68,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
zap.String("wallet_ref", sourceWalletRef),
zap.String("network", network.Name.String()),
)
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", ""); err != nil {
if _, err := s.updateTransferStatus(ctx, transferRef, model.TransferStatusSuccess, "", ""); err != nil {
s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err))
}
return nil
@@ -76,11 +76,14 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress)
if err != nil {
_, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "")
s.logger.Warn("Failed to submit transfer", zap.String("transfer_ref", transferRef), zap.Error(err))
if _, e := s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), ""); e != nil {
s.logger.Warn("Failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(e))
}
return err
}
if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil {
if _, err := s.updateTransferStatus(ctx, transferRef, model.TransferStatusWaiting, "", txHash); err != nil {
s.logger.Warn("Failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err))
}
@@ -94,15 +97,15 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet
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))
}
return nil
failureReason := ""
pStatus := model.TransferStatusSuccess
if receipt != nil && receipt.Status != types.ReceiptStatusSuccessful {
failureReason = "transaction reverted"
pStatus = model.TransferStatusFailed
}
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))
if _, err := s.updateTransferStatus(ctx, transferRef, pStatus, failureReason, txHash); err != nil {
s.logger.Warn("Failed to update transfer status", zap.Error(err),
zap.String("transfer_ref", transferRef), zap.String("status", string(pStatus)))
}
return nil
}

View File

@@ -0,0 +1,77 @@
package gateway
import (
"context"
"fmt"
"github.com/tech/sendico/gateway/tron/storage/model"
paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway"
pmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/payments/rail"
"go.uber.org/zap"
)
func isFinalStatus(t *model.Transfer) bool {
switch t.Status {
case model.TransferStatusFailed, model.TransferStatusSuccess, model.TransferStatusCancelled:
return true
default:
return false
}
}
func toOpStatus(t *model.Transfer) rail.OperationResult {
switch t.Status {
case model.TransferStatusFailed:
return rail.OperationResultFailed
case model.TransferStatusSuccess:
return rail.OperationResultSuccess
case model.TransferStatusCancelled:
return rail.OperationResultCancelled
default:
panic(fmt.Sprintf("toOpStatus: unexpected transfer status: %s", t.Status))
}
}
func toError(t *model.Transfer) string {
if t == nil {
return ""
}
if t.Status == model.TransferStatusSuccess {
return ""
}
return t.FailureReason
}
func (s *Service) updateTransferStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason, txHash string) (*model.Transfer, error) {
transfer, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, status, failureReason, txHash)
if err != nil {
s.logger.Warn("Failed to update transfer status", zap.String("transfer_ref", transferRef), zap.String("status", string(status)), zap.Error(err))
}
if isFinalStatus(transfer) {
s.emitTransferStatusEvent(transfer)
}
return transfer, err
}
func (s *Service) emitTransferStatusEvent(transfer *model.Transfer) {
if s == nil || s.producer == nil || transfer == nil {
return
}
exec := pmodel.PaymentGatewayExecution{
PaymentIntentID: transfer.IntentRef,
IdempotencyKey: transfer.IdempotencyKey,
ExecutedMoney: transfer.NetAmount,
PaymentRef: transfer.PaymentRef,
Status: toOpStatus(transfer),
OperationRef: transfer.OperationRef,
Error: toError(transfer),
TransferRef: transfer.TransferRef,
}
env := paymentgateway.PaymentGatewayExecution(mservice.ChainGateway, &exec)
if err := s.producer.SendMessage(env); err != nil {
s.logger.Warn("Failed to publish transfer status event", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef))
}
}

View File

@@ -6,18 +6,20 @@ import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
type TransferStatus string
const (
TransferStatusPending TransferStatus = "pending"
TransferStatusSigning TransferStatus = "signing"
TransferStatusSubmitted TransferStatus = "submitted"
TransferStatusConfirmed TransferStatus = "confirmed"
TransferStatusFailed TransferStatus = "failed"
TransferStatusCancelled TransferStatus = "cancelled"
TransferStatusCreated TransferStatus = "created" // record exists, not started
TransferStatusProcessing TransferStatus = "processing" // we are working on it
TransferStatusWaiting TransferStatus = "waiting" // waiting external world
TransferStatusSuccess TransferStatus = "success" // final success
TransferStatusFailed TransferStatus = "failed" // final failure
TransferStatusCancelled TransferStatus = "cancelled" // final cancelled
)
// ServiceFee represents a fee component applied to a transfer.
@@ -38,21 +40,23 @@ type TransferDestination struct {
type Transfer struct {
storable.Base `bson:",inline" json:",inline"`
OperationRef string `bson:"operationRef" json:"operationRef"`
TransferRef string `bson:"transferRef" json:"transferRef"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
IntentRef string `bson:"intentRef" json:"intentRef"`
OrganizationRef string `bson:"organizationRef" json:"organizationRef"`
SourceWalletRef string `bson:"sourceWalletRef" json:"sourceWalletRef"`
Destination TransferDestination `bson:"destination" json:"destination"`
Network string `bson:"network" json:"network"`
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"`
ContractAddress string `bson:"contractAddress" json:"contractAddress"`
RequestedAmount *moneyv1.Money `bson:"requestedAmount" json:"requestedAmount"`
NetAmount *moneyv1.Money `bson:"netAmount" json:"netAmount"`
RequestedAmount *paymenttypes.Money `bson:"requestedAmount" json:"requestedAmount"`
NetAmount *paymenttypes.Money `bson:"netAmount" json:"netAmount"`
Fees []ServiceFee `bson:"fees,omitempty" json:"fees,omitempty"`
Status TransferStatus `bson:"status" json:"status"`
TxHash string `bson:"txHash,omitempty" json:"txHash,omitempty"`
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
ClientReference string `bson:"clientReference,omitempty" json:"clientReference,omitempty"`
PaymentRef string `bson:"paymentRef,omitempty" json:"paymentRef,omitempty"`
LastStatusAt time.Time `bson:"lastStatusAt" json:"lastStatusAt"`
}
@@ -81,6 +85,7 @@ func (t *Transfer) Normalize() {
t.TransferRef = strings.TrimSpace(t.TransferRef)
t.IdempotencyKey = strings.TrimSpace(t.IdempotencyKey)
t.OrganizationRef = strings.TrimSpace(t.OrganizationRef)
t.IntentRef = strings.TrimSpace(t.IntentRef)
t.SourceWalletRef = strings.TrimSpace(t.SourceWalletRef)
t.Network = strings.TrimSpace(strings.ToLower(t.Network))
t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol))
@@ -89,5 +94,5 @@ func (t *Transfer) Normalize() {
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)
t.PaymentRef = strings.TrimSpace(t.PaymentRef)
}

View File

@@ -79,7 +79,7 @@ func (t *Transfers) Create(ctx context.Context, transfer *model.Transfer) (*mode
return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey")
}
if transfer.Status == "" {
transfer.Status = model.TransferStatusPending
transfer.Status = model.TransferStatusCreated
}
if transfer.LastStatusAt.IsZero() {
transfer.LastStatusAt = time.Now().UTC()