package client import ( "context" "crypto/tls" "fmt" "strings" "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" "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) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) Close() error } 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 grpcConnectorClient } // New dials the chain gateway endpoint and returns a ready client. func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) { cfg.setDefaults() if strings.TrimSpace(cfg.Address) == "" { return nil, merrors.InvalidArgument("chain-gateway: address is required") } dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout) defer cancel() dialOpts := make([]grpc.DialOption, 0, len(opts)+1) dialOpts = append(dialOpts, opts...) if cfg.Insecure { dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) } else { dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))) } conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...) if err != nil { return nil, merrors.Internal(fmt.Sprintf("chain-gateway: dial %s: %s", cfg.Address, err.Error())) } return &chainGatewayClient{ cfg: cfg, conn: conn, client: connectorv1.NewConnectorServiceClient(conn), }, nil } // NewWithClient injects a pre-built gateway client (useful for tests). func NewWithClient(cfg Config, gc grpcConnectorClient) Client { cfg.setDefaults() return &chainGatewayClient{ cfg: cfg, client: gc, } } func (c *chainGatewayClient) Close() error { if c.conn != nil { return c.conn.Close() } return nil } func (c *chainGatewayClient) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() 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() 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() 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() 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() 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() 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() 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() 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() 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() 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) { timeout := c.cfg.CallTimeout if timeout <= 0 { timeout = 3 * time.Second } 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())) } describable := account.GetDescribable() label := strings.TrimSpace(account.GetLabel()) if describable == nil { if label != "" { describable = &describablev1.Describable{Name: label} } } else if strings.TrimSpace(describable.GetName()) == "" && label != "" { desc := strings.TrimSpace(describable.GetDescription()) if desc == "" { describable = &describablev1.Describable{Name: label} } else { describable = &describablev1.Describable{Name: label, Description: &desc} } } 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: describable, } } 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 }