interface refactoring

This commit is contained in:
Stephan D
2026-01-05 01:22:47 +01:00
parent fcd831902a
commit 7424ef751c
16 changed files with 3623 additions and 133 deletions

View File

@@ -8,13 +8,19 @@ import (
"time"
"github.com/tech/sendico/pkg/merrors"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
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"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/structpb"
)
const chainConnectorID = "chain"
// Client exposes typed helpers around the chain gateway gRPC API.
type Client interface {
CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error)
@@ -30,23 +36,21 @@ type Client interface {
Close() error
}
type grpcGatewayClient interface {
CreateManagedWallet(ctx context.Context, in *chainv1.CreateManagedWalletRequest, opts ...grpc.CallOption) (*chainv1.CreateManagedWalletResponse, error)
GetManagedWallet(ctx context.Context, in *chainv1.GetManagedWalletRequest, opts ...grpc.CallOption) (*chainv1.GetManagedWalletResponse, error)
ListManagedWallets(ctx context.Context, in *chainv1.ListManagedWalletsRequest, opts ...grpc.CallOption) (*chainv1.ListManagedWalletsResponse, error)
GetWalletBalance(ctx context.Context, in *chainv1.GetWalletBalanceRequest, opts ...grpc.CallOption) (*chainv1.GetWalletBalanceResponse, error)
SubmitTransfer(ctx context.Context, in *chainv1.SubmitTransferRequest, opts ...grpc.CallOption) (*chainv1.SubmitTransferResponse, error)
GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error)
ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error)
EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error)
ComputeGasTopUp(ctx context.Context, in *chainv1.ComputeGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.ComputeGasTopUpResponse, error)
EnsureGasTopUp(ctx context.Context, in *chainv1.EnsureGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.EnsureGasTopUpResponse, error)
type grpcConnectorClient interface {
GetCapabilities(ctx context.Context, in *connectorv1.GetCapabilitiesRequest, opts ...grpc.CallOption) (*connectorv1.GetCapabilitiesResponse, error)
OpenAccount(ctx context.Context, in *connectorv1.OpenAccountRequest, opts ...grpc.CallOption) (*connectorv1.OpenAccountResponse, error)
GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error)
ListAccounts(ctx context.Context, in *connectorv1.ListAccountsRequest, opts ...grpc.CallOption) (*connectorv1.ListAccountsResponse, error)
GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error)
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error)
ListOperations(ctx context.Context, in *connectorv1.ListOperationsRequest, opts ...grpc.CallOption) (*connectorv1.ListOperationsResponse, error)
}
type chainGatewayClient struct {
cfg Config
conn *grpc.ClientConn
client grpcGatewayClient
client grpcConnectorClient
}
// New dials the chain gateway endpoint and returns a ready client.
@@ -76,12 +80,12 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
return &chainGatewayClient{
cfg: cfg,
conn: conn,
client: unifiedv1.NewUnifiedGatewayServiceClient(conn),
client: connectorv1.NewConnectorServiceClient(conn),
}, nil
}
// NewWithClient injects a pre-built gateway client (useful for tests).
func NewWithClient(cfg Config, gc grpcGatewayClient) Client {
func NewWithClient(cfg Config, gc grpcConnectorClient) Client {
cfg.setDefaults()
return &chainGatewayClient{
cfg: cfg,
@@ -99,61 +103,213 @@ func (c *chainGatewayClient) Close() error {
func (c *chainGatewayClient) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.CreateManagedWallet(ctx, req)
params, err := walletParamsFromRequest(req)
if err != nil {
return nil, err
}
label := ""
if desc := req.GetDescribable(); desc != nil {
label = strings.TrimSpace(desc.GetName())
}
resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET,
Asset: assetStringFromChainAsset(req.GetAsset()),
OwnerRef: strings.TrimSpace(req.GetOwnerRef()),
Label: label,
Params: params,
})
if err != nil {
return nil, err
}
if resp.GetError() != nil {
return nil, connectorError(resp.GetError())
}
wallet := managedWalletFromAccount(resp.GetAccount())
return &chainv1.CreateManagedWalletResponse{Wallet: wallet}, nil
}
func (c *chainGatewayClient) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.GetManagedWallet(ctx, req)
if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required")
}
resp, err := c.client.GetAccount(ctx, &connectorv1.GetAccountRequest{AccountRef: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetWalletRef())}})
if err != nil {
return nil, err
}
return &chainv1.GetManagedWalletResponse{Wallet: managedWalletFromAccount(resp.GetAccount())}, nil
}
func (c *chainGatewayClient) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.ListManagedWallets(ctx, req)
assetString := ""
ownerRef := ""
var page *paginationv1.CursorPageRequest
if req != nil {
assetString = assetStringFromChainAsset(req.GetAsset())
ownerRef = strings.TrimSpace(req.GetOwnerRef())
page = req.GetPage()
}
resp, err := c.client.ListAccounts(ctx, &connectorv1.ListAccountsRequest{
OwnerRef: ownerRef,
Asset: assetString,
Page: page,
})
if err != nil {
return nil, err
}
wallets := make([]*chainv1.ManagedWallet, 0, len(resp.GetAccounts()))
for _, account := range resp.GetAccounts() {
wallets = append(wallets, managedWalletFromAccount(account))
}
return &chainv1.ListManagedWalletsResponse{Wallets: wallets, Page: resp.GetPage()}, nil
}
func (c *chainGatewayClient) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.GetWalletBalance(ctx, req)
if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required")
}
resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{AccountRef: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetWalletRef())}})
if err != nil {
return nil, err
}
balance := resp.GetBalance()
if balance == nil {
return nil, merrors.Internal("chain-gateway: balance response missing")
}
return &chainv1.GetWalletBalanceResponse{Balance: &chainv1.WalletBalance{
Available: balance.GetAvailable(),
PendingInbound: balance.GetPendingInbound(),
PendingOutbound: balance.GetPendingOutbound(),
CalculatedAt: balance.GetCalculatedAt(),
}}, nil
}
func (c *chainGatewayClient) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.SubmitTransfer(ctx, req)
if req == nil {
return nil, merrors.InvalidArgument("chain-gateway: request is required")
}
operation, err := operationFromTransfer(req)
if err != nil {
return nil, err
}
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
transfer := transferFromReceipt(req, resp.GetReceipt())
return &chainv1.SubmitTransferResponse{Transfer: transfer}, nil
}
func (c *chainGatewayClient) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.GetTransfer(ctx, req)
if req == nil || strings.TrimSpace(req.GetTransferRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: transfer_ref is required")
}
resp, err := c.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetTransferRef())})
if err != nil {
return nil, err
}
return &chainv1.GetTransferResponse{Transfer: transferFromOperation(resp.GetOperation())}, nil
}
func (c *chainGatewayClient) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.ListTransfers(ctx, req)
source := ""
status := chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED
var page *paginationv1.CursorPageRequest
if req != nil {
source = strings.TrimSpace(req.GetSourceWalletRef())
status = req.GetStatus()
page = req.GetPage()
}
resp, err := c.client.ListOperations(ctx, &connectorv1.ListOperationsRequest{
AccountRef: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: source},
Status: operationStatusFromTransfer(status),
Page: page,
})
if err != nil {
return nil, err
}
transfers := make([]*chainv1.Transfer, 0, len(resp.GetOperations()))
for _, op := range resp.GetOperations() {
transfers = append(transfers, transferFromOperation(op))
}
return &chainv1.ListTransfersResponse{Transfers: transfers, Page: resp.GetPage()}, nil
}
func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.EstimateTransferFee(ctx, req)
if req == nil {
return nil, merrors.InvalidArgument("chain-gateway: request is required")
}
operation, err := feeEstimateOperation(req)
if err != nil {
return nil, err
}
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
return estimateFromReceipt(resp.GetReceipt()), nil
}
func (c *chainGatewayClient) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.ComputeGasTopUp(ctx, req)
if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required")
}
operation, err := gasTopUpComputeOperation(req)
if err != nil {
return nil, err
}
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
return computeGasTopUpFromReceipt(resp.GetReceipt()), nil
}
func (c *chainGatewayClient) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
return c.client.EnsureGasTopUp(ctx, req)
if req == nil {
return nil, merrors.InvalidArgument("chain-gateway: request is required")
}
operation, err := gasTopUpEnsureOperation(req)
if err != nil {
return nil, err
}
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
return ensureGasTopUpFromReceipt(resp.GetReceipt()), nil
}
func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
@@ -163,3 +319,495 @@ func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context,
}
return context.WithTimeout(ctx, timeout)
}
func walletParamsFromRequest(req *chainv1.CreateManagedWalletRequest) (*structpb.Struct, error) {
if req == nil {
return nil, nil
}
params := map[string]interface{}{
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
}
if asset := req.GetAsset(); asset != nil {
params["network"] = asset.GetChain().String()
params["token_symbol"] = strings.TrimSpace(asset.GetTokenSymbol())
params["contract_address"] = strings.TrimSpace(asset.GetContractAddress())
}
desc := ""
if describable := req.GetDescribable(); describable != nil {
desc = strings.TrimSpace(describable.GetDescription())
}
if desc != "" {
params["description"] = desc
}
if len(req.GetMetadata()) > 0 {
params["metadata"] = mapStringToInterface(req.GetMetadata())
}
return structpb.NewStruct(params)
}
func managedWalletFromAccount(account *connectorv1.Account) *chainv1.ManagedWallet {
if account == nil {
return nil
}
details := map[string]interface{}{}
if account.GetProviderDetails() != nil {
details = account.GetProviderDetails().AsMap()
}
walletRef := ""
if ref := account.GetRef(); ref != nil {
walletRef = strings.TrimSpace(ref.GetAccountId())
}
if v := stringFromDetails(details, "wallet_ref"); v != "" {
walletRef = v
}
organizationRef := stringFromDetails(details, "organization_ref")
ownerRef := stringFromDetails(details, "owner_ref")
if ownerRef == "" {
ownerRef = strings.TrimSpace(account.GetOwnerRef())
}
asset := &chainv1.Asset{
Chain: chainNetworkFromString(stringFromDetails(details, "network")),
TokenSymbol: strings.TrimSpace(stringFromDetails(details, "token_symbol")),
ContractAddress: strings.TrimSpace(stringFromDetails(details, "contract_address")),
}
if asset.GetTokenSymbol() == "" {
asset.TokenSymbol = strings.TrimSpace(tokenFromAssetString(account.GetAsset()))
}
return &chainv1.ManagedWallet{
WalletRef: walletRef,
OrganizationRef: organizationRef,
OwnerRef: ownerRef,
Asset: asset,
DepositAddress: stringFromDetails(details, "deposit_address"),
Status: managedWalletStatusFromAccount(account.GetState()),
CreatedAt: account.GetCreatedAt(),
UpdatedAt: account.GetUpdatedAt(),
Describable: &describablev1.Describable{
Name: strings.TrimSpace(account.GetLabel()),
},
}
}
func operationFromTransfer(req *chainv1.SubmitTransferRequest) (*connectorv1.Operation, error) {
if req == nil {
return nil, merrors.InvalidArgument("chain-gateway: request is required")
}
if strings.TrimSpace(req.GetIdempotencyKey()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: idempotency_key is required")
}
if strings.TrimSpace(req.GetSourceWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: source_wallet_ref is required")
}
if req.GetDestination() == nil {
return nil, merrors.InvalidArgument("chain-gateway: destination is required")
}
if req.GetAmount() == nil {
return nil, merrors.InvalidArgument("chain-gateway: amount is required")
}
params := map[string]interface{}{
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
"client_reference": strings.TrimSpace(req.GetClientReference()),
}
if memo := strings.TrimSpace(req.GetDestination().GetMemo()); memo != "" {
params["destination_memo"] = memo
}
if len(req.GetMetadata()) > 0 {
params["metadata"] = mapStringToInterface(req.GetMetadata())
}
if len(req.GetFees()) > 0 {
params["fees"] = feesToInterface(req.GetFees())
}
op := &connectorv1.Operation{
Type: connectorv1.OperationType_TRANSFER,
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}},
Money: req.GetAmount(),
Params: structFromMap(params),
}
to, err := destinationToParty(req.GetDestination())
if err != nil {
return nil, err
}
op.To = to
return op, nil
}
func destinationToParty(dest *chainv1.TransferDestination) (*connectorv1.OperationParty, error) {
if dest == nil {
return nil, merrors.InvalidArgument("chain-gateway: destination is required")
}
switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef:
return &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(d.ManagedWalletRef)}}}, nil
case *chainv1.TransferDestination_ExternalAddress:
return &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{ExternalRef: strings.TrimSpace(d.ExternalAddress)}}}, nil
default:
return nil, merrors.InvalidArgument("chain-gateway: destination is required")
}
}
func transferFromReceipt(req *chainv1.SubmitTransferRequest, receipt *connectorv1.OperationReceipt) *chainv1.Transfer {
transfer := &chainv1.Transfer{}
if req != nil {
transfer.IdempotencyKey = strings.TrimSpace(req.GetIdempotencyKey())
transfer.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef())
transfer.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef())
transfer.Destination = req.GetDestination()
transfer.RequestedAmount = req.GetAmount()
transfer.NetAmount = req.GetAmount()
}
if receipt != nil {
transfer.TransferRef = strings.TrimSpace(receipt.GetOperationId())
transfer.Status = transferStatusFromOperation(receipt.GetStatus())
transfer.TransactionHash = strings.TrimSpace(receipt.GetProviderRef())
}
return transfer
}
func transferFromOperation(op *connectorv1.Operation) *chainv1.Transfer {
if op == nil {
return nil
}
transfer := &chainv1.Transfer{
TransferRef: strings.TrimSpace(op.GetOperationId()),
IdempotencyKey: strings.TrimSpace(op.GetOperationId()),
RequestedAmount: op.GetMoney(),
NetAmount: op.GetMoney(),
Status: transferStatusFromOperation(op.GetStatus()),
TransactionHash: strings.TrimSpace(op.GetProviderRef()),
CreatedAt: op.GetCreatedAt(),
UpdatedAt: op.GetUpdatedAt(),
}
if from := op.GetFrom(); from != nil && from.GetAccount() != nil {
transfer.SourceWalletRef = strings.TrimSpace(from.GetAccount().GetAccountId())
}
if to := op.GetTo(); to != nil {
if account := to.GetAccount(); account != nil {
transfer.Destination = &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}
}
if external := to.GetExternal(); external != nil {
transfer.Destination = &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(external.GetExternalRef())}}
}
}
return transfer
}
func feeEstimateOperation(req *chainv1.EstimateTransferFeeRequest) (*connectorv1.Operation, error) {
if req == nil {
return nil, merrors.InvalidArgument("chain-gateway: request is required")
}
if strings.TrimSpace(req.GetSourceWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: source_wallet_ref is required")
}
if req.GetDestination() == nil {
return nil, merrors.InvalidArgument("chain-gateway: destination is required")
}
if req.GetAmount() == nil {
return nil, merrors.InvalidArgument("chain-gateway: amount is required")
}
params := map[string]interface{}{}
op := &connectorv1.Operation{
Type: connectorv1.OperationType_FEE_ESTIMATE,
IdempotencyKey: feeEstimateKey(req),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}},
Money: req.GetAmount(),
Params: structFromMap(params),
}
to, err := destinationToParty(req.GetDestination())
if err != nil {
return nil, err
}
op.To = to
return op, nil
}
func estimateFromReceipt(receipt *connectorv1.OperationReceipt) *chainv1.EstimateTransferFeeResponse {
resp := &chainv1.EstimateTransferFeeResponse{}
if receipt == nil || receipt.GetResult() == nil {
return resp
}
data := receipt.GetResult().AsMap()
if networkFee, ok := data["network_fee"].(map[string]interface{}); ok {
amount := strings.TrimSpace(fmt.Sprint(networkFee["amount"]))
currency := strings.TrimSpace(fmt.Sprint(networkFee["currency"]))
if amount != "" && currency != "" {
resp.NetworkFee = &moneyv1.Money{Amount: amount, Currency: currency}
}
}
if ctx, ok := data["estimation_context"].(string); ok {
resp.EstimationContext = strings.TrimSpace(ctx)
}
return resp
}
func gasTopUpComputeOperation(req *chainv1.ComputeGasTopUpRequest) (*connectorv1.Operation, error) {
if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required")
}
fee := req.GetEstimatedTotalFee()
if fee == nil {
return nil, merrors.InvalidArgument("chain-gateway: estimated_total_fee is required")
}
params := map[string]interface{}{
"mode": "compute",
"estimated_total_fee": map[string]interface{}{"amount": fee.GetAmount(), "currency": fee.GetCurrency()},
}
return &connectorv1.Operation{
Type: connectorv1.OperationType_GAS_TOPUP,
IdempotencyKey: fmt.Sprintf("gas_topup_compute:%s:%s", strings.TrimSpace(req.GetWalletRef()), strings.TrimSpace(fee.GetAmount())),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetWalletRef())}}},
Params: structFromMap(params),
}, nil
}
func gasTopUpEnsureOperation(req *chainv1.EnsureGasTopUpRequest) (*connectorv1.Operation, error) {
if req == nil {
return nil, merrors.InvalidArgument("chain-gateway: request is required")
}
if strings.TrimSpace(req.GetIdempotencyKey()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: idempotency_key is required")
}
if strings.TrimSpace(req.GetSourceWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: source_wallet_ref is required")
}
if strings.TrimSpace(req.GetTargetWalletRef()) == "" {
return nil, merrors.InvalidArgument("chain-gateway: target_wallet_ref is required")
}
fee := req.GetEstimatedTotalFee()
if fee == nil {
return nil, merrors.InvalidArgument("chain-gateway: estimated_total_fee is required")
}
params := map[string]interface{}{
"mode": "ensure",
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()),
"target_wallet_ref": strings.TrimSpace(req.GetTargetWalletRef()),
"client_reference": strings.TrimSpace(req.GetClientReference()),
"estimated_total_fee": map[string]interface{}{"amount": fee.GetAmount(), "currency": fee.GetCurrency()},
}
if len(req.GetMetadata()) > 0 {
params["metadata"] = mapStringToInterface(req.GetMetadata())
}
return &connectorv1.Operation{
Type: connectorv1.OperationType_GAS_TOPUP,
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}},
Params: structFromMap(params),
}, nil
}
func computeGasTopUpFromReceipt(receipt *connectorv1.OperationReceipt) *chainv1.ComputeGasTopUpResponse {
resp := &chainv1.ComputeGasTopUpResponse{}
if receipt == nil || receipt.GetResult() == nil {
return resp
}
data := receipt.GetResult().AsMap()
if amount, ok := data["topup_amount"].(map[string]interface{}); ok {
resp.TopupAmount = &moneyv1.Money{
Amount: strings.TrimSpace(fmt.Sprint(amount["amount"])),
Currency: strings.TrimSpace(fmt.Sprint(amount["currency"])),
}
}
if capHit, ok := data["cap_hit"].(bool); ok {
resp.CapHit = capHit
}
return resp
}
func ensureGasTopUpFromReceipt(receipt *connectorv1.OperationReceipt) *chainv1.EnsureGasTopUpResponse {
resp := &chainv1.EnsureGasTopUpResponse{}
if receipt == nil || receipt.GetResult() == nil {
return resp
}
data := receipt.GetResult().AsMap()
if amount, ok := data["topup_amount"].(map[string]interface{}); ok {
resp.TopupAmount = &moneyv1.Money{
Amount: strings.TrimSpace(fmt.Sprint(amount["amount"])),
Currency: strings.TrimSpace(fmt.Sprint(amount["currency"])),
}
}
if capHit, ok := data["cap_hit"].(bool); ok {
resp.CapHit = capHit
}
if transferRef, ok := data["transfer_ref"].(string); ok {
resp.Transfer = &chainv1.Transfer{TransferRef: strings.TrimSpace(transferRef)}
}
return resp
}
func feeEstimateKey(req *chainv1.EstimateTransferFeeRequest) string {
if req == nil || req.GetAmount() == nil {
return "fee_estimate"
}
return fmt.Sprintf("fee_estimate:%s:%s:%s", strings.TrimSpace(req.GetSourceWalletRef()), strings.TrimSpace(req.GetAmount().GetCurrency()), strings.TrimSpace(req.GetAmount().GetAmount()))
}
func connectorError(err *connectorv1.ConnectorError) error {
if err == nil {
return nil
}
msg := strings.TrimSpace(err.GetMessage())
switch err.GetCode() {
case connectorv1.ErrorCode_INVALID_PARAMS:
return merrors.InvalidArgument(msg)
case connectorv1.ErrorCode_NOT_FOUND:
return merrors.NoData(msg)
case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND:
return merrors.NotImplemented(msg)
case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE:
return merrors.Internal(msg)
default:
return merrors.Internal(msg)
}
}
func structFromMap(data map[string]interface{}) *structpb.Struct {
if len(data) == 0 {
return nil
}
result, err := structpb.NewStruct(data)
if err != nil {
return nil
}
return result
}
func mapStringToInterface(input map[string]string) map[string]interface{} {
if len(input) == 0 {
return nil
}
out := make(map[string]interface{}, len(input))
for k, v := range input {
out[k] = v
}
return out
}
func feesToInterface(fees []*chainv1.ServiceFeeBreakdown) []interface{} {
if len(fees) == 0 {
return nil
}
result := make([]interface{}, 0, len(fees))
for _, fee := range fees {
if fee == nil || fee.GetAmount() == nil {
continue
}
result = append(result, map[string]interface{}{
"fee_code": strings.TrimSpace(fee.GetFeeCode()),
"description": strings.TrimSpace(fee.GetDescription()),
"amount": strings.TrimSpace(fee.GetAmount().GetAmount()),
"currency": strings.TrimSpace(fee.GetAmount().GetCurrency()),
})
}
if len(result) == 0 {
return nil
}
return result
}
func stringFromDetails(details map[string]interface{}, key string) string {
if details == nil {
return ""
}
if value, ok := details[key]; ok {
return strings.TrimSpace(fmt.Sprint(value))
}
return ""
}
func managedWalletStatusFromAccount(state connectorv1.AccountState) chainv1.ManagedWalletStatus {
switch state {
case connectorv1.AccountState_ACCOUNT_ACTIVE:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE
case connectorv1.AccountState_ACCOUNT_SUSPENDED:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED
case connectorv1.AccountState_ACCOUNT_CLOSED:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED
default:
return chainv1.ManagedWalletStatus_MANAGED_WALLET_STATUS_UNSPECIFIED
}
}
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_FAILED:
return connectorv1.OperationStatus_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_CANCELED
default:
return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED
}
}
func assetStringFromChainAsset(asset *chainv1.Asset) string {
if asset == nil {
return ""
}
symbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
if symbol == "" {
return ""
}
suffix := chainAssetSuffix(asset.GetChain())
if suffix == "" {
return symbol
}
return symbol + "-" + suffix
}
func chainAssetSuffix(chain chainv1.ChainNetwork) string {
switch chain {
case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET:
return "ETH"
case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE:
return "ARB"
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET:
return "TRC20"
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE:
return "TRC20"
default:
return ""
}
}
func tokenFromAssetString(asset string) string {
if asset == "" {
return ""
}
if idx := strings.Index(asset, "-"); idx > 0 {
return asset[:idx]
}
return asset
}
func chainNetworkFromString(value string) chainv1.ChainNetwork {
value = strings.ToUpper(strings.TrimSpace(value))
if value == "" {
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}
if val, ok := chainv1.ChainNetwork_value[value]; ok {
return chainv1.ChainNetwork(val)
}
if !strings.HasPrefix(value, "CHAIN_NETWORK_") {
value = "CHAIN_NETWORK_" + value
}
if val, ok := chainv1.ChainNetwork_value[value]; ok {
return chainv1.ChainNetwork(val)
}
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED
}

View File

@@ -0,0 +1,691 @@
package gateway
import (
"context"
"errors"
"fmt"
"strings"
"github.com/tech/sendico/gateway/chain/internal/appversion"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/merrors"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/structpb"
)
const chainConnectorID = "chain"
func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) {
return &connectorv1.GetCapabilitiesResponse{
Capabilities: &connectorv1.ConnectorCapabilities{
ConnectorType: chainConnectorID,
Version: appversion.Create().Short(),
SupportedAccountKinds: []connectorv1.AccountKind{connectorv1.AccountKind_CHAIN_MANAGED_WALLET},
SupportedOperationTypes: []connectorv1.OperationType{
connectorv1.OperationType_TRANSFER,
connectorv1.OperationType_FEE_ESTIMATE,
connectorv1.OperationType_GAS_TOPUP,
},
OpenAccountParams: chainOpenAccountParams(),
OperationParams: chainOperationParams(),
},
}, nil
}
func (s *Service) OpenAccount(ctx context.Context, req *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) {
if req == nil {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: request is required", nil, "")}, nil
}
if req.GetKind() != connectorv1.AccountKind_CHAIN_MANAGED_WALLET {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported account kind", nil, "")}, nil
}
reader := params.New(req.GetParams())
orgRef := strings.TrimSpace(reader.String("organization_ref"))
if orgRef == "" {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref is required", nil, "")}, nil
}
asset, err := parseChainAsset(strings.TrimSpace(req.GetAsset()), reader)
if err != nil {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), nil, "")}, nil
}
resp, err := s.CreateManagedWallet(ctx, &chainv1.CreateManagedWalletRequest{
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
OrganizationRef: orgRef,
OwnerRef: strings.TrimSpace(req.GetOwnerRef()),
Asset: asset,
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
Describable: describableFromLabel(req.GetLabel(), reader.String("description")),
})
if err != nil {
return &connectorv1.OpenAccountResponse{Error: connectorError(mapErrorCode(err), err.Error(), nil, "")}, nil
}
return &connectorv1.OpenAccountResponse{Account: chainWalletToAccount(resp.GetWallet())}, nil
}
func (s *Service) GetAccount(ctx context.Context, req *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) {
if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" {
return nil, merrors.InvalidArgument("get_account: account_ref.account_id is required")
}
resp, err := s.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: strings.TrimSpace(req.GetAccountRef().GetAccountId())})
if err != nil {
return nil, err
}
return &connectorv1.GetAccountResponse{Account: chainWalletToAccount(resp.GetWallet())}, nil
}
func (s *Service) ListAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
if req == nil {
return nil, merrors.InvalidArgument("list_accounts: request is required")
}
asset := (*chainv1.Asset)(nil)
if assetString := strings.TrimSpace(req.GetAsset()); assetString != "" {
parsed, err := parseChainAsset(assetString, params.New(nil))
if err != nil {
return nil, merrors.InvalidArgument(err.Error())
}
asset = parsed
}
resp, err := s.ListManagedWallets(ctx, &chainv1.ListManagedWalletsRequest{
OwnerRef: strings.TrimSpace(req.GetOwnerRef()),
Asset: asset,
Page: req.GetPage(),
})
if err != nil {
return nil, err
}
accounts := make([]*connectorv1.Account, 0, len(resp.GetWallets()))
for _, wallet := range resp.GetWallets() {
accounts = append(accounts, chainWalletToAccount(wallet))
}
return &connectorv1.ListAccountsResponse{
Accounts: accounts,
Page: resp.GetPage(),
}, nil
}
func (s *Service) GetBalance(ctx context.Context, req *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) {
if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" {
return nil, merrors.InvalidArgument("get_balance: account_ref.account_id is required")
}
resp, err := s.GetWalletBalance(ctx, &chainv1.GetWalletBalanceRequest{WalletRef: strings.TrimSpace(req.GetAccountRef().GetAccountId())})
if err != nil {
return nil, err
}
bal := resp.GetBalance()
return &connectorv1.GetBalanceResponse{
Balance: &connectorv1.Balance{
AccountRef: req.GetAccountRef(),
Available: bal.GetAvailable(),
PendingInbound: bal.GetPendingInbound(),
PendingOutbound: bal.GetPendingOutbound(),
CalculatedAt: bal.GetCalculatedAt(),
},
}, nil
}
func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
if req == nil || req.GetOperation() == nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
}
op := req.GetOperation()
if strings.TrimSpace(op.GetIdempotencyKey()) == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
}
reader := params.New(op.GetParams())
orgRef := strings.TrimSpace(reader.String("organization_ref"))
source := operationAccountID(op.GetFrom())
if source == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "operation: from.account is required", op, "")}}, nil
}
switch op.GetType() {
case connectorv1.OperationType_TRANSFER:
dest, err := transferDestinationFromOperation(op)
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
amount := op.GetMoney()
if amount == nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: money is required", op, "")}}, nil
}
amount = normalizeMoneyForChain(amount)
if orgRef == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: organization_ref is required", op, "")}}, nil
}
resp, err := s.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
OrganizationRef: orgRef,
SourceWalletRef: source,
Destination: dest,
Amount: amount,
Fees: parseChainFees(reader),
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
ClientReference: strings.TrimSpace(reader.String("client_reference")),
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
transfer := resp.GetTransfer()
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Status: chainTransferStatusToOperation(transfer.GetStatus()),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
},
}, nil
case connectorv1.OperationType_FEE_ESTIMATE:
dest, err := transferDestinationFromOperation(op)
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
amount := op.GetMoney()
if amount == nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "estimate: money is required", op, "")}}, nil
}
amount = normalizeMoneyForChain(amount)
opID := strings.TrimSpace(op.GetOperationId())
if opID == "" {
opID = strings.TrimSpace(op.GetIdempotencyKey())
}
resp, err := s.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{
SourceWalletRef: source,
Destination: dest,
Amount: amount,
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
result := feeEstimateResult(resp)
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Result: result,
},
}, nil
case connectorv1.OperationType_GAS_TOPUP:
fee, err := parseMoneyFromMap(reader.Map("estimated_total_fee"))
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
fee = normalizeMoneyForChain(fee)
mode := strings.ToLower(strings.TrimSpace(reader.String("mode")))
if mode == "" {
mode = "compute"
}
switch mode {
case "compute":
opID := strings.TrimSpace(op.GetOperationId())
if opID == "" {
opID = strings.TrimSpace(op.GetIdempotencyKey())
}
resp, err := s.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{
WalletRef: source,
EstimatedTotalFee: fee,
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), ""),
},
}, nil
case "ensure":
opID := strings.TrimSpace(op.GetOperationId())
if opID == "" {
opID = strings.TrimSpace(op.GetIdempotencyKey())
}
if orgRef == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: organization_ref is required", op, "")}}, nil
}
target := strings.TrimSpace(reader.String("target_wallet_ref"))
if target == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: target_wallet_ref is required", op, "")}}, nil
}
resp, err := s.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
OrganizationRef: orgRef,
SourceWalletRef: source,
TargetWalletRef: target,
EstimatedTotalFee: fee,
Metadata: shared.CloneMetadata(reader.StringMap("metadata")),
ClientReference: strings.TrimSpace(reader.String("client_reference")),
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
transferRef := ""
if transfer := resp.GetTransfer(); transfer != nil {
transferRef = strings.TrimSpace(transfer.GetTransferRef())
}
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: opID,
Status: connectorv1.OperationStatus_CONFIRMED,
Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), transferRef),
},
}, nil
default:
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: invalid mode", op, "")}}, nil
}
default:
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
}
}
func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())})
if err != nil {
return nil, err
}
return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(resp.GetTransfer())}, nil
}
func (s *Service) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
if req == nil {
return nil, merrors.InvalidArgument("list_operations: request is required")
}
source := ""
if req.GetAccountRef() != nil {
source = strings.TrimSpace(req.GetAccountRef().GetAccountId())
}
resp, err := s.ListTransfers(ctx, &chainv1.ListTransfersRequest{
SourceWalletRef: source,
Status: chainStatusFromOperation(req.GetStatus()),
Page: req.GetPage(),
})
if err != nil {
return nil, err
}
ops := make([]*connectorv1.Operation, 0, len(resp.GetTransfers()))
for _, transfer := range resp.GetTransfers() {
ops = append(ops, chainTransferToOperation(transfer))
}
return &connectorv1.ListOperationsResponse{Operations: ops, Page: resp.GetPage()}, nil
}
func chainOpenAccountParams() []*connectorv1.ParamSpec {
return []*connectorv1.ParamSpec{
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference for the wallet."},
{Key: "network", Type: connectorv1.ParamType_STRING, Required: true, Description: "Blockchain network name."},
{Key: "token_symbol", Type: connectorv1.ParamType_STRING, Required: true, Description: "Token symbol (e.g., USDT)."},
{Key: "contract_address", Type: connectorv1.ParamType_STRING, Required: false, Description: "Token contract address override."},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Additional metadata map."},
{Key: "description", Type: connectorv1.ParamType_STRING, Required: false, Description: "Wallet description."},
}
}
func chainOperationParams() []*connectorv1.OperationParamSpec {
return []*connectorv1.OperationParamSpec{
{OperationType: connectorv1.OperationType_TRANSFER, Params: []*connectorv1.ParamSpec{
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference."},
{Key: "destination_memo", Type: connectorv1.ParamType_STRING, Required: false, Description: "Destination memo/tag."},
{Key: "client_reference", Type: connectorv1.ParamType_STRING, Required: false, Description: "Client reference id."},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Transfer metadata."},
{Key: "fees", Type: connectorv1.ParamType_JSON, Required: false, Description: "Service fee breakdowns."},
}},
{OperationType: connectorv1.OperationType_FEE_ESTIMATE, Params: []*connectorv1.ParamSpec{
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Organization reference."},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Estimate metadata."},
}},
{OperationType: connectorv1.OperationType_GAS_TOPUP, Params: []*connectorv1.ParamSpec{
{Key: "mode", Type: connectorv1.ParamType_STRING, Required: false, Description: "compute | ensure."},
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Organization reference (required for ensure)."},
{Key: "target_wallet_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Target wallet ref (ensure)."},
{Key: "estimated_total_fee", Type: connectorv1.ParamType_JSON, Required: true, Description: "Estimated total fee {amount,currency}."},
{Key: "client_reference", Type: connectorv1.ParamType_STRING, Required: false, Description: "Client reference."},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Top-up metadata."},
}},
}
}
func chainWalletToAccount(wallet *chainv1.ManagedWallet) *connectorv1.Account {
if wallet == nil {
return nil
}
details, _ := structpb.NewStruct(map[string]interface{}{
"deposit_address": wallet.GetDepositAddress(),
"organization_ref": wallet.GetOrganizationRef(),
"owner_ref": wallet.GetOwnerRef(),
"network": wallet.GetAsset().GetChain().String(),
"token_symbol": wallet.GetAsset().GetTokenSymbol(),
"contract_address": wallet.GetAsset().GetContractAddress(),
"wallet_ref": wallet.GetWalletRef(),
})
return &connectorv1.Account{
Ref: &connectorv1.AccountRef{
ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(wallet.GetWalletRef()),
},
Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET,
Asset: assetStringFromChainAsset(wallet.GetAsset()),
State: chainWalletState(wallet.GetStatus()),
Label: strings.TrimSpace(wallet.GetDescribable().GetName()),
OwnerRef: strings.TrimSpace(wallet.GetOwnerRef()),
ProviderDetails: details,
CreatedAt: wallet.GetCreatedAt(),
UpdatedAt: wallet.GetUpdatedAt(),
}
}
func chainWalletState(status chainv1.ManagedWalletStatus) connectorv1.AccountState {
switch status {
case chainv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE:
return connectorv1.AccountState_ACCOUNT_ACTIVE
case chainv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED:
return connectorv1.AccountState_ACCOUNT_SUSPENDED
case chainv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED:
return connectorv1.AccountState_ACCOUNT_CLOSED
default:
return connectorv1.AccountState_ACCOUNT_STATE_UNSPECIFIED
}
}
func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) {
if op == nil {
return nil, fmt.Errorf("transfer: operation is required")
}
if to := op.GetTo(); to != nil {
if account := to.GetAccount(); account != nil {
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}, nil
}
if ext := to.GetExternal(); ext != nil {
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil
}
}
return nil, fmt.Errorf("transfer: to.account or to.external is required")
}
func normalizeMoneyForChain(m *moneyv1.Money) *moneyv1.Money {
if m == nil {
return nil
}
currency := strings.TrimSpace(m.GetCurrency())
if idx := strings.Index(currency, "-"); idx > 0 {
currency = currency[:idx]
}
return &moneyv1.Money{
Amount: strings.TrimSpace(m.GetAmount()),
Currency: currency,
}
}
func parseChainFees(reader params.Reader) []*chainv1.ServiceFeeBreakdown {
rawFees := reader.List("fees")
if len(rawFees) == 0 {
return nil
}
result := make([]*chainv1.ServiceFeeBreakdown, 0, len(rawFees))
for _, item := range rawFees {
raw, ok := item.(map[string]interface{})
if !ok {
continue
}
amount := strings.TrimSpace(fmt.Sprint(raw["amount"]))
currency := strings.TrimSpace(fmt.Sprint(raw["currency"]))
if amount == "" || currency == "" {
continue
}
result = append(result, &chainv1.ServiceFeeBreakdown{
FeeCode: strings.TrimSpace(fmt.Sprint(raw["fee_code"])),
Description: strings.TrimSpace(fmt.Sprint(raw["description"])),
Amount: &moneyv1.Money{Amount: amount, Currency: currency},
})
}
if len(result) == 0 {
return nil
}
return result
}
func parseMoneyFromMap(raw map[string]interface{}) (*moneyv1.Money, error) {
if raw == nil {
return nil, fmt.Errorf("money is required")
}
amount := strings.TrimSpace(fmt.Sprint(raw["amount"]))
currency := strings.TrimSpace(fmt.Sprint(raw["currency"]))
if amount == "" || currency == "" {
return nil, fmt.Errorf("money is required")
}
return &moneyv1.Money{
Amount: amount,
Currency: currency,
}, nil
}
func feeEstimateResult(resp *chainv1.EstimateTransferFeeResponse) *structpb.Struct {
if resp == nil {
return nil
}
payload := map[string]interface{}{
"estimation_context": strings.TrimSpace(resp.GetEstimationContext()),
}
if fee := resp.GetNetworkFee(); fee != nil {
payload["network_fee"] = map[string]interface{}{
"amount": strings.TrimSpace(fee.GetAmount()),
"currency": strings.TrimSpace(fee.GetCurrency()),
}
}
result, err := structpb.NewStruct(payload)
if err != nil {
return nil
}
return result
}
func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct {
payload := map[string]interface{}{
"cap_hit": capHit,
}
if amount != nil {
payload["topup_amount"] = map[string]interface{}{
"amount": strings.TrimSpace(amount.GetAmount()),
"currency": strings.TrimSpace(amount.GetCurrency()),
}
}
if strings.TrimSpace(transferRef) != "" {
payload["transfer_ref"] = strings.TrimSpace(transferRef)
}
result, err := structpb.NewStruct(payload)
if err != nil {
return nil
}
return result
}
func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
if transfer == nil {
return nil
}
op := &connectorv1.Operation{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: chainTransferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()),
}}},
}
if dest := transfer.GetDestination(); dest != nil {
switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef:
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: chainConnectorID,
AccountId: strings.TrimSpace(d.ManagedWalletRef),
}}}
case *chainv1.TransferDestination_ExternalAddress:
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{
ExternalRef: strings.TrimSpace(d.ExternalAddress),
}}}
}
}
return op
}
func chainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return connectorv1.OperationStatus_CONFIRMED
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_CANCELED
default:
return connectorv1.OperationStatus_PENDING
}
}
func chainStatusFromOperation(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_STATUS_UNSPECIFIED
}
}
func parseChainAsset(assetString string, reader params.Reader) (*chainv1.Asset, error) {
network := strings.TrimSpace(reader.String("network"))
token := strings.TrimSpace(reader.String("token_symbol"))
contract := strings.TrimSpace(reader.String("contract_address"))
if token == "" {
token = tokenFromAssetString(assetString)
}
if network == "" {
network = networkFromAssetString(assetString)
}
if token == "" {
return nil, fmt.Errorf("asset: token_symbol is required")
}
chain := shared.ChainEnumFromName(network)
if chain == chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED {
return nil, fmt.Errorf("asset: network is required")
}
return &chainv1.Asset{
Chain: chain,
TokenSymbol: strings.ToUpper(token),
ContractAddress: strings.ToLower(contract),
}, nil
}
func tokenFromAssetString(asset string) string {
if asset == "" {
return ""
}
if idx := strings.Index(asset, "-"); idx > 0 {
return asset[:idx]
}
return asset
}
func networkFromAssetString(asset string) string {
if asset == "" {
return ""
}
idx := strings.Index(asset, "-")
if idx < 0 {
return ""
}
return strings.TrimSpace(asset[idx+1:])
}
func assetStringFromChainAsset(asset *chainv1.Asset) string {
if asset == nil {
return ""
}
symbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
if symbol == "" {
return ""
}
suffix := chainAssetSuffix(asset.GetChain())
if suffix == "" {
return symbol
}
return symbol + "-" + suffix
}
func chainAssetSuffix(chain chainv1.ChainNetwork) string {
switch chain {
case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET:
return "ETH"
case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE:
return "ARB"
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET:
return "TRC20"
case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE:
return "TRC20"
default:
return ""
}
}
func describableFromLabel(label, desc string) *describablev1.Describable {
label = strings.TrimSpace(label)
desc = strings.TrimSpace(desc)
if label == "" && desc == "" {
return nil
}
return &describablev1.Describable{
Name: label,
Description: &desc,
}
}
func operationAccountID(party *connectorv1.OperationParty) string {
if party == nil {
return ""
}
if account := party.GetAccount(); account != nil {
return strings.TrimSpace(account.GetAccountId())
}
return ""
}
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{
Code: code,
Message: strings.TrimSpace(message),
AccountId: strings.TrimSpace(accountID),
}
if op != nil {
err.CorrelationId = strings.TrimSpace(op.GetCorrelationId())
err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId())
err.OperationId = strings.TrimSpace(op.GetOperationId())
}
return err
}
func mapErrorCode(err error) connectorv1.ErrorCode {
switch {
case errors.Is(err, merrors.ErrInvalidArg):
return connectorv1.ErrorCode_INVALID_PARAMS
case errors.Is(err, merrors.ErrNoData):
return connectorv1.ErrorCode_NOT_FOUND
case errors.Is(err, merrors.ErrNotImplemented):
return connectorv1.ErrorCode_UNSUPPORTED_OPERATION
case errors.Is(err, merrors.ErrInternal):
return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE
default:
return connectorv1.ErrorCode_PROVIDER_ERROR
}
}

View File

@@ -19,8 +19,8 @@ import (
msg "github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"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"
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1"
"google.golang.org/grpc"
)
@@ -34,7 +34,7 @@ var (
errStorageUnavailable = serviceError("chain_gateway: storage not initialised")
)
// Service implements the UnifiedGatewayService RPC contract for chain operations.
// Service implements the ConnectorService RPC contract for chain operations.
type Service struct {
logger mlogger.Logger
storage storage.Repository
@@ -52,7 +52,7 @@ type Service struct {
commands commands.Registry
announcers []*discovery.Announcer
unifiedv1.UnimplementedUnifiedGatewayServiceServer
connectorv1.UnimplementedConnectorServiceServer
}
// NewService constructs the chain gateway service skeleton.
@@ -95,7 +95,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
// Register wires the service onto the provided gRPC router.
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s)
connectorv1.RegisterConnectorServiceServer(reg, s)
})
}

View File

@@ -5,12 +5,15 @@ import (
"strings"
"time"
"github.com/shopspring/decimal"
"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"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/structpb"
)
// Client wraps the Monetix gateway gRPC API.
@@ -22,9 +25,14 @@ type Client interface {
Close() error
}
type grpcConnectorClient interface {
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error)
}
type gatewayClient struct {
conn *grpc.ClientConn
client unifiedv1.UnifiedGatewayServiceClient
client grpcConnectorClient
cfg Config
logger *zap.Logger
}
@@ -44,12 +52,12 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
if err != nil {
return nil, merrors.Internal("mntx: dial failed: " + err.Error())
return nil, merrors.Internal("mntx: dial failed: "+err.Error())
}
return &gatewayClient{
conn: conn,
client: unifiedv1.NewUnifiedGatewayServiceClient(conn),
client: connectorv1.NewConnectorServiceClient(conn),
cfg: cfg,
logger: cfg.Logger,
}, nil
@@ -70,37 +78,253 @@ func (g *gatewayClient) callContext(ctx context.Context, method string) (context
if timeout <= 0 {
timeout = 5 * time.Second
}
fields := []zap.Field{
zap.String("method", method),
zap.Duration("timeout", timeout),
if g.logger != nil {
fields := []zap.Field{
zap.String("method", method),
zap.Duration("timeout", timeout),
}
if deadline, ok := ctx.Deadline(); ok {
fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline)))
}
g.logger.Info("Mntx gateway client call timeout applied", fields...)
}
if deadline, ok := ctx.Deadline(); ok {
fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline)))
}
g.logger.Info("Mntx gateway client call timeout applied", fields...)
return context.WithTimeout(ctx, timeout)
}
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
ctx, cancel := g.callContext(ctx, "CreateCardPayout")
defer cancel()
return g.client.CreateCardPayout(ctx, req)
operation, err := operationFromCardPayout(req)
if err != nil {
return nil, err
}
resp, err := g.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), resp.GetReceipt())}, nil
}
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
ctx, cancel := g.callContext(ctx, "CreateCardTokenPayout")
defer cancel()
return g.client.CreateCardTokenPayout(ctx, req)
operation, err := operationFromTokenPayout(req)
if err != nil {
return nil, err
}
resp, err := g.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), resp.GetReceipt())}, nil
}
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
ctx, cancel := g.callContext(ctx, "GetCardPayoutStatus")
defer cancel()
return g.client.GetCardPayoutStatus(ctx, req)
if req == nil || strings.TrimSpace(req.GetPayoutId()) == "" {
return nil, merrors.InvalidArgument("mntx: payout_id is required")
}
resp, err := g.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetPayoutId())})
if err != nil {
return nil, err
}
return &mntxv1.GetCardPayoutStatusResponse{Payout: payoutFromOperation(resp.GetOperation())}, nil
}
func (g *gatewayClient) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) {
ctx, cancel := g.callContext(ctx, "ListGatewayInstances")
defer cancel()
return g.client.ListGatewayInstances(ctx, req)
return nil, merrors.NotImplemented("mntx: ListGatewayInstances not supported via connector")
}
func operationFromCardPayout(req *mntxv1.CardPayoutRequest) (*connectorv1.Operation, error) {
if req == nil {
return nil, merrors.InvalidArgument("mntx: request is required")
}
params := payoutParamsFromCard(req)
money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency())
return &connectorv1.Operation{
Type: connectorv1.OperationType_PAYOUT,
IdempotencyKey: strings.TrimSpace(req.GetPayoutId()),
Money: money,
Params: structFromMap(params),
}, nil
}
func operationFromTokenPayout(req *mntxv1.CardTokenPayoutRequest) (*connectorv1.Operation, error) {
if req == nil {
return nil, merrors.InvalidArgument("mntx: request is required")
}
params := payoutParamsFromToken(req)
money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency())
return &connectorv1.Operation{
Type: connectorv1.OperationType_PAYOUT,
IdempotencyKey: strings.TrimSpace(req.GetPayoutId()),
Money: money,
Params: structFromMap(params),
}, nil
}
func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} {
params := map[string]interface{}{
"payout_id": strings.TrimSpace(req.GetPayoutId()),
"project_id": req.GetProjectId(),
"customer_id": strings.TrimSpace(req.GetCustomerId()),
"customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()),
"customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()),
"customer_last_name": strings.TrimSpace(req.GetCustomerLastName()),
"customer_ip": strings.TrimSpace(req.GetCustomerIp()),
"customer_zip": strings.TrimSpace(req.GetCustomerZip()),
"customer_country": strings.TrimSpace(req.GetCustomerCountry()),
"customer_state": strings.TrimSpace(req.GetCustomerState()),
"customer_city": strings.TrimSpace(req.GetCustomerCity()),
"customer_address": strings.TrimSpace(req.GetCustomerAddress()),
"amount_minor": req.GetAmountMinor(),
"currency": strings.TrimSpace(req.GetCurrency()),
"card_pan": strings.TrimSpace(req.GetCardPan()),
"card_exp_year": req.GetCardExpYear(),
"card_exp_month": req.GetCardExpMonth(),
"card_holder": strings.TrimSpace(req.GetCardHolder()),
}
if len(req.GetMetadata()) > 0 {
params["metadata"] = mapStringToInterface(req.GetMetadata())
}
return params
}
func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interface{} {
params := map[string]interface{}{
"payout_id": strings.TrimSpace(req.GetPayoutId()),
"project_id": req.GetProjectId(),
"customer_id": strings.TrimSpace(req.GetCustomerId()),
"customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()),
"customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()),
"customer_last_name": strings.TrimSpace(req.GetCustomerLastName()),
"customer_ip": strings.TrimSpace(req.GetCustomerIp()),
"customer_zip": strings.TrimSpace(req.GetCustomerZip()),
"customer_country": strings.TrimSpace(req.GetCustomerCountry()),
"customer_state": strings.TrimSpace(req.GetCustomerState()),
"customer_city": strings.TrimSpace(req.GetCustomerCity()),
"customer_address": strings.TrimSpace(req.GetCustomerAddress()),
"amount_minor": req.GetAmountMinor(),
"currency": strings.TrimSpace(req.GetCurrency()),
"card_token": strings.TrimSpace(req.GetCardToken()),
"card_holder": strings.TrimSpace(req.GetCardHolder()),
"masked_pan": strings.TrimSpace(req.GetMaskedPan()),
}
if len(req.GetMetadata()) > 0 {
params["metadata"] = mapStringToInterface(req.GetMetadata())
}
return params
}
func moneyFromMinor(amount int64, currency string) *moneyv1.Money {
if amount <= 0 {
return nil
}
dec := decimal.NewFromInt(amount).Div(decimal.NewFromInt(100))
return &moneyv1.Money{
Amount: dec.StringFixed(2),
Currency: strings.ToUpper(strings.TrimSpace(currency)),
}
}
func payoutFromReceipt(payoutID string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState {
state := &mntxv1.CardPayoutState{PayoutId: strings.TrimSpace(payoutID)}
if receipt == nil {
return state
}
state.Status = payoutStatusFromOperation(receipt.GetStatus())
state.ProviderPaymentId = strings.TrimSpace(receipt.GetProviderRef())
return state
}
func payoutFromOperation(op *connectorv1.Operation) *mntxv1.CardPayoutState {
if op == nil {
return nil
}
state := &mntxv1.CardPayoutState{
PayoutId: strings.TrimSpace(op.GetOperationId()),
Status: payoutStatusFromOperation(op.GetStatus()),
ProviderPaymentId: strings.TrimSpace(op.GetProviderRef()),
}
if money := op.GetMoney(); money != nil {
state.Currency = strings.TrimSpace(money.GetCurrency())
state.AmountMinor = minorFromMoney(money)
}
return state
}
func minorFromMoney(m *moneyv1.Money) int64 {
if m == nil {
return 0
}
amount := strings.TrimSpace(m.GetAmount())
if amount == "" {
return 0
}
dec, err := decimal.NewFromString(amount)
if err != nil {
return 0
}
return dec.Mul(decimal.NewFromInt(100)).IntPart()
}
func payoutStatusFromOperation(status connectorv1.OperationStatus) mntxv1.PayoutStatus {
switch status {
case connectorv1.OperationStatus_CONFIRMED:
return mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED
case connectorv1.OperationStatus_FAILED:
return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED
case connectorv1.OperationStatus_PENDING, connectorv1.OperationStatus_SUBMITTED:
return mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING
default:
return mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED
}
}
func connectorError(err *connectorv1.ConnectorError) error {
if err == nil {
return nil
}
msg := strings.TrimSpace(err.GetMessage())
switch err.GetCode() {
case connectorv1.ErrorCode_INVALID_PARAMS:
return merrors.InvalidArgument(msg)
case connectorv1.ErrorCode_NOT_FOUND:
return merrors.NoData(msg)
case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND:
return merrors.NotImplemented(msg)
case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE:
return merrors.Internal(msg)
default:
return merrors.Internal(msg)
}
}
func structFromMap(data map[string]interface{}) *structpb.Struct {
if len(data) == 0 {
return nil
}
result, err := structpb.NewStruct(data)
if err != nil {
return nil
}
return result
}
func mapStringToInterface(input map[string]string) map[string]interface{} {
if len(input) == 0 {
return nil
}
out := make(map[string]interface{}, len(input))
for k, v := range input {
out[k] = v
}
return out
}

View File

@@ -7,6 +7,7 @@ replace github.com/tech/sendico/pkg => ../../pkg
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/prometheus/client_golang v1.23.2
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1
google.golang.org/grpc v1.78.0

View File

@@ -0,0 +1,294 @@
package gateway
import (
"context"
"errors"
"fmt"
"strings"
"github.com/shopspring/decimal"
"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"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
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,
SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_PAYOUT},
OperationParams: mntxOperationParams(),
},
}, nil
}
func (s *Service) OpenAccount(_ context.Context, _ *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported", nil, "")}, nil
}
func (s *Service) GetAccount(_ context.Context, _ *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) {
return nil, merrors.NotImplemented("get_account: unsupported")
}
func (s *Service) ListAccounts(_ context.Context, _ *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
return nil, merrors.NotImplemented("list_accounts: unsupported")
}
func (s *Service) GetBalance(_ context.Context, _ *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) {
return nil, merrors.NotImplemented("get_balance: unsupported")
}
func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
if req == nil || req.GetOperation() == nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
}
op := req.GetOperation()
if strings.TrimSpace(op.GetIdempotencyKey()) == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
}
if op.GetType() != connectorv1.OperationType_PAYOUT {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
}
reader := params.New(op.GetParams())
amountMinor, currency, err := payoutAmount(op, reader)
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
payoutID := strings.TrimSpace(reader.String("payout_id"))
if payoutID == "" {
payoutID = strings.TrimSpace(op.GetIdempotencyKey())
}
if strings.TrimSpace(reader.String("card_token")) != "" {
resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequest(reader, payoutID, amountMinor, currency))
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil
}
resp, err := s.CreateCardPayout(ctx, buildCardPayoutRequest(reader, payoutID, amountMinor, currency))
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil
}
func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
resp, err := s.GetCardPayoutStatus(ctx, &mntxv1.GetCardPayoutStatusRequest{PayoutId: strings.TrimSpace(req.GetOperationId())})
if err != nil {
return nil, err
}
return &connectorv1.GetOperationResponse{Operation: payoutToOperation(resp.GetPayout())}, nil
}
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
return nil, merrors.NotImplemented("list_operations: unsupported")
}
func mntxOperationParams() []*connectorv1.OperationParamSpec {
return []*connectorv1.OperationParamSpec{
{OperationType: connectorv1.OperationType_PAYOUT, Params: []*connectorv1.ParamSpec{
{Key: "customer_id", Type: connectorv1.ParamType_STRING, Required: true},
{Key: "customer_first_name", Type: connectorv1.ParamType_STRING, Required: true},
{Key: "customer_last_name", Type: connectorv1.ParamType_STRING, Required: true},
{Key: "customer_ip", Type: connectorv1.ParamType_STRING, Required: true},
{Key: "card_token", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "card_pan", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "card_exp_year", Type: connectorv1.ParamType_INT, Required: false},
{Key: "card_exp_month", Type: connectorv1.ParamType_INT, Required: false},
{Key: "card_holder", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "amount_minor", Type: connectorv1.ParamType_INT, Required: false},
{Key: "project_id", Type: connectorv1.ParamType_INT, Required: false},
{Key: "customer_middle_name", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "customer_country", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "customer_state", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "customer_city", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "customer_address", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "customer_zip", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "masked_pan", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false},
}},
}
}
func payoutAmount(op *connectorv1.Operation, reader params.Reader) (int64, string, error) {
if op == nil {
return 0, "", fmt.Errorf("payout: operation is required")
}
currency := currencyFromOperation(op)
if currency == "" {
return 0, "", fmt.Errorf("payout: currency is required")
}
if minor, ok := reader.Int64("amount_minor"); ok && minor > 0 {
return minor, currency, nil
}
money := op.GetMoney()
if money == nil {
return 0, "", fmt.Errorf("payout: money is required")
}
amount := strings.TrimSpace(money.GetAmount())
if amount == "" {
return 0, "", fmt.Errorf("payout: amount is required")
}
dec, err := decimal.NewFromString(amount)
if err != nil {
return 0, "", fmt.Errorf("payout: invalid amount")
}
minor := dec.Mul(decimal.NewFromInt(100)).IntPart()
return minor, currency, nil
}
func currencyFromOperation(op *connectorv1.Operation) string {
if op == nil || op.GetMoney() == nil {
return ""
}
currency := strings.TrimSpace(op.GetMoney().GetCurrency())
if idx := strings.Index(currency, "-"); idx > 0 {
currency = currency[:idx]
}
return strings.ToUpper(currency)
}
func buildCardTokenPayoutRequest(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")),
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"),
}
return req
}
func buildCardPayoutRequest(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")),
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"),
}
}
func readerInt64(reader params.Reader, key string) int64 {
if v, ok := reader.Int64(key); ok {
return v
}
return 0
}
func payoutReceipt(state *mntxv1.CardPayoutState) *connectorv1.OperationReceipt {
if state == nil {
return &connectorv1.OperationReceipt{
Status: connectorv1.OperationStatus_PENDING,
}
}
return &connectorv1.OperationReceipt{
OperationId: strings.TrimSpace(state.GetPayoutId()),
Status: payoutStatusToOperation(state.GetStatus()),
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
}
}
func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation {
if state == nil {
return nil
}
return &connectorv1.Operation{
OperationId: strings.TrimSpace(state.GetPayoutId()),
Type: connectorv1.OperationType_PAYOUT,
Status: payoutStatusToOperation(state.GetStatus()),
Money: &moneyv1.Money{
Amount: minorToDecimal(state.GetAmountMinor()),
Currency: strings.ToUpper(strings.TrimSpace(state.GetCurrency())),
},
ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()),
CreatedAt: state.GetCreatedAt(),
UpdatedAt: state.GetUpdatedAt(),
}
}
func minorToDecimal(amount int64) string {
dec := decimal.NewFromInt(amount).Div(decimal.NewFromInt(100))
return dec.StringFixed(2)
}
func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationStatus {
switch status {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
return connectorv1.OperationStatus_CONFIRMED
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
return connectorv1.OperationStatus_FAILED
case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING:
return connectorv1.OperationStatus_PENDING
default:
return connectorv1.OperationStatus_PENDING
}
}
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{
Code: code,
Message: strings.TrimSpace(message),
AccountId: strings.TrimSpace(accountID),
}
if op != nil {
err.CorrelationId = strings.TrimSpace(op.GetCorrelationId())
err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId())
err.OperationId = strings.TrimSpace(op.GetOperationId())
}
return err
}
func mapErrorCode(err error) connectorv1.ErrorCode {
switch {
case errors.Is(err, merrors.ErrInvalidArg):
return connectorv1.ErrorCode_INVALID_PARAMS
case errors.Is(err, merrors.ErrNoData):
return connectorv1.ErrorCode_NOT_FOUND
case errors.Is(err, merrors.ErrNotImplemented):
return connectorv1.ErrorCode_UNSUPPORTED_OPERATION
case errors.Is(err, merrors.ErrInternal):
return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE
default:
return connectorv1.ErrorCode_PROVIDER_ERROR
}
}

View File

@@ -15,7 +15,7 @@ import (
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1"
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"go.uber.org/zap"
"google.golang.org/grpc"
)
@@ -31,7 +31,7 @@ type Service struct {
gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor
announcer *discovery.Announcer
unifiedv1.UnimplementedUnifiedGatewayServiceServer
connectorv1.UnimplementedConnectorServiceServer
}
type payoutFailure interface {
@@ -96,7 +96,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service {
// Register wires the service onto the provided gRPC router.
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s)
connectorv1.RegisterConnectorServiceServer(reg, s)
})
}

View File

@@ -0,0 +1,254 @@
package gateway
import (
"context"
"errors"
"fmt"
"strings"
"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"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
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,
SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_TRANSFER},
OperationParams: tgsettleOperationParams(),
},
}, nil
}
func (s *Service) OpenAccount(_ context.Context, _ *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) {
return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported", nil, "")}, nil
}
func (s *Service) GetAccount(_ context.Context, _ *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) {
return nil, merrors.NotImplemented("get_account: unsupported")
}
func (s *Service) ListAccounts(_ context.Context, _ *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
return nil, merrors.NotImplemented("list_accounts: unsupported")
}
func (s *Service) GetBalance(_ context.Context, _ *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) {
return nil, merrors.NotImplemented("get_balance: unsupported")
}
func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) {
if req == nil || req.GetOperation() == nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil
}
op := req.GetOperation()
if strings.TrimSpace(op.GetIdempotencyKey()) == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil
}
if op.GetType() != connectorv1.OperationType_TRANSFER {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil
}
reader := params.New(op.GetParams())
paymentIntentID := strings.TrimSpace(reader.String("payment_intent_id"))
if paymentIntentID == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: payment_intent_id is required", op, "")}}, nil
}
source := operationAccountID(op.GetFrom())
if source == "" {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: from.account is required", op, "")}}, nil
}
dest, err := transferDestinationFromOperation(op)
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil
}
amount := op.GetMoney()
if amount == nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: money is required", op, "")}}, nil
}
metadata := reader.StringMap("metadata")
if metadata == nil {
metadata = map[string]string{}
}
metadata[metadataPaymentIntentID] = paymentIntentID
if quoteRef := strings.TrimSpace(reader.String("quote_ref")); quoteRef != "" {
metadata[metadataQuoteRef] = quoteRef
}
if targetChatID := strings.TrimSpace(reader.String("target_chat_id")); targetChatID != "" {
metadata[metadataTargetChatID] = targetChatID
}
if outgoingLeg := strings.TrimSpace(reader.String("outgoing_leg")); outgoingLeg != "" {
metadata[metadataOutgoingLeg] = outgoingLeg
}
resp, err := s.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{
IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()),
OrganizationRef: strings.TrimSpace(reader.String("organization_ref")),
SourceWalletRef: source,
Destination: dest,
Amount: normalizeMoneyForTransfer(amount),
Metadata: metadata,
ClientReference: paymentIntentID,
})
if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil
}
transfer := resp.GetTransfer()
return &connectorv1.SubmitOperationResponse{
Receipt: &connectorv1.OperationReceipt{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Status: transferStatusToOperation(transfer.GetStatus()),
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
},
}, nil
}
func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) {
if req == nil || strings.TrimSpace(req.GetOperationId()) == "" {
return nil, merrors.InvalidArgument("get_operation: operation_id is required")
}
resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())})
if err != nil {
return nil, err
}
return &connectorv1.GetOperationResponse{Operation: transferToOperation(resp.GetTransfer())}, nil
}
func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) {
return nil, merrors.NotImplemented("list_operations: unsupported")
}
func tgsettleOperationParams() []*connectorv1.OperationParamSpec {
return []*connectorv1.OperationParamSpec{
{OperationType: connectorv1.OperationType_TRANSFER, Params: []*connectorv1.ParamSpec{
{Key: "payment_intent_id", Type: connectorv1.ParamType_STRING, Required: true},
{Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "quote_ref", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "target_chat_id", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "outgoing_leg", Type: connectorv1.ParamType_STRING, Required: false},
{Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false},
}},
}
}
func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) {
if op == nil {
return nil, fmt.Errorf("transfer: operation is required")
}
if to := op.GetTo(); to != nil {
if account := to.GetAccount(); account != nil {
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}, nil
}
if ext := to.GetExternal(); ext != nil {
return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil
}
}
return nil, fmt.Errorf("transfer: to.account or to.external is required")
}
func normalizeMoneyForTransfer(m *moneyv1.Money) *moneyv1.Money {
if m == nil {
return nil
}
currency := strings.TrimSpace(m.GetCurrency())
if idx := strings.Index(currency, "-"); idx > 0 {
currency = currency[:idx]
}
return &moneyv1.Money{
Amount: strings.TrimSpace(m.GetAmount()),
Currency: currency,
}
}
func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation {
if transfer == nil {
return nil
}
op := &connectorv1.Operation{
OperationId: strings.TrimSpace(transfer.GetTransferRef()),
Type: connectorv1.OperationType_TRANSFER,
Status: transferStatusToOperation(transfer.GetStatus()),
Money: transfer.GetRequestedAmount(),
ProviderRef: strings.TrimSpace(transfer.GetTransferRef()),
CreatedAt: transfer.GetCreatedAt(),
UpdatedAt: transfer.GetUpdatedAt(),
}
if source := strings.TrimSpace(transfer.GetSourceWalletRef()); source != "" {
op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: tgsettleConnectorID,
AccountId: source,
}}}
}
if dest := transfer.GetDestination(); dest != nil {
switch d := dest.GetDestination().(type) {
case *chainv1.TransferDestination_ManagedWalletRef:
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{
ConnectorId: tgsettleConnectorID,
AccountId: strings.TrimSpace(d.ManagedWalletRef),
}}}
case *chainv1.TransferDestination_ExternalAddress:
op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{
ExternalRef: strings.TrimSpace(d.ExternalAddress),
}}}
}
}
return op
}
func transferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus {
switch status {
case chainv1.TransferStatus_TRANSFER_CONFIRMED:
return connectorv1.OperationStatus_CONFIRMED
case chainv1.TransferStatus_TRANSFER_FAILED:
return connectorv1.OperationStatus_FAILED
case chainv1.TransferStatus_TRANSFER_CANCELLED:
return connectorv1.OperationStatus_CANCELED
default:
return connectorv1.OperationStatus_PENDING
}
}
func operationAccountID(party *connectorv1.OperationParty) string {
if party == nil {
return ""
}
if account := party.GetAccount(); account != nil {
return strings.TrimSpace(account.GetAccountId())
}
return ""
}
func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError {
err := &connectorv1.ConnectorError{
Code: code,
Message: strings.TrimSpace(message),
AccountId: strings.TrimSpace(accountID),
}
if op != nil {
err.CorrelationId = strings.TrimSpace(op.GetCorrelationId())
err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId())
err.OperationId = strings.TrimSpace(op.GetOperationId())
}
return err
}
func mapErrorCode(err error) connectorv1.ErrorCode {
switch {
case errors.Is(err, merrors.ErrInvalidArg):
return connectorv1.ErrorCode_INVALID_PARAMS
case errors.Is(err, merrors.ErrNoData):
return connectorv1.ErrorCode_NOT_FOUND
case errors.Is(err, merrors.ErrNotImplemented):
return connectorv1.ErrorCode_UNSUPPORTED_OPERATION
case errors.Is(err, merrors.ErrInternal):
return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE
default:
return connectorv1.ErrorCode_PROVIDER_ERROR
}
}

View File

@@ -20,10 +20,10 @@ import (
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
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"
unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1"
"github.com/tech/sendico/pkg/server/grpcapp"
"go.uber.org/zap"
"google.golang.org/grpc"
@@ -65,7 +65,7 @@ type Service struct {
pending map[string]*model.PaymentGatewayIntent
consumers []msg.Consumer
unifiedv1.UnimplementedUnifiedGatewayServiceServer
connectorv1.UnimplementedConnectorServiceServer
}
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, broker mb.Broker, cfg Config) *Service {
@@ -89,7 +89,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s)
connectorv1.RegisterConnectorServiceServer(reg, s)
})
}