From 8788ff67ec1aac3d4a5053a2a45791144de0a77f Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 30 Jan 2026 15:51:28 +0100 Subject: [PATCH] new tron gateway --- api/gateway/tron/.air.toml | 46 + api/gateway/tron/.gitignore | 4 + api/gateway/tron/client/client.go | 792 ++++++++++++++++++ api/gateway/tron/client/client_test.go | 68 ++ api/gateway/tron/client/config.go | 20 + api/gateway/tron/client/fake.go | 99 +++ api/gateway/tron/client/rail_gateway.go | 287 +++++++ api/gateway/tron/config.dev.yml | 72 ++ api/gateway/tron/config.yml | 72 ++ api/gateway/tron/entrypoint.sh | 15 + api/gateway/tron/env/.gitignore | 1 + api/gateway/tron/go.mod | 99 +++ api/gateway/tron/go.sum | 400 +++++++++ .../tron/internal/appversion/version.go | 27 + .../tron/internal/keymanager/config.go | 13 + .../tron/internal/keymanager/keymanager.go | 26 + .../tron/internal/keymanager/vault/manager.go | 341 ++++++++ .../internal/server/internal/serverimp.go | 431 ++++++++++ api/gateway/tron/internal/server/server.go | 12 + .../service/gateway/commands/registry.go | 48 ++ .../gateway/commands/transfer/convert_fees.go | 43 + .../service/gateway/commands/transfer/deps.go | 35 + .../gateway/commands/transfer/destination.go | 65 ++ .../commands/transfer/destination_address.go | 27 + .../service/gateway/commands/transfer/fee.go | 119 +++ .../gateway/commands/transfer/gas_topup.go | 290 +++++++ .../service/gateway/commands/transfer/get.go | 47 ++ .../service/gateway/commands/transfer/list.go | 58 ++ .../gateway/commands/transfer/proto.go | 53 ++ .../gateway/commands/transfer/submit.go | 156 ++++ .../gateway/commands/wallet/balance.go | 150 ++++ .../service/gateway/commands/wallet/create.go | 164 ++++ .../service/gateway/commands/wallet/deps.go | 35 + .../service/gateway/commands/wallet/get.go | 47 ++ .../service/gateway/commands/wallet/list.go | 62 ++ .../commands/wallet/onchain_balance.go | 63 ++ .../service/gateway/commands/wallet/proto.go | 66 ++ .../internal/service/gateway/connector.go | 629 ++++++++++++++ .../internal/service/gateway/driver/driver.go | 36 + .../gateway/driver/evm/estimate_gas_test.go | 31 + .../service/gateway/driver/evm/evm.go | 734 ++++++++++++++++ .../service/gateway/driver/evm/gas_topup.go | 108 +++ .../gateway/driver/evm/gas_topup_test.go | 146 ++++ .../service/gateway/driver/tron/address.go | 193 +++++ .../gateway/driver/tron/confirmation.go | 165 ++++ .../service/gateway/driver/tron/driver.go | 304 +++++++ .../gateway/driver/tron/driver_test.go | 33 + .../service/gateway/driver/tron/gas_topup.go | 143 ++++ .../gateway/driver/tron/gas_topup_test.go | 147 ++++ .../service/gateway/driver/tron/transfer.go | 308 +++++++ .../gateway/driver/tron/transfer_test.go | 67 ++ .../service/gateway/drivers/registry.go | 68 ++ .../tron/internal/service/gateway/executor.go | 349 ++++++++ .../tron/internal/service/gateway/metrics.go | 65 ++ .../tron/internal/service/gateway/options.go | 99 +++ .../service/gateway/rpcclient/clients.go | 204 +++++ .../service/gateway/rpcclient/registry.go | 54 ++ .../tron/internal/service/gateway/service.go | 225 +++++ .../internal/service/gateway/service_test.go | 664 +++++++++++++++ .../tron/internal/service/gateway/settings.go | 43 + .../service/gateway/shared/gas_topup.go | 32 + .../service/gateway/shared/helpers.go | 148 ++++ .../internal/service/gateway/shared/hex.go | 50 ++ .../service/gateway/shared/hex_test.go | 16 + .../service/gateway/transfer_execution.go | 142 ++++ .../service/gateway/tronclient/client.go | 219 +++++ .../service/gateway/tronclient/registry.go | 127 +++ api/gateway/tron/main.go | 17 + api/gateway/tron/storage/model/deposit.go | 54 ++ api/gateway/tron/storage/model/transfer.go | 93 ++ .../tron/storage/model/transfer_test.go | 50 ++ api/gateway/tron/storage/model/wallet.go | 134 +++ api/gateway/tron/storage/mongo/repository.go | 98 +++ .../tron/storage/mongo/store/deposits.go | 161 ++++ .../tron/storage/mongo/store/transfers.go | 200 +++++ .../tron/storage/mongo/store/wallets.go | 288 +++++++ api/gateway/tron/storage/storage.go | 53 ++ 77 files changed, 11050 insertions(+) create mode 100644 api/gateway/tron/.air.toml create mode 100644 api/gateway/tron/.gitignore create mode 100644 api/gateway/tron/client/client.go create mode 100644 api/gateway/tron/client/client_test.go create mode 100644 api/gateway/tron/client/config.go create mode 100644 api/gateway/tron/client/fake.go create mode 100644 api/gateway/tron/client/rail_gateway.go create mode 100644 api/gateway/tron/config.dev.yml create mode 100644 api/gateway/tron/config.yml create mode 100644 api/gateway/tron/entrypoint.sh create mode 100644 api/gateway/tron/env/.gitignore create mode 100644 api/gateway/tron/go.mod create mode 100644 api/gateway/tron/go.sum create mode 100644 api/gateway/tron/internal/appversion/version.go create mode 100644 api/gateway/tron/internal/keymanager/config.go create mode 100644 api/gateway/tron/internal/keymanager/keymanager.go create mode 100644 api/gateway/tron/internal/keymanager/vault/manager.go create mode 100644 api/gateway/tron/internal/server/internal/serverimp.go create mode 100644 api/gateway/tron/internal/server/server.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/registry.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/transfer/convert_fees.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/transfer/deps.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/transfer/destination.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/transfer/destination_address.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/transfer/fee.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/transfer/gas_topup.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/transfer/get.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/transfer/list.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/transfer/proto.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/transfer/submit.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/wallet/balance.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/wallet/create.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/wallet/deps.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/wallet/get.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/wallet/list.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/wallet/onchain_balance.go create mode 100644 api/gateway/tron/internal/service/gateway/commands/wallet/proto.go create mode 100644 api/gateway/tron/internal/service/gateway/connector.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/driver.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/evm/estimate_gas_test.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/evm/evm.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/evm/gas_topup.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/evm/gas_topup_test.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/tron/address.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/tron/confirmation.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/tron/driver.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/tron/driver_test.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/tron/gas_topup.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/tron/gas_topup_test.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/tron/transfer.go create mode 100644 api/gateway/tron/internal/service/gateway/driver/tron/transfer_test.go create mode 100644 api/gateway/tron/internal/service/gateway/drivers/registry.go create mode 100644 api/gateway/tron/internal/service/gateway/executor.go create mode 100644 api/gateway/tron/internal/service/gateway/metrics.go create mode 100644 api/gateway/tron/internal/service/gateway/options.go create mode 100644 api/gateway/tron/internal/service/gateway/rpcclient/clients.go create mode 100644 api/gateway/tron/internal/service/gateway/rpcclient/registry.go create mode 100644 api/gateway/tron/internal/service/gateway/service.go create mode 100644 api/gateway/tron/internal/service/gateway/service_test.go create mode 100644 api/gateway/tron/internal/service/gateway/settings.go create mode 100644 api/gateway/tron/internal/service/gateway/shared/gas_topup.go create mode 100644 api/gateway/tron/internal/service/gateway/shared/helpers.go create mode 100644 api/gateway/tron/internal/service/gateway/shared/hex.go create mode 100644 api/gateway/tron/internal/service/gateway/shared/hex_test.go create mode 100644 api/gateway/tron/internal/service/gateway/transfer_execution.go create mode 100644 api/gateway/tron/internal/service/gateway/tronclient/client.go create mode 100644 api/gateway/tron/internal/service/gateway/tronclient/registry.go create mode 100644 api/gateway/tron/main.go create mode 100644 api/gateway/tron/storage/model/deposit.go create mode 100644 api/gateway/tron/storage/model/transfer.go create mode 100644 api/gateway/tron/storage/model/transfer_test.go create mode 100644 api/gateway/tron/storage/model/wallet.go create mode 100644 api/gateway/tron/storage/mongo/repository.go create mode 100644 api/gateway/tron/storage/mongo/store/deposits.go create mode 100644 api/gateway/tron/storage/mongo/store/transfers.go create mode 100644 api/gateway/tron/storage/mongo/store/wallets.go create mode 100644 api/gateway/tron/storage/storage.go diff --git a/api/gateway/tron/.air.toml b/api/gateway/tron/.air.toml new file mode 100644 index 00000000..16f8c34b --- /dev/null +++ b/api/gateway/tron/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + entrypoint = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go", "_templ.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/api/gateway/tron/.gitignore b/api/gateway/tron/.gitignore new file mode 100644 index 00000000..436d3e5e --- /dev/null +++ b/api/gateway/tron/.gitignore @@ -0,0 +1,4 @@ +internal/generated +.gocache +app +tmp diff --git a/api/gateway/tron/client/client.go b/api/gateway/tron/client/client.go new file mode 100644 index 00000000..90abc5e2 --- /dev/null +++ b/api/gateway/tron/client/client.go @@ -0,0 +1,792 @@ +package client + +import ( + "context" + "crypto/tls" + "fmt" + "strings" + "time" + + chainasset "github.com/tech/sendico/pkg/chain" + "github.com/tech/sendico/pkg/merrors" + pmodel "github.com/tech/sendico/pkg/model" + 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" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/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" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +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: chainasset.AssetString(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 := "" + var ownerRefFilter *wrapperspb.StringValue + orgRef := "" + var page *paginationv1.CursorPageRequest + if req != nil { + assetString = chainasset.AssetString(req.GetAsset()) + ownerRefFilter = req.GetOwnerRefFilter() + orgRef = strings.TrimSpace(req.GetOrganizationRef()) + page = req.GetPage() + } + resp, err := c.client.ListAccounts(ctx, &connectorv1.ListAccountsRequest{ + OwnerRefFilter: ownerRefFilter, + OrganizationRef: orgRef, + Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET, + 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"] = chainasset.NetworkName(asset.GetChain()) + 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: chainasset.NetworkFromString(stringFromDetails(details, "network")), + TokenSymbol: strings.TrimSpace(stringFromDetails(details, "token_symbol")), + ContractAddress: strings.TrimSpace(stringFromDetails(details, "contract_address")), + } + if asset.GetTokenSymbol() == "" { + asset.TokenSymbol = strings.TrimSpace(chainasset.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 + setOperationRolesFromMetadata(op, req.GetMetadata()) + 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 setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[string]string) { + if op == nil || len(metadata) == 0 { + return + } + if raw := strings.TrimSpace(metadata[pmodel.MetadataKeyFromRole]); raw != "" { + if role, ok := pmodel.Parse(raw); ok && role != "" { + op.FromRole = pmodel.ToProto(role) + } + } + if raw := strings.TrimSpace(metadata[pmodel.MetadataKeyToRole]); raw != "" { + if role, ok := pmodel.Parse(raw); ok && role != "" { + op.ToRole = pmodel.ToProto(role) + } + } +} + +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 + } +} diff --git a/api/gateway/tron/client/client_test.go b/api/gateway/tron/client/client_test.go new file mode 100644 index 00000000..f32d0d95 --- /dev/null +++ b/api/gateway/tron/client/client_test.go @@ -0,0 +1,68 @@ +package client + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +type stubConnectorClient struct { + listReq *connectorv1.ListAccountsRequest +} + +func (s *stubConnectorClient) GetCapabilities(ctx context.Context, in *connectorv1.GetCapabilitiesRequest, opts ...grpc.CallOption) (*connectorv1.GetCapabilitiesResponse, error) { + return &connectorv1.GetCapabilitiesResponse{}, nil +} + +func (s *stubConnectorClient) OpenAccount(ctx context.Context, in *connectorv1.OpenAccountRequest, opts ...grpc.CallOption) (*connectorv1.OpenAccountResponse, error) { + return &connectorv1.OpenAccountResponse{}, nil +} + +func (s *stubConnectorClient) GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error) { + return &connectorv1.GetAccountResponse{}, nil +} + +func (s *stubConnectorClient) ListAccounts(ctx context.Context, in *connectorv1.ListAccountsRequest, opts ...grpc.CallOption) (*connectorv1.ListAccountsResponse, error) { + s.listReq = in + return &connectorv1.ListAccountsResponse{}, nil +} + +func (s *stubConnectorClient) GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error) { + return &connectorv1.GetBalanceResponse{}, nil +} + +func (s *stubConnectorClient) SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) { + return &connectorv1.SubmitOperationResponse{}, nil +} + +func (s *stubConnectorClient) GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error) { + return &connectorv1.GetOperationResponse{}, nil +} + +func (s *stubConnectorClient) ListOperations(ctx context.Context, in *connectorv1.ListOperationsRequest, opts ...grpc.CallOption) (*connectorv1.ListOperationsResponse, error) { + return &connectorv1.ListOperationsResponse{}, nil +} + +func TestListManagedWallets_ForwardsOrganizationRef(t *testing.T) { + stub := &stubConnectorClient{} + client := NewWithClient(Config{}, stub) + + _, err := client.ListManagedWallets(context.Background(), &chainv1.ListManagedWalletsRequest{ + OrganizationRef: "org-1", + OwnerRefFilter: wrapperspb.String("owner-1"), + Asset: &chainv1.Asset{ + Chain: chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, + TokenSymbol: "USDC", + }, + }) + require.NoError(t, err) + require.NotNil(t, stub.listReq) + require.Equal(t, "org-1", stub.listReq.GetOrganizationRef()) + require.Equal(t, "owner-1", stub.listReq.GetOwnerRefFilter().GetValue()) + require.Equal(t, connectorv1.AccountKind_CHAIN_MANAGED_WALLET, stub.listReq.GetKind()) +} diff --git a/api/gateway/tron/client/config.go b/api/gateway/tron/client/config.go new file mode 100644 index 00000000..b0ae55a1 --- /dev/null +++ b/api/gateway/tron/client/config.go @@ -0,0 +1,20 @@ +package client + +import "time" + +// Config captures connection settings for the chain gateway gRPC service. +type Config struct { + Address string + DialTimeout time.Duration + CallTimeout time.Duration + Insecure bool +} + +func (c *Config) setDefaults() { + if c.DialTimeout <= 0 { + c.DialTimeout = 5 * time.Second + } + if c.CallTimeout <= 0 { + c.CallTimeout = 3 * time.Second + } +} diff --git a/api/gateway/tron/client/fake.go b/api/gateway/tron/client/fake.go new file mode 100644 index 00000000..a95da43a --- /dev/null +++ b/api/gateway/tron/client/fake.go @@ -0,0 +1,99 @@ +package client + +import ( + "context" + + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" +) + +// Fake implements Client for tests. +type Fake struct { + CreateManagedWalletFn func(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) + GetManagedWalletFn func(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) + ListManagedWalletsFn func(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) + GetWalletBalanceFn func(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) + SubmitTransferFn func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) + GetTransferFn func(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) + ListTransfersFn func(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) + EstimateTransferFeeFn func(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) + ComputeGasTopUpFn func(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) + EnsureGasTopUpFn func(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) + CloseFn func() error +} + +func (f *Fake) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) { + if f.CreateManagedWalletFn != nil { + return f.CreateManagedWalletFn(ctx, req) + } + return &chainv1.CreateManagedWalletResponse{}, nil +} + +func (f *Fake) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) { + if f.GetManagedWalletFn != nil { + return f.GetManagedWalletFn(ctx, req) + } + return &chainv1.GetManagedWalletResponse{}, nil +} + +func (f *Fake) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) { + if f.ListManagedWalletsFn != nil { + return f.ListManagedWalletsFn(ctx, req) + } + return &chainv1.ListManagedWalletsResponse{}, nil +} + +func (f *Fake) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) { + if f.GetWalletBalanceFn != nil { + return f.GetWalletBalanceFn(ctx, req) + } + return &chainv1.GetWalletBalanceResponse{}, nil +} + +func (f *Fake) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { + if f.SubmitTransferFn != nil { + return f.SubmitTransferFn(ctx, req) + } + return &chainv1.SubmitTransferResponse{}, nil +} + +func (f *Fake) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) { + if f.GetTransferFn != nil { + return f.GetTransferFn(ctx, req) + } + return &chainv1.GetTransferResponse{}, nil +} + +func (f *Fake) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) { + if f.ListTransfersFn != nil { + return f.ListTransfersFn(ctx, req) + } + return &chainv1.ListTransfersResponse{}, nil +} + +func (f *Fake) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) { + if f.EstimateTransferFeeFn != nil { + return f.EstimateTransferFeeFn(ctx, req) + } + return &chainv1.EstimateTransferFeeResponse{}, nil +} + +func (f *Fake) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) { + if f.ComputeGasTopUpFn != nil { + return f.ComputeGasTopUpFn(ctx, req) + } + return &chainv1.ComputeGasTopUpResponse{}, nil +} + +func (f *Fake) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) { + if f.EnsureGasTopUpFn != nil { + return f.EnsureGasTopUpFn(ctx, req) + } + return &chainv1.EnsureGasTopUpResponse{}, nil +} + +func (f *Fake) Close() error { + if f.CloseFn != nil { + return f.CloseFn() + } + return nil +} diff --git a/api/gateway/tron/client/rail_gateway.go b/api/gateway/tron/client/rail_gateway.go new file mode 100644 index 00000000..3f128c83 --- /dev/null +++ b/api/gateway/tron/client/rail_gateway.go @@ -0,0 +1,287 @@ +package client + +import ( + "context" + "strings" + + "github.com/tech/sendico/pkg/merrors" + pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/payments/rail" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// RailGatewayConfig defines metadata for the rail gateway adapter. +type RailGatewayConfig struct { + Rail string + Network string + Capabilities rail.RailCapabilities +} + +type chainRailGateway struct { + client Client + rail string + network string + capabilities rail.RailCapabilities +} + +// NewRailGateway wraps a chain gateway client into a rail gateway adapter. +func NewRailGateway(client Client, cfg RailGatewayConfig) rail.RailGateway { + railName := strings.ToUpper(strings.TrimSpace(cfg.Rail)) + if railName == "" { + railName = "CRYPTO" + } + return &chainRailGateway{ + client: client, + rail: railName, + network: strings.ToUpper(strings.TrimSpace(cfg.Network)), + capabilities: cfg.Capabilities, + } +} + +func (g *chainRailGateway) Rail() string { + return g.rail +} + +func (g *chainRailGateway) Network() string { + return g.network +} + +func (g *chainRailGateway) Capabilities() rail.RailCapabilities { + return g.capabilities +} + +func (g *chainRailGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { + if g.client == nil { + return rail.RailResult{}, merrors.Internal("chain gateway: client is required") + } + orgRef := strings.TrimSpace(req.OrganizationRef) + if orgRef == "" { + return rail.RailResult{}, merrors.InvalidArgument("chain gateway: organization_ref is required") + } + source := strings.TrimSpace(req.FromAccountID) + if source == "" { + return rail.RailResult{}, merrors.InvalidArgument("chain gateway: from_account_id is required") + } + destRef := strings.TrimSpace(req.ToAccountID) + if destRef == "" { + return rail.RailResult{}, merrors.InvalidArgument("chain gateway: to_account_id is required") + } + currency := strings.TrimSpace(req.Currency) + amountValue := strings.TrimSpace(req.Amount) + if currency == "" || amountValue == "" { + return rail.RailResult{}, merrors.InvalidArgument("chain gateway: amount is required") + } + reqNetwork := strings.TrimSpace(req.Network) + if g.network != "" && reqNetwork != "" && !strings.EqualFold(g.network, reqNetwork) { + return rail.RailResult{}, merrors.InvalidArgument("chain gateway: network mismatch") + } + if strings.TrimSpace(req.IdempotencyKey) == "" { + return rail.RailResult{}, merrors.InvalidArgument("chain gateway: idempotency_key is required") + } + + dest, err := g.resolveDestination(ctx, destRef, strings.TrimSpace(req.DestinationMemo)) + if err != nil { + return rail.RailResult{}, err + } + + fees := toServiceFees(req.Fees) + if len(fees) == 0 && req.Fee != nil { + if amt := moneyFromRail(req.Fee); amt != nil { + fees = []*chainv1.ServiceFeeBreakdown{{ + FeeCode: "fee", + Amount: amt, + }} + } + } + + resp, err := g.client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ + IdempotencyKey: strings.TrimSpace(req.IdempotencyKey), + OrganizationRef: orgRef, + SourceWalletRef: source, + Destination: dest, + Amount: &moneyv1.Money{ + Currency: currency, + Amount: amountValue, + }, + Fees: fees, + Metadata: transferMetadataWithRoles(req.Metadata, req.FromRole, req.ToRole), + ClientReference: strings.TrimSpace(req.ClientReference), + }) + if err != nil { + return rail.RailResult{}, err + } + if resp == nil || resp.GetTransfer() == nil { + return rail.RailResult{}, merrors.Internal("chain gateway: missing transfer response") + } + + transfer := resp.GetTransfer() + return rail.RailResult{ + ReferenceID: strings.TrimSpace(transfer.GetTransferRef()), + Status: statusFromTransfer(transfer.GetStatus()), + FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), + }, nil +} + +func (g *chainRailGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) { + if g.client == nil { + return rail.ObserveResult{}, merrors.Internal("chain gateway: client is required") + } + ref := strings.TrimSpace(referenceID) + if ref == "" { + return rail.ObserveResult{}, merrors.InvalidArgument("chain gateway: reference_id is required") + } + resp, err := g.client.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: ref}) + if err != nil { + return rail.ObserveResult{}, err + } + if resp == nil || resp.GetTransfer() == nil { + return rail.ObserveResult{}, merrors.Internal("chain gateway: missing transfer response") + } + transfer := resp.GetTransfer() + return rail.ObserveResult{ + ReferenceID: ref, + Status: statusFromTransfer(transfer.GetStatus()), + FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), + }, nil +} + +func (g *chainRailGateway) Block(ctx context.Context, req rail.BlockRequest) (rail.RailResult, error) { + return rail.RailResult{}, merrors.NotImplemented("chain gateway: block not supported") +} + +func (g *chainRailGateway) Release(ctx context.Context, req rail.ReleaseRequest) (rail.RailResult, error) { + return rail.RailResult{}, merrors.NotImplemented("chain gateway: release not supported") +} + +func (g *chainRailGateway) resolveDestination(ctx context.Context, destRef, memo string) (*chainv1.TransferDestination, error) { + managed, err := g.isManagedWallet(ctx, destRef) + if err != nil { + return nil, err + } + if managed { + return &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: destRef}, + }, nil + } + return &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: destRef}, + Memo: memo, + }, nil +} + +func (g *chainRailGateway) isManagedWallet(ctx context.Context, walletRef string) (bool, error) { + resp, err := g.client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef}) + if err != nil { + if status.Code(err) == codes.NotFound { + return false, nil + } + return false, err + } + if resp == nil || resp.GetWallet() == nil { + return false, nil + } + return true, nil +} + +func statusFromTransfer(status chainv1.TransferStatus) string { + switch status { + case chainv1.TransferStatus_TRANSFER_CONFIRMED: + return rail.TransferStatusSuccess + case chainv1.TransferStatus_TRANSFER_FAILED: + return rail.TransferStatusFailed + case chainv1.TransferStatus_TRANSFER_CANCELLED: + return rail.TransferStatusRejected + case chainv1.TransferStatus_TRANSFER_SIGNING, + chainv1.TransferStatus_TRANSFER_PENDING, + chainv1.TransferStatus_TRANSFER_SUBMITTED: + return rail.TransferStatusPending + default: + return rail.TransferStatusPending + } +} + +func toServiceFees(fees []rail.FeeBreakdown) []*chainv1.ServiceFeeBreakdown { + if len(fees) == 0 { + return nil + } + result := make([]*chainv1.ServiceFeeBreakdown, 0, len(fees)) + for _, fee := range fees { + amount := moneyFromRail(fee.Amount) + if amount == nil { + continue + } + result = append(result, &chainv1.ServiceFeeBreakdown{ + FeeCode: strings.TrimSpace(fee.FeeCode), + Amount: amount, + Description: strings.TrimSpace(fee.Description), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func moneyFromRail(m *rail.Money) *moneyv1.Money { + if m == nil { + return nil + } + currency := strings.TrimSpace(m.GetCurrency()) + amount := strings.TrimSpace(m.GetAmount()) + if currency == "" || amount == "" { + return nil + } + return &moneyv1.Money{ + Currency: currency, + Amount: amount, + } +} + +func railMoneyFromProto(m *moneyv1.Money) *rail.Money { + if m == nil { + return nil + } + currency := strings.TrimSpace(m.GetCurrency()) + amount := strings.TrimSpace(m.GetAmount()) + if currency == "" || amount == "" { + return nil + } + return &rail.Money{ + Currency: currency, + Amount: amount, + } +} + +func transferMetadataWithRoles(metadata map[string]string, fromRole, toRole pmodel.AccountRole) map[string]string { + result := cloneMetadata(metadata) + if strings.TrimSpace(string(fromRole)) != "" { + if result == nil { + result = map[string]string{} + } + result[pmodel.MetadataKeyFromRole] = strings.TrimSpace(string(fromRole)) + } + if strings.TrimSpace(string(toRole)) != "" { + if result == nil { + result = map[string]string{} + } + result[pmodel.MetadataKeyToRole] = strings.TrimSpace(string(toRole)) + } + if len(result) == 0 { + return nil + } + return result +} + +func cloneMetadata(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + result := make(map[string]string, len(input)) + for key, value := range input { + result[key] = value + } + return result +} diff --git a/api/gateway/tron/config.dev.yml b/api/gateway/tron/config.dev.yml new file mode 100644 index 00000000..09e1fe04 --- /dev/null +++ b/api/gateway/tron/config.dev.yml @@ -0,0 +1,72 @@ +runtime: + shutdown_timeout_seconds: 15 + +grpc: + network: tcp + address: ":50071" + advertise_host: "dev-tron-gateway" + enable_reflection: true + enable_health: true + +metrics: + address: ":9407" + +database: + driver: mongodb + settings: + host_env: TRON_GATEWAY_MONGO_HOST + port_env: TRON_GATEWAY_MONGO_PORT + database_env: TRON_GATEWAY_MONGO_DATABASE + user_env: TRON_GATEWAY_MONGO_USER + password_env: TRON_GATEWAY_MONGO_PASSWORD + auth_source_env: TRON_GATEWAY_MONGO_AUTH_SOURCE + replica_set_env: TRON_GATEWAY_MONGO_REPLICA_SET + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: TRON Gateway Service + max_reconnects: 10 + reconnect_wait: 5 + buffer_size: 1024 + +chains: + - name: tron_nile + chain_id: 3448148188 # Nile testnet + native_token: TRX + rpc_url_env: TRON_GATEWAY_RPC_URL + grpc_url_env: TRON_GATEWAY_GRPC_URL + grpc_token_env: TRON_GATEWAY_GRPC_TOKEN + gas_topup_policy: + buffer_percent: 0.10 + min_native_balance_trx: 10 + rounding_unit_trx: 1 + max_topup_trx: 100 + tokens: + - symbol: USDT + contract: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + - symbol: USDC + contract: "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8" + +service_wallet: + chain: tron_nile + address_env: TRON_GATEWAY_SERVICE_WALLET_ADDRESS + private_key_env: TRON_GATEWAY_SERVICE_WALLET_KEY + +key_management: + driver: vault + settings: + address: "http://dev-vault:8200" + token_env: VAULT_TOKEN + namespace: "" + mount_path: kv + key_prefix: gateway/tron/wallets + +cache: + wallet_balance_ttl_seconds: 120 + rpc_request_timeout_seconds: 15 diff --git a/api/gateway/tron/config.yml b/api/gateway/tron/config.yml new file mode 100644 index 00000000..80dbcec7 --- /dev/null +++ b/api/gateway/tron/config.yml @@ -0,0 +1,72 @@ +runtime: + shutdown_timeout_seconds: 15 + +grpc: + network: tcp + address: ":50071" + advertise_host: "sendico_tron_gateway" + enable_reflection: true + enable_health: true + +metrics: + address: ":9407" + +database: + driver: mongodb + settings: + host_env: TRON_GATEWAY_MONGO_HOST + port_env: TRON_GATEWAY_MONGO_PORT + database_env: TRON_GATEWAY_MONGO_DATABASE + user_env: TRON_GATEWAY_MONGO_USER + password_env: TRON_GATEWAY_MONGO_PASSWORD + auth_source_env: TRON_GATEWAY_MONGO_AUTH_SOURCE + replica_set_env: TRON_GATEWAY_MONGO_REPLICA_SET + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: TRON Gateway Service + max_reconnects: 10 + reconnect_wait: 5 + buffer_size: 1024 + +chains: + - name: tron_mainnet + chain_id: 728126428 # 0x2b6653dc + native_token: TRX + rpc_url_env: TRON_GATEWAY_RPC_URL + grpc_url_env: TRON_GATEWAY_GRPC_URL + grpc_token_env: TRON_GATEWAY_GRPC_TOKEN + gas_topup_policy: + buffer_percent: 0.10 + min_native_balance_trx: 10 + rounding_unit_trx: 1 + max_topup_trx: 100 + tokens: + - symbol: USDT + contract: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + - symbol: USDC + contract: "TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8" + +service_wallet: + chain: tron_mainnet + address_env: TRON_GATEWAY_SERVICE_WALLET_ADDRESS + private_key_env: TRON_GATEWAY_SERVICE_WALLET_KEY + +key_management: + driver: vault + settings: + address: "https://vault.sendico.io" + token_env: VAULT_TOKEN + namespace: "" + mount_path: kv + key_prefix: gateway/tron/wallets + +cache: + wallet_balance_ttl_seconds: 120 + rpc_request_timeout_seconds: 15 diff --git a/api/gateway/tron/entrypoint.sh b/api/gateway/tron/entrypoint.sh new file mode 100644 index 00000000..c3c1dc1e --- /dev/null +++ b/api/gateway/tron/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +if [ -n "${VAULT_TOKEN_FILE:-}" ] && [ -f "${VAULT_TOKEN_FILE}" ]; then + token="$(cat "${VAULT_TOKEN_FILE}" 2>/dev/null | tr -d '[:space:]')" + if [ -n "${token}" ]; then + export VAULT_TOKEN="${token}" + fi +fi + +if [ -z "${VAULT_TOKEN:-}" ]; then + echo "[entrypoint] VAULT_TOKEN is not set; expected Vault Agent sink to write a token to ${VAULT_TOKEN_FILE:-/run/vault/token}" >&2 +fi + +exec /app/tron-gateway "$@" diff --git a/api/gateway/tron/env/.gitignore b/api/gateway/tron/env/.gitignore new file mode 100644 index 00000000..f2a8cbe3 --- /dev/null +++ b/api/gateway/tron/env/.gitignore @@ -0,0 +1 @@ +.env.api diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod new file mode 100644 index 00000000..ab13ecb0 --- /dev/null +++ b/api/gateway/tron/go.mod @@ -0,0 +1,99 @@ +module github.com/tech/sendico/gateway/tron + +go 1.25.6 + +replace github.com/tech/sendico/pkg => ../../pkg + +require ( + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 + github.com/ethereum/go-ethereum v1.16.8 + github.com/fbsobreira/gotron-sdk v0.24.1 + github.com/hashicorp/vault/api v1.22.0 + github.com/mitchellh/mapstructure v1.5.0 + github.com/prometheus/client_golang v1.23.2 + github.com/shopspring/decimal v1.4.0 + github.com/stretchr/testify v1.11.1 + github.com/tech/sendico/pkg v0.1.0 + go.mongodb.org/mongo-driver v1.17.7 + go.uber.org/zap v1.27.1 + google.golang.org/grpc v1.78.0 + google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260128015922-c6a88330dfcd // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.3.6 // indirect + github.com/casbin/casbin/v2 v2.135.0 // indirect + github.com/casbin/govaluate v1.10.0 // indirect + github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/consensys/gnark-crypto v0.19.2 // indirect + github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect + github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/deckarep/golang-set v1.8.0 // indirect + github.com/deckarep/golang-set/v2 v2.8.0 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect + github.com/ethereum/go-verkle v0.2.2 // indirect + github.com/go-chi/chi/v5 v5.2.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/holiman/uint256 v1.3.2 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/nats.go v1.48.0 // indirect + github.com/nats-io/nkeys v0.4.14 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/pborman/uuid v1.2.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/rjeczalik/notify v0.9.3 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/shengdoushi/base58 v1.0.0 // indirect + github.com/shirou/gopsutil v3.21.11+incompatible // indirect + github.com/supranational/blst v0.3.16 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/tyler-smith/go-bip39 v1.1.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect +) diff --git a/api/gateway/tron/go.sum b/api/gateway/tron/go.sum new file mode 100644 index 00000000..6d4e5598 --- /dev/null +++ b/api/gateway/tron/go.sum @@ -0,0 +1,400 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260128015922-c6a88330dfcd h1:Q1TFWVLXoK7DoPIIBE7K0lDScjlxcRI0IUxjrm3yZ8A= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260128015922-c6a88330dfcd/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= +github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/btcsuite/btcd/btcec/v2 v2.3.6 h1:IzlsEr9olcSRKB/n7c4351F3xHKxS2lma+1UFGCYd4E= +github.com/btcsuite/btcd/btcec/v2 v2.3.6/go.mod h1:m22FrOAiuxl/tht9wIqAoGHcbnCCaPWyauO8y2LGGtQ= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E= +github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80= +github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg= +github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= +github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4= +github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo= +github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= +github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s= +github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= +github.com/ethereum/go-ethereum v1.16.8 h1:LLLfkZWijhR5m6yrAXbdlTeXoqontH+Ga2f9igY7law= +github.com/ethereum/go-ethereum v1.16.8/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk= +github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= +github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fbsobreira/gotron-sdk v0.24.1 h1:YxvF26zyXNkho1GxywQeq/gRi70aQ6sbWYop6OTWL7E= +github.com/fbsobreira/gotron-sdk v0.24.1/go.mod h1:6E0ac5F3fsVlw+HgfZRAUWl2AkIVuOKvYYtDp7pqbYw= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= +github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= +github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.14 h1:ofx8UiyHP5S4Q52/THHucCJsMWu6zhf4DLh0U2593HE= +github.com/nats-io/nkeys v0.4.14/go.mod h1:seG5UKwYdZXb7M1y1vvu53mNh3xq2B6um/XUgYAgvkM= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= +github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= +github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= +github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/shengdoushi/base58 v1.0.0 h1:tGe4o6TmdXFJWoI31VoSWvuaKxf0Px3gqa3sUWhAxBs= +github.com/shengdoushi/base58 v1.0.0/go.mod h1:m5uIILfzcKMw6238iWAhP4l3s5+uXyF3+bJKUNhAL9I= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= +github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= +github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= +github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver v1.17.7 h1:a9w+U3Vt67eYzcfq3k/OAv284/uUUkL0uP75VE5rCOU= +go.mongodb.org/mongo-driver v1.17.7/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/gateway/tron/internal/appversion/version.go b/api/gateway/tron/internal/appversion/version.go new file mode 100644 index 00000000..54cbc8d5 --- /dev/null +++ b/api/gateway/tron/internal/appversion/version.go @@ -0,0 +1,27 @@ +package appversion + +import ( + "github.com/tech/sendico/pkg/version" + vf "github.com/tech/sendico/pkg/version/factory" +) + +// Build information. Populated at build-time. +var ( + Version string + Revision string + Branch string + BuildUser string + BuildDate string +) + +func Create() version.Printer { + info := version.Info{ + Program: "Sendico TRON Gateway Service", + Revision: Revision, + Branch: Branch, + BuildUser: BuildUser, + BuildDate: BuildDate, + Version: Version, + } + return vf.Create(&info) +} diff --git a/api/gateway/tron/internal/keymanager/config.go b/api/gateway/tron/internal/keymanager/config.go new file mode 100644 index 00000000..ceb80b02 --- /dev/null +++ b/api/gateway/tron/internal/keymanager/config.go @@ -0,0 +1,13 @@ +package keymanager + +import "github.com/tech/sendico/pkg/model" + +// Driver identifies the key management backend implementation. +type Driver string + +const ( + DriverVault Driver = "vault" +) + +// Config represents a configured key manager driver with arbitrary settings. +type Config = model.DriverConfig[Driver] diff --git a/api/gateway/tron/internal/keymanager/keymanager.go b/api/gateway/tron/internal/keymanager/keymanager.go new file mode 100644 index 00000000..bbbae65a --- /dev/null +++ b/api/gateway/tron/internal/keymanager/keymanager.go @@ -0,0 +1,26 @@ +package keymanager + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/fbsobreira/gotron-sdk/pkg/proto/core" +) + +// ManagedWalletKey captures information returned after provisioning a managed wallet key. +type ManagedWalletKey struct { + KeyID string + Address string + PublicKey string +} + +// Manager defines the contract for managing managed wallet keys. +type Manager interface { + // CreateManagedWalletKey provisions a new managed wallet key for the provided wallet reference and network. + CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*ManagedWalletKey, error) + // SignTransaction signs the provided transaction using the identified key material. + SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) + // SignTronTransaction signs a native TRON transaction using the identified key material. + SignTronTransaction(ctx context.Context, keyID string, tx *core.Transaction) (*core.Transaction, error) +} diff --git a/api/gateway/tron/internal/keymanager/vault/manager.go b/api/gateway/tron/internal/keymanager/vault/manager.go new file mode 100644 index 00000000..a0ec67af --- /dev/null +++ b/api/gateway/tron/internal/keymanager/vault/manager.go @@ -0,0 +1,341 @@ +package vault + +import ( + "context" + stdecdsa "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "math/big" + "os" + "path" + "strings" + + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + troncore "github.com/fbsobreira/gotron-sdk/pkg/proto/core" + "github.com/hashicorp/vault/api" + "go.uber.org/zap" + "google.golang.org/protobuf/proto" + + "github.com/tech/sendico/gateway/tron/internal/keymanager" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" +) + +// Config describes how to connect to Vault for managed wallet keys. +type Config struct { + Address string `mapstructure:"address"` + TokenEnv string `mapstructure:"token_env"` + Namespace string `mapstructure:"namespace"` + MountPath string `mapstructure:"mount_path"` + KeyPrefix string `mapstructure:"key_prefix"` +} + +// Manager implements the keymanager.Manager contract backed by HashiCorp Vault. +type Manager struct { + logger mlogger.Logger + client *api.Client + store *api.KVv2 + keyPrefix string +} + +// New constructs a Vault-backed key manager. +func New(logger mlogger.Logger, cfg Config) (*Manager, error) { + if logger == nil { + return nil, merrors.InvalidArgument("vault key manager: logger is required") + } + address := strings.TrimSpace(cfg.Address) + if address == "" { + logger.Error("Vault address missing") + return nil, merrors.InvalidArgument("vault key manager: address is required") + } + tokenEnv := strings.TrimSpace(cfg.TokenEnv) + if tokenEnv == "" { + logger.Error("Vault token env missing") + return nil, merrors.InvalidArgument("vault key manager: token_env is required") + } + token := strings.TrimSpace(os.Getenv(tokenEnv)) + if token == "" { + logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv)) + return nil, merrors.InvalidArgument("vault key manager: token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)") + } + mountPath := strings.Trim(strings.TrimSpace(cfg.MountPath), "/") + if mountPath == "" { + logger.Error("Vault mount path missing") + return nil, merrors.InvalidArgument("vault key manager: mount_path is required") + } + keyPrefix := strings.Trim(strings.TrimSpace(cfg.KeyPrefix), "/") + if keyPrefix == "" { + keyPrefix = "gateway/chain/wallets" + } + + clientCfg := api.DefaultConfig() + clientCfg.Address = address + + client, err := api.NewClient(clientCfg) + if err != nil { + logger.Error("Failed to create vault client", zap.Error(err)) + return nil, merrors.Internal("vault key manager: failed to create client: " + err.Error()) + } + client.SetToken(token) + if ns := strings.TrimSpace(cfg.Namespace); ns != "" { + client.SetNamespace(ns) + } + + kv := client.KVv2(mountPath) + + return &Manager{ + logger: logger.Named("vault"), + client: client, + store: kv, + keyPrefix: keyPrefix, + }, nil +} + +// CreateManagedWalletKey creates a new managed wallet key and stores it in Vault. +func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) { + if strings.TrimSpace(walletRef) == "" { + m.logger.Warn("WalletRef missing for managed key creation", zap.String("network", network)) + return nil, merrors.InvalidArgument("vault key manager: walletRef is required") + } + if strings.TrimSpace(network) == "" { + m.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef)) + return nil, merrors.InvalidArgument("vault key manager: network is required") + } + + privateKey, err := stdecdsa.GenerateKey(secp256k1.S256(), rand.Reader) + if err != nil { + m.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) + return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error()) + } + privateKeyBytes := crypto.FromECDSA(privateKey) + publicKey := privateKey.PublicKey + publicKeyBytes := crypto.FromECDSAPub(&publicKey) + publicKeyHex := hex.EncodeToString(publicKeyBytes) + address := crypto.PubkeyToAddress(publicKey).Hex() + + err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address) + if err != nil { + m.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) + zeroBytes(privateKeyBytes) + zeroBytes(publicKeyBytes) + return nil, err + } + zeroBytes(privateKeyBytes) + zeroBytes(publicKeyBytes) + + m.logger.Info("Managed wallet key created", + zap.String("wallet_ref", walletRef), + zap.String("network", network), + zap.String("address", strings.ToLower(address)), + ) + + return &keymanager.ManagedWalletKey{ + KeyID: m.buildKeyID(network, walletRef), + Address: strings.ToLower(address), + PublicKey: publicKeyHex, + }, nil +} + +func (m *Manager) persistKey(ctx context.Context, walletRef, network string, privateKey, publicKey []byte, address string) error { + secretPath := m.buildKeyID(network, walletRef) + payload := map[string]interface{}{ + "private_key": hex.EncodeToString(privateKey), + "public_key": hex.EncodeToString(publicKey), + "address": strings.ToLower(address), + "network": strings.ToLower(network), + } + if _, err := m.store.Put(ctx, secretPath, payload); err != nil { + return merrors.Internal("vault key manager: failed to write secret at " + secretPath + ": " + err.Error()) + } + return nil +} + +func (m *Manager) buildKeyID(network, walletRef string) string { + net := strings.Trim(strings.ToLower(network), "/") + return path.Join(m.keyPrefix, net, walletRef) +} + +// SignTransaction loads the key material from Vault and signs the transaction. +func (m *Manager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { + if strings.TrimSpace(keyID) == "" { + m.logger.Warn("Signing failed: empty key id") + return nil, merrors.InvalidArgument("vault key manager: keyID is required") + } + if tx == nil { + m.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID)) + return nil, merrors.InvalidArgument("vault key manager: transaction is nil") + } + if chainID == nil { + m.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID)) + return nil, merrors.InvalidArgument("vault key manager: chainID is nil") + } + + material, err := m.loadKey(ctx, keyID) + if err != nil { + m.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err)) + return nil, err + } + + keyBytes, err := hex.DecodeString(material.PrivateKey) + if err != nil { + m.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err)) + return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error()) + } + defer zeroBytes(keyBytes) + + privateKey, err := crypto.ToECDSA(keyBytes) + if err != nil { + m.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err)) + return nil, merrors.Internal("vault key manager: failed to construct private key: " + err.Error()) + } + + signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey) + if err != nil { + m.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err)) + return nil, merrors.Internal("vault key manager: failed to sign transaction: " + err.Error()) + } + m.logger.Info("Transaction signed with managed key", + zap.String("key_id", keyID), + zap.String("network", material.Network), + zap.String("tx_hash", signed.Hash().Hex()), + ) + return signed, nil +} + +// SignTronTransaction signs a native TRON transaction using the identified key material. +func (m *Manager) SignTronTransaction(ctx context.Context, keyID string, tx *troncore.Transaction) (*troncore.Transaction, error) { + if strings.TrimSpace(keyID) == "" { + m.logger.Warn("TRON signing failed: empty key id") + return nil, merrors.InvalidArgument("vault key manager: keyID is required") + } + if tx == nil { + m.logger.Warn("TRON signing failed: nil transaction", zap.String("key_id", keyID)) + return nil, merrors.InvalidArgument("vault key manager: transaction is nil") + } + if tx.GetRawData() == nil { + m.logger.Warn("TRON signing failed: nil raw data", zap.String("key_id", keyID)) + return nil, merrors.InvalidArgument("vault key manager: transaction raw_data is nil") + } + + material, err := m.loadKey(ctx, keyID) + if err != nil { + m.logger.Warn("Failed to load key material for TRON signing", zap.String("key_id", keyID), zap.Error(err)) + return nil, err + } + + keyBytes, err := hex.DecodeString(material.PrivateKey) + if err != nil { + m.logger.Warn("Invalid key material for TRON signing", zap.String("key_id", keyID), zap.Error(err)) + return nil, merrors.Internal("vault key manager: invalid key material: " + err.Error()) + } + defer zeroBytes(keyBytes) + + // Marshal the raw_data to bytes for hashing + rawBytes, err := proto.Marshal(tx.GetRawData()) + if err != nil { + m.logger.Warn("Failed to marshal TRON transaction raw_data", zap.String("key_id", keyID), zap.Error(err)) + return nil, merrors.Internal("vault key manager: failed to marshal transaction: " + err.Error()) + } + + // SHA256 hash of the raw_data + hash := sha256.Sum256(rawBytes) + + // Create secp256k1 private key + privKey := secp256k1.PrivKeyFromBytes(keyBytes) + + // Sign using compact signature format (65 bytes: r[32] || s[32] || recovery_id[1]) + signature := ecdsa.SignCompact(privKey, hash[:], false) + + // TRON expects signature in format: r[32] || s[32] || v[1] + // SignCompact returns: recovery_id[1] || r[32] || s[32] + // We need to rearrange to: r[32] || s[32] || recovery_id[1] + if len(signature) != 65 { + m.logger.Warn("Unexpected signature length", zap.String("key_id", keyID), zap.Int("length", len(signature))) + return nil, merrors.Internal("vault key manager: unexpected signature length") + } + + tronSig := make([]byte, 65) + copy(tronSig[0:32], signature[1:33]) // r + copy(tronSig[32:64], signature[33:65]) // s + tronSig[64] = signature[0] // recovery id (v) + + // Append signature to transaction + tx.Signature = append(tx.Signature, tronSig) + + m.logger.Info("TRON transaction signed with managed key", + zap.String("key_id", keyID), + zap.String("network", material.Network), + ) + + return tx, nil +} + +type keyMaterial struct { + PrivateKey string + PublicKey string + Address string + Network string +} + +func (m *Manager) loadKey(ctx context.Context, keyID string) (*keyMaterial, error) { + secretPath := strings.Trim(strings.TrimPrefix(keyID, "/"), "/") + secret, err := m.store.Get(ctx, secretPath) + if err != nil { + m.logger.Warn("Failed to read secret", zap.String("path", secretPath), zap.Error(err)) + return nil, merrors.Internal("vault key manager: failed to read secret at " + secretPath + ": " + err.Error()) + } + if secret == nil || secret.Data == nil { + m.logger.Warn("Secret not found", zap.String("path", secretPath)) + return nil, merrors.NoData("vault key manager: secret " + secretPath + " not found") + } + + getString := func(key string) (string, error) { + val, ok := secret.Data[key] + if !ok { + m.logger.Warn("Secret missing field", zap.String("path", secretPath), zap.String("field", key)) + return "", merrors.Internal("vault key manager: secret " + secretPath + " missing " + key) + } + str, ok := val.(string) + if !ok || strings.TrimSpace(str) == "" { + m.logger.Warn("Secret field invalid", zap.String("path", secretPath), zap.String("field", key)) + return "", merrors.Internal("vault key manager: secret " + secretPath + " invalid " + key) + } + return str, nil + } + + privateKey, err := getString("private_key") + if err != nil { + return nil, err + } + publicKey, err := getString("public_key") + if err != nil { + return nil, err + } + address, err := getString("address") + if err != nil { + return nil, err + } + network, err := getString("network") + if err != nil { + return nil, err + } + + return &keyMaterial{ + PrivateKey: privateKey, + PublicKey: publicKey, + Address: address, + Network: network, + }, nil +} + +func zeroBytes(data []byte) { + for i := range data { + data[i] = 0 + } +} + +var _ keymanager.Manager = (*Manager)(nil) diff --git a/api/gateway/tron/internal/server/internal/serverimp.go b/api/gateway/tron/internal/server/internal/serverimp.go new file mode 100644 index 00000000..c3a97b8b --- /dev/null +++ b/api/gateway/tron/internal/server/internal/serverimp.go @@ -0,0 +1,431 @@ +package serverimp + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/mitchellh/mapstructure" + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/tron/internal/keymanager" + vaultmanager "github.com/tech/sendico/gateway/tron/internal/keymanager/vault" + gatewayservice "github.com/tech/sendico/gateway/tron/internal/service/gateway" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/drivers" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient" + gatewayshared "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient" + "github.com/tech/sendico/gateway/tron/storage" + gatewaymongo "github.com/tech/sendico/gateway/tron/storage/mongo" + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/merrors" + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/mlogger" + pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/server/grpcapp" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +type Imp struct { + logger mlogger.Logger + file string + debug bool + + config *config + app *grpcapp.App[storage.Repository] + + rpcClients *rpcclient.Clients + tronClients *tronclient.Registry + service *gatewayservice.Service +} + +type config struct { + *grpcapp.Config `yaml:",inline"` + Chains []chainConfig `yaml:"chains"` + ServiceWallet serviceWalletConfig `yaml:"service_wallet"` + KeyManagement keymanager.Config `yaml:"key_management"` + Settings gatewayservice.CacheSettings `yaml:"cache"` +} + +type chainConfig struct { + Name string `yaml:"name"` + RPCURLEnv string `yaml:"rpc_url_env"` + GRPCURLEnv string `yaml:"grpc_url_env"` // Native TRON gRPC endpoint + GRPCTokenEnv string `yaml:"grpc_token_env"` + ChainID uint64 `yaml:"chain_id"` + NativeToken string `yaml:"native_token"` + Tokens []tokenConfig `yaml:"tokens"` + GasTopUpPolicy *gasTopUpPolicyConfig `yaml:"gas_topup_policy"` +} + +type serviceWalletConfig struct { + Chain string `yaml:"chain"` + Address string `yaml:"address"` + AddressEnv string `yaml:"address_env"` + PrivateKeyEnv string `yaml:"private_key_env"` +} + +type tokenConfig struct { + Symbol string `yaml:"symbol"` + Contract string `yaml:"contract"` + ContractEnv string `yaml:"contract_env"` +} + +type gasTopUpPolicyConfig struct { + gasTopUpRuleConfig `yaml:",inline"` + Native *gasTopUpRuleConfig `yaml:"native"` + Contract *gasTopUpRuleConfig `yaml:"contract"` +} + +type gasTopUpRuleConfig struct { + BufferPercent float64 `yaml:"buffer_percent"` + MinNativeBalanceTRX float64 `yaml:"min_native_balance_trx"` + RoundingUnitTRX float64 `yaml:"rounding_unit_trx"` + MaxTopUpTRX float64 `yaml:"max_topup_trx"` +} + +// Create initialises the chain gateway server implementation. +func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { + return &Imp{ + logger: logger.Named("server"), + file: file, + debug: debug, + }, nil +} + +func (i *Imp) Shutdown() { + if i.app == nil { + return + } + + timeout := 15 * time.Second + if i.config != nil && i.config.Runtime != nil { + timeout = i.config.Runtime.ShutdownTimeout() + } + + if i.service != nil { + i.service.Shutdown() + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + i.app.Shutdown(ctx) + if i.rpcClients != nil { + i.rpcClients.Close() + } + if i.tronClients != nil { + i.tronClients.Close() + } +} + +func (i *Imp) Start() error { + cfg, err := i.loadConfig() + if err != nil { + return err + } + i.config = cfg + + repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) { + return gatewaymongo.New(logger, conn) + } + + cl := i.logger.Named("config") + networkConfigs, err := resolveNetworkConfigs(cl.Named("network"), cfg.Chains) + if err != nil { + i.logger.Error("Invalid chain network configuration", zap.Error(err)) + return err + } + rpcClients, err := rpcclient.Prepare(context.Background(), i.logger.Named("rpc"), networkConfigs) + if err != nil { + i.logger.Error("Failed to prepare rpc clients", zap.Error(err)) + return err + } + i.rpcClients = rpcClients + walletConfig := resolveServiceWallet(cl.Named("wallet"), cfg.ServiceWallet) + keyManager, err := resolveKeyManager(i.logger.Named("key_manager"), cfg.KeyManagement) + if err != nil { + return err + } + driverRegistry, err := drivers.NewRegistry(i.logger.Named("drivers"), networkConfigs) + if err != nil { + return err + } + + // Prepare native TRON gRPC clients (optional - fallback to EVM if not configured) + tronClients, err := tronclient.Prepare(context.Background(), i.logger.Named("tron"), networkConfigs) + if err != nil { + i.logger.Warn("TRON gRPC clients not available, falling back to EVM", zap.Error(err)) + // Continue without TRON clients - will fallback to EVM + } else { + i.tronClients = tronClients + } + + serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { + invokeURI := "" + if cfg.GRPC != nil { + invokeURI = cfg.GRPC.DiscoveryInvokeURI() + } + opts := []gatewayservice.Option{ + gatewayservice.WithDiscoveryInvokeURI(invokeURI), + gatewayservice.WithNetworks(networkConfigs), + gatewayservice.WithServiceWallet(walletConfig), + gatewayservice.WithKeyManager(keyManager), + gatewayservice.WithRPCClients(rpcClients), + gatewayservice.WithTronClients(i.tronClients), + gatewayservice.WithDriverRegistry(driverRegistry), + gatewayservice.WithSettings(cfg.Settings), + } + svc := gatewayservice.NewService(logger, repo, producer, opts...) + i.service = svc + return svc, nil + } + + app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory) + if err != nil { + return err + } + i.app = app + + return i.app.Start() +} + +func (i *Imp) loadConfig() (*config, error) { + data, err := os.ReadFile(i.file) + if err != nil { + i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) + return nil, err + } + + cfg := &config{ + Config: &grpcapp.Config{}, + } + if err := yaml.Unmarshal(data, cfg); err != nil { + i.logger.Error("Failed to parse configuration", zap.Error(err)) + return nil, err + } + + if cfg.Runtime == nil { + cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15} + } + + if cfg.GRPC == nil { + cfg.GRPC = &routers.GRPCConfig{ + Network: "tcp", + Address: ":50071", + EnableReflection: true, + EnableHealth: true, + } + } + + return cfg, nil +} + +func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatewayshared.Network, error) { + result := make([]gatewayshared.Network, 0, len(chains)) + for _, chain := range chains { + if strings.TrimSpace(chain.Name) == "" { + logger.Warn("Skipping unnamed chain configuration") + continue + } + network, ok := pmodel.ParseChainNetwork(chain.Name) + if !ok { + logger.Error("Unknown chain network", zap.String("chain", chain.Name)) + return nil, merrors.InvalidArgument(fmt.Sprintf("unknown chain network: %s", chain.Name)) + } + if !network.IsValid() { + logger.Error("Invalid chain network", zap.String("chain", chain.Name)) + return nil, merrors.InvalidArgument(fmt.Sprintf("invalid chain network: %s", chain.Name)) + } + rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv)) + if rpcURL == "" { + logger.Error("RPC url not configured", zap.String("chain", network.String()), zap.String("env", chain.RPCURLEnv)) + return nil, merrors.InvalidArgument(fmt.Sprintf("chain RPC endpoint not configured (chain=%s env=%s)", network, chain.RPCURLEnv)) + } + contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens)) + for _, token := range chain.Tokens { + symbol := strings.TrimSpace(token.Symbol) + if symbol == "" { + logger.Warn("Skipping token with empty symbol", zap.String("chain", network.String())) + continue + } + addr := strings.TrimSpace(token.Contract) + env := strings.TrimSpace(token.ContractEnv) + if addr == "" && env != "" { + addr = strings.TrimSpace(os.Getenv(env)) + } + if addr == "" { + if env != "" { + logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", network.String())) + } else { + logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("chain", network.String())) + } + continue + } + contracts = append(contracts, gatewayshared.TokenContract{ + Symbol: symbol, + ContractAddress: addr, + }) + } + + gasPolicy, err := buildGasTopUpPolicy(network.String(), chain.GasTopUpPolicy) + if err != nil { + logger.Error("Invalid gas top-up policy", zap.String("chain", network.String()), zap.Error(err)) + return nil, err + } + + // Resolve optional TRON gRPC URL + grpcURL := "" + if grpcEnv := strings.TrimSpace(chain.GRPCURLEnv); grpcEnv != "" { + grpcURL = strings.TrimSpace(os.Getenv(grpcEnv)) + if grpcURL != "" { + logger.Info("TRON gRPC URL configured", zap.String("chain", network.String()), zap.String("env", grpcEnv)) + } + } + grpcToken := "" + if grpcTokenEnv := strings.TrimSpace(chain.GRPCTokenEnv); grpcTokenEnv != "" { + grpcToken = strings.TrimSpace(os.Getenv(grpcTokenEnv)) + if grpcToken != "" { + logger.Info("TRON gRPC token configured", zap.String("chain", network.String()), zap.String("env", grpcTokenEnv)) + } + } + + result = append(result, gatewayshared.Network{ + Name: network, + RPCURL: rpcURL, + GRPCUrl: grpcURL, + GRPCToken: grpcToken, + ChainID: chain.ChainID, + NativeToken: chain.NativeToken, + TokenConfigs: contracts, + GasTopUpPolicy: gasPolicy, + }) + } + return result, nil +} + +func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) { + if cfg == nil { + return nil, nil + } + defaultRule, defaultSet, err := parseGasTopUpRule(chainName, "default", cfg.gasTopUpRuleConfig) + if err != nil { + return nil, err + } + if !defaultSet { + return nil, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy is required", chainName)) + } + + policy := &gatewayshared.GasTopUpPolicy{ + Default: defaultRule, + } + + if cfg.Native != nil { + rule, set, err := parseGasTopUpRule(chainName, "native", *cfg.Native) + if err != nil { + return nil, err + } + if set { + policy.Native = &rule + } + } + if cfg.Contract != nil { + rule, set, err := parseGasTopUpRule(chainName, "contract", *cfg.Contract) + if err != nil { + return nil, err + } + if set { + policy.Contract = &rule + } + } + + return policy, nil +} + +func parseGasTopUpRule(chainName, label string, cfg gasTopUpRuleConfig) (gatewayshared.GasTopUpRule, bool, error) { + if cfg.BufferPercent == 0 && cfg.MinNativeBalanceTRX == 0 && cfg.RoundingUnitTRX == 0 && cfg.MaxTopUpTRX == 0 { + return gatewayshared.GasTopUpRule{}, false, nil + } + if cfg.BufferPercent < 0 { + return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s buffer_percent must be >= 0", chainName, label)) + } + if cfg.MinNativeBalanceTRX < 0 { + return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s min_native_balance_trx must be >= 0", chainName, label)) + } + if cfg.RoundingUnitTRX <= 0 { + return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s rounding_unit_trx must be > 0", chainName, label)) + } + if cfg.MaxTopUpTRX <= 0 { + return gatewayshared.GasTopUpRule{}, true, merrors.InvalidArgument(fmt.Sprintf("chain %s gas_topup_policy %s max_topup_trx must be > 0", chainName, label)) + } + return gatewayshared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(cfg.BufferPercent), + MinNativeBalance: decimal.NewFromFloat(cfg.MinNativeBalanceTRX), + RoundingUnit: decimal.NewFromFloat(cfg.RoundingUnitTRX), + MaxTopUp: decimal.NewFromFloat(cfg.MaxTopUpTRX), + }, true, nil +} + +func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewayshared.ServiceWallet { + address := strings.TrimSpace(cfg.Address) + if address == "" && cfg.AddressEnv != "" { + address = strings.TrimSpace(os.Getenv(cfg.AddressEnv)) + } + + privateKey := strings.TrimSpace(os.Getenv(cfg.PrivateKeyEnv)) + + network, ok := pmodel.ParseChainNetwork(cfg.Chain) + if !ok { + logger.Warn("Unknown service wallet chain network", zap.String("chain", cfg.Chain)) + } + + if address == "" { + if cfg.AddressEnv != "" { + logger.Warn("Service wallet address not configured", zap.String("env", cfg.AddressEnv)) + } else { + logger.Warn("Service wallet address not configured", zap.String("chain", network.String())) + } + } + if privateKey == "" { + logger.Warn("Service wallet private key not configured", zap.String("env", cfg.PrivateKeyEnv)) + } + + return gatewayshared.ServiceWallet{ + Network: network, + Address: address, + PrivateKey: privateKey, + } +} + +func resolveKeyManager(logger mlogger.Logger, cfg keymanager.Config) (keymanager.Manager, error) { + driver := strings.ToLower(strings.TrimSpace(string(cfg.Driver))) + if driver == "" { + err := merrors.InvalidArgument("key management driver is not configured") + logger.Error("Key management driver missing") + return nil, err + } + + switch keymanager.Driver(driver) { + case keymanager.DriverVault: + settings := vaultmanager.Config{} + if len(cfg.Settings) > 0 { + if err := mapstructure.Decode(cfg.Settings, &settings); err != nil { + logger.Error("Failed to decode vault key manager settings", zap.Error(err), zap.Any("settings", cfg.Settings)) + return nil, merrors.InvalidArgument("invalid vault key manager settings: " + err.Error()) + } + } + manager, err := vaultmanager.New(logger, settings) + if err != nil { + logger.Error("Failed to initialise vault key manager", zap.Error(err)) + return nil, err + } + return manager, nil + default: + err := merrors.InvalidArgument("unsupported key management driver: " + driver) + logger.Error("Unsupported key management driver", zap.String("driver", driver)) + return nil, err + } +} diff --git a/api/gateway/tron/internal/server/server.go b/api/gateway/tron/internal/server/server.go new file mode 100644 index 00000000..08aa677b --- /dev/null +++ b/api/gateway/tron/internal/server/server.go @@ -0,0 +1,12 @@ +package server + +import ( + serverimp "github.com/tech/sendico/gateway/tron/internal/server/internal" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" +) + +// Create constructs the chain gateway server implementation. +func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return serverimp.Create(logger, file, debug) +} diff --git a/api/gateway/tron/internal/service/gateway/commands/registry.go b/api/gateway/tron/internal/service/gateway/commands/registry.go new file mode 100644 index 00000000..29cfdaa5 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/registry.go @@ -0,0 +1,48 @@ +package commands + +import ( + "context" + + "github.com/tech/sendico/gateway/tron/internal/service/gateway/commands/transfer" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/commands/wallet" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" +) + +type Unary[TReq any, TResp any] interface { + Execute(context.Context, *TReq) gsresponse.Responder[TResp] +} + +type Registry struct { + CreateManagedWallet Unary[chainv1.CreateManagedWalletRequest, chainv1.CreateManagedWalletResponse] + GetManagedWallet Unary[chainv1.GetManagedWalletRequest, chainv1.GetManagedWalletResponse] + ListManagedWallets Unary[chainv1.ListManagedWalletsRequest, chainv1.ListManagedWalletsResponse] + GetWalletBalance Unary[chainv1.GetWalletBalanceRequest, chainv1.GetWalletBalanceResponse] + + SubmitTransfer Unary[chainv1.SubmitTransferRequest, chainv1.SubmitTransferResponse] + GetTransfer Unary[chainv1.GetTransferRequest, chainv1.GetTransferResponse] + ListTransfers Unary[chainv1.ListTransfersRequest, chainv1.ListTransfersResponse] + EstimateTransfer Unary[chainv1.EstimateTransferFeeRequest, chainv1.EstimateTransferFeeResponse] + ComputeGasTopUp Unary[chainv1.ComputeGasTopUpRequest, chainv1.ComputeGasTopUpResponse] + EnsureGasTopUp Unary[chainv1.EnsureGasTopUpRequest, chainv1.EnsureGasTopUpResponse] +} + +type RegistryDeps struct { + Wallet wallet.Deps + Transfer transfer.Deps +} + +func NewRegistry(deps RegistryDeps) Registry { + return Registry{ + CreateManagedWallet: wallet.NewCreateManagedWallet(deps.Wallet.WithLogger("wallet.create")), + GetManagedWallet: wallet.NewGetManagedWallet(deps.Wallet.WithLogger("wallet.get")), + ListManagedWallets: wallet.NewListManagedWallets(deps.Wallet.WithLogger("wallet.list")), + GetWalletBalance: wallet.NewGetWalletBalance(deps.Wallet.WithLogger("wallet.balance")), + SubmitTransfer: transfer.NewSubmitTransfer(deps.Transfer.WithLogger("transfer.submit")), + GetTransfer: transfer.NewGetTransfer(deps.Transfer.WithLogger("transfer.get")), + ListTransfers: transfer.NewListTransfers(deps.Transfer.WithLogger("transfer.list")), + EstimateTransfer: transfer.NewEstimateTransfer(deps.Transfer.WithLogger("transfer.estimate_fee")), + ComputeGasTopUp: transfer.NewComputeGasTopUp(deps.Transfer.WithLogger("gas_topup.compute")), + EnsureGasTopUp: transfer.NewEnsureGasTopUp(deps.Transfer.WithLogger("gas_topup.ensure")), + } +} diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/convert_fees.go b/api/gateway/tron/internal/service/gateway/commands/transfer/convert_fees.go new file mode 100644 index 00000000..58546d91 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/convert_fees.go @@ -0,0 +1,43 @@ +package transfer + +import ( + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" +) + +func convertFees(fees []*chainv1.ServiceFeeBreakdown, currency string) ([]model.ServiceFee, decimal.Decimal, error) { + result := make([]model.ServiceFee, 0, len(fees)) + sum := decimal.NewFromInt(0) + for _, fee := range fees { + if fee == nil || fee.GetAmount() == nil { + return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount is required") + } + amtCurrency := strings.ToUpper(strings.TrimSpace(fee.GetAmount().GetCurrency())) + if amtCurrency != strings.ToUpper(currency) { + return nil, decimal.Decimal{}, merrors.InvalidArgument("fee currency mismatch") + } + amtValue := strings.TrimSpace(fee.GetAmount().GetAmount()) + if amtValue == "" { + return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount is required") + } + dec, err := decimal.NewFromString(amtValue) + if err != nil { + return nil, decimal.Decimal{}, merrors.InvalidArgument("invalid fee amount") + } + if dec.IsNegative() { + return nil, decimal.Decimal{}, merrors.InvalidArgument("fee amount must be non-negative") + } + sum = sum.Add(dec) + result = append(result, model.ServiceFee{ + FeeCode: strings.TrimSpace(fee.GetFeeCode()), + Amount: shared.CloneMoney(fee.GetAmount()), + Description: strings.TrimSpace(fee.GetDescription()), + }) + } + return result, sum, nil +} diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/deps.go b/api/gateway/tron/internal/service/gateway/commands/transfer/deps.go new file mode 100644 index 00000000..c0fd566e --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/deps.go @@ -0,0 +1,35 @@ +package transfer + +import ( + "context" + "time" + + "github.com/tech/sendico/gateway/tron/internal/keymanager" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/drivers" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient" + "github.com/tech/sendico/gateway/tron/storage" + clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/mlogger" +) + +type Deps struct { + Logger mlogger.Logger + Drivers *drivers.Registry + Networks *rpcclient.Registry + TronClients *tronclient.Registry // Native TRON gRPC clients + KeyManager keymanager.Manager + Storage storage.Repository + Clock clockpkg.Clock + RPCTimeout time.Duration + EnsureRepository func(context.Context) error + LaunchExecution func(transferRef, sourceWalletRef string, network shared.Network) +} + +func (d Deps) WithLogger(name string) Deps { + if d.Logger != nil { + d.Logger = d.Logger.Named(name) + } + return d +} diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/destination.go b/api/gateway/tron/internal/service/gateway/commands/transfer/destination.go new file mode 100644 index 00000000..69a54551 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/destination.go @@ -0,0 +1,65 @@ +package transfer + +import ( + "context" + "strings" + + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" +) + +func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDestination, source *model.ManagedWallet) (model.TransferDestination, error) { + if dest == nil { + return model.TransferDestination{}, merrors.InvalidArgument("destination is required") + } + managedRef := strings.TrimSpace(dest.GetManagedWalletRef()) + external := strings.TrimSpace(dest.GetExternalAddress()) + if managedRef != "" && external != "" { + deps.Logger.Warn("Both managed and external destination provided") + return model.TransferDestination{}, merrors.InvalidArgument("destination must be managed_wallet_ref or external_address") + } + if managedRef != "" { + wallet, err := deps.Storage.Wallets().Get(ctx, managedRef) + if err != nil { + deps.Logger.Warn("Destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef)) + return model.TransferDestination{}, err + } + if !strings.EqualFold(wallet.Network, source.Network) { + deps.Logger.Warn("Destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network)) + return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch") + } + if strings.TrimSpace(wallet.DepositAddress) == "" { + deps.Logger.Warn("Destination wallet missing deposit address", zap.String("managed_wallet_ref", managedRef)) + return model.TransferDestination{}, merrors.InvalidArgument("destination wallet missing deposit address") + } + return model.TransferDestination{ + ManagedWalletRef: wallet.WalletRef, + Memo: strings.TrimSpace(dest.GetMemo()), + }, nil + } + if external == "" { + deps.Logger.Warn("Destination external address missing") + return model.TransferDestination{}, merrors.InvalidArgument("destination is required") + } + if deps.Drivers == nil { + deps.Logger.Warn("Chain drivers missing", zap.String("network", source.Network)) + return model.TransferDestination{}, merrors.Internal("chain drivers not configured") + } + chainDriver, err := deps.Drivers.Driver(source.Network) + if err != nil { + deps.Logger.Warn("Unsupported chain driver", zap.String("network", source.Network), zap.Error(err)) + return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet") + } + normalized, err := chainDriver.NormalizeAddress(external) + if err != nil { + deps.Logger.Warn("Invalid external address", zap.Error(err)) + return model.TransferDestination{}, err + } + return model.TransferDestination{ + ExternalAddress: normalized, + ExternalAddressOriginal: external, + Memo: strings.TrimSpace(dest.GetMemo()), + }, nil +} diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/destination_address.go b/api/gateway/tron/internal/service/gateway/commands/transfer/destination_address.go new file mode 100644 index 00000000..ae80dd09 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/destination_address.go @@ -0,0 +1,27 @@ +package transfer + +import ( + "context" + "strings" + + "github.com/tech/sendico/gateway/tron/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func destinationAddress(ctx context.Context, deps Deps, chainDriver driver.Driver, dest model.TransferDestination) (string, error) { + if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" { + wallet, err := deps.Storage.Wallets().Get(ctx, ref) + if err != nil { + return "", err + } + if strings.TrimSpace(wallet.DepositAddress) == "" { + return "", merrors.Internal("destination wallet missing deposit address") + } + return chainDriver.NormalizeAddress(wallet.DepositAddress) + } + if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" { + return chainDriver.NormalizeAddress(addr) + } + return "", merrors.InvalidArgument("transfer destination address not resolved") +} diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/fee.go b/api/gateway/tron/internal/service/gateway/commands/transfer/fee.go new file mode 100644 index 00000000..39049294 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/fee.go @@ -0,0 +1,119 @@ +package transfer + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/gateway/tron/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mservice" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" +) + +type estimateTransferFeeCommand struct { + deps Deps +} + +func NewEstimateTransfer(deps Deps) *estimateTransferFeeCommand { + return &estimateTransferFeeCommand{deps: deps} +} + +func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) gsresponse.Responder[chainv1.EstimateTransferFeeResponse] { + if err := c.deps.EnsureRepository(ctx); err != nil { + c.deps.Logger.Warn("Repository unavailable", zap.Error(err)) + return gsresponse.Unavailable[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if req == nil { + c.deps.Logger.Warn("Empty request received") + return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required")) + } + + sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef()) + if sourceWalletRef == "" { + c.deps.Logger.Warn("Source wallet ref missing") + return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required")) + } + amount := req.GetAmount() + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { + c.deps.Logger.Warn("Amount missing or incomplete") + return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required")) + } + + sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef) + if err != nil { + if errors.Is(err, merrors.ErrNoData) { + c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef)) + return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) + } + c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef)) + return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network)) + networkCfg, ok := c.deps.Networks.Network(networkKey) + if !ok { + c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey)) + return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet")) + } + if c.deps.Drivers == nil { + c.deps.Logger.Warn("Chain drivers missing", zap.String("network", networkKey)) + return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured")) + } + chainDriver, err := c.deps.Drivers.Driver(networkKey) + if err != nil { + c.deps.Logger.Warn("Unsupported chain driver", zap.String("network", networkKey), zap.Error(err)) + return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet")) + } + + dest, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet) + if err != nil { + if errors.Is(err, merrors.ErrNoData) { + c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef())) + return gsresponse.NotFound[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) + } + c.deps.Logger.Warn("Invalid destination", zap.Error(err)) + return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + destinationAddress, err := destinationAddress(ctx, c.deps, chainDriver, dest) + if err != nil { + c.deps.Logger.Warn("Failed to resolve destination address", zap.Error(err)) + return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + walletForFee := sourceWallet + nativeCurrency := shared.NativeCurrency(networkCfg) + if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amount.GetCurrency()) { + copyWallet := *sourceWallet + copyWallet.ContractAddress = "" + copyWallet.TokenSymbol = nativeCurrency + walletForFee = ©Wallet + } + + driverDeps := driver.Deps{ + Logger: c.deps.Logger, + Registry: c.deps.Networks, + TronRegistry: c.deps.TronClients, + KeyManager: c.deps.KeyManager, + RPCTimeout: c.deps.RPCTimeout, + } + feeMoney, err := chainDriver.EstimateFee(ctx, driverDeps, networkCfg, walletForFee, destinationAddress, amount) + if err != nil { + c.deps.Logger.Warn("Fee estimation failed", zap.Error(err)) + return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + contextLabel := "erc20_transfer" + if strings.TrimSpace(walletForFee.ContractAddress) == "" { + contextLabel = "native_transfer" + } + resp := &chainv1.EstimateTransferFeeResponse{ + NetworkFee: feeMoney, + EstimationContext: contextLabel, + } + return gsresponse.Success(resp) +} diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/gas_topup.go b/api/gateway/tron/internal/service/gateway/commands/transfer/gas_topup.go new file mode 100644 index 00000000..cdcf5585 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/gas_topup.go @@ -0,0 +1,290 @@ +package transfer + +import ( + "context" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/commands/wallet" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/driver/evm" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/driver/tron" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" +) + +type computeGasTopUpCommand struct { + deps Deps +} + +func NewComputeGasTopUp(deps Deps) *computeGasTopUpCommand { + return &computeGasTopUpCommand{deps: deps} +} + +func (c *computeGasTopUpCommand) Execute(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) gsresponse.Responder[chainv1.ComputeGasTopUpResponse] { + if err := c.deps.EnsureRepository(ctx); err != nil { + c.deps.Logger.Warn("Repository unavailable", zap.Error(err)) + return gsresponse.Unavailable[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if req == nil { + c.deps.Logger.Warn("Nil request") + return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required")) + } + + walletRef := strings.TrimSpace(req.GetWalletRef()) + if walletRef == "" { + c.deps.Logger.Warn("Wallet ref missing") + return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required")) + } + estimatedFee := req.GetEstimatedTotalFee() + if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" { + c.deps.Logger.Warn("Estimated fee missing") + return gsresponse.InvalidArgument[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required")) + } + + topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, walletRef, estimatedFee) + if err != nil { + return gsresponse.Auto[chainv1.ComputeGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + logDecision(c.deps.Logger, walletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel) + + return gsresponse.Success(&chainv1.ComputeGasTopUpResponse{ + TopupAmount: topUp, + CapHit: capHit, + }) +} + +type ensureGasTopUpCommand struct { + deps Deps +} + +func NewEnsureGasTopUp(deps Deps) *ensureGasTopUpCommand { + return &ensureGasTopUpCommand{deps: deps} +} + +func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) gsresponse.Responder[chainv1.EnsureGasTopUpResponse] { + if err := c.deps.EnsureRepository(ctx); err != nil { + c.deps.Logger.Warn("Repository unavailable", zap.Error(err)) + return gsresponse.Unavailable[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if req == nil { + c.deps.Logger.Warn("Nil request") + return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("request is required")) + } + + idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) + if idempotencyKey == "" { + c.deps.Logger.Warn("Idempotency key missing") + return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required")) + } + organizationRef := strings.TrimSpace(req.GetOrganizationRef()) + if organizationRef == "" { + c.deps.Logger.Warn("Organization ref missing") + return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required")) + } + sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef()) + if sourceWalletRef == "" { + c.deps.Logger.Warn("Source wallet ref missing") + return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required")) + } + targetWalletRef := strings.TrimSpace(req.GetTargetWalletRef()) + if targetWalletRef == "" { + c.deps.Logger.Warn("Target wallet ref missing") + return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("target_wallet_ref is required")) + } + estimatedFee := req.GetEstimatedTotalFee() + if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" { + c.deps.Logger.Warn("Estimated fee missing") + return gsresponse.InvalidArgument[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("estimated_total_fee is required")) + } + + topUp, capHit, decision, nativeBalance, walletModel, err := computeGasTopUp(ctx, c.deps, targetWalletRef, estimatedFee) + if err != nil { + return gsresponse.Auto[chainv1.EnsureGasTopUpResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + logDecision(c.deps.Logger, targetWalletRef, estimatedFee, nativeBalance, topUp, capHit, decision, walletModel) + + if topUp == nil || strings.TrimSpace(topUp.GetAmount()) == "" { + return gsresponse.Success(&chainv1.EnsureGasTopUpResponse{ + TopupAmount: nil, + CapHit: capHit, + }) + } + + submitReq := &chainv1.SubmitTransferRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: organizationRef, + SourceWalletRef: sourceWalletRef, + Destination: &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef}, + }, + Amount: topUp, + Metadata: shared.CloneMetadata(req.GetMetadata()), + ClientReference: strings.TrimSpace(req.GetClientReference()), + } + + submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq) + return func(ctx context.Context) (*chainv1.EnsureGasTopUpResponse, error) { + submitResp, err := submitResponder(ctx) + if err != nil { + return nil, err + } + return &chainv1.EnsureGasTopUpResponse{ + TopupAmount: topUp, + CapHit: capHit, + Transfer: submitResp.GetTransfer(), + }, nil + } +} + +func computeGasTopUp(ctx context.Context, deps Deps, walletRef string, estimatedFee *moneyv1.Money) (*moneyv1.Money, bool, *tron.GasTopUpDecision, *moneyv1.Money, *model.ManagedWallet, error) { + walletRef = strings.TrimSpace(walletRef) + estimatedFee = shared.CloneMoney(estimatedFee) + walletModel, err := deps.Storage.Wallets().Get(ctx, walletRef) + if err != nil { + return nil, false, nil, nil, nil, err + } + + networkKey := strings.ToLower(strings.TrimSpace(walletModel.Network)) + networkCfg, ok := deps.Networks.Network(networkKey) + if !ok { + return nil, false, nil, nil, nil, merrors.InvalidArgument("unsupported chain for wallet") + } + + nativeBalance, err := nativeBalanceForWallet(ctx, deps, walletModel) + if err != nil { + return nil, false, nil, nil, nil, err + } + + if strings.HasPrefix(networkKey, "tron") { + topUp, decision, err := tron.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance) + if err != nil { + return nil, false, nil, nil, nil, err + } + return topUp, decision.CapHit, &decision, nativeBalance, walletModel, nil + } + + if networkCfg.GasTopUpPolicy != nil { + topUp, capHit, err := evm.ComputeGasTopUp(networkCfg, walletModel, estimatedFee, nativeBalance) + if err != nil { + return nil, false, nil, nil, nil, err + } + return topUp, capHit, nil, nativeBalance, walletModel, nil + } + + topUp, err := defaultGasTopUp(estimatedFee, nativeBalance) + if err != nil { + return nil, false, nil, nil, nil, err + } + return topUp, false, nil, nativeBalance, walletModel, nil +} + +func nativeBalanceForWallet(ctx context.Context, deps Deps, walletModel *model.ManagedWallet) (*moneyv1.Money, error) { + if walletModel == nil { + return nil, merrors.InvalidArgument("wallet is required") + } + walletDeps := wallet.Deps{ + Logger: deps.Logger.Named("wallet"), + Drivers: deps.Drivers, + Networks: deps.Networks, + KeyManager: nil, + Storage: deps.Storage, + Clock: deps.Clock, + BalanceCacheTTL: 0, + RPCTimeout: deps.RPCTimeout, + EnsureRepository: deps.EnsureRepository, + } + _, nativeBalance, err := wallet.OnChainWalletBalances(ctx, walletDeps, walletModel) + if err != nil { + return nil, err + } + if nativeBalance == nil || strings.TrimSpace(nativeBalance.GetAmount()) == "" || strings.TrimSpace(nativeBalance.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("native balance is unavailable") + } + return nativeBalance, nil +} + +func defaultGasTopUp(estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, error) { + if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("estimated fee is required") + } + if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("native balance is required") + } + if !strings.EqualFold(estimatedFee.GetCurrency(), currentBalance.GetCurrency()) { + return nil, merrors.InvalidArgument("native balance currency mismatch") + } + + estimated, err := decimal.NewFromString(strings.TrimSpace(estimatedFee.GetAmount())) + if err != nil { + return nil, err + } + current, err := decimal.NewFromString(strings.TrimSpace(currentBalance.GetAmount())) + if err != nil { + return nil, err + } + required := estimated.Sub(current) + if !required.IsPositive() { + return nil, nil + } + return &moneyv1.Money{ + Currency: strings.ToUpper(strings.TrimSpace(estimatedFee.GetCurrency())), + Amount: required.String(), + }, nil +} + +func logDecision(logger mlogger.Logger, walletRef string, estimatedFee *moneyv1.Money, nativeBalance *moneyv1.Money, topUp *moneyv1.Money, capHit bool, decision *tron.GasTopUpDecision, walletModel *model.ManagedWallet) { + if logger == nil { + return + } + fields := []zap.Field{ + zap.String("wallet_ref", walletRef), + zap.String("estimated_total_fee", amountString(estimatedFee)), + zap.String("current_native_balance", amountString(nativeBalance)), + zap.String("topup_amount", amountString(topUp)), + zap.Bool("cap_hit", capHit), + } + if walletModel != nil { + fields = append(fields, zap.String("network", strings.TrimSpace(walletModel.Network))) + } + if decision != nil { + fields = append(fields, + zap.String("estimated_total_fee_trx", decision.EstimatedFeeTRX.String()), + zap.String("current_native_balance_trx", decision.CurrentBalanceTRX.String()), + zap.String("required_trx", decision.RequiredTRX.String()), + zap.String("buffered_required_trx", decision.BufferedRequiredTRX.String()), + zap.String("min_balance_topup_trx", decision.MinBalanceTopUpTRX.String()), + zap.String("raw_topup_trx", decision.RawTopUpTRX.String()), + zap.String("rounded_topup_trx", decision.RoundedTopUpTRX.String()), + zap.String("topup_trx", decision.TopUpTRX.String()), + zap.String("operation_type", decision.OperationType), + ) + } + logger.Info("Gas top-up decision", fields...) +} + +func amountString(m *moneyv1.Money) string { + if m == nil { + return "" + } + amount := strings.TrimSpace(m.GetAmount()) + currency := strings.TrimSpace(m.GetCurrency()) + if amount == "" && currency == "" { + return "" + } + if currency == "" { + return amount + } + if amount == "" { + return currency + } + return amount + " " + currency +} diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/get.go b/api/gateway/tron/internal/service/gateway/commands/transfer/get.go new file mode 100644 index 00000000..940cbeb1 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/get.go @@ -0,0 +1,47 @@ +package transfer + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mservice" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" +) + +type getTransferCommand struct { + deps Deps +} + +func NewGetTransfer(deps Deps) *getTransferCommand { + return &getTransferCommand{deps: deps} +} + +func (c *getTransferCommand) Execute(ctx context.Context, req *chainv1.GetTransferRequest) gsresponse.Responder[chainv1.GetTransferResponse] { + if err := c.deps.EnsureRepository(ctx); err != nil { + c.deps.Logger.Warn("Repository unavailable", zap.Error(err)) + return gsresponse.Unavailable[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if req == nil { + c.deps.Logger.Warn("Nil request") + return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) + } + transferRef := strings.TrimSpace(req.GetTransferRef()) + if transferRef == "" { + c.deps.Logger.Warn("Transfer_ref missing") + return gsresponse.InvalidArgument[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("transfer_ref is required")) + } + transfer, err := c.deps.Storage.Transfers().Get(ctx, transferRef) + if err != nil { + if errors.Is(err, merrors.ErrNoData) { + c.deps.Logger.Warn("Not found", zap.String("transfer_ref", transferRef)) + return gsresponse.NotFound[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err) + } + c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("transfer_ref", transferRef)) + return gsresponse.Auto[chainv1.GetTransferResponse](c.deps.Logger, mservice.ChainGateway, err) + } + return gsresponse.Success(&chainv1.GetTransferResponse{Transfer: toProtoTransfer(transfer)}) +} diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/list.go b/api/gateway/tron/internal/service/gateway/commands/transfer/list.go new file mode 100644 index 00000000..65c123af --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/list.go @@ -0,0 +1,58 @@ +package transfer + +import ( + "context" + "strings" + + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/mservice" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" +) + +type listTransfersCommand struct { + deps Deps +} + +func NewListTransfers(deps Deps) *listTransfersCommand { + return &listTransfersCommand{deps: deps} +} + +func (c *listTransfersCommand) Execute(ctx context.Context, req *chainv1.ListTransfersRequest) gsresponse.Responder[chainv1.ListTransfersResponse] { + if err := c.deps.EnsureRepository(ctx); err != nil { + c.deps.Logger.Warn("Repository unavailable", zap.Error(err)) + return gsresponse.Unavailable[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err) + } + filter := model.TransferFilter{} + if req != nil { + filter.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef()) + filter.DestinationWalletRef = strings.TrimSpace(req.GetDestinationWalletRef()) + if status := shared.TransferStatusToModel(req.GetStatus()); status != "" { + filter.Status = status + } + if page := req.GetPage(); page != nil { + filter.Cursor = strings.TrimSpace(page.GetCursor()) + filter.Limit = page.GetLimit() + } + } + + result, err := c.deps.Storage.Transfers().List(ctx, filter) + if err != nil { + c.deps.Logger.Warn("Storage list failed", zap.Error(err)) + return gsresponse.Auto[chainv1.ListTransfersResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + protoTransfers := make([]*chainv1.Transfer, 0, len(result.Items)) + for _, transfer := range result.Items { + protoTransfers = append(protoTransfers, toProtoTransfer(transfer)) + } + + resp := &chainv1.ListTransfersResponse{ + Transfers: protoTransfers, + Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor}, + } + return gsresponse.Success(resp) +} diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/proto.go b/api/gateway/tron/internal/service/gateway/commands/transfer/proto.go new file mode 100644 index 00000000..6f5ccf92 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/proto.go @@ -0,0 +1,53 @@ +package transfer + +import ( + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func toProtoTransfer(transfer *model.Transfer) *chainv1.Transfer { + if transfer == nil { + return nil + } + destination := &chainv1.TransferDestination{} + if transfer.Destination.ManagedWalletRef != "" { + destination.Destination = &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: transfer.Destination.ManagedWalletRef} + } else if transfer.Destination.ExternalAddress != "" { + destination.Destination = &chainv1.TransferDestination_ExternalAddress{ExternalAddress: transfer.Destination.ExternalAddress} + } + destination.Memo = transfer.Destination.Memo + + protoFees := make([]*chainv1.ServiceFeeBreakdown, 0, len(transfer.Fees)) + for _, fee := range transfer.Fees { + protoFees = append(protoFees, &chainv1.ServiceFeeBreakdown{ + FeeCode: fee.FeeCode, + Amount: shared.CloneMoney(fee.Amount), + Description: fee.Description, + }) + } + + asset := &chainv1.Asset{ + Chain: shared.ChainEnumFromName(transfer.Network), + TokenSymbol: transfer.TokenSymbol, + ContractAddress: transfer.ContractAddress, + } + + return &chainv1.Transfer{ + TransferRef: transfer.TransferRef, + IdempotencyKey: transfer.IdempotencyKey, + OrganizationRef: transfer.OrganizationRef, + SourceWalletRef: transfer.SourceWalletRef, + Destination: destination, + Asset: asset, + RequestedAmount: shared.CloneMoney(transfer.RequestedAmount), + NetAmount: shared.CloneMoney(transfer.NetAmount), + Fees: protoFees, + Status: shared.TransferStatusToProto(transfer.Status), + TransactionHash: transfer.TxHash, + FailureReason: transfer.FailureReason, + CreatedAt: timestamppb.New(transfer.CreatedAt.UTC()), + UpdatedAt: timestamppb.New(transfer.UpdatedAt.UTC()), + } +} diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/submit.go b/api/gateway/tron/internal/service/gateway/commands/transfer/submit.go new file mode 100644 index 00000000..089e3dc5 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/submit.go @@ -0,0 +1,156 @@ +package transfer + +import ( + "context" + "errors" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mservice" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" +) + +type submitTransferCommand struct { + deps Deps +} + +func NewSubmitTransfer(deps Deps) *submitTransferCommand { + return &submitTransferCommand{deps: deps} +} + +func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.SubmitTransferRequest) gsresponse.Responder[chainv1.SubmitTransferResponse] { + if err := c.deps.EnsureRepository(ctx); err != nil { + c.deps.Logger.Warn("Repository unavailable", zap.Error(err)) + return gsresponse.Unavailable[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if req == nil { + c.deps.Logger.Warn("Nil request") + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) + } + + idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) + if idempotencyKey == "" { + c.deps.Logger.Warn("Missing idempotency key") + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required")) + } + organizationRef := strings.TrimSpace(req.GetOrganizationRef()) + if organizationRef == "" { + c.deps.Logger.Warn("Missing organization ref") + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required")) + } + sourceWalletRef := strings.TrimSpace(req.GetSourceWalletRef()) + if sourceWalletRef == "" { + c.deps.Logger.Warn("Missing source wallet ref") + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("source_wallet_ref is required")) + } + amount := req.GetAmount() + if amount == nil { + c.deps.Logger.Warn("Missing amount") + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount is required")) + } + amountCurrency := strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) + if amountCurrency == "" { + c.deps.Logger.Warn("Missing amount currency") + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.currency is required")) + } + amountValue := strings.TrimSpace(amount.GetAmount()) + if amountValue == "" { + c.deps.Logger.Warn("Missing amount value") + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required")) + } + + sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef) + if err != nil { + if errors.Is(err, merrors.ErrNoData) { + c.deps.Logger.Warn("Source wallet not found", zap.String("source_wallet_ref", sourceWalletRef)) + return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) + } + c.deps.Logger.Warn("Storage get wallet failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef)) + return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if !strings.EqualFold(sourceWallet.OrganizationRef, organizationRef) { + c.deps.Logger.Warn("Organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef)) + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet")) + } + networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network)) + networkCfg, ok := c.deps.Networks.Network(networkKey) + if !ok { + c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey)) + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet")) + } + + destination, err := resolveDestination(ctx, c.deps, req.GetDestination(), sourceWallet) + if err != nil { + if errors.Is(err, merrors.ErrNoData) { + c.deps.Logger.Warn("Destination not found", zap.String("destination_wallet_ref", req.GetDestination().GetManagedWalletRef())) + return gsresponse.NotFound[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) + } + c.deps.Logger.Warn("Invalid destination", zap.Error(err)) + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + fees, feeSum, err := convertFees(req.GetFees(), amountCurrency) + if err != nil { + c.deps.Logger.Warn("Fee conversion failed", zap.Error(err)) + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) + } + amountDec, err := decimal.NewFromString(amountValue) + if err != nil { + c.deps.Logger.Warn("Invalid amount", zap.Error(err)) + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("invalid amount")) + } + netDec := amountDec.Sub(feeSum) + if netDec.IsNegative() { + c.deps.Logger.Warn("Fees exceed amount", zap.String("amount", amountValue), zap.String("fee_sum", feeSum.String())) + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("fees exceed amount")) + } + + netAmount := shared.CloneMoney(amount) + netAmount.Amount = netDec.String() + + effectiveTokenSymbol := sourceWallet.TokenSymbol + effectiveContractAddress := sourceWallet.ContractAddress + nativeCurrency := shared.NativeCurrency(networkCfg) + if nativeCurrency != "" && strings.EqualFold(nativeCurrency, amountCurrency) { + effectiveTokenSymbol = nativeCurrency + effectiveContractAddress = "" + } + + transfer := &model.Transfer{ + IdempotencyKey: idempotencyKey, + TransferRef: shared.GenerateTransferRef(), + OrganizationRef: organizationRef, + SourceWalletRef: sourceWalletRef, + Destination: destination, + Network: sourceWallet.Network, + TokenSymbol: effectiveTokenSymbol, + ContractAddress: effectiveContractAddress, + RequestedAmount: shared.CloneMoney(amount), + NetAmount: netAmount, + Fees: fees, + Status: model.TransferStatusPending, + ClientReference: strings.TrimSpace(req.GetClientReference()), + LastStatusAt: c.deps.Clock.Now().UTC(), + } + + saved, err := c.deps.Storage.Transfers().Create(ctx, transfer) + if err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + c.deps.Logger.Debug("Transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", idempotencyKey)) + return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)}) + } + c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef)) + return gsresponse.Auto[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + if c.deps.LaunchExecution != nil { + c.deps.LaunchExecution(saved.TransferRef, sourceWalletRef, networkCfg) + } + + return gsresponse.Success(&chainv1.SubmitTransferResponse{Transfer: toProtoTransfer(saved)}) +} diff --git a/api/gateway/tron/internal/service/gateway/commands/wallet/balance.go b/api/gateway/tron/internal/service/gateway/commands/wallet/balance.go new file mode 100644 index 00000000..ac860cd2 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/wallet/balance.go @@ -0,0 +1,150 @@ +package wallet + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mservice" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const fallbackBalanceCacheTTL = 2 * time.Minute + +type getWalletBalanceCommand struct { + deps Deps +} + +func NewGetWalletBalance(deps Deps) *getWalletBalanceCommand { + return &getWalletBalanceCommand{deps: deps} +} + +func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetWalletBalanceRequest) gsresponse.Responder[chainv1.GetWalletBalanceResponse] { + if err := c.deps.EnsureRepository(ctx); err != nil { + c.deps.Logger.Warn("Repository unavailable", zap.Error(err)) + return gsresponse.Unavailable[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if req == nil { + c.deps.Logger.Warn("Nil request") + return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) + } + walletRef := strings.TrimSpace(req.GetWalletRef()) + if walletRef == "" { + c.deps.Logger.Warn("Wallet_ref missing") + return gsresponse.InvalidArgument[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required")) + } + wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef) + if err != nil { + if errors.Is(err, merrors.ErrNoData) { + c.deps.Logger.Warn("Not found", zap.String("wallet_ref", walletRef)) + return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) + } + c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef)) + return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + tokenBalance, nativeBalance, chainErr := OnChainWalletBalances(ctx, c.deps, wallet) + if chainErr != nil { + c.deps.Logger.Warn("On-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef)) + stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef) + if err != nil { + if errors.Is(err, merrors.ErrNoData) { + c.deps.Logger.Warn("Cached balance not found", zap.String("wallet_ref", walletRef)) + return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr) + } + return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if c.isCachedBalanceStale(stored) { + c.deps.Logger.Info("Cached balance is stale", + zap.String("wallet_ref", walletRef), + zap.Time("calculated_at", stored.CalculatedAt), + zap.Duration("ttl", c.cacheTTL()), + ) + return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr) + } + return gsresponse.Success(&chainv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(stored)}) + } + + calculatedAt := c.now() + c.persistCachedBalance(ctx, walletRef, tokenBalance, nativeBalance, calculatedAt) + + return gsresponse.Success(&chainv1.GetWalletBalanceResponse{ + Balance: onChainBalanceToProto(tokenBalance, nativeBalance, calculatedAt), + }) +} + +func onChainBalanceToProto(balance *moneyv1.Money, native *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance { + if balance == nil && native == nil { + return nil + } + currency := "" + if balance != nil { + currency = balance.Currency + } + zero := zeroMoney(currency) + return &chainv1.WalletBalance{ + Available: balance, + NativeAvailable: native, + PendingInbound: zero, + PendingOutbound: zero, + CalculatedAt: timestamppb.New(calculatedAt.UTC()), + } +} + +func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, nativeAvailable *moneyv1.Money, calculatedAt time.Time) { + if available == nil && nativeAvailable == nil { + return + } + record := &model.WalletBalance{ + WalletRef: walletRef, + Available: shared.CloneMoney(available), + NativeAvailable: shared.CloneMoney(nativeAvailable), + CalculatedAt: calculatedAt, + } + currency := "" + if available != nil { + currency = available.Currency + } + record.PendingInbound = zeroMoney(currency) + record.PendingOutbound = zeroMoney(currency) + if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil { + c.deps.Logger.Warn("Failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err)) + } +} + +func (c *getWalletBalanceCommand) isCachedBalanceStale(balance *model.WalletBalance) bool { + if balance == nil || balance.CalculatedAt.IsZero() { + return true + } + return c.now().After(balance.CalculatedAt.Add(c.cacheTTL())) +} + +func (c *getWalletBalanceCommand) cacheTTL() time.Duration { + if c.deps.BalanceCacheTTL > 0 { + return c.deps.BalanceCacheTTL + } + // Fallback to sane default if not configured. + return fallbackBalanceCacheTTL +} + +func (c *getWalletBalanceCommand) now() time.Time { + if c.deps.Clock != nil { + return c.deps.Clock.Now().UTC() + } + return time.Now().UTC() +} + +func zeroMoney(currency string) *moneyv1.Money { + if strings.TrimSpace(currency) == "" { + return nil + } + return &moneyv1.Money{Currency: currency, Amount: "0"} +} diff --git a/api/gateway/tron/internal/service/gateway/commands/wallet/create.go b/api/gateway/tron/internal/service/gateway/commands/wallet/create.go new file mode 100644 index 00000000..7e4d671a --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/wallet/create.go @@ -0,0 +1,164 @@ +package wallet + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + pkgmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" +) + +type createManagedWalletCommand struct { + deps Deps +} + +func NewCreateManagedWallet(deps Deps) *createManagedWalletCommand { + return &createManagedWalletCommand{deps: deps} +} + +func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.CreateManagedWalletRequest) gsresponse.Responder[chainv1.CreateManagedWalletResponse] { + if err := c.deps.EnsureRepository(ctx); err != nil { + c.deps.Logger.Warn("Repository unavailable", zap.Error(err)) + return gsresponse.Unavailable[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if req == nil { + c.deps.Logger.Warn("Nil request") + return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) + } + + idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey()) + if idempotencyKey == "" { + c.deps.Logger.Warn("Missing idempotency key") + return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required")) + } + organizationRef := strings.TrimSpace(req.GetOrganizationRef()) + if organizationRef == "" { + c.deps.Logger.Warn("Missing organization ref") + return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref is required")) + } + ownerRef := strings.TrimSpace(req.GetOwnerRef()) + + asset := req.GetAsset() + if asset == nil { + c.deps.Logger.Warn("Missing asset") + return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required")) + } + + chainKey, _ := shared.ChainKeyFromEnum(asset.GetChain()) + if chainKey == "" { + c.deps.Logger.Warn("Unsupported chain", zap.Any("chain", asset.GetChain())) + return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) + } + networkCfg, ok := c.deps.Networks.Network(chainKey) + if !ok { + c.deps.Logger.Warn("Unsupported chain in config", zap.String("chain", chainKey)) + return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) + } + if c.deps.Drivers == nil { + c.deps.Logger.Warn("Chain drivers missing", zap.String("chain", chainKey)) + return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured")) + } + chainDriver, err := c.deps.Drivers.Driver(chainKey) + if err != nil { + c.deps.Logger.Warn("Unsupported chain driver", zap.String("chain", chainKey), zap.Error(err)) + return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) + } + + tokenSymbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol())) + if tokenSymbol == "" { + c.deps.Logger.Warn("Missing token symbol") + return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset.token_symbol is required")) + } + contractAddress := strings.ToLower(strings.TrimSpace(asset.GetContractAddress())) + if contractAddress == "" { + if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) { + contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol) + if contractAddress == "" { + c.deps.Logger.Warn("Unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey)) + return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain")) + } + } + } + + walletRef := shared.GenerateWalletRef() + if c.deps.KeyManager == nil { + c.deps.Logger.Warn("Key manager missing") + return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager not configured")) + } + + keyInfo, err := c.deps.KeyManager.CreateManagedWalletKey(ctx, walletRef, chainKey) + if err != nil { + c.deps.Logger.Warn("Key manager error", zap.Error(err)) + return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if keyInfo == nil || strings.TrimSpace(keyInfo.Address) == "" { + c.deps.Logger.Warn("Key manager returned empty address") + return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address")) + } + depositAddress, err := chainDriver.FormatAddress(keyInfo.Address) + if err != nil { + c.deps.Logger.Warn("Invalid derived deposit address", zap.String("wallet_ref", walletRef), zap.Error(err)) + return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + metadata := shared.CloneMetadata(req.GetMetadata()) + desc := req.GetDescribable() + name := strings.TrimSpace(desc.GetName()) + if name == "" { + name = strings.TrimSpace(metadata["name"]) + } + var description *string + if desc != nil && desc.Description != nil { + if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" { + description = &trimmed + } + } + if description == nil { + if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" { + description = &trimmed + } + } + if name == "" { + name = walletRef + } + + wallet := &model.ManagedWallet{ + Describable: pkgmodel.Describable{ + Name: name, + Description: description, + }, + IdempotencyKey: idempotencyKey, + WalletRef: walletRef, + OrganizationRef: organizationRef, + OwnerRef: ownerRef, + Network: chainKey, + TokenSymbol: tokenSymbol, + ContractAddress: contractAddress, + DepositAddress: depositAddress, + KeyReference: keyInfo.KeyID, + Status: model.ManagedWalletStatusActive, + Metadata: metadata, + } + if description != nil { + wallet.Describable.Description = description + } + + created, err := c.deps.Storage.Wallets().Create(ctx, wallet) + if err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + c.deps.Logger.Debug("Wallet already exists", zap.String("wallet_ref", walletRef), zap.String("idempotency_key", idempotencyKey)) + return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)}) + } + c.deps.Logger.Warn("Storage create failed", zap.Error(err), zap.String("wallet_ref", walletRef)) + return gsresponse.Auto[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + return gsresponse.Success(&chainv1.CreateManagedWalletResponse{Wallet: toProtoManagedWallet(created)}) +} diff --git a/api/gateway/tron/internal/service/gateway/commands/wallet/deps.go b/api/gateway/tron/internal/service/gateway/commands/wallet/deps.go new file mode 100644 index 00000000..12a94d07 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/wallet/deps.go @@ -0,0 +1,35 @@ +package wallet + +import ( + "context" + "time" + + "github.com/tech/sendico/gateway/tron/internal/keymanager" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/drivers" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient" + "github.com/tech/sendico/gateway/tron/storage" + clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/mlogger" +) + +type Deps struct { + Logger mlogger.Logger + Drivers *drivers.Registry + Networks *rpcclient.Registry + TronClients *tronclient.Registry // Native TRON gRPC clients + KeyManager keymanager.Manager + Storage storage.Repository + Clock clockpkg.Clock + BalanceCacheTTL time.Duration + RPCTimeout time.Duration + EnsureRepository func(context.Context) error +} + +func (d Deps) WithLogger(name string) Deps { + if d.Logger == nil { + panic("wallet deps: logger is required") + } + d.Logger = d.Logger.Named(name) + return d +} diff --git a/api/gateway/tron/internal/service/gateway/commands/wallet/get.go b/api/gateway/tron/internal/service/gateway/commands/wallet/get.go new file mode 100644 index 00000000..11ab552f --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/wallet/get.go @@ -0,0 +1,47 @@ +package wallet + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mservice" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" +) + +type getManagedWalletCommand struct { + deps Deps +} + +func NewGetManagedWallet(deps Deps) *getManagedWalletCommand { + return &getManagedWalletCommand{deps: deps} +} + +func (c *getManagedWalletCommand) Execute(ctx context.Context, req *chainv1.GetManagedWalletRequest) gsresponse.Responder[chainv1.GetManagedWalletResponse] { + if err := c.deps.EnsureRepository(ctx); err != nil { + c.deps.Logger.Warn("Repository unavailable", zap.Error(err)) + return gsresponse.Unavailable[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) + } + if req == nil { + c.deps.Logger.Warn("Nil request") + return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("nil request")) + } + walletRef := strings.TrimSpace(req.GetWalletRef()) + if walletRef == "" { + c.deps.Logger.Warn("Wallet_ref missing") + return gsresponse.InvalidArgument[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required")) + } + wallet, err := c.deps.Storage.Wallets().Get(ctx, walletRef) + if err != nil { + if errors.Is(err, merrors.ErrNoData) { + c.deps.Logger.Warn("Not found", zap.String("wallet_ref", walletRef)) + return gsresponse.NotFound[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) + } + c.deps.Logger.Warn("Storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef)) + return gsresponse.Auto[chainv1.GetManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, err) + } + return gsresponse.Success(&chainv1.GetManagedWalletResponse{Wallet: toProtoManagedWallet(wallet)}) +} diff --git a/api/gateway/tron/internal/service/gateway/commands/wallet/list.go b/api/gateway/tron/internal/service/gateway/commands/wallet/list.go new file mode 100644 index 00000000..e0fa581c --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/wallet/list.go @@ -0,0 +1,62 @@ +package wallet + +import ( + "context" + "strings" + + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/mservice" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" +) + +type listManagedWalletsCommand struct { + deps Deps +} + +func NewListManagedWallets(deps Deps) *listManagedWalletsCommand { + return &listManagedWalletsCommand{deps: deps} +} + +func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.ListManagedWalletsRequest) gsresponse.Responder[chainv1.ListManagedWalletsResponse] { + if err := c.deps.EnsureRepository(ctx); err != nil { + c.deps.Logger.Warn("Repository unavailable", zap.Error(err)) + return gsresponse.Unavailable[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err) + } + filter := model.ManagedWalletFilter{} + if req != nil { + filter.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef()) + if req.GetOwnerRefFilter() != nil { + ownerRef := strings.TrimSpace(req.GetOwnerRefFilter().GetValue()) + filter.OwnerRefFilter = &ownerRef + } + if asset := req.GetAsset(); asset != nil { + filter.Network, _ = shared.ChainKeyFromEnum(asset.GetChain()) + filter.TokenSymbol = strings.TrimSpace(asset.GetTokenSymbol()) + } + if page := req.GetPage(); page != nil { + filter.Cursor = strings.TrimSpace(page.GetCursor()) + filter.Limit = page.GetLimit() + } + } + + result, err := c.deps.Storage.Wallets().List(ctx, filter) + if err != nil { + c.deps.Logger.Warn("Storage list failed", zap.Error(err)) + return gsresponse.Auto[chainv1.ListManagedWalletsResponse](c.deps.Logger, mservice.ChainGateway, err) + } + + protoWallets := make([]*chainv1.ManagedWallet, 0, len(result.Items)) + for i := range result.Items { + protoWallets = append(protoWallets, toProtoManagedWallet(&result.Items[i])) + } + + resp := &chainv1.ListManagedWalletsResponse{ + Wallets: protoWallets, + Page: &paginationv1.CursorPageResponse{NextCursor: result.NextCursor}, + } + return gsresponse.Success(resp) +} diff --git a/api/gateway/tron/internal/service/gateway/commands/wallet/onchain_balance.go b/api/gateway/tron/internal/service/gateway/commands/wallet/onchain_balance.go new file mode 100644 index 00000000..3b0720e4 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/wallet/onchain_balance.go @@ -0,0 +1,63 @@ +package wallet + +import ( + "context" + "fmt" + "strings" + + "github.com/tech/sendico/gateway/tron/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + "go.uber.org/zap" +) + +func OnChainWalletBalances(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, *moneyv1.Money, error) { + logger := deps.Logger + if wallet == nil { + return nil, nil, merrors.InvalidArgument("wallet is required") + } + if deps.Networks == nil { + return nil, nil, merrors.Internal("rpc clients not initialised") + } + if deps.Drivers == nil { + return nil, nil, merrors.Internal("chain drivers not configured") + } + + networkKey := strings.ToLower(strings.TrimSpace(wallet.Network)) + network, ok := deps.Networks.Network(networkKey) + if !ok { + logger.Warn("Requested network is not configured", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", networkKey), + ) + return nil, nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey)) + } + + chainDriver, err := deps.Drivers.Driver(networkKey) + if err != nil { + logger.Warn("Chain driver not configured", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", networkKey), + zap.Error(err), + ) + return nil, nil, merrors.InvalidArgument("unsupported chain") + } + + driverDeps := driver.Deps{ + Logger: deps.Logger, + Registry: deps.Networks, + TronRegistry: deps.TronClients, + KeyManager: deps.KeyManager, + RPCTimeout: deps.RPCTimeout, + } + tokenBalance, err := chainDriver.Balance(ctx, driverDeps, network, wallet) + if err != nil { + return nil, nil, err + } + nativeBalance, err := chainDriver.NativeBalance(ctx, driverDeps, network, wallet) + if err != nil { + return nil, nil, err + } + return tokenBalance, nativeBalance, nil +} diff --git a/api/gateway/tron/internal/service/gateway/commands/wallet/proto.go b/api/gateway/tron/internal/service/gateway/commands/wallet/proto.go new file mode 100644 index 00000000..0c8f31b4 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/commands/wallet/proto.go @@ -0,0 +1,66 @@ +package wallet + +import ( + "strings" + + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet { + if wallet == nil { + return nil + } + asset := &chainv1.Asset{ + Chain: shared.ChainEnumFromName(wallet.Network), + TokenSymbol: wallet.TokenSymbol, + ContractAddress: wallet.ContractAddress, + } + name := strings.TrimSpace(wallet.Name) + if name == "" { + name = strings.TrimSpace(wallet.Metadata["name"]) + } + if name == "" { + name = wallet.WalletRef + } + description := "" + switch { + case wallet.Description != nil: + description = strings.TrimSpace(*wallet.Description) + default: + description = strings.TrimSpace(wallet.Metadata["description"]) + } + desc := &describablev1.Describable{Name: name} + if description != "" { + desc.Description = &description + } + + return &chainv1.ManagedWallet{ + WalletRef: wallet.WalletRef, + OrganizationRef: wallet.OrganizationRef, + OwnerRef: wallet.OwnerRef, + Asset: asset, + DepositAddress: wallet.DepositAddress, + Status: shared.ManagedWalletStatusToProto(wallet.Status), + Metadata: shared.CloneMetadata(wallet.Metadata), + CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()), + UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()), + Describable: desc, + } +} + +func toProtoWalletBalance(balance *model.WalletBalance) *chainv1.WalletBalance { + if balance == nil { + return nil + } + return &chainv1.WalletBalance{ + Available: shared.CloneMoney(balance.Available), + NativeAvailable: shared.CloneMoney(balance.NativeAvailable), + PendingInbound: shared.CloneMoney(balance.PendingInbound), + PendingOutbound: shared.CloneMoney(balance.PendingOutbound), + CalculatedAt: timestamppb.New(balance.CalculatedAt.UTC()), + } +} diff --git a/api/gateway/tron/internal/service/gateway/connector.go b/api/gateway/tron/internal/service/gateway/connector.go new file mode 100644 index 00000000..2da8d59e --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/connector.go @@ -0,0 +1,629 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/tech/sendico/gateway/tron/internal/appversion" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + chainasset "github.com/tech/sendico/pkg/chain" + "github.com/tech/sendico/pkg/connector/params" + "github.com/tech/sendico/pkg/merrors" + describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.uber.org/zap" + "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 { + s.logger.Warn("Error listing accounts", zap.Error(err)) + return nil, err + } + asset = parsed + } + resp, err := s.ListManagedWallets(ctx, &chainv1.ListManagedWalletsRequest{ + OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()), + OwnerRefFilter: req.GetOwnerRefFilter(), + 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: chainasset.AssetString(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(), + Describable: wallet.GetDescribable(), + } +} + +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, merrors.InvalidArgument("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, merrors.InvalidArgument("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, merrors.InvalidArgument("money is required") + } + amount := strings.TrimSpace(fmt.Sprint(raw["amount"])) + currency := strings.TrimSpace(fmt.Sprint(raw["currency"])) + if amount == "" || currency == "" { + return nil, merrors.InvalidArgument("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) { + return chainasset.ParseAsset( + assetString, + reader.String("network"), + reader.String("token_symbol"), + reader.String("contract_address"), + ) +} + +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 + } +} diff --git a/api/gateway/tron/internal/service/gateway/driver/driver.go b/api/gateway/tron/internal/service/gateway/driver/driver.go new file mode 100644 index 00000000..52579dda --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/driver.go @@ -0,0 +1,36 @@ +package driver + +import ( + "context" + "time" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/tech/sendico/gateway/tron/internal/keymanager" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/mlogger" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +// Deps bundles dependencies shared across chain drivers. +type Deps struct { + Logger mlogger.Logger + Registry *rpcclient.Registry + TronRegistry *tronclient.Registry // Native TRON gRPC clients + KeyManager keymanager.Manager + RPCTimeout time.Duration +} + +// Driver defines chain-specific behavior for wallet and transfer operations. +type Driver interface { + Name() string + FormatAddress(address string) (string, error) + NormalizeAddress(address string) (string, error) + Balance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) + NativeBalance(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) + EstimateFee(ctx context.Context, deps Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) + SubmitTransfer(ctx context.Context, deps Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) + AwaitConfirmation(ctx context.Context, deps Deps, network shared.Network, txHash string) (*types.Receipt, error) +} diff --git a/api/gateway/tron/internal/service/gateway/driver/evm/estimate_gas_test.go b/api/gateway/tron/internal/service/gateway/driver/evm/estimate_gas_test.go new file mode 100644 index 00000000..ebef980a --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/evm/estimate_gas_test.go @@ -0,0 +1,31 @@ +package evm + +import ( + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +func TestTronEstimateCallUsesData(t *testing.T) { + from := common.HexToAddress("0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8") + to := common.HexToAddress("0xa614f803b6fd780986a42c78ec9c7f77e6ded13c") + callMsg := ethereum.CallMsg{ + From: from, + To: &to, + GasPrice: big.NewInt(100), + Data: []byte{0xa9, 0x05, 0x9c, 0xbb}, + } + + call := tronEstimateCall(callMsg) + + require.Equal(t, strings.ToLower(from.Hex()), call["from"]) + require.Equal(t, strings.ToLower(to.Hex()), call["to"]) + require.Equal(t, "0x64", call["gasPrice"]) + require.Equal(t, "0xa9059cbb", call["data"]) + _, hasInput := call["input"] + require.False(t, hasInput) +} diff --git a/api/gateway/tron/internal/service/gateway/driver/evm/evm.go b/api/gateway/tron/internal/service/gateway/driver/evm/evm.go new file mode 100644 index 00000000..823fc201 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/evm/evm.go @@ -0,0 +1,734 @@ +package evm + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + "go.uber.org/zap" +) + +var ( + erc20ABI abi.ABI +) + +func init() { + var err error + erc20ABI, err = abi.JSON(strings.NewReader(erc20ABIJSON)) + if err != nil { + panic("evm driver: failed to parse erc20 abi: " + err.Error()) + } +} + +const erc20ABIJSON = ` +[ + { + "constant": false, + "inputs": [ + { "name": "_to", "type": "address" }, + { "name": "_value", "type": "uint256" } + ], + "name": "transfer", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "decimals", + "outputs": [{ "name": "", "type": "uint8" }], + "payable": false, + "stateMutability": "view", + "type": "function" + } +]` + +// NormalizeAddress validates and normalizes EVM hex addresses. +func NormalizeAddress(address string) (string, error) { + trimmed := strings.TrimSpace(address) + if trimmed == "" { + return "", merrors.InvalidArgument("address is required") + } + if !common.IsHexAddress(trimmed) { + return "", merrors.InvalidArgument("invalid hex address") + } + return strings.ToLower(common.HexToAddress(trimmed).Hex()), nil +} + +func nativeCurrency(network shared.Network) string { + currency := strings.ToUpper(strings.TrimSpace(network.NativeToken)) + if currency == "" { + currency = strings.ToUpper(network.Name.String()) + } + return currency +} + +func parseBaseUnitAmount(amount string) (*big.Int, error) { + trimmed := strings.TrimSpace(amount) + if trimmed == "" { + return nil, merrors.InvalidArgument("amount is required") + } + value, ok := new(big.Int).SetString(trimmed, 10) + if !ok { + return nil, merrors.InvalidArgument("invalid amount") + } + if value.Sign() < 0 { + return nil, merrors.InvalidArgument("amount must be non-negative") + } + return value, nil +} + +// Balance fetches ERC20 token balance for the provided address. +func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) { + logger := deps.Logger.Named("evm") + registry := deps.Registry + + if registry == nil { + return nil, merrors.Internal("rpc clients not initialised") + } + if wallet == nil { + return nil, merrors.InvalidArgument("wallet is required") + } + + normalizedAddress, err := NormalizeAddress(address) + if err != nil { + return nil, err + } + + rpcURL := strings.TrimSpace(network.RPCURL) + logFields := []zap.Field{ + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name.String()), + zap.String("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))), + zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))), + zap.String("wallet_address", normalizedAddress), + } + if rpcURL == "" { + logger.Warn("Network rpc url is not configured", logFields...) + return nil, merrors.Internal("network rpc url is not configured") + } + + contract := strings.TrimSpace(wallet.ContractAddress) + if contract == "" { + logger.Debug("Native balance requested", logFields...) + return NativeBalance(ctx, deps, network, wallet, normalizedAddress) + } + if !common.IsHexAddress(contract) { + logger.Warn("Invalid contract address for balance fetch", logFields...) + return nil, merrors.InvalidArgument("invalid contract address") + } + + logger.Info("Fetching on-chain wallet balance", logFields...) + + rpcClient, err := registry.RPCClient(network.Name.String()) + if err != nil { + logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...) + return nil, err + } + + timeout := deps.RPCTimeout + if timeout <= 0 { + timeout = 10 * time.Second + } + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + logger.Debug("Calling token decimals", logFields...) + decimals, err := readDecimals(timeoutCtx, rpcClient, contract) + if err != nil { + logger.Warn("Token decimals call failed", append(logFields, zap.Error(err))...) + return nil, err + } + + logger.Debug("Calling token balanceOf", append(logFields, zap.Uint8("decimals", decimals))...) + bal, err := readBalanceOf(timeoutCtx, rpcClient, contract, normalizedAddress) + if err != nil { + logger.Warn("Token balanceOf call failed", append(logFields, zap.Uint8("decimals", decimals), zap.Error(err))...) + return nil, err + } + + dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals)) + logger.Info("On-chain wallet balance fetched", + append(logFields, + zap.Uint8("decimals", decimals), + zap.String("balance_raw", bal.String()), + zap.String("balance", dec.String()), + )..., + ) + return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil +} + +// NativeBalance fetches native token balance for the provided address. +func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, address string) (*moneyv1.Money, error) { + logger := deps.Logger.Named("evm") + registry := deps.Registry + + if registry == nil { + return nil, merrors.Internal("rpc clients not initialised") + } + if wallet == nil { + return nil, merrors.InvalidArgument("wallet is required") + } + + normalizedAddress, err := NormalizeAddress(address) + if err != nil { + return nil, err + } + + rpcURL := strings.TrimSpace(network.RPCURL) + logFields := []zap.Field{ + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name.String()), + zap.String("wallet_address", normalizedAddress), + } + if rpcURL == "" { + logger.Warn("Network rpc url is not configured", logFields...) + return nil, merrors.Internal("network rpc url is not configured") + } + + client, err := registry.Client(network.Name.String()) + if err != nil { + logger.Warn("Failed to fetch rpc client", append(logFields, zap.Error(err))...) + return nil, err + } + + timeout := deps.RPCTimeout + if timeout <= 0 { + timeout = 10 * time.Second + } + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + bal, err := client.BalanceAt(timeoutCtx, common.HexToAddress(normalizedAddress), nil) + if err != nil { + logger.Warn("Native balance call failed", append(logFields, zap.Error(err))...) + return nil, err + } + + logger.Debug("On-chain native balance fetched", + append(logFields, + zap.String("balance_raw", bal.String()), + )..., + ) + return &moneyv1.Money{ + Currency: nativeCurrency(network), + Amount: bal.String(), + }, nil +} + +// EstimateFee estimates ERC20 transfer fees for the given parameters. +func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, fromAddress, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) { + logger := deps.Logger.Named("evm") + registry := deps.Registry + + if registry == nil { + return nil, merrors.Internal("rpc clients not initialised") + } + if wallet == nil { + return nil, merrors.InvalidArgument("wallet is required") + } + if amount == nil { + return nil, merrors.InvalidArgument("amount is required") + } + + rpcURL := strings.TrimSpace(network.RPCURL) + if rpcURL == "" { + return nil, merrors.InvalidArgument("network rpc url not configured") + } + if _, err := NormalizeAddress(fromAddress); err != nil { + return nil, merrors.InvalidArgument("invalid source wallet address") + } + if _, err := NormalizeAddress(destination); err != nil { + return nil, merrors.InvalidArgument("invalid destination address") + } + + client, err := registry.Client(network.Name.String()) + if err != nil { + logger.Warn("Failed to resolve client", zap.Error(err), zap.String("network_name", network.Name.String())) + return nil, err + } + rpcClient, err := registry.RPCClient(network.Name.String()) + if err != nil { + logger.Warn("Failed to resolve RPC client", zap.Error(err), zap.String("network_name", network.Name.String())) + return nil, err + } + + timeout := deps.RPCTimeout + if timeout <= 0 { + timeout = 15 * time.Second + } + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + contract := strings.TrimSpace(wallet.ContractAddress) + toAddr := common.HexToAddress(destination) + fromAddr := common.HexToAddress(fromAddress) + + if contract == "" { + amountBase, err := parseBaseUnitAmount(amount.GetAmount()) + if err != nil { + logger.Warn("Failed to parse base unit amount", zap.Error(err), zap.String("amount", amount.GetAmount())) + return nil, err + } + gasPrice, err := client.SuggestGasPrice(timeoutCtx) + if err != nil { + logger.Warn("Failed to suggest gas price", zap.Error(err)) + return nil, merrors.Internal("failed to suggest gas price: " + err.Error()) + } + callMsg := ethereum.CallMsg{ + From: fromAddr, + To: &toAddr, + GasPrice: gasPrice, + Value: amountBase, + } + gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg) + if err != nil { + logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_mesasge", callMsg)) + return nil, merrors.Internal("failed to estimate gas: " + err.Error()) + } + fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit)) + feeDec := decimal.NewFromBigInt(fee, 0) + return &moneyv1.Money{ + Currency: nativeCurrency(network), + Amount: feeDec.String(), + }, nil + } + if !common.IsHexAddress(contract) { + logger.Warn("Failed to validate contract", zap.String("contract", contract)) + return nil, merrors.InvalidArgument("invalid token contract address") + } + + tokenAddr := common.HexToAddress(contract) + + decimals, err := erc20Decimals(timeoutCtx, rpcClient, tokenAddr) + if err != nil { + logger.Warn("Failed to read token decimals", zap.Error(err)) + return nil, err + } + + amountBase, err := toBaseUnits(strings.TrimSpace(amount.GetAmount()), decimals) + if err != nil { + return nil, err + } + + input, err := erc20ABI.Pack("transfer", toAddr, amountBase) + if err != nil { + logger.Warn("Failed to encode transfer call", zap.Error(err)) + return nil, merrors.Internal("failed to encode transfer call: " + err.Error()) + } + + gasPrice, err := client.SuggestGasPrice(timeoutCtx) + if err != nil { + logger.Warn("Failed to suggest gas price", zap.Error(err)) + return nil, merrors.Internal("failed to suggest gas price: " + err.Error()) + } + + callMsg := ethereum.CallMsg{ + From: fromAddr, + To: &tokenAddr, + GasPrice: gasPrice, + Data: input, + } + gasLimit, err := estimateGas(timeoutCtx, network, client, rpcClient, callMsg) + if err != nil { + logger.Warn("Failed to estimate gas", zap.Error(err), zap.Any("call_message", callMsg)) + return nil, merrors.Internal("failed to estimate gas: " + err.Error()) + } + + fee := new(big.Int).Mul(gasPrice, new(big.Int).SetUint64(gasLimit)) + feeDec := decimal.NewFromBigInt(fee, 0) + + return &moneyv1.Money{ + Currency: nativeCurrency(network), + Amount: feeDec.String(), + }, nil +} + +// SubmitTransfer submits an ERC20 transfer on an EVM-compatible chain. +func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, fromAddress, destination string) (string, error) { + logger := deps.Logger.Named("evm") + registry := deps.Registry + + if deps.KeyManager == nil { + logger.Warn("Key manager not configured") + return "", executorInternal("key manager is not configured", nil) + } + if registry == nil { + return "", executorInternal("rpc clients not initialised", nil) + } + rpcURL := strings.TrimSpace(network.RPCURL) + if rpcURL == "" { + logger.Warn("Network rpc url missing", zap.String("network", network.Name.String())) + return "", executorInvalid("network rpc url is not configured") + } + if source == nil || transfer == nil { + logger.Warn("Transfer context missing") + return "", executorInvalid("transfer context missing") + } + if strings.TrimSpace(source.KeyReference) == "" { + logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef)) + return "", executorInvalid("source wallet missing key reference") + } + if _, err := NormalizeAddress(fromAddress); err != nil { + logger.Warn("Invalid source wallet address", zap.String("wallet_ref", source.WalletRef)) + return "", executorInvalid("invalid source wallet address") + } + if _, err := NormalizeAddress(destination); err != nil { + logger.Warn("Invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destination)) + return "", executorInvalid("invalid destination address " + destination) + } + + logger.Info("Submitting transfer", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("source_wallet_ref", source.WalletRef), + zap.String("network", network.Name.String()), + zap.String("destination", strings.ToLower(destination)), + ) + + client, err := registry.Client(network.Name.String()) + if err != nil { + logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name.String())) + return "", err + } + rpcClient, err := registry.RPCClient(network.Name.String()) + if err != nil { + logger.Warn("Failed to initialise RPC client", zap.String("network", network.Name.String())) + return "", err + } + + sourceAddress := common.HexToAddress(fromAddress) + destinationAddr := common.HexToAddress(destination) + + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + nonce, err := client.PendingNonceAt(ctx, sourceAddress) + if err != nil { + logger.Warn("Failed to fetch nonce", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), zap.String("wallet_ref", source.WalletRef), + ) + return "", executorInternal("failed to fetch nonce", err) + } + + gasPrice, err := client.SuggestGasPrice(ctx) + if err != nil { + logger.Warn("Failed to suggest gas price", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), zap.String("network", network.Name.String()), + ) + return "", executorInternal("failed to suggest gas price", err) + } + + chainID := new(big.Int).SetUint64(network.ChainID) + + contract := strings.TrimSpace(transfer.ContractAddress) + amount := transfer.NetAmount + if amount == nil || strings.TrimSpace(amount.Amount) == "" { + logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef)) + return "", executorInvalid("transfer missing net amount") + } + + var tx *types.Transaction + if contract == "" { + amountInt, err := parseBaseUnitAmount(amount.Amount) + if err != nil { + logger.Warn("Invalid native amount", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef)) + return "", err + } + callMsg := ethereum.CallMsg{ + From: sourceAddress, + To: &destinationAddr, + GasPrice: gasPrice, + Value: amountInt, + } + gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg) + if err != nil { + logger.Warn("Failed to estimate gas", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + ) + return "", executorInternal("failed to estimate gas", err) + } + tx = types.NewTransaction(nonce, destinationAddr, amountInt, gasLimit, gasPrice, nil) + } else { + if !common.IsHexAddress(contract) { + logger.Warn("Invalid token contract address", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("contract", contract), + ) + return "", executorInvalid("invalid token contract address " + contract) + } + tokenAddress := common.HexToAddress(contract) + + decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress) + if err != nil { + logger.Warn("Failed to read token decimals", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), zap.String("contract", contract), + ) + return "", err + } + + amountInt, err := toBaseUnits(amount.Amount, decimals) + if err != nil { + logger.Warn("Failed to convert amount to base units", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), zap.String("amount", amount.Amount), + ) + return "", err + } + + input, err := erc20ABI.Pack("transfer", destinationAddr, amountInt) + if err != nil { + logger.Warn("Failed to encode transfer call", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + ) + return "", executorInternal("failed to encode transfer call", err) + } + + callMsg := ethereum.CallMsg{ + From: sourceAddress, + To: &tokenAddress, + GasPrice: gasPrice, + Data: input, + } + gasLimit, err := estimateGas(ctx, network, client, rpcClient, callMsg) + if err != nil { + logger.Warn("Failed to estimate gas", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + ) + return "", executorInternal("failed to estimate gas", err) + } + + tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input) + } + + signedTx, err := deps.KeyManager.SignTransaction(ctx, source.KeyReference, tx, chainID) + if err != nil { + logger.Warn("Failed to sign transaction", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), zap.String("wallet_ref", source.WalletRef), + ) + return "", err + } + + if err := client.SendTransaction(ctx, signedTx); err != nil { + logger.Warn("Failed to send transaction", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + ) + return "", executorInternal("failed to send transaction", err) + } + + txHash := signedTx.Hash().Hex() + logger.Info("Transaction submitted", zap.String("transfer_ref", transfer.TransferRef), + zap.String("tx_hash", txHash), zap.String("network", network.Name.String()), + ) + + return txHash, nil +} + +// AwaitConfirmation waits for the transaction receipt. +func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) { + logger := deps.Logger.Named("evm") + registry := deps.Registry + + if strings.TrimSpace(txHash) == "" { + logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name.String())) + return nil, executorInvalid("tx hash is required") + } + rpcURL := strings.TrimSpace(network.RPCURL) + if rpcURL == "" { + logger.Warn("Network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash)) + return nil, executorInvalid("network rpc url is not configured") + } + if registry == nil { + return nil, executorInternal("rpc clients not initialised", nil) + } + + client, err := registry.Client(network.Name.String()) + if err != nil { + return nil, err + } + + hash := common.HexToHash(txHash) + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + for { + receipt, err := client.TransactionReceipt(ctx, hash) + if err != nil { + if errors.Is(err, ethereum.NotFound) { + select { + case <-ticker.C: + logger.Debug("Transaction not yet mined", zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + ) + continue + case <-ctx.Done(): + logger.Warn("Context cancelled while awaiting confirmation", zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + ) + return nil, ctx.Err() + } + } + logger.Warn("Failed to fetch transaction receipt", zap.Error(err), + zap.String("tx_hash", txHash), zap.String("network", network.Name.String()), + ) + return nil, executorInternal("failed to fetch transaction receipt", err) + } + logger.Info("Transaction confirmed", zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), zap.Uint64("status", receipt.Status), + zap.Uint64("block_number", receipt.BlockNumber.Uint64()), + ) + return receipt, nil + } +} + +func readDecimals(ctx context.Context, client *rpc.Client, token string) (uint8, error) { + call := map[string]string{ + "to": strings.ToLower(common.HexToAddress(token).Hex()), + "data": "0x313ce567", + } + var hexResp string + if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil { + return 0, merrors.Internal("decimals call failed: " + err.Error()) + } + val, err := shared.DecodeHexUint8(hexResp) + if err != nil { + return 0, merrors.Internal("decimals decode failed: " + err.Error()) + } + return val, nil +} + +func readBalanceOf(ctx context.Context, client *rpc.Client, token string, wallet string) (*big.Int, error) { + tokenAddr := common.HexToAddress(token) + walletAddr := common.HexToAddress(wallet) + addr := strings.TrimPrefix(walletAddr.Hex(), "0x") + if len(addr) < 64 { + addr = strings.Repeat("0", 64-len(addr)) + addr + } + call := map[string]string{ + "to": strings.ToLower(tokenAddr.Hex()), + "data": "0x70a08231" + addr, + } + var hexResp string + if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil { + return nil, merrors.Internal("balanceOf call failed: " + err.Error()) + } + bigVal, err := shared.DecodeHexBig(hexResp) + if err != nil { + return nil, merrors.Internal("balanceOf decode failed: " + err.Error()) + } + return bigVal, nil +} + +func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) { + call := map[string]string{ + "to": strings.ToLower(token.Hex()), + "data": "0x313ce567", + } + var hexResp string + if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil { + return 0, executorInternal("decimals call failed", err) + } + val, err := shared.DecodeHexUint8(hexResp) + if err != nil { + return 0, executorInternal("decimals decode failed", err) + } + return val, nil +} + +type gasEstimator interface { + EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) +} + +func estimateGas(ctx context.Context, network shared.Network, client gasEstimator, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) { + if isTronNetwork(network) { + if rpcClient == nil { + return 0, merrors.Internal("rpc client not initialised") + } + return estimateGasTron(ctx, rpcClient, callMsg) + } + return client.EstimateGas(ctx, callMsg) +} + +func estimateGasTron(ctx context.Context, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) { + call := tronEstimateCall(callMsg) + var hexResp string + if err := rpcClient.CallContext(ctx, &hexResp, "eth_estimateGas", call); err != nil { + return 0, err + } + val, err := shared.DecodeHexBig(hexResp) + if err != nil { + return 0, err + } + if val == nil { + return 0, merrors.Internal("failed to decode gas estimate") + } + return val.Uint64(), nil +} + +func tronEstimateCall(callMsg ethereum.CallMsg) map[string]string { + call := make(map[string]string) + if callMsg.From != (common.Address{}) { + call["from"] = strings.ToLower(callMsg.From.Hex()) + } + if callMsg.To != nil { + call["to"] = strings.ToLower(callMsg.To.Hex()) + } + if callMsg.Gas > 0 { + call["gas"] = hexutil.EncodeUint64(callMsg.Gas) + } + if callMsg.GasPrice != nil { + call["gasPrice"] = hexutil.EncodeBig(callMsg.GasPrice) + } + if callMsg.Value != nil { + call["value"] = hexutil.EncodeBig(callMsg.Value) + } + if len(callMsg.Data) > 0 { + call["data"] = hexutil.Encode(callMsg.Data) + } + return call +} + +func isTronNetwork(network shared.Network) bool { + return network.Name.IsTron() +} + +func toBaseUnits(amount string, decimals uint8) (*big.Int, error) { + value, err := decimal.NewFromString(strings.TrimSpace(amount)) + if err != nil { + return nil, merrors.InvalidArgument("invalid amount " + amount + ": " + err.Error()) + } + if value.IsNegative() { + return nil, merrors.InvalidArgument("amount must be positive") + } + multiplier := decimal.NewFromInt(1).Shift(int32(decimals)) + scaled := value.Mul(multiplier) + if !scaled.Equal(scaled.Truncate(0)) { + return nil, merrors.InvalidArgument("amount " + amount + " exceeds token precision") + } + return scaled.BigInt(), nil +} + +func executorInvalid(msg string) error { + return merrors.InvalidArgument("executor: " + msg) +} + +func executorInternal(msg string, err error) error { + if err != nil { + msg = msg + ": " + err.Error() + } + return merrors.Internal("executor: " + msg) +} diff --git a/api/gateway/tron/internal/service/gateway/driver/evm/gas_topup.go b/api/gateway/tron/internal/service/gateway/driver/evm/gas_topup.go new file mode 100644 index 00000000..3f9a6bd7 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/evm/gas_topup.go @@ -0,0 +1,108 @@ +package evm + +import ( + "fmt" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +var evmBaseUnitFactor = decimal.NewFromInt(1_000_000_000_000_000_000) + +// ComputeGasTopUp applies the network policy to decide an EVM native-token top-up amount. +func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, bool, error) { + if wallet == nil { + return nil, false, merrors.InvalidArgument("wallet is required") + } + if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" { + return nil, false, merrors.InvalidArgument("estimated fee is required") + } + if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" { + return nil, false, merrors.InvalidArgument("current native balance is required") + } + if network.GasTopUpPolicy == nil { + return nil, false, merrors.InvalidArgument("gas top-up policy is not configured") + } + + nativeCurrency := strings.TrimSpace(network.NativeToken) + if nativeCurrency == "" { + nativeCurrency = strings.ToUpper(network.Name.String()) + } + if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) { + return nil, false, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency)) + } + if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) { + return nil, false, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency)) + } + + estimatedNative, err := evmToNative(estimatedFee) + if err != nil { + return nil, false, err + } + currentNative, err := evmToNative(currentBalance) + if err != nil { + return nil, false, err + } + + isContract := strings.TrimSpace(wallet.ContractAddress) != "" + rule, ok := network.GasTopUpPolicy.Rule(isContract) + if !ok { + return nil, false, merrors.InvalidArgument("gas top-up policy is not configured") + } + if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) { + return nil, false, merrors.InvalidArgument("gas top-up rounding unit must be > 0") + } + if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) { + return nil, false, merrors.InvalidArgument("gas top-up max top-up must be > 0") + } + + required := estimatedNative.Sub(currentNative) + if required.IsNegative() { + required = decimal.Zero + } + bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent)) + + minBalanceTopUp := rule.MinNativeBalance.Sub(currentNative) + if minBalanceTopUp.IsNegative() { + minBalanceTopUp = decimal.Zero + } + + rawTopUp := bufferedRequired + if minBalanceTopUp.GreaterThan(rawTopUp) { + rawTopUp = minBalanceTopUp + } + + roundedTopUp := decimal.Zero + if rawTopUp.IsPositive() { + roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit) + } + + topUp := roundedTopUp + capHit := false + if topUp.GreaterThan(rule.MaxTopUp) { + topUp = rule.MaxTopUp + capHit = true + } + + if !topUp.IsPositive() { + return nil, capHit, nil + } + + baseUnits := topUp.Mul(evmBaseUnitFactor).Ceil().Truncate(0) + return &moneyv1.Money{ + Currency: strings.ToUpper(nativeCurrency), + Amount: baseUnits.StringFixed(0), + }, capHit, nil +} + +func evmToNative(amount *moneyv1.Money) (decimal.Decimal, error) { + value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount())) + if err != nil { + return decimal.Zero, err + } + return value.Div(evmBaseUnitFactor), nil +} diff --git a/api/gateway/tron/internal/service/gateway/driver/evm/gas_topup_test.go b/api/gateway/tron/internal/service/gateway/driver/evm/gas_topup_test.go new file mode 100644 index 00000000..3c0fbb5d --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/evm/gas_topup_test.go @@ -0,0 +1,146 @@ +package evm + +import ( + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +func TestComputeGasTopUp_BalanceSufficient(t *testing.T) { + network := ethNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("30")) + require.NoError(t, err) + require.Nil(t, topUp) + require.False(t, capHit) +} + +func TestComputeGasTopUp_BufferedRequired(t *testing.T) { + network := ethNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("50"), ethMoney("10")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.False(t, capHit) + require.Equal(t, "46000000000000000000", topUp.GetAmount()) + require.Equal(t, "ETH", topUp.GetCurrency()) +} + +func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) { + network := ethNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("5"), ethMoney("1")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.False(t, capHit) + require.Equal(t, "19000000000000000000", topUp.GetAmount()) +} + +func TestComputeGasTopUp_RoundsUp(t *testing.T) { + policy := shared.GasTopUpPolicy{ + Default: shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0), + MinNativeBalance: decimal.NewFromFloat(0), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(100), + }, + } + network := ethNetwork(&policy) + wallet := &model.ManagedWallet{} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("1.1"), ethMoney("0")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.False(t, capHit) + require.Equal(t, "2000000000000000000", topUp.GetAmount()) +} + +func TestComputeGasTopUp_CapHit(t *testing.T) { + policy := shared.GasTopUpPolicy{ + Default: shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0), + MinNativeBalance: decimal.NewFromFloat(0), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(10), + }, + } + network := ethNetwork(&policy) + wallet := &model.ManagedWallet{} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("100"), ethMoney("0")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.True(t, capHit) + require.Equal(t, "10000000000000000000", topUp.GetAmount()) +} + +func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) { + network := ethNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("0"), ethMoney("5")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.False(t, capHit) + require.Equal(t, "15000000000000000000", topUp.GetAmount()) +} + +func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) { + policy := shared.GasTopUpPolicy{ + Default: shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0.1), + MinNativeBalance: decimal.NewFromFloat(10), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(100), + }, + Contract: &shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0.5), + MinNativeBalance: decimal.NewFromFloat(5), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(100), + }, + } + network := ethNetwork(&policy) + wallet := &model.ManagedWallet{ContractAddress: "0xcontract"} + + topUp, capHit, err := ComputeGasTopUp(network, wallet, ethMoney("10"), ethMoney("0")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.False(t, capHit) + require.Equal(t, "15000000000000000000", topUp.GetAmount()) +} + +func defaultPolicy() *shared.GasTopUpPolicy { + return &shared.GasTopUpPolicy{ + Default: shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0.15), + MinNativeBalance: decimal.NewFromFloat(20), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(500), + }, + } +} + +func ethNetwork(policy *shared.GasTopUpPolicy) shared.Network { + return shared.Network{ + Name: "ethereum_mainnet", + NativeToken: "ETH", + GasTopUpPolicy: policy, + } +} + +func ethMoney(eth string) *moneyv1.Money { + value, _ := decimal.NewFromString(eth) + baseUnits := value.Mul(evmBaseUnitFactor).Truncate(0) + return &moneyv1.Money{ + Currency: "ETH", + Amount: baseUnits.StringFixed(0), + } +} diff --git a/api/gateway/tron/internal/service/gateway/driver/tron/address.go b/api/gateway/tron/internal/service/gateway/driver/tron/address.go new file mode 100644 index 00000000..ff6048a6 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/tron/address.go @@ -0,0 +1,193 @@ +package tron + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "math/big" + "strings" + + "github.com/tech/sendico/pkg/merrors" +) + +const tronHexPrefix = "0x" + +var base58Alphabet = []byte("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + +func normalizeAddress(address string) (string, error) { + trimmed := strings.TrimSpace(address) + if trimmed == "" { + return "", merrors.InvalidArgument("address is required") + } + if strings.HasPrefix(trimmed, tronHexPrefix) || isHexString(trimmed) { + return hexToBase58(trimmed) + } + decoded, err := base58Decode(trimmed) + if err != nil { + return "", err + } + if err := validateChecksum(decoded); err != nil { + return "", err + } + return base58Encode(decoded), nil +} + +func rpcAddress(address string) (string, error) { + trimmed := strings.TrimSpace(address) + if trimmed == "" { + return "", merrors.InvalidArgument("address is required") + } + if strings.HasPrefix(trimmed, tronHexPrefix) || isHexString(trimmed) { + return normalizeHexRPC(trimmed) + } + return base58ToHex(trimmed) +} + +func hexToBase58(address string) (string, error) { + bytesAddr, err := parseHexAddress(address) + if err != nil { + return "", err + } + payload := append(bytesAddr, checksum(bytesAddr)...) + return base58Encode(payload), nil +} + +func base58ToHex(address string) (string, error) { + decoded, err := base58Decode(address) + if err != nil { + return "", err + } + if err := validateChecksum(decoded); err != nil { + return "", err + } + return tronHexPrefix + hex.EncodeToString(decoded[1:21]), nil +} + +func parseHexAddress(address string) ([]byte, error) { + trimmed := strings.TrimPrefix(strings.TrimSpace(address), tronHexPrefix) + if trimmed == "" { + return nil, merrors.InvalidArgument("address is required") + } + if len(trimmed)%2 == 1 { + trimmed = "0" + trimmed + } + decoded, err := hex.DecodeString(trimmed) + if err != nil { + return nil, merrors.InvalidArgument("invalid hex address") + } + switch len(decoded) { + case 20: + prefixed := make([]byte, 21) + prefixed[0] = 0x41 + copy(prefixed[1:], decoded) + return prefixed, nil + case 21: + if decoded[0] != 0x41 { + return nil, merrors.InvalidArgument("invalid tron address prefix") + } + return decoded, nil + default: + return nil, merrors.InvalidArgument(fmt.Sprintf("invalid tron address length %d", len(decoded))) + } +} + +func normalizeHexRPC(address string) (string, error) { + decoded, err := parseHexAddress(address) + if err != nil { + return "", err + } + return tronHexPrefix + hex.EncodeToString(decoded[1:21]), nil +} + +func validateChecksum(decoded []byte) error { + if len(decoded) != 25 { + return merrors.InvalidArgument("invalid tron address length") + } + payload := decoded[:21] + expected := checksum(payload) + if !bytes.Equal(expected, decoded[21:]) { + return merrors.InvalidArgument("invalid tron address checksum") + } + if payload[0] != 0x41 { + return merrors.InvalidArgument("invalid tron address prefix") + } + return nil +} + +func checksum(payload []byte) []byte { + first := sha256.Sum256(payload) + second := sha256.Sum256(first[:]) + return second[:4] +} + +func base58Encode(input []byte) string { + if len(input) == 0 { + return "" + } + x := new(big.Int).SetBytes(input) + base := big.NewInt(58) + zero := big.NewInt(0) + mod := new(big.Int) + + encoded := make([]byte, 0, len(input)) + for x.Cmp(zero) > 0 { + x.DivMod(x, base, mod) + encoded = append(encoded, base58Alphabet[mod.Int64()]) + } + for _, b := range input { + if b != 0 { + break + } + encoded = append(encoded, base58Alphabet[0]) + } + reverse(encoded) + return string(encoded) +} + +func base58Decode(input string) ([]byte, error) { + result := big.NewInt(0) + base := big.NewInt(58) + + for i := 0; i < len(input); i++ { + idx := bytes.IndexByte(base58Alphabet, input[i]) + if idx < 0 { + return nil, merrors.InvalidArgument("invalid base58 address") + } + result.Mul(result, base) + result.Add(result, big.NewInt(int64(idx))) + } + + decoded := result.Bytes() + zeroCount := 0 + for zeroCount < len(input) && input[zeroCount] == base58Alphabet[0] { + zeroCount++ + } + if zeroCount > 0 { + decoded = append(make([]byte, zeroCount), decoded...) + } + return decoded, nil +} + +func reverse(data []byte) { + for i, j := 0, len(data)-1; i < j; i, j = i+1, j-1 { + data[i], data[j] = data[j], data[i] + } +} + +func isHexString(value string) bool { + trimmed := strings.TrimPrefix(strings.TrimSpace(value), tronHexPrefix) + if trimmed == "" { + return false + } + for _, r := range trimmed { + switch { + case r >= '0' && r <= '9': + case r >= 'a' && r <= 'f': + case r >= 'A' && r <= 'F': + default: + return false + } + } + return true +} diff --git a/api/gateway/tron/internal/service/gateway/driver/tron/confirmation.go b/api/gateway/tron/internal/service/gateway/driver/tron/confirmation.go new file mode 100644 index 00000000..44c52c93 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/tron/confirmation.go @@ -0,0 +1,165 @@ +package tron + +import ( + "context" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum/core/types" + troncore "github.com/fbsobreira/gotron-sdk/pkg/proto/core" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +const ( + // Default poll interval for confirmation checking + defaultPollInterval = 3 * time.Second +) + +// AwaitConfirmationNative waits for transaction confirmation using native TRON gRPC. +func AwaitConfirmationNative( + ctx context.Context, + logger mlogger.Logger, + tronClient *tronclient.Client, + txHash string, +) (*types.Receipt, error) { + if tronClient == nil { + return nil, merrors.Internal("tron driver: tron client not initialized") + } + + normalizedHash := normalizeTxHash(txHash) + if normalizedHash == "" { + logger.Warn("Missing transaction hash for confirmation") + return nil, merrors.InvalidArgument("tron driver: tx hash is required") + } + + logger.Debug("Awaiting native TRON confirmation", + zap.String("tx_hash", normalizedHash), + ) + + ticker := time.NewTicker(defaultPollInterval) + defer ticker.Stop() + + for { + txInfo, err := tronClient.GetTransactionInfoByID(normalizedHash) + if err != nil { + // Not found yet, continue polling + logger.Debug("Transaction not yet confirmed", + zap.String("tx_hash", normalizedHash), + zap.Error(err), + ) + } else if txInfo != nil && txInfo.BlockNumber > 0 { + // Transaction is confirmed + receipt := convertTronInfoToReceipt(txInfo) + logger.Info("Native TRON transaction confirmed", + zap.String("tx_hash", normalizedHash), + zap.Int64("block_number", txInfo.BlockNumber), + zap.Uint64("status", receipt.Status), + zap.Int64("fee", txInfo.Fee), + ) + return receipt, nil + } + + select { + case <-ticker.C: + continue + case <-ctx.Done(): + logger.Warn("Context cancelled while awaiting TRON confirmation", + zap.String("tx_hash", normalizedHash), + ) + return nil, ctx.Err() + } + } +} + +// convertTronInfoToReceipt converts TRON TransactionInfo to go-ethereum Receipt for compatibility. +func convertTronInfoToReceipt(txInfo *troncore.TransactionInfo) *types.Receipt { + if txInfo == nil { + return nil + } + + // Determine status: TRON uses 0 = SUCCESS, 1 = FAILED + // go-ethereum uses 1 = success, 0 = failure + var status uint64 + if txInfo.Result == troncore.TransactionInfo_SUCESS { + status = 1 // Success + } else { + status = 0 // Failed + } + + // Create a receipt that's compatible with the existing codebase + receipt := &types.Receipt{ + Status: status, + BlockNumber: big.NewInt(txInfo.BlockNumber), + // TRON fees are in SUN, but we store as-is for logging + GasUsed: uint64(txInfo.Fee), + } + + // Set transaction hash from txInfo.Id + if len(txInfo.Id) == 32 { + var txHash [32]byte + copy(txHash[:], txInfo.Id) + receipt.TxHash = txHash + } + + // Set contract address if available + if len(txInfo.ContractAddress) > 0 { + var contractAddr [20]byte + // TRON addresses are 21 bytes with 0x41 prefix, take last 20 + if len(txInfo.ContractAddress) == 21 { + copy(contractAddr[:], txInfo.ContractAddress[1:]) + } else if len(txInfo.ContractAddress) == 20 { + copy(contractAddr[:], txInfo.ContractAddress) + } + receipt.ContractAddress = contractAddr + } + + return receipt +} + +// GetTransactionStatus checks the current status of a TRON transaction. +// Returns: +// - 1 if confirmed and successful +// - 0 if confirmed but failed +// - -1 if not yet confirmed (pending) +func GetTransactionStatus( + ctx context.Context, + logger mlogger.Logger, + tronClient *tronclient.Client, + txHash string, +) (int, error) { + if tronClient == nil { + return -1, merrors.Internal("tron driver: tron client not initialized") + } + + normalizedHash := normalizeTxHash(txHash) + if normalizedHash == "" { + return -1, merrors.InvalidArgument("tron driver: tx hash is required") + } + + txInfo, err := tronClient.GetTransactionInfoByID(normalizedHash) + if err != nil { + // Transaction not found or error - treat as pending + return -1, nil + } + + if txInfo == nil || txInfo.BlockNumber == 0 { + // Not yet confirmed + return -1, nil + } + + // Confirmed - check result + if txInfo.Result == troncore.TransactionInfo_SUCESS { + return 1, nil + } + return 0, nil +} + +// isTronNetwork checks if the network name indicates a TRON network. +func isTronNetwork(networkName string) bool { + name := strings.ToLower(strings.TrimSpace(networkName)) + return strings.HasPrefix(name, "tron") +} diff --git a/api/gateway/tron/internal/service/gateway/driver/tron/driver.go b/api/gateway/tron/internal/service/gateway/driver/tron/driver.go new file mode 100644 index 00000000..78225c17 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/tron/driver.go @@ -0,0 +1,304 @@ +package tron + +import ( + "context" + "strings" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/driver/evm" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + "go.uber.org/zap" +) + +// Driver implements Tron-specific behavior, including address conversion. +type Driver struct { + logger mlogger.Logger +} + +func New(logger mlogger.Logger) *Driver { + return &Driver{logger: logger.Named("tron")} +} + +func (d *Driver) Name() string { + return "tron" +} + +func (d *Driver) FormatAddress(address string) (string, error) { + d.logger.Debug("Format address", zap.String("address", address)) + normalized, err := normalizeAddress(address) + if err != nil { + d.logger.Warn("Format address failed", zap.String("address", address), zap.Error(err)) + } + return normalized, err +} + +func (d *Driver) NormalizeAddress(address string) (string, error) { + d.logger.Debug("Normalize address", zap.String("address", address)) + normalized, err := normalizeAddress(address) + if err != nil { + d.logger.Warn("Normalize address failed", zap.String("address", address), zap.Error(err)) + } + return normalized, err +} + +func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { + if wallet == nil { + return nil, merrors.InvalidArgument("wallet is required") + } + d.logger.Debug("Balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name.String())) + rpcAddr, err := rpcAddress(wallet.DepositAddress) + if err != nil { + d.logger.Warn("Balance address conversion failed", zap.Error(err), + zap.String("wallet_ref", wallet.WalletRef), + zap.String("address", wallet.DepositAddress), + ) + return nil, err + } + driverDeps := deps + driverDeps.Logger = d.logger + result, err := evm.Balance(ctx, driverDeps, network, wallet, rpcAddr) + if err != nil { + d.logger.Warn("Balance failed", zap.Error(err), + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name.String()), + ) + } else if result != nil { + d.logger.Debug("Balance result", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name.String()), + zap.String("amount", result.Amount), + zap.String("currency", result.Currency), + ) + } + return result, err +} + +func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { + if wallet == nil { + return nil, merrors.InvalidArgument("wallet is required") + } + d.logger.Debug("Native balance request", zap.String("wallet_ref", wallet.WalletRef), zap.String("network", network.Name.String())) + rpcAddr, err := rpcAddress(wallet.DepositAddress) + if err != nil { + d.logger.Warn("Native balance address conversion failed", zap.Error(err), + zap.String("wallet_ref", wallet.WalletRef), + zap.String("address", wallet.DepositAddress), + ) + return nil, err + } + driverDeps := deps + driverDeps.Logger = d.logger + result, err := evm.NativeBalance(ctx, driverDeps, network, wallet, rpcAddr) + if err != nil { + d.logger.Warn("Native balance failed", zap.Error(err), + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name.String()), + ) + } else if result != nil { + d.logger.Debug("Native balance result", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name.String()), + zap.String("amount", result.Amount), + zap.String("currency", result.Currency), + ) + } + return result, err +} + +func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) { + if wallet == nil { + return nil, merrors.InvalidArgument("wallet is required") + } + if amount == nil { + return nil, merrors.InvalidArgument("amount is required") + } + d.logger.Debug("Estimate fee request", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name.String()), + zap.String("destination", destination), + ) + rpcFrom, err := rpcAddress(wallet.DepositAddress) + if err != nil { + d.logger.Warn("Estimate fee address conversion failed", zap.Error(err), + zap.String("wallet_ref", wallet.WalletRef), + zap.String("address", wallet.DepositAddress), + ) + return nil, err + } + rpcTo, err := rpcAddress(destination) + if err != nil { + d.logger.Warn("Estimate fee destination conversion failed", zap.Error(err), + zap.String("wallet_ref", wallet.WalletRef), + zap.String("destination", destination), + ) + return nil, err + } + if rpcFrom == rpcTo { + return &moneyv1.Money{ + Currency: nativeCurrency(network), + Amount: "0", + }, nil + } + driverDeps := deps + driverDeps.Logger = d.logger + result, err := evm.EstimateFee(ctx, driverDeps, network, wallet, rpcFrom, rpcTo, amount) + if err != nil { + d.logger.Warn("Estimate fee failed", zap.Error(err), + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name.String()), + zap.String("from_address", wallet.DepositAddress), + zap.String("from_rpc", rpcFrom), + zap.String("to_address", destination), + zap.String("to_rpc", rpcTo), + ) + } else if result != nil { + d.logger.Debug("Estimate fee result", + zap.String("wallet_ref", wallet.WalletRef), + zap.String("network", network.Name.String()), + zap.String("amount", result.Amount), + zap.String("currency", result.Currency), + ) + } + return result, err +} + +func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) { + if source == nil { + return "", merrors.InvalidArgument("source wallet is required") + } + d.logger.Debug("Submit transfer request", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name.String()), + zap.String("destination", destination), + ) + + // Check if native TRON gRPC is available + if deps.TronRegistry != nil && deps.TronRegistry.HasClient(network.Name.String()) { + d.logger.Debug("Using native TRON gRPC for transfer", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name.String()), + ) + tronClient, err := deps.TronRegistry.Client(network.Name.String()) + if err != nil { + d.logger.Warn("Failed to get TRON client, falling back to EVM", zap.Error(err)) + } else { + txHash, err := SubmitTransferNative(ctx, d.logger, tronClient, deps.KeyManager, transfer, source, destination) + if err != nil { + d.logger.Warn("Native TRON transfer failed", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name.String()), + ) + } else { + d.logger.Debug("Native TRON transfer result", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name.String()), + zap.String("tx_hash", txHash), + ) + } + return txHash, err + } + } + + // Fallback to EVM JSON-RPC + rpcFrom, err := rpcAddress(source.DepositAddress) + if err != nil { + d.logger.Warn("Submit transfer address conversion failed", zap.Error(err), + zap.String("wallet_ref", source.WalletRef), + zap.String("address", source.DepositAddress), + ) + return "", err + } + rpcTo, err := rpcAddress(destination) + if err != nil { + d.logger.Warn("Submit transfer destination conversion failed", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + zap.String("destination", destination), + ) + return "", err + } + driverDeps := deps + driverDeps.Logger = d.logger + txHash, err := evm.SubmitTransfer(ctx, driverDeps, network, transfer, source, rpcFrom, rpcTo) + if err != nil { + d.logger.Warn("Submit transfer failed", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name.String()), + ) + } else { + d.logger.Debug("Submit transfer result", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name.String()), + zap.String("tx_hash", txHash), + ) + } + return txHash, err +} + +func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) { + d.logger.Debug("Awaiting confirmation", + zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + ) + + // Check if native TRON gRPC is available + if deps.TronRegistry != nil && deps.TronRegistry.HasClient(network.Name.String()) { + d.logger.Debug("Using native TRON gRPC for confirmation", + zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + ) + tronClient, err := deps.TronRegistry.Client(network.Name.String()) + if err != nil { + d.logger.Warn("Failed to get TRON client, falling back to EVM", zap.Error(err)) + } else { + receipt, err := AwaitConfirmationNative(ctx, d.logger, tronClient, txHash) + if err != nil { + d.logger.Warn("Native TRON confirmation failed", zap.Error(err), + zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + ) + } else if receipt != nil { + d.logger.Debug("Native TRON confirmation result", + zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + zap.Uint64("block_number", receipt.BlockNumber.Uint64()), + zap.Uint64("status", receipt.Status), + ) + } + return receipt, err + } + } + + // Fallback to EVM JSON-RPC + driverDeps := deps + driverDeps.Logger = d.logger + receipt, err := evm.AwaitConfirmation(ctx, driverDeps, network, txHash) + if err != nil { + d.logger.Warn("Awaiting of confirmation failed", zap.Error(err), + zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + ) + } else if receipt != nil { + d.logger.Debug("Await confirmation result", + zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + zap.Uint64("block_number", receipt.BlockNumber.Uint64()), + zap.Uint64("status", receipt.Status), + ) + } + return receipt, err +} + +func nativeCurrency(network shared.Network) string { + currency := strings.ToUpper(strings.TrimSpace(network.NativeToken)) + if currency == "" { + currency = strings.ToUpper(network.Name.String()) + } + return currency +} + +var _ driver.Driver = (*Driver)(nil) diff --git a/api/gateway/tron/internal/service/gateway/driver/tron/driver_test.go b/api/gateway/tron/internal/service/gateway/driver/tron/driver_test.go new file mode 100644 index 00000000..fb1b3db1 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/tron/driver_test.go @@ -0,0 +1,33 @@ +package tron + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + "go.uber.org/zap" +) + +func TestEstimateFeeSelfTransferReturnsZero(t *testing.T) { + logger := zap.NewNop() + d := New(logger) + wallet := &model.ManagedWallet{ + WalletRef: "wallet_ref", + DepositAddress: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF", + } + network := shared.Network{ + Name: "tron_mainnet", + NativeToken: "TRX", + } + amount := &moneyv1.Money{Currency: "TRX", Amount: "1000000"} + + fee, err := d.EstimateFee(context.Background(), driver.Deps{}, network, wallet, wallet.DepositAddress, amount) + require.NoError(t, err) + require.NotNil(t, fee) + require.Equal(t, "TRX", fee.GetCurrency()) + require.Equal(t, "0", fee.GetAmount()) +} diff --git a/api/gateway/tron/internal/service/gateway/driver/tron/gas_topup.go b/api/gateway/tron/internal/service/gateway/driver/tron/gas_topup.go new file mode 100644 index 00000000..bf7d23cc --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/tron/gas_topup.go @@ -0,0 +1,143 @@ +package tron + +import ( + "fmt" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +var tronBaseUnitFactor = decimal.NewFromInt(1_000_000) + +// GasTopUpDecision captures the applied policy inputs and outputs (in TRX units). +type GasTopUpDecision struct { + CurrentBalanceTRX decimal.Decimal + EstimatedFeeTRX decimal.Decimal + RequiredTRX decimal.Decimal + BufferedRequiredTRX decimal.Decimal + MinBalanceTopUpTRX decimal.Decimal + RawTopUpTRX decimal.Decimal + RoundedTopUpTRX decimal.Decimal + TopUpTRX decimal.Decimal + CapHit bool + OperationType string +} + +// ComputeGasTopUp applies the network policy to decide a TRX top-up amount. +func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estimatedFee *moneyv1.Money, currentBalance *moneyv1.Money) (*moneyv1.Money, GasTopUpDecision, error) { + decision := GasTopUpDecision{} + if wallet == nil { + return nil, decision, merrors.InvalidArgument("wallet is required") + } + if estimatedFee == nil || strings.TrimSpace(estimatedFee.GetAmount()) == "" || strings.TrimSpace(estimatedFee.GetCurrency()) == "" { + return nil, decision, merrors.InvalidArgument("estimated fee is required") + } + if currentBalance == nil || strings.TrimSpace(currentBalance.GetAmount()) == "" || strings.TrimSpace(currentBalance.GetCurrency()) == "" { + return nil, decision, merrors.InvalidArgument("current native balance is required") + } + if network.GasTopUpPolicy == nil { + return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured") + } + + nativeCurrency := strings.TrimSpace(network.NativeToken) + if nativeCurrency == "" { + nativeCurrency = strings.ToUpper(network.Name.String()) + } + if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) { + return nil, decision, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency)) + } + if !strings.EqualFold(nativeCurrency, currentBalance.GetCurrency()) { + return nil, decision, merrors.InvalidArgument(fmt.Sprintf("native balance currency mismatch (expected %s)", nativeCurrency)) + } + + estimatedTRX, err := tronToTRX(estimatedFee) + if err != nil { + return nil, decision, err + } + currentTRX, err := tronToTRX(currentBalance) + if err != nil { + return nil, decision, err + } + + isContract := strings.TrimSpace(wallet.ContractAddress) != "" + rule, ok := network.GasTopUpPolicy.Rule(isContract) + if !ok { + return nil, decision, merrors.InvalidArgument("gas top-up policy is not configured") + } + if rule.RoundingUnit.LessThanOrEqual(decimal.Zero) { + return nil, decision, merrors.InvalidArgument("gas top-up rounding unit must be > 0") + } + if rule.MaxTopUp.LessThanOrEqual(decimal.Zero) { + return nil, decision, merrors.InvalidArgument("gas top-up max top-up must be > 0") + } + + required := estimatedTRX.Sub(currentTRX) + if required.IsNegative() { + required = decimal.Zero + } + bufferedRequired := required.Mul(decimal.NewFromInt(1).Add(rule.BufferPercent)) + + minBalanceTopUp := rule.MinNativeBalance.Sub(currentTRX) + if minBalanceTopUp.IsNegative() { + minBalanceTopUp = decimal.Zero + } + + rawTopUp := bufferedRequired + if minBalanceTopUp.GreaterThan(rawTopUp) { + rawTopUp = minBalanceTopUp + } + + roundedTopUp := decimal.Zero + if rawTopUp.IsPositive() { + roundedTopUp = rawTopUp.Div(rule.RoundingUnit).Ceil().Mul(rule.RoundingUnit) + } + + topUp := roundedTopUp + capHit := false + if topUp.GreaterThan(rule.MaxTopUp) { + topUp = rule.MaxTopUp + capHit = true + } + + decision = GasTopUpDecision{ + CurrentBalanceTRX: currentTRX, + EstimatedFeeTRX: estimatedTRX, + RequiredTRX: required, + BufferedRequiredTRX: bufferedRequired, + MinBalanceTopUpTRX: minBalanceTopUp, + RawTopUpTRX: rawTopUp, + RoundedTopUpTRX: roundedTopUp, + TopUpTRX: topUp, + CapHit: capHit, + OperationType: operationType(isContract), + } + + if !topUp.IsPositive() { + return nil, decision, nil + } + + baseUnits := topUp.Mul(tronBaseUnitFactor).Ceil().Truncate(0) + return &moneyv1.Money{ + Currency: strings.ToUpper(nativeCurrency), + Amount: baseUnits.StringFixed(0), + }, decision, nil +} + +func tronToTRX(amount *moneyv1.Money) (decimal.Decimal, error) { + value, err := decimal.NewFromString(strings.TrimSpace(amount.GetAmount())) + if err != nil { + return decimal.Zero, err + } + return value.Div(tronBaseUnitFactor), nil +} + +func operationType(contract bool) string { + if contract { + return "trc20" + } + return "native" +} diff --git a/api/gateway/tron/internal/service/gateway/driver/tron/gas_topup_test.go b/api/gateway/tron/internal/service/gateway/driver/tron/gas_topup_test.go new file mode 100644 index 00000000..bdccb51c --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/tron/gas_topup_test.go @@ -0,0 +1,147 @@ +package tron + +import ( + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +func TestComputeGasTopUp_BalanceSufficient(t *testing.T) { + network := tronNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("30")) + require.NoError(t, err) + require.Nil(t, topUp) + require.True(t, decision.TopUpTRX.IsZero()) +} + +func TestComputeGasTopUp_BufferedRequired(t *testing.T) { + network := tronNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("50"), tronMoney("10")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.Equal(t, "46000000", topUp.GetAmount()) + require.Equal(t, "TRX", topUp.GetCurrency()) + require.Equal(t, "46", decision.TopUpTRX.String()) +} + +func TestComputeGasTopUp_MinBalanceBinding(t *testing.T) { + network := tronNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("5"), tronMoney("1")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.Equal(t, "19000000", topUp.GetAmount()) + require.Equal(t, "19", decision.TopUpTRX.String()) +} + +func TestComputeGasTopUp_RoundsUp(t *testing.T) { + policy := shared.GasTopUpPolicy{ + Default: shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0), + MinNativeBalance: decimal.NewFromFloat(0), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(100), + }, + } + network := tronNetwork(&policy) + wallet := &model.ManagedWallet{} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("1.1"), tronMoney("0")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.Equal(t, "2000000", topUp.GetAmount()) + require.Equal(t, "2", decision.TopUpTRX.String()) +} + +func TestComputeGasTopUp_CapHit(t *testing.T) { + policy := shared.GasTopUpPolicy{ + Default: shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0), + MinNativeBalance: decimal.NewFromFloat(0), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(10), + }, + } + network := tronNetwork(&policy) + wallet := &model.ManagedWallet{} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("100"), tronMoney("0")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.Equal(t, "10000000", topUp.GetAmount()) + require.True(t, decision.CapHit) +} + +func TestComputeGasTopUp_MinBalanceWhenRequiredZero(t *testing.T) { + network := tronNetwork(defaultPolicy()) + wallet := &model.ManagedWallet{} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("0"), tronMoney("5")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.Equal(t, "15000000", topUp.GetAmount()) + require.Equal(t, "15", decision.TopUpTRX.String()) +} + +func TestComputeGasTopUp_ContractPolicyOverride(t *testing.T) { + policy := shared.GasTopUpPolicy{ + Default: shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0.1), + MinNativeBalance: decimal.NewFromFloat(10), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(100), + }, + Contract: &shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0.5), + MinNativeBalance: decimal.NewFromFloat(5), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(100), + }, + } + network := tronNetwork(&policy) + wallet := &model.ManagedWallet{ContractAddress: "0xcontract"} + + topUp, decision, err := ComputeGasTopUp(network, wallet, tronMoney("10"), tronMoney("0")) + require.NoError(t, err) + require.NotNil(t, topUp) + require.Equal(t, "15000000", topUp.GetAmount()) + require.Equal(t, "15", decision.TopUpTRX.String()) + require.Equal(t, "trc20", decision.OperationType) +} + +func defaultPolicy() *shared.GasTopUpPolicy { + return &shared.GasTopUpPolicy{ + Default: shared.GasTopUpRule{ + BufferPercent: decimal.NewFromFloat(0.15), + MinNativeBalance: decimal.NewFromFloat(20), + RoundingUnit: decimal.NewFromFloat(1), + MaxTopUp: decimal.NewFromFloat(500), + }, + } +} + +func tronNetwork(policy *shared.GasTopUpPolicy) shared.Network { + return shared.Network{ + Name: "tron_mainnet", + NativeToken: "TRX", + GasTopUpPolicy: policy, + } +} + +func tronMoney(trx string) *moneyv1.Money { + value, _ := decimal.NewFromString(trx) + baseUnits := value.Mul(tronBaseUnitFactor).Truncate(0) + return &moneyv1.Money{ + Currency: "TRX", + Amount: baseUnits.StringFixed(0), + } +} diff --git a/api/gateway/tron/internal/service/gateway/driver/tron/transfer.go b/api/gateway/tron/internal/service/gateway/driver/tron/transfer.go new file mode 100644 index 00000000..97b19f27 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/tron/transfer.go @@ -0,0 +1,308 @@ +package tron + +import ( + "context" + "encoding/hex" + "math/big" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/tron/internal/keymanager" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +const ( + // Default fee limit for TRC20 transfers (100 TRX in SUN) + defaultTRC20FeeLimit = 100_000_000 + // SUN per TRX + sunPerTRX = 1_000_000 +) + +// SubmitTransferNative submits a transfer using native TRON gRPC. +func SubmitTransferNative( + ctx context.Context, + logger mlogger.Logger, + tronClient *tronclient.Client, + keyManager keymanager.Manager, + transfer *model.Transfer, + source *model.ManagedWallet, + destination string, +) (string, error) { + if tronClient == nil { + return "", merrors.Internal("tron driver: tron client not initialized") + } + if keyManager == nil { + return "", merrors.Internal("tron driver: key manager not configured") + } + if transfer == nil || source == nil { + return "", merrors.InvalidArgument("tron driver: transfer context missing") + } + if strings.TrimSpace(source.KeyReference) == "" { + logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef)) + return "", merrors.InvalidArgument("tron driver: source wallet missing key reference") + } + + rawFrom := strings.TrimSpace(source.DepositAddress) + rawTo := strings.TrimSpace(destination) + if rawFrom == "" || rawTo == "" { + return "", merrors.InvalidArgument("tron driver: invalid addresses") + } + + fromAddr, err := normalizeAddress(rawFrom) + if err != nil { + logger.Warn("Invalid TRON source address", zap.String("wallet_ref", source.WalletRef), zap.Error(err)) + return "", err + } + toAddr, err := normalizeAddress(rawTo) + if err != nil { + logger.Warn("Invalid TRON destination address", zap.String("transfer_ref", transfer.TransferRef), zap.Error(err)) + return "", err + } + + amount := transfer.NetAmount + if amount == nil || strings.TrimSpace(amount.Amount) == "" { + logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef)) + return "", merrors.InvalidArgument("tron driver: transfer missing net amount") + } + + contract := strings.TrimSpace(transfer.ContractAddress) + if contract != "" { + normalizedContract, err := normalizeAddress(contract) + if err != nil { + logger.Warn("Invalid TRON contract address", zap.String("transfer_ref", transfer.TransferRef), zap.Error(err)) + return "", err + } + contract = normalizedContract + } + + logger.Info("Submitting native TRON transfer", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("from", fromAddr), + zap.String("to", toAddr), + zap.String("contract", contract), + zap.String("amount", amount.Amount), + ) + + var txID string + + if contract == "" { + // Native TRX transfer + txID, err = submitTRXTransfer(ctx, logger, tronClient, keyManager, source, fromAddr, toAddr, amount.Amount) + } else { + // TRC20 token transfer + txID, err = submitTRC20Transfer(ctx, logger, tronClient, keyManager, source, fromAddr, toAddr, contract, amount.Amount) + } + + if err != nil { + logger.Warn("Native TRON transfer failed", + zap.String("transfer_ref", transfer.TransferRef), + zap.Error(err), + ) + return "", err + } + + logger.Info("Native TRON transfer submitted", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("tx_id", txID), + ) + + return txID, nil +} + +func submitTRXTransfer( + ctx context.Context, + logger mlogger.Logger, + tronClient *tronclient.Client, + keyManager keymanager.Manager, + source *model.ManagedWallet, + from, to, amountStr string, +) (string, error) { + // Parse amount (should be in base units = SUN for TRX) + amountSun, err := parseAmountToSun(amountStr) + if err != nil { + logger.Warn("Failed to parse TRX amount", zap.String("amount", amountStr), zap.Error(err)) + return "", err + } + + logger.Debug("Creating TRX transfer transaction", + zap.String("from", from), + zap.String("to", to), + zap.Int64("amount_sun", amountSun), + ) + + // Create unsigned transaction + txExt, err := tronClient.Transfer(from, to, amountSun) + if err != nil { + logger.Warn("Failed to create TRX transfer transaction", zap.Error(err)) + return "", merrors.Internal("tron driver: failed to create transfer: " + err.Error()) + } + + if txExt == nil || txExt.Transaction == nil { + return "", merrors.Internal("tron driver: nil transaction returned") + } + + // Sign transaction + signedTx, err := keyManager.SignTronTransaction(ctx, source.KeyReference, txExt.Transaction) + if err != nil { + logger.Warn("Failed to sign TRX transfer", zap.Error(err)) + return "", err + } + + // Broadcast transaction + result, err := tronClient.Broadcast(signedTx) + if err != nil { + logger.Warn("Failed to broadcast TRX transfer", zap.Error(err)) + return "", merrors.Internal("tron driver: broadcast failed: " + err.Error()) + } + + if result != nil && !result.GetResult() { + msg := string(result.GetMessage()) + logger.Warn("TRX transfer broadcast rejected", zap.String("message", msg)) + return "", merrors.Internal("tron driver: broadcast rejected: " + msg) + } + + return tronclient.TxIDFromExtention(txExt), nil +} + +func submitTRC20Transfer( + ctx context.Context, + logger mlogger.Logger, + tronClient *tronclient.Client, + keyManager keymanager.Manager, + source *model.ManagedWallet, + from, to, contract, amountStr string, +) (string, error) { + decimals, err := tronClient.TRC20GetDecimals(contract) + if err != nil { + logger.Warn("Failed to read TRC20 decimals", zap.String("contract", contract), zap.Error(err)) + return "", err + } + + amount, err := toBaseUnits(amountStr, decimals) + if err != nil { + logger.Warn("Failed to convert TRC20 amount to base units", + zap.String("amount", amountStr), + zap.String("decimals", decimals.String()), + zap.Error(err), + ) + return "", err + } + + logger.Debug("Creating TRC20 transfer transaction", + zap.String("from", from), + zap.String("to", to), + zap.String("contract", contract), + zap.String("amount_raw", amountStr), + zap.String("amount_base", amount.String()), + zap.String("decimals", decimals.String()), + ) + + // Create unsigned transaction + txExt, err := tronClient.TRC20Send(from, to, contract, amount, defaultTRC20FeeLimit) + if err != nil { + logger.Warn("Failed to create TRC20 transfer transaction", zap.Error(err)) + return "", merrors.Internal("tron driver: failed to create TRC20 transfer: " + err.Error()) + } + + if txExt == nil || txExt.Transaction == nil { + return "", merrors.Internal("tron driver: nil transaction returned") + } + + // Sign transaction + signedTx, err := keyManager.SignTronTransaction(ctx, source.KeyReference, txExt.Transaction) + if err != nil { + logger.Warn("Failed to sign TRC20 transfer", zap.Error(err)) + return "", err + } + + // Broadcast transaction + result, err := tronClient.Broadcast(signedTx) + if err != nil { + logger.Warn("Failed to broadcast TRC20 transfer", zap.Error(err)) + return "", merrors.Internal("tron driver: broadcast failed: " + err.Error()) + } + + if result != nil && !result.GetResult() { + msg := string(result.GetMessage()) + logger.Warn("TRC20 transfer broadcast rejected", zap.String("message", msg)) + return "", merrors.Internal("tron driver: broadcast rejected: " + msg) + } + + return tronclient.TxIDFromExtention(txExt), nil +} + +func parseAmountToSun(amountStr string) (int64, error) { + trimmed := strings.TrimSpace(amountStr) + if trimmed == "" { + return 0, merrors.InvalidArgument("tron driver: amount is required") + } + + // Amount is already in base units (SUN) + value, ok := new(big.Int).SetString(trimmed, 10) + if !ok { + return 0, merrors.InvalidArgument("tron driver: invalid amount: " + trimmed) + } + + if value.Sign() < 0 { + return 0, merrors.InvalidArgument("tron driver: amount must be non-negative") + } + + if !value.IsInt64() { + return 0, merrors.InvalidArgument("tron driver: amount exceeds int64 range") + } + + return value.Int64(), nil +} + +func toBaseUnits(amountStr string, decimals *big.Int) (*big.Int, error) { + trimmed := strings.TrimSpace(amountStr) + if trimmed == "" { + return nil, merrors.InvalidArgument("tron driver: amount is required") + } + if decimals == nil { + return nil, merrors.InvalidArgument("tron driver: token decimals missing") + } + if decimals.Sign() < 0 { + return nil, merrors.InvalidArgument("tron driver: token decimals must be non-negative") + } + if decimals.BitLen() > 31 { + return nil, merrors.InvalidArgument("tron driver: token decimals out of range") + } + + value, err := decimal.NewFromString(trimmed) + if err != nil { + return nil, merrors.InvalidArgument("tron driver: invalid amount: " + trimmed) + } + if value.IsNegative() { + return nil, merrors.InvalidArgument("tron driver: amount must be non-negative") + } + + shift := int32(decimals.Int64()) + multiplier := decimal.NewFromInt(1).Shift(shift) + scaled := value.Mul(multiplier) + if !scaled.Equal(scaled.Truncate(0)) { + return nil, merrors.InvalidArgument("tron driver: amount " + trimmed + " exceeds token precision") + } + + return scaled.BigInt(), nil +} + +// normalizeTxHash ensures consistent tx hash format (lowercase hex without 0x prefix). +func normalizeTxHash(txHash string) string { + h := strings.TrimSpace(txHash) + h = strings.TrimPrefix(h, "0x") + h = strings.TrimPrefix(h, "0X") + return strings.ToLower(h) +} + +// txHashToHex converts a byte slice transaction ID to hex string. +func txHashToHex(txID []byte) string { + if len(txID) == 0 { + return "" + } + return hex.EncodeToString(txID) +} diff --git a/api/gateway/tron/internal/service/gateway/driver/tron/transfer_test.go b/api/gateway/tron/internal/service/gateway/driver/tron/transfer_test.go new file mode 100644 index 00000000..d4b3e535 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/driver/tron/transfer_test.go @@ -0,0 +1,67 @@ +package tron + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestToBaseUnits(t *testing.T) { + tests := []struct { + name string + amount string + decimals *big.Int + want string + expectErr bool + }{ + { + name: "fractional amount", + amount: "0.7", + decimals: big.NewInt(6), + want: "700000", + }, + { + name: "decimal amount", + amount: "9.3", + decimals: big.NewInt(6), + want: "9300000", + }, + { + name: "integer amount", + amount: "12", + decimals: big.NewInt(6), + want: "12000000", + }, + { + name: "lowest unit", + amount: "0.000001", + decimals: big.NewInt(6), + want: "1", + }, + { + name: "exceeds precision", + amount: "0.0000001", + decimals: big.NewInt(6), + expectErr: true, + }, + { + name: "missing decimals", + amount: "1", + decimals: nil, + expectErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := toBaseUnits(test.amount, test.decimals) + if test.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, test.want, result.String()) + }) + } +} diff --git a/api/gateway/tron/internal/service/gateway/drivers/registry.go b/api/gateway/tron/internal/service/gateway/drivers/registry.go new file mode 100644 index 00000000..51debd85 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/drivers/registry.go @@ -0,0 +1,68 @@ +package drivers + +import ( + "fmt" + "strings" + + "github.com/tech/sendico/gateway/tron/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/driver/tron" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +// Registry maps configured network keys to chain drivers. +type Registry struct { + byNetwork map[string]driver.Driver +} + +// NewRegistry selects drivers for the configured networks. +func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, error) { + if logger == nil { + return nil, merrors.InvalidArgument("driver registry: logger is required") + } + result := &Registry{byNetwork: map[string]driver.Driver{}} + for _, network := range networks { + if !network.Name.IsValid() { + continue + } + name := network.Name.String() + chainDriver, err := resolveDriver(logger, name) + if err != nil { + logger.Error("Unsupported chain driver", zap.String("network", name), zap.Error(err)) + return nil, err + } + result.byNetwork[name] = chainDriver + } + if len(result.byNetwork) == 0 { + return nil, merrors.InvalidArgument("driver registry: no supported networks configured") + } + logger.Info("Chain drivers configured", zap.Int("count", len(result.byNetwork))) + return result, nil +} + +// Driver resolves a driver for the provided network key. +func (r *Registry) Driver(network string) (driver.Driver, error) { + if r == nil || len(r.byNetwork) == 0 { + return nil, merrors.Internal("driver registry is not configured") + } + key := strings.ToLower(strings.TrimSpace(network)) + if key == "" { + return nil, merrors.InvalidArgument("network is required") + } + chainDriver, ok := r.byNetwork[key] + if !ok { + return nil, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", key)) + } + return chainDriver, nil +} + +func resolveDriver(logger mlogger.Logger, network string) (driver.Driver, error) { + switch { + case strings.HasPrefix(network, "tron"): + return tron.New(logger), nil + default: + return nil, merrors.InvalidArgument("tron gateway only supports tron networks, got " + network) + } +} diff --git a/api/gateway/tron/internal/service/gateway/executor.go b/api/gateway/tron/internal/service/gateway/executor.go new file mode 100644 index 00000000..135e8c3a --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/executor.go @@ -0,0 +1,349 @@ +package gateway + +import ( + "context" + "errors" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/rpc" + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "go.uber.org/zap" + + "github.com/tech/sendico/gateway/tron/internal/keymanager" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" +) + +// TransferExecutor handles on-chain submission of transfers. +type TransferExecutor interface { + SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) + AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) +} + +// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain. +func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager, clients *rpcclient.Clients) TransferExecutor { + return &onChainExecutor{ + logger: logger.Named("executor"), + keyManager: keyManager, + clients: clients, + } +} + +type onChainExecutor struct { + logger mlogger.Logger + keyManager keymanager.Manager + + clients *rpcclient.Clients +} + +func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network shared.Network) (string, error) { + if o.keyManager == nil { + o.logger.Warn("Key manager not configured") + return "", executorInternal("key manager is not configured", nil) + } + rpcURL := strings.TrimSpace(network.RPCURL) + if rpcURL == "" { + o.logger.Warn("Network rpc url missing", zap.String("network", network.Name.String())) + return "", executorInvalid("network rpc url is not configured") + } + if source == nil || transfer == nil { + o.logger.Warn("Transfer context missing") + return "", executorInvalid("transfer context missing") + } + if strings.TrimSpace(source.KeyReference) == "" { + o.logger.Warn("Source wallet missing key reference", zap.String("wallet_ref", source.WalletRef)) + return "", executorInvalid("source wallet missing key reference") + } + if strings.TrimSpace(source.DepositAddress) == "" { + o.logger.Warn("Source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef)) + return "", executorInvalid("source wallet missing deposit address") + } + if !common.IsHexAddress(destinationAddress) { + o.logger.Warn("Invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress)) + return "", executorInvalid("invalid destination address " + destinationAddress) + } + + o.logger.Info("Submitting transfer", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("source_wallet_ref", source.WalletRef), + zap.String("network", network.Name.String()), + zap.String("destination", strings.ToLower(destinationAddress)), + ) + + client, err := o.clients.Client(network.Name.String()) + if err != nil { + o.logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name.String())) + return "", err + } + rpcClient, err := o.clients.RPCClient(network.Name.String()) + if err != nil { + o.logger.Warn("Failed to initialise RPC client", + zap.String("network", network.Name.String()), + zap.Error(err), + ) + return "", err + } + + sourceAddress := common.HexToAddress(source.DepositAddress) + destination := common.HexToAddress(destinationAddress) + + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + nonce, err := client.PendingNonceAt(ctx, sourceAddress) + if err != nil { + o.logger.Warn("Failed to fetch nonce", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + zap.String("wallet_ref", source.WalletRef), + ) + return "", executorInternal("failed to fetch nonce", err) + } + + gasPrice, err := client.SuggestGasPrice(ctx) + if err != nil { + o.logger.Warn("Failed to suggest gas price", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("network", network.Name.String()), + zap.Error(err), + ) + return "", executorInternal("failed to suggest gas price", err) + } + + var tx *types.Transaction + var txHash string + + chainID := new(big.Int).SetUint64(network.ChainID) + + if strings.TrimSpace(transfer.ContractAddress) == "" { + o.logger.Warn("Native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef)) + return "", merrors.NotImplemented("executor: native token transfers not yet supported") + } + + if !common.IsHexAddress(transfer.ContractAddress) { + o.logger.Warn("Invalid token contract address", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("contract", transfer.ContractAddress), + ) + return "", executorInvalid("invalid token contract address " + transfer.ContractAddress) + } + tokenAddress := common.HexToAddress(transfer.ContractAddress) + + decimals, err := erc20Decimals(ctx, rpcClient, tokenAddress) + if err != nil { + o.logger.Warn("Failed to read token decimals", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + zap.String("contract", transfer.ContractAddress), + ) + return "", err + } + + amount := transfer.NetAmount + if amount == nil || strings.TrimSpace(amount.Amount) == "" { + o.logger.Warn("Transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef)) + return "", executorInvalid("transfer missing net amount") + } + amountInt, err := toBaseUnits(amount.Amount, decimals) + if err != nil { + o.logger.Warn("Failed to convert amount to base units", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + zap.String("amount", amount.Amount), + ) + return "", err + } + + input, err := erc20ABI.Pack("transfer", destination, amountInt) + if err != nil { + o.logger.Warn("Failed to encode transfer call", + zap.String("transfer_ref", transfer.TransferRef), + zap.Error(err), + ) + return "", executorInternal("failed to encode transfer call", err) + } + + callMsg := ethereum.CallMsg{ + From: sourceAddress, + To: &tokenAddress, + GasPrice: gasPrice, + Data: input, + } + gasLimit, err := client.EstimateGas(ctx, callMsg) + if err != nil { + o.logger.Warn("Failed to estimate gas", + zap.String("transfer_ref", transfer.TransferRef), + zap.Error(err), + ) + return "", executorInternal("failed to estimate gas", err) + } + + tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input) + + signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID) + if err != nil { + o.logger.Warn("Failed to sign transaction", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + zap.String("wallet_ref", source.WalletRef), + ) + return "", err + } + + if err := client.SendTransaction(ctx, signedTx); err != nil { + o.logger.Warn("Failed to send transaction", zap.Error(err), + zap.String("transfer_ref", transfer.TransferRef), + ) + return "", executorInternal("failed to send transaction", err) + } + + txHash = signedTx.Hash().Hex() + o.logger.Info("Transaction submitted", + zap.String("transfer_ref", transfer.TransferRef), + zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + ) + + return txHash, nil +} + +func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) { + if strings.TrimSpace(txHash) == "" { + o.logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name.String())) + return nil, executorInvalid("tx hash is required") + } + rpcURL := strings.TrimSpace(network.RPCURL) + if rpcURL == "" { + o.logger.Warn("Network RPC url missing while awaiting confirmation", zap.String("tx_hash", txHash)) + return nil, executorInvalid("network rpc url is not configured") + } + + client, err := o.clients.Client(network.Name.String()) + if err != nil { + return nil, err + } + + hash := common.HexToHash(txHash) + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + for { + receipt, err := client.TransactionReceipt(ctx, hash) + if err != nil { + if errors.Is(err, ethereum.NotFound) { + select { + case <-ticker.C: + o.logger.Debug("Transaction not yet mined", + zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + ) + continue + case <-ctx.Done(): + o.logger.Warn("Context cancelled while awaiting confirmation", + zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + ) + return nil, ctx.Err() + } + } + o.logger.Warn("Failed to fetch transaction receipt", + zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + zap.Error(err), + ) + return nil, executorInternal("failed to fetch transaction receipt", err) + } + o.logger.Info("Transaction confirmed", + zap.String("tx_hash", txHash), + zap.String("network", network.Name.String()), + zap.Uint64("block_number", receipt.BlockNumber.Uint64()), + zap.Uint64("status", receipt.Status), + ) + return receipt, nil + } +} + +var ( + erc20ABI abi.ABI +) + +func init() { + var err error + erc20ABI, err = abi.JSON(strings.NewReader(erc20ABIJSON)) + if err != nil { + panic("executor: failed to parse erc20 abi: " + err.Error()) + } +} + +const erc20ABIJSON = ` +[ + { + "constant": false, + "inputs": [ + { "name": "_to", "type": "address" }, + { "name": "_value", "type": "uint256" } + ], + "name": "transfer", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "decimals", + "outputs": [{ "name": "", "type": "uint8" }], + "payable": false, + "stateMutability": "view", + "type": "function" + } +]` + +func erc20Decimals(ctx context.Context, client *rpc.Client, token common.Address) (uint8, error) { + call := map[string]string{ + "to": strings.ToLower(token.Hex()), + "data": "0x313ce567", + } + var hexResp string + if err := client.CallContext(ctx, &hexResp, "eth_call", call, "latest"); err != nil { + return 0, executorInternal("decimals call failed", err) + } + val, err := shared.DecodeHexUint8(hexResp) + if err != nil { + return 0, executorInternal("decimals decode failed", err) + } + return val, nil +} + +func toBaseUnits(amount string, decimals uint8) (*big.Int, error) { + value, err := decimal.NewFromString(strings.TrimSpace(amount)) + if err != nil { + return nil, executorInvalid("invalid amount " + amount + ": " + err.Error()) + } + if value.IsNegative() { + return nil, executorInvalid("amount must be positive") + } + multiplier := decimal.NewFromInt(1).Shift(int32(decimals)) + scaled := value.Mul(multiplier) + if !scaled.Equal(scaled.Truncate(0)) { + return nil, executorInvalid("amount " + amount + " exceeds token precision") + } + return scaled.BigInt(), nil +} + +func executorInvalid(msg string) error { + return merrors.InvalidArgument("executor: " + msg) +} + +func executorInternal(msg string, err error) error { + if err != nil { + msg = msg + ": " + err.Error() + } + return merrors.Internal("executor: " + msg) +} diff --git a/api/gateway/tron/internal/service/gateway/metrics.go b/api/gateway/tron/internal/service/gateway/metrics.go new file mode 100644 index 00000000..1f9aced1 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/metrics.go @@ -0,0 +1,65 @@ +package gateway + +import ( + "errors" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/tech/sendico/pkg/merrors" +) + +var ( + metricsOnce sync.Once + + rpcLatency *prometheus.HistogramVec + rpcStatus *prometheus.CounterVec +) + +func initMetrics() { + metricsOnce.Do(func() { + rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "sendico", + Subsystem: "chain_gateway", + Name: "rpc_latency_seconds", + Help: "Latency distribution for chain gateway RPC handlers.", + Buckets: prometheus.DefBuckets, + }, []string{"method"}) + + rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "chain_gateway", + Name: "rpc_requests_total", + Help: "Total number of RPC invocations grouped by method and status.", + }, []string{"method", "status"}) + }) +} + +func observeRPC(method string, err error, duration time.Duration) { + if rpcLatency != nil { + rpcLatency.WithLabelValues(method).Observe(duration.Seconds()) + } + if rpcStatus != nil { + rpcStatus.WithLabelValues(method, statusLabel(err)).Inc() + } +} + +func statusLabel(err error) string { + switch { + case err == nil: + return "ok" + case errors.Is(err, merrors.ErrInvalidArg): + return "invalid_argument" + case errors.Is(err, merrors.ErrNoData): + return "not_found" + case errors.Is(err, merrors.ErrDataConflict): + return "conflict" + case errors.Is(err, merrors.ErrAccessDenied): + return "denied" + case errors.Is(err, merrors.ErrInternal): + return "internal" + default: + return "error" + } +} diff --git a/api/gateway/tron/internal/service/gateway/options.go b/api/gateway/tron/internal/service/gateway/options.go new file mode 100644 index 00000000..d49ee38b --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/options.go @@ -0,0 +1,99 @@ +package gateway + +import ( + "strings" + + "github.com/tech/sendico/gateway/tron/internal/keymanager" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/drivers" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient" + clockpkg "github.com/tech/sendico/pkg/clock" +) + +// Option configures the Service. +type Option func(*Service) + +// WithKeyManager configures the service key manager. +func WithKeyManager(manager keymanager.Manager) Option { + return func(s *Service) { + s.keyManager = manager + } +} + +// WithRPCClients configures pre-initialised RPC clients. +func WithRPCClients(clients *rpcclient.Clients) Option { + return func(s *Service) { + s.rpcClients = clients + } +} + +// WithTronClients configures native TRON gRPC clients. +func WithTronClients(clients *tronclient.Registry) Option { + return func(s *Service) { + s.tronClients = clients + } +} + +// WithNetworks configures supported blockchain networks. +func WithNetworks(networks []shared.Network) Option { + return func(s *Service) { + if len(networks) == 0 { + return + } + if s.networks == nil { + s.networks = make(map[string]shared.Network, len(networks)) + } + for _, network := range networks { + if !network.Name.IsValid() { + continue + } + clone := network + if clone.TokenConfigs == nil { + clone.TokenConfigs = []shared.TokenContract{} + } + for i := range clone.TokenConfigs { + clone.TokenConfigs[i].Symbol = strings.ToUpper(strings.TrimSpace(clone.TokenConfigs[i].Symbol)) + clone.TokenConfigs[i].ContractAddress = strings.ToLower(strings.TrimSpace(clone.TokenConfigs[i].ContractAddress)) + } + s.networks[clone.Name.String()] = clone + } + } +} + +// WithServiceWallet configures the service wallet binding. +func WithServiceWallet(wallet shared.ServiceWallet) Option { + return func(s *Service) { + s.serviceWallet = wallet + } +} + +// WithDriverRegistry configures the chain driver registry. +func WithDriverRegistry(registry *drivers.Registry) Option { + return func(s *Service) { + s.drivers = registry + } +} + +// WithClock overrides the service clock. +func WithClock(clk clockpkg.Clock) Option { + return func(s *Service) { + if clk != nil { + s.clock = clk + } + } +} + +// WithSettings applies gateway settings. +func WithSettings(settings CacheSettings) Option { + return func(s *Service) { + s.settings = settings.withDefaults() + } +} + +// WithDiscoveryInvokeURI sets the invoke URI used when announcing the gateway. +func WithDiscoveryInvokeURI(invokeURI string) Option { + return func(s *Service) { + s.invokeURI = strings.TrimSpace(invokeURI) + } +} diff --git a/api/gateway/tron/internal/service/gateway/rpcclient/clients.go b/api/gateway/tron/internal/service/gateway/rpcclient/clients.go new file mode 100644 index 00000000..724d7b3d --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/rpcclient/clients.go @@ -0,0 +1,204 @@ +package rpcclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +// Clients holds pre-initialised RPC clients keyed by network name. +type Clients struct { + logger mlogger.Logger + clients map[string]clientEntry +} + +type clientEntry struct { + eth *ethclient.Client + rpc *rpc.Client +} + +// Prepare dials all configured networks up front and returns a ready-to-use client set. +func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Network) (*Clients, error) { + if logger == nil { + return nil, merrors.Internal("rpc clients: logger is required") + } + clientLogger := logger.Named("rpc_client") + result := &Clients{ + logger: clientLogger, + clients: make(map[string]clientEntry), + } + + for _, network := range networks { + name := network.Name.String() + rpcURL := strings.TrimSpace(network.RPCURL) + if !network.Name.IsValid() { + clientLogger.Warn("Skipping network with invalid name during rpc client preparation") + continue + } + if rpcURL == "" { + result.Close() + err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", name)) + clientLogger.Warn("Rpc url missing", zap.String("network", name)) + return nil, err + } + + fields := []zap.Field{ + zap.String("network", name), + } + clientLogger.Info("Initialising rpc client", fields...) + + dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + httpClient := &http.Client{ + Transport: &loggingRoundTripper{ + logger: clientLogger, + network: name, + endpoint: rpcURL, + base: http.DefaultTransport, + }, + } + rpcCli, err := rpc.DialOptions(dialCtx, rpcURL, rpc.WithHTTPClient(httpClient)) + cancel() + if err != nil { + result.Close() + clientLogger.Warn("Failed to dial rpc endpoint", append(fields, zap.Error(err))...) + return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error())) + } + client := ethclient.NewClient(rpcCli) + result.clients[name] = clientEntry{ + eth: client, + rpc: rpcCli, + } + clientLogger.Info("RPC client ready", fields...) + } + + if len(result.clients) == 0 { + clientLogger.Warn("No rpc clients were initialised") + return nil, merrors.InvalidArgument("no rpc clients initialised") + } else { + clientLogger.Info("RPC clients initialised", zap.Int("count", len(result.clients))) + } + + return result, nil +} + +// Client returns a prepared client for the given network name. +func (c *Clients) Client(network string) (*ethclient.Client, error) { + if c == nil { + return nil, merrors.Internal("RPC clients not initialised") + } + name := strings.ToLower(strings.TrimSpace(network)) + entry, ok := c.clients[name] + if !ok || entry.eth == nil { + return nil, merrors.InvalidArgument(fmt.Sprintf("RPC client not configured for network %s", name)) + } + return entry.eth, nil +} + +// RPCClient returns the raw RPC client for low-level calls. +func (c *Clients) RPCClient(network string) (*rpc.Client, error) { + if c == nil { + return nil, merrors.Internal("rpc clients not initialised") + } + name := strings.ToLower(strings.TrimSpace(network)) + entry, ok := c.clients[name] + if !ok || entry.rpc == nil { + return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name)) + } + return entry.rpc, nil +} + +// Close tears down all RPC clients, logging each close. +func (c *Clients) Close() { + if c == nil { + return + } + for name, entry := range c.clients { + if entry.rpc != nil { + entry.rpc.Close() + } else if entry.eth != nil { + entry.eth.Close() + } + if c.logger != nil { + c.logger.Info("RPC client closed", zap.String("network", name)) + } + } +} + +type loggingRoundTripper struct { + logger mlogger.Logger + network string + endpoint string + base http.RoundTripper +} + +func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if l.base == nil { + l.base = http.DefaultTransport + } + + var reqBody []byte + if req.Body != nil { + raw, _ := io.ReadAll(req.Body) + reqBody = raw + req.Body = io.NopCloser(strings.NewReader(string(raw))) + } + + fields := []zap.Field{ + zap.String("network", l.network), + } + if len(reqBody) > 0 { + fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048))) + } + l.logger.Debug("RPC request", fields...) + + resp, err := l.base.RoundTrip(req) + if err != nil { + l.logger.Warn("RPC http request failed", append(fields, zap.Error(err))...) + return nil, err + } + + bodyBytes, _ := io.ReadAll(resp.Body) + resp.Body.Close() + resp.Body = io.NopCloser(strings.NewReader(string(bodyBytes))) + + respFields := append(fields, + zap.Int("status_code", resp.StatusCode), + ) + if contentType := strings.TrimSpace(resp.Header.Get("Content-Type")); contentType != "" { + respFields = append(respFields, zap.String("content_type", contentType)) + } + if len(bodyBytes) > 0 { + respFields = append(respFields, zap.String("rpc_response", truncate(string(bodyBytes), 2048))) + } + l.logger.Debug("RPC response", respFields...) + if resp.StatusCode >= 400 { + l.logger.Warn("RPC response error", respFields...) + } else if len(bodyBytes) == 0 { + l.logger.Warn("RPC response empty body", respFields...) + } else if len(bodyBytes) > 0 && !json.Valid(bodyBytes) { + l.logger.Warn("RPC response invalid JSON", respFields...) + } + + return resp, nil +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 3 { + return s[:max] + } + return s[:max-3] + "..." +} diff --git a/api/gateway/tron/internal/service/gateway/rpcclient/registry.go b/api/gateway/tron/internal/service/gateway/rpcclient/registry.go new file mode 100644 index 00000000..98317d5f --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/rpcclient/registry.go @@ -0,0 +1,54 @@ +package rpcclient + +import ( + "strings" + + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/pkg/merrors" +) + +// Registry binds static network metadata with prepared RPC clients. +type Registry struct { + networks map[string]shared.Network + clients *Clients +} + +// NewRegistry constructs a registry keyed by lower-cased network name. +func NewRegistry(networks map[string]shared.Network, clients *Clients) *Registry { + return &Registry{ + networks: networks, + clients: clients, + } +} + +// Network fetches network metadata by key (case-insensitive). +func (r *Registry) Network(key string) (shared.Network, bool) { + if r == nil || len(r.networks) == 0 { + return shared.Network{}, false + } + n, ok := r.networks[strings.ToLower(strings.TrimSpace(key))] + return n, ok +} + +// Client returns the prepared RPC client for the given network name. +func (r *Registry) Client(key string) (*ethclient.Client, error) { + if r == nil || r.clients == nil { + return nil, merrors.Internal("rpc clients not initialised") + } + return r.clients.Client(strings.ToLower(strings.TrimSpace(key))) +} + +// RPCClient returns the raw RPC client for low-level calls. +func (r *Registry) RPCClient(key string) (*rpc.Client, error) { + if r == nil || r.clients == nil { + return nil, merrors.Internal("rpc clients not initialised") + } + return r.clients.RPCClient(strings.ToLower(strings.TrimSpace(key))) +} + +// Networks exposes the registry map for iteration when needed. +func (r *Registry) Networks() map[string]shared.Network { + return r.networks +} diff --git a/api/gateway/tron/internal/service/gateway/service.go b/api/gateway/tron/internal/service/gateway/service.go new file mode 100644 index 00000000..42d0d01a --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/service.go @@ -0,0 +1,225 @@ +package gateway + +import ( + "context" + + "github.com/tech/sendico/gateway/tron/internal/appversion" + "github.com/tech/sendico/gateway/tron/internal/keymanager" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/commands" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/commands/transfer" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/commands/wallet" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/drivers" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/rpcclient" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/tronclient" + "github.com/tech/sendico/gateway/tron/storage" + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/discovery" + 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" + "google.golang.org/grpc" +) + +type serviceError string + +func (e serviceError) Error() string { + return string(e) +} + +var ( + errStorageUnavailable = serviceError("chain_gateway: storage not initialised") +) + +// Service implements the ConnectorService RPC contract for chain operations. +type Service struct { + logger mlogger.Logger + storage storage.Repository + producer msg.Producer + clock clockpkg.Clock + + settings CacheSettings + + networks map[string]shared.Network + serviceWallet shared.ServiceWallet + keyManager keymanager.Manager + rpcClients *rpcclient.Clients + tronClients *tronclient.Registry // Native TRON gRPC clients + networkRegistry *rpcclient.Registry + drivers *drivers.Registry + commands commands.Registry + announcers []*discovery.Announcer + invokeURI string + + connectorv1.UnimplementedConnectorServiceServer +} + +// NewService constructs the chain gateway service skeleton. +func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service { + svc := &Service{ + logger: logger.Named("service"), + storage: repo, + producer: producer, + clock: clockpkg.System{}, + settings: defaultSettings(), + networks: map[string]shared.Network{}, + } + + initMetrics() + + for _, opt := range opts { + if opt != nil { + opt(svc) + } + } + + if svc.clock == nil { + svc.clock = clockpkg.System{} + } + if svc.networks == nil { + svc.networks = map[string]shared.Network{} + } + svc.settings = svc.settings.withDefaults() + svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients) + + svc.commands = commands.NewRegistry(commands.RegistryDeps{ + Wallet: commandsWalletDeps(svc), + Transfer: commandsTransferDeps(svc), + }) + svc.startDiscoveryAnnouncers() + + return svc +} + +// Register wires the service onto the provided gRPC router. +func (s *Service) Register(router routers.GRPC) error { + return router.Register(func(reg grpc.ServiceRegistrar) { + connectorv1.RegisterConnectorServiceServer(reg, s) + }) +} + +func (s *Service) Shutdown() { + if s == nil { + return + } + for _, announcer := range s.announcers { + if announcer != nil { + announcer.Stop() + } + } +} + +func (s *Service) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) { + return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req) +} + +func (s *Service) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) { + return executeUnary(ctx, s, "GetManagedWallet", s.commands.GetManagedWallet.Execute, req) +} + +func (s *Service) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) { + return executeUnary(ctx, s, "ListManagedWallets", s.commands.ListManagedWallets.Execute, req) +} + +func (s *Service) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) { + return executeUnary(ctx, s, "GetWalletBalance", s.commands.GetWalletBalance.Execute, req) +} + +func (s *Service) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { + return executeUnary(ctx, s, "SubmitTransfer", s.commands.SubmitTransfer.Execute, req) +} + +func (s *Service) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) { + return executeUnary(ctx, s, "GetTransfer", s.commands.GetTransfer.Execute, req) +} + +func (s *Service) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) { + return executeUnary(ctx, s, "ListTransfers", s.commands.ListTransfers.Execute, req) +} + +func (s *Service) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) { + return executeUnary(ctx, s, "EstimateTransferFee", s.commands.EstimateTransfer.Execute, req) +} + +func (s *Service) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) { + return executeUnary(ctx, s, "ComputeGasTopUp", s.commands.ComputeGasTopUp.Execute, req) +} + +func (s *Service) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) { + return executeUnary(ctx, s, "EnsureGasTopUp", s.commands.EnsureGasTopUp.Execute, req) +} + +func (s *Service) ensureRepository(ctx context.Context) error { + if s.storage == nil { + return errStorageUnavailable + } + return s.storage.Ping(ctx) +} + +func commandsWalletDeps(s *Service) wallet.Deps { + return wallet.Deps{ + Logger: s.logger.Named("command"), + Drivers: s.drivers, + Networks: s.networkRegistry, + TronClients: s.tronClients, + KeyManager: s.keyManager, + Storage: s.storage, + Clock: s.clock, + BalanceCacheTTL: s.settings.walletBalanceCacheTTL(), + RPCTimeout: s.settings.rpcTimeout(), + EnsureRepository: s.ensureRepository, + } +} + +func commandsTransferDeps(s *Service) transfer.Deps { + return transfer.Deps{ + Logger: s.logger.Named("transfer_cmd"), + Drivers: s.drivers, + Networks: s.networkRegistry, + TronClients: s.tronClients, + KeyManager: s.keyManager, + Storage: s.storage, + Clock: s.clock, + RPCTimeout: s.settings.rpcTimeout(), + EnsureRepository: s.ensureRepository, + LaunchExecution: s.launchTransferExecution, + } +} + +func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) { + start := svc.clock.Now() + resp, err := gsresponse.Unary(svc.logger, mservice.ChainGateway, handler)(ctx, req) + observeRPC(method, err, svc.clock.Now().Sub(start)) + return resp, err +} + +func (s *Service) startDiscoveryAnnouncers() { + if s == nil || s.producer == nil || len(s.networks) == 0 { + return + } + version := appversion.Create().Short() + for _, network := range s.networks { + currencies := []string{shared.NativeCurrency(network)} + for _, token := range network.TokenConfigs { + if token.Symbol != "" { + currencies = append(currencies, token.Symbol) + } + } + announce := discovery.Announcement{ + Service: "CRYPTO_RAIL_GATEWAY", + Rail: "CRYPTO", + Network: network.Name.String(), + Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"}, + Currencies: currencies, + InvokeURI: s.invokeURI, + Version: version, + } + announcer := discovery.NewAnnouncer(s.logger, s.producer, string(mservice.ChainGateway), announce) + announcer.Start() + s.announcers = append(s.announcers, announcer) + } +} diff --git a/api/gateway/tron/internal/service/gateway/service_test.go b/api/gateway/tron/internal/service/gateway/service_test.go new file mode 100644 index 00000000..451d6294 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/service_test.go @@ -0,0 +1,664 @@ +package gateway + +import ( + "context" + "fmt" + "math/big" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + pmodel "github.com/tech/sendico/pkg/model" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + ichainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/tech/sendico/gateway/tron/internal/keymanager" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/drivers" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" + + "github.com/ethereum/go-ethereum/core/types" + troncore "github.com/fbsobreira/gotron-sdk/pkg/proto/core" +) + +const ( + walletDefaultLimit int64 = 50 + walletMaxLimit int64 = 200 + transferDefaultLimit int64 = 50 + transferMaxLimit int64 = 200 + depositDefaultLimit int64 = 100 + depositMaxLimit int64 = 500 +) + +func TestCreateManagedWallet_Idempotent(t *testing.T) { + svc, repo := newTestService(t) + + ctx := context.Background() + req := &ichainv1.CreateManagedWalletRequest{ + IdempotencyKey: "idem-1", + OrganizationRef: "org-1", + OwnerRef: "owner-1", + Asset: &ichainv1.Asset{ + Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, + TokenSymbol: "USDT", + }, + } + + resp, err := svc.CreateManagedWallet(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp.GetWallet()) + firstRef := resp.GetWallet().GetWalletRef() + require.NotEmpty(t, firstRef) + + resp2, err := svc.CreateManagedWallet(ctx, req) + require.NoError(t, err) + require.Equal(t, firstRef, resp2.GetWallet().GetWalletRef()) + + // ensure stored only once + require.Equal(t, 1, repo.wallets.count()) +} + +func TestCreateManagedWallet_NativeTokenWithoutContract(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + resp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{ + IdempotencyKey: "idem-native", + OrganizationRef: "org-1", + OwnerRef: "owner-1", + Asset: &ichainv1.Asset{ + Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, + TokenSymbol: "TRX", + }, + }) + require.NoError(t, err) + require.NotNil(t, resp.GetWallet()) + require.Equal(t, "TRX", resp.GetWallet().GetAsset().GetTokenSymbol()) + require.Empty(t, resp.GetWallet().GetAsset().GetContractAddress()) +} + +func TestListAccounts_OrganizationRefFilters(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + _, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{ + IdempotencyKey: "idem-org-1", + OrganizationRef: "org-1", + OwnerRef: "owner-1", + Asset: &ichainv1.Asset{ + Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, + TokenSymbol: "USDT", + }, + }) + require.NoError(t, err) + + _, err = svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{ + IdempotencyKey: "idem-org-2", + OrganizationRef: "org-2", + OwnerRef: "owner-2", + Asset: &ichainv1.Asset{ + Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, + TokenSymbol: "USDT", + }, + }) + require.NoError(t, err) + + resp, err := svc.ListAccounts(ctx, &connectorv1.ListAccountsRequest{ + OrganizationRef: "org-1", + Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET, + }) + require.NoError(t, err) + require.Len(t, resp.GetAccounts(), 1) + + details := resp.GetAccounts()[0].GetProviderDetails() + require.NotNil(t, details) + orgField := details.GetFields()["organization_ref"] + require.NotNil(t, orgField) + require.Equal(t, "org-1", orgField.GetStringValue()) +} + +func TestSubmitTransfer_ManagedDestination(t *testing.T) { + svc, repo := newTestService(t) + ctx := context.Background() + + // create source wallet + srcResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{ + IdempotencyKey: "idem-src", + OrganizationRef: "org-1", + OwnerRef: "owner-1", + Asset: &ichainv1.Asset{ + Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, + TokenSymbol: "USDT", + }, + }) + require.NoError(t, err) + srcRef := srcResp.GetWallet().GetWalletRef() + + // destination wallet + dstResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{ + IdempotencyKey: "idem-dst", + OrganizationRef: "org-1", + OwnerRef: "owner-2", + Asset: &ichainv1.Asset{ + Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, + TokenSymbol: "USDT", + }, + }) + require.NoError(t, err) + dstRef := dstResp.GetWallet().GetWalletRef() + + transferResp, err := svc.SubmitTransfer(ctx, &ichainv1.SubmitTransferRequest{ + IdempotencyKey: "transfer-1", + OrganizationRef: "org-1", + SourceWalletRef: srcRef, + Destination: &ichainv1.TransferDestination{ + Destination: &ichainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: dstRef}, + }, + Amount: &moneyv1.Money{Currency: "USDT", Amount: "100"}, + Fees: []*ichainv1.ServiceFeeBreakdown{ + { + FeeCode: "service", + Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"}, + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, transferResp.GetTransfer()) + require.Equal(t, "95", transferResp.GetTransfer().GetNetAmount().GetAmount()) + + stored := repo.transfers.get(transferResp.GetTransfer().GetTransferRef()) + require.NotNil(t, stored) + require.Equal(t, model.TransferStatusPending, stored.Status) + + // GetTransfer + getResp, err := svc.GetTransfer(ctx, &ichainv1.GetTransferRequest{TransferRef: stored.TransferRef}) + require.NoError(t, err) + require.Equal(t, stored.TransferRef, getResp.GetTransfer().GetTransferRef()) + + // ListTransfers + listResp, err := svc.ListTransfers(ctx, &ichainv1.ListTransfersRequest{ + SourceWalletRef: srcRef, + Page: &paginationv1.CursorPageRequest{Limit: 10}, + }) + require.NoError(t, err) + require.Len(t, listResp.GetTransfers(), 1) + require.Equal(t, stored.TransferRef, listResp.GetTransfers()[0].GetTransferRef()) +} + +func TestGetWalletBalance_NotFound(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + _, err := svc.GetWalletBalance(ctx, &ichainv1.GetWalletBalanceRequest{WalletRef: "missing"}) + require.Error(t, err) + st, _ := status.FromError(err) + require.Equal(t, codes.NotFound, st.Code()) +} + +func TestGetWalletBalance_ReturnsCachedNativeAvailable(t *testing.T) { + svc, repo := newTestService(t) + ctx := context.Background() + + createResp, err := svc.CreateManagedWallet(ctx, &ichainv1.CreateManagedWalletRequest{ + IdempotencyKey: "idem-balance", + OrganizationRef: "org-1", + OwnerRef: "owner-1", + Asset: &ichainv1.Asset{ + Chain: ichainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, + TokenSymbol: "USDT", + }, + }) + require.NoError(t, err) + walletRef := createResp.GetWallet().GetWalletRef() + + err = repo.wallets.SaveBalance(ctx, &model.WalletBalance{ + WalletRef: walletRef, + Available: &moneyv1.Money{Currency: "USDT", Amount: "25"}, + NativeAvailable: &moneyv1.Money{Currency: "TRX", Amount: "0.5"}, + CalculatedAt: time.Now().UTC(), + }) + require.NoError(t, err) + + resp, err := svc.GetWalletBalance(ctx, &ichainv1.GetWalletBalanceRequest{WalletRef: walletRef}) + require.NoError(t, err) + require.NotNil(t, resp.GetBalance()) + require.Equal(t, "0.5", resp.GetBalance().GetNativeAvailable().GetAmount()) + require.Equal(t, "TRX", resp.GetBalance().GetNativeAvailable().GetCurrency()) +} + +// ---- in-memory storage implementation ---- + +type inMemoryRepository struct { + wallets *inMemoryWallets + transfers *inMemoryTransfers + deposits *inMemoryDeposits +} + +func newInMemoryRepository() *inMemoryRepository { + return &inMemoryRepository{ + wallets: newInMemoryWallets(), + transfers: newInMemoryTransfers(), + deposits: newInMemoryDeposits(), + } +} + +func (r *inMemoryRepository) Ping(context.Context) error { return nil } +func (r *inMemoryRepository) Wallets() storage.WalletsStore { return r.wallets } +func (r *inMemoryRepository) Transfers() storage.TransfersStore { return r.transfers } +func (r *inMemoryRepository) Deposits() storage.DepositsStore { return r.deposits } + +// Wallets store + +type inMemoryWallets struct { + mu sync.Mutex + wallets map[string]*model.ManagedWallet + byIdemp map[string]string + balances map[string]*model.WalletBalance +} + +func newInMemoryWallets() *inMemoryWallets { + return &inMemoryWallets{ + wallets: make(map[string]*model.ManagedWallet), + byIdemp: make(map[string]string), + balances: make(map[string]*model.WalletBalance), + } +} + +func (w *inMemoryWallets) count() int { + w.mu.Lock() + defer w.mu.Unlock() + return len(w.wallets) +} + +func (w *inMemoryWallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*model.ManagedWallet, error) { + w.mu.Lock() + defer w.mu.Unlock() + + if wallet == nil { + return nil, merrors.InvalidArgument("walletsStore: nil wallet") + } + wallet.Normalize() + if wallet.IdempotencyKey == "" { + return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey") + } + + if existingRef, ok := w.byIdemp[wallet.IdempotencyKey]; ok { + existing := w.wallets[existingRef] + return existing, merrors.ErrDataConflict + } + + if wallet.WalletRef == "" { + wallet.WalletRef = primitive.NewObjectID().Hex() + } + if wallet.GetID() == nil || wallet.GetID().IsZero() { + wallet.SetID(primitive.NewObjectID()) + } else { + wallet.Update() + } + + w.wallets[wallet.WalletRef] = wallet + w.byIdemp[wallet.IdempotencyKey] = wallet.WalletRef + return wallet, nil +} + +func (w *inMemoryWallets) Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) { + w.mu.Lock() + defer w.mu.Unlock() + wallet, ok := w.wallets[strings.TrimSpace(walletRef)] + if !ok { + return nil, merrors.NoData("wallet not found") + } + return wallet, nil +} + +func (w *inMemoryWallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) { + w.mu.Lock() + defer w.mu.Unlock() + + items := make([]model.ManagedWallet, 0, len(w.wallets)) + for _, wallet := range w.wallets { + if filter.OrganizationRef != "" && !strings.EqualFold(wallet.OrganizationRef, filter.OrganizationRef) { + continue + } + if filter.OwnerRefFilter != nil { + ownerRef := strings.TrimSpace(*filter.OwnerRefFilter) + if !strings.EqualFold(wallet.OwnerRef, ownerRef) { + continue + } + } + if filter.Network != "" && !strings.EqualFold(wallet.Network, filter.Network) { + continue + } + if filter.TokenSymbol != "" && !strings.EqualFold(wallet.TokenSymbol, filter.TokenSymbol) { + continue + } + items = append(items, *wallet) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].ID.Timestamp().Before(items[j].ID.Timestamp()) + }) + + startIndex := 0 + if cursor := strings.TrimSpace(filter.Cursor); cursor != "" { + if oid, err := primitive.ObjectIDFromHex(cursor); err == nil { + for idx, item := range items { + if item.ID.Timestamp().After(oid.Timestamp()) { + startIndex = idx + break + } + } + } + } + + limit := int(sanitizeLimit(filter.Limit, walletDefaultLimit, walletMaxLimit)) + end := startIndex + limit + hasMore := false + if end < len(items) { + hasMore = true + items = items[startIndex:end] + } else { + items = items[startIndex:] + } + + nextCursor := "" + if hasMore && len(items) > 0 { + nextCursor = items[len(items)-1].ID.Hex() + } + + return &model.ManagedWalletList{Items: items, NextCursor: nextCursor}, nil +} + +func (w *inMemoryWallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error { + w.mu.Lock() + defer w.mu.Unlock() + if balance == nil { + return merrors.InvalidArgument("walletsStore: nil balance") + } + balance.Normalize() + if balance.WalletRef == "" { + return merrors.InvalidArgument("walletsStore: empty walletRef for balance") + } + if balance.CalculatedAt.IsZero() { + balance.CalculatedAt = time.Now().UTC() + } + existing, ok := w.balances[balance.WalletRef] + if !ok { + if balance.GetID() == nil || balance.GetID().IsZero() { + balance.SetID(primitive.NewObjectID()) + } + w.balances[balance.WalletRef] = balance + return nil + } + existing.Available = balance.Available + existing.PendingInbound = balance.PendingInbound + existing.PendingOutbound = balance.PendingOutbound + existing.CalculatedAt = balance.CalculatedAt + existing.Update() + return nil +} + +func (w *inMemoryWallets) GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) { + w.mu.Lock() + defer w.mu.Unlock() + balance, ok := w.balances[strings.TrimSpace(walletRef)] + if !ok { + return nil, merrors.NoData("wallet balance not found") + } + return balance, nil +} + +// Transfers store + +type inMemoryTransfers struct { + mu sync.Mutex + items map[string]*model.Transfer + byIdemp map[string]string +} + +func newInMemoryTransfers() *inMemoryTransfers { + return &inMemoryTransfers{ + items: make(map[string]*model.Transfer), + byIdemp: make(map[string]string), + } +} + +func (t *inMemoryTransfers) Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) { + t.mu.Lock() + defer t.mu.Unlock() + if transfer == nil { + return nil, merrors.InvalidArgument("transfersStore: nil transfer") + } + transfer.Normalize() + if transfer.IdempotencyKey == "" { + return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey") + } + if ref, ok := t.byIdemp[transfer.IdempotencyKey]; ok { + return t.items[ref], merrors.ErrDataConflict + } + if transfer.TransferRef == "" { + transfer.TransferRef = primitive.NewObjectID().Hex() + } + if transfer.GetID() == nil || transfer.GetID().IsZero() { + transfer.SetID(primitive.NewObjectID()) + } else { + transfer.Update() + } + t.items[transfer.TransferRef] = transfer + t.byIdemp[transfer.IdempotencyKey] = transfer.TransferRef + return transfer, nil +} + +func (t *inMemoryTransfers) Get(ctx context.Context, transferRef string) (*model.Transfer, error) { + t.mu.Lock() + defer t.mu.Unlock() + transfer, ok := t.items[strings.TrimSpace(transferRef)] + if !ok { + return nil, merrors.NoData("transfer not found") + } + return transfer, nil +} + +func (t *inMemoryTransfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) { + t.mu.Lock() + defer t.mu.Unlock() + items := make([]*model.Transfer, 0, len(t.items)) + for _, transfer := range t.items { + if filter.SourceWalletRef != "" && !strings.EqualFold(transfer.SourceWalletRef, filter.SourceWalletRef) { + continue + } + if filter.DestinationWalletRef != "" && !strings.EqualFold(transfer.Destination.ManagedWalletRef, filter.DestinationWalletRef) { + continue + } + if filter.Status != "" && transfer.Status != filter.Status { + continue + } + items = append(items, transfer) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].ID.Timestamp().Before(items[j].ID.Timestamp()) + }) + + start := 0 + if cursor := strings.TrimSpace(filter.Cursor); cursor != "" { + if oid, err := primitive.ObjectIDFromHex(cursor); err == nil { + for idx, item := range items { + if item.ID.Timestamp().After(oid.Timestamp()) { + start = idx + break + } + } + } + } + + limit := int(sanitizeLimit(filter.Limit, transferDefaultLimit, transferMaxLimit)) + end := start + limit + hasMore := false + if end < len(items) { + hasMore = true + items = items[start:end] + } else { + items = items[start:] + } + + nextCursor := "" + if hasMore && len(items) > 0 { + nextCursor = items[len(items)-1].ID.Hex() + } + + return &model.TransferList{Items: items, NextCursor: nextCursor}, nil +} + +func (t *inMemoryTransfers) UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error) { + t.mu.Lock() + defer t.mu.Unlock() + transfer, ok := t.items[strings.TrimSpace(transferRef)] + if !ok { + return nil, merrors.NoData("transfer not found") + } + transfer.Status = status + if status == model.TransferStatusFailed { + transfer.FailureReason = strings.TrimSpace(failureReason) + } else { + transfer.FailureReason = "" + } + transfer.TxHash = strings.TrimSpace(txHash) + transfer.LastStatusAt = time.Now().UTC() + transfer.Update() + return transfer, nil +} + +// helper for tests +func (t *inMemoryTransfers) get(ref string) *model.Transfer { + t.mu.Lock() + defer t.mu.Unlock() + return t.items[ref] +} + +// Deposits store (minimal for tests) + +type inMemoryDeposits struct { + mu sync.Mutex + items map[string]*model.Deposit +} + +func newInMemoryDeposits() *inMemoryDeposits { + return &inMemoryDeposits{items: make(map[string]*model.Deposit)} +} + +func (d *inMemoryDeposits) Record(ctx context.Context, deposit *model.Deposit) error { + d.mu.Lock() + defer d.mu.Unlock() + if deposit == nil { + return merrors.InvalidArgument("depositsStore: nil deposit") + } + deposit.Normalize() + if deposit.DepositRef == "" { + return merrors.InvalidArgument("depositsStore: empty depositRef") + } + if existing, ok := d.items[deposit.DepositRef]; ok { + existing.Status = deposit.Status + existing.LastStatusAt = time.Now().UTC() + existing.Update() + return nil + } + if deposit.GetID() == nil || deposit.GetID().IsZero() { + deposit.SetID(primitive.NewObjectID()) + } + if deposit.ObservedAt.IsZero() { + deposit.ObservedAt = time.Now().UTC() + } + if deposit.RecordedAt.IsZero() { + deposit.RecordedAt = time.Now().UTC() + } + deposit.LastStatusAt = time.Now().UTC() + d.items[deposit.DepositRef] = deposit + return nil +} + +func (d *inMemoryDeposits) ListPending(ctx context.Context, network string, limit int32) ([]*model.Deposit, error) { + d.mu.Lock() + defer d.mu.Unlock() + results := make([]*model.Deposit, 0) + for _, deposit := range d.items { + if deposit.Status != model.DepositStatusPending { + continue + } + if network != "" && !strings.EqualFold(deposit.Network, network) { + continue + } + results = append(results, deposit) + } + sort.Slice(results, func(i, j int) bool { + return results[i].ObservedAt.Before(results[j].ObservedAt) + }) + limitVal := int(sanitizeLimit(limit, depositDefaultLimit, depositMaxLimit)) + if len(results) > limitVal { + results = results[:limitVal] + } + return results, nil +} + +// shared helpers + +func sanitizeLimit(requested int32, def, max int64) int64 { + if requested <= 0 { + return def + } + if requested > int32(max) { + return max + } + return int64(requested) +} + +func newTestService(t *testing.T) (*Service, *inMemoryRepository) { + repo := newInMemoryRepository() + logger := zap.NewNop() + networks := []shared.Network{{ + Name: pmodel.ChainNetworkTronMainnet, + NativeToken: "TRX", + TokenConfigs: []shared.TokenContract{ + {Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"}, + }, + }} + driverRegistry, err := drivers.NewRegistry(logger.Named("drivers"), networks) + require.NoError(t, err) + svc := NewService(logger, repo, nil, + WithKeyManager(&fakeKeyManager{}), + WithNetworks(networks), + WithServiceWallet(shared.ServiceWallet{Network: pmodel.ChainNetworkTronMainnet, Address: "TServiceWalletAddress"}), + WithDriverRegistry(driverRegistry), + ) + return svc, repo +} + +type fakeKeyManager struct{} + +func (f *fakeKeyManager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) { + // Use a valid Tron address format for testing + return &keymanager.ManagedWalletKey{ + KeyID: fmt.Sprintf("%s/%s", strings.ToLower(network), walletRef), + Address: "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF", + PublicKey: strings.Repeat("b", 128), + }, nil +} + +func (f *fakeKeyManager) SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) { + return tx, nil +} + +func (f *fakeKeyManager) SignTronTransaction(ctx context.Context, keyID string, tx *troncore.Transaction) (*troncore.Transaction, error) { + return tx, nil +} diff --git a/api/gateway/tron/internal/service/gateway/settings.go b/api/gateway/tron/internal/service/gateway/settings.go new file mode 100644 index 00000000..edbbd857 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/settings.go @@ -0,0 +1,43 @@ +package gateway + +import "time" + +const defaultWalletBalanceCacheTTL = 120 * time.Second +const defaultRPCRequestTimeout = 15 * time.Second + +// CacheSettings holds tunable gateway behaviour. +type CacheSettings struct { + WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"` + RPCRequestTimeoutSeconds int `yaml:"rpc_request_timeout_seconds"` +} + +func defaultSettings() CacheSettings { + return CacheSettings{ + WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()), + RPCRequestTimeoutSeconds: int(defaultRPCRequestTimeout.Seconds()), + } +} + +func (s CacheSettings) withDefaults() CacheSettings { + if s.WalletBalanceCacheTTLSeconds <= 0 { + s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds()) + } + if s.RPCRequestTimeoutSeconds <= 0 { + s.RPCRequestTimeoutSeconds = int(defaultRPCRequestTimeout.Seconds()) + } + return s +} + +func (s CacheSettings) walletBalanceCacheTTL() time.Duration { + if s.WalletBalanceCacheTTLSeconds <= 0 { + return defaultWalletBalanceCacheTTL + } + return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second +} + +func (s CacheSettings) rpcTimeout() time.Duration { + if s.RPCRequestTimeoutSeconds <= 0 { + return defaultRPCRequestTimeout + } + return time.Duration(s.RPCRequestTimeoutSeconds) * time.Second +} diff --git a/api/gateway/tron/internal/service/gateway/shared/gas_topup.go b/api/gateway/tron/internal/service/gateway/shared/gas_topup.go new file mode 100644 index 00000000..cfb2b51e --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/shared/gas_topup.go @@ -0,0 +1,32 @@ +package shared + +import "github.com/shopspring/decimal" + +// GasTopUpRule defines buffer, minimum, rounding, and cap behavior for native gas top-ups. +type GasTopUpRule struct { + BufferPercent decimal.Decimal + MinNativeBalance decimal.Decimal + RoundingUnit decimal.Decimal + MaxTopUp decimal.Decimal +} + +// GasTopUpPolicy captures default and optional overrides for native vs contract transfers. +type GasTopUpPolicy struct { + Default GasTopUpRule + Native *GasTopUpRule + Contract *GasTopUpRule +} + +// Rule selects the policy rule for the transfer type. +func (p *GasTopUpPolicy) Rule(contractTransfer bool) (GasTopUpRule, bool) { + if p == nil { + return GasTopUpRule{}, false + } + if contractTransfer && p.Contract != nil { + return *p.Contract, true + } + if !contractTransfer && p.Native != nil { + return *p.Native, true + } + return p.Default, true +} diff --git a/api/gateway/tron/internal/service/gateway/shared/helpers.go b/api/gateway/tron/internal/service/gateway/shared/helpers.go new file mode 100644 index 00000000..069c4359 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/shared/helpers.go @@ -0,0 +1,148 @@ +package shared + +import ( + "strings" + + "github.com/tech/sendico/gateway/tron/storage/model" + chainasset "github.com/tech/sendico/pkg/chain" + pmodel "github.com/tech/sendico/pkg/model" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// CloneMoney defensively copies a Money proto. +func CloneMoney(m *moneyv1.Money) *moneyv1.Money { + if m == nil { + return nil + } + return &moneyv1.Money{Amount: m.GetAmount(), Currency: m.GetCurrency()} +} + +// CloneMetadata defensively copies metadata maps. +func CloneMetadata(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + clone := make(map[string]string, len(input)) + for k, v := range input { + clone[k] = v + } + return clone +} + +// ResolveContractAddress finds a token contract for a symbol in a case-insensitive manner. +func ResolveContractAddress(tokens []TokenContract, symbol string) string { + upper := strings.ToUpper(symbol) + for _, token := range tokens { + if strings.EqualFold(token.Symbol, upper) && token.ContractAddress != "" { + return strings.ToLower(token.ContractAddress) + } + } + return "" +} + +func GenerateWalletRef() string { + return primitive.NewObjectID().Hex() +} + +func GenerateTransferRef() string { + return primitive.NewObjectID().Hex() +} + +func ChainKeyFromEnum(chain chainv1.ChainNetwork) (string, chainv1.ChainNetwork) { + if name, ok := chainv1.ChainNetwork_name[int32(chain)]; ok { + key := strings.ToLower(strings.TrimPrefix(name, "CHAIN_NETWORK_")) + return key, chain + } + return "", chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED +} + +func ChainEnumFromName(name string) chainv1.ChainNetwork { + return chainasset.NetworkFromString(name) +} + +func ManagedWalletStatusToProto(status model.ManagedWalletStatus) chainv1.ManagedWalletStatus { + switch status { + case model.ManagedWalletStatusActive: + return chainv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE + case model.ManagedWalletStatusSuspended: + return chainv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED + case model.ManagedWalletStatusClosed: + return chainv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED + default: + return chainv1.ManagedWalletStatus_MANAGED_WALLET_STATUS_UNSPECIFIED + } +} + +func TransferStatusToModel(status chainv1.TransferStatus) model.TransferStatus { + switch status { + case chainv1.TransferStatus_TRANSFER_PENDING: + return model.TransferStatusPending + case chainv1.TransferStatus_TRANSFER_SIGNING: + return model.TransferStatusSigning + case chainv1.TransferStatus_TRANSFER_SUBMITTED: + return model.TransferStatusSubmitted + case chainv1.TransferStatus_TRANSFER_CONFIRMED: + return model.TransferStatusConfirmed + case chainv1.TransferStatus_TRANSFER_FAILED: + return model.TransferStatusFailed + case chainv1.TransferStatus_TRANSFER_CANCELLED: + return model.TransferStatusCancelled + default: + return "" + } +} + +func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus { + switch status { + case model.TransferStatusPending: + return chainv1.TransferStatus_TRANSFER_PENDING + case model.TransferStatusSigning: + return chainv1.TransferStatus_TRANSFER_SIGNING + case model.TransferStatusSubmitted: + return chainv1.TransferStatus_TRANSFER_SUBMITTED + case model.TransferStatusConfirmed: + return chainv1.TransferStatus_TRANSFER_CONFIRMED + case model.TransferStatusFailed: + return chainv1.TransferStatus_TRANSFER_FAILED + case model.TransferStatusCancelled: + return chainv1.TransferStatus_TRANSFER_CANCELLED + default: + return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED + } +} + +// NativeCurrency returns the canonical native token symbol for a network. +func NativeCurrency(network Network) string { + currency := strings.ToUpper(strings.TrimSpace(network.NativeToken)) + if currency == "" { + currency = strings.ToUpper(network.Name.String()) + } + return currency +} + +// Network describes a supported blockchain network and known token contracts. +type Network struct { + Name pmodel.ChainNetwork + RPCURL string + GRPCUrl string // Native TRON gRPC endpoint (for transactions) + GRPCToken string // Optional auth token for TRON gRPC (x-token header) + ChainID uint64 + NativeToken string + TokenConfigs []TokenContract + GasTopUpPolicy *GasTopUpPolicy +} + +// TokenContract captures the metadata needed to work with a specific on-chain token. +type TokenContract struct { + Symbol string + ContractAddress string +} + +// ServiceWallet captures the managed service wallet configuration. +type ServiceWallet struct { + Network pmodel.ChainNetwork + Address string + PrivateKey string +} diff --git a/api/gateway/tron/internal/service/gateway/shared/hex.go b/api/gateway/tron/internal/service/gateway/shared/hex.go new file mode 100644 index 00000000..e33ac582 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/shared/hex.go @@ -0,0 +1,50 @@ +package shared + +import ( + "math/big" + "strings" + + "github.com/tech/sendico/pkg/merrors" +) + +var ( + errHexEmpty = merrors.InvalidArgument("hex value is empty") + errHexInvalid = merrors.InvalidArgument("invalid hex number") + errHexOutOfRange = merrors.InvalidArgument("hex number out of range") +) + +// DecodeHexBig parses a hex string that may include leading zero digits. +func DecodeHexBig(input string) (*big.Int, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return nil, errHexEmpty + } + noPrefix := strings.TrimPrefix(trimmed, "0x") + if noPrefix == "" { + return nil, errHexEmpty + } + value := strings.TrimLeft(noPrefix, "0") + if value == "" { + return big.NewInt(0), nil + } + val := new(big.Int) + if _, ok := val.SetString(value, 16); !ok { + return nil, errHexInvalid + } + return val, nil +} + +// DecodeHexUint8 parses a hex string into uint8, allowing leading zeros. +func DecodeHexUint8(input string) (uint8, error) { + val, err := DecodeHexBig(input) + if err != nil { + return 0, err + } + if val == nil { + return 0, errHexInvalid + } + if val.BitLen() > 8 { + return 0, errHexOutOfRange + } + return uint8(val.Uint64()), nil +} diff --git a/api/gateway/tron/internal/service/gateway/shared/hex_test.go b/api/gateway/tron/internal/service/gateway/shared/hex_test.go new file mode 100644 index 00000000..e99a6ef5 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/shared/hex_test.go @@ -0,0 +1,16 @@ +package shared + +import "testing" + +func TestDecodeHexUint8_LeadingZeros(t *testing.T) { + t.Parallel() + + const resp = "0x0000000000000000000000000000000000000000000000000000000000000006" + val, err := DecodeHexUint8(resp) + if err != nil { + t.Fatalf("DecodeHexUint8 error: %v", err) + } + if val != 6 { + t.Fatalf("DecodeHexUint8 value = %d, want 6", val) + } +} diff --git a/api/gateway/tron/internal/service/gateway/transfer_execution.go b/api/gateway/tron/internal/service/gateway/transfer_execution.go new file mode 100644 index 00000000..5983840a --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/transfer_execution.go @@ -0,0 +1,142 @@ +package gateway + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/driver" + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" +) + +func (s *Service) launchTransferExecution(transferRef, sourceWalletRef string, network shared.Network) { + if s.drivers == nil { + return + } + + go func(ref, walletRef string, net shared.Network) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + if err := s.executeTransfer(ctx, ref, walletRef, net); err != nil { + s.logger.Warn("Failed to execute transfer", zap.String("transfer_ref", ref), zap.Error(err)) + } + }(transferRef, sourceWalletRef, network) +} + +func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWalletRef string, network shared.Network) error { + transfer, err := s.storage.Transfers().Get(ctx, transferRef) + if err != nil { + return err + } + + sourceWallet, err := s.storage.Wallets().Get(ctx, sourceWalletRef) + if err != nil { + return err + } + + if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil { + s.logger.Warn("Failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err)) + } + + driverDeps := s.driverDeps() + chainDriver, err := s.driverForNetwork(network.Name.String()) + if err != nil { + _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + return err + } + + destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination) + if err != nil { + _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + return err + } + + sourceAddress, err := chainDriver.NormalizeAddress(sourceWallet.DepositAddress) + if err != nil { + _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + return err + } + if chainDriver.Name() == "tron" && sourceAddress == destinationAddress { + s.logger.Info("Self transfer detected; skipping submission", + zap.String("transfer_ref", transferRef), + zap.String("wallet_ref", sourceWalletRef), + zap.String("network", network.Name.String()), + ) + if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", ""); err != nil { + s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err)) + } + return nil + } + + txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress) + if err != nil { + _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + return err + } + + if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil { + s.logger.Warn("Failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err)) + } + + receiptCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + receipt, err := chainDriver.AwaitConfirmation(receiptCtx, driverDeps, network, txHash) + if err != nil { + if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) { + s.logger.Warn("Failed to await transfer confirmation", zap.String("transfer_ref", transferRef), zap.Error(err)) + } + return err + } + + if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful { + if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", txHash); err != nil { + s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err)) + } + return nil + } + + if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, "transaction reverted", txHash); err != nil { + s.logger.Warn("Failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(err)) + } + return nil +} + +func (s *Service) destinationAddress(ctx context.Context, chainDriver driver.Driver, dest model.TransferDestination) (string, error) { + if ref := strings.TrimSpace(dest.ManagedWalletRef); ref != "" { + wallet, err := s.storage.Wallets().Get(ctx, ref) + if err != nil { + return "", err + } + if strings.TrimSpace(wallet.DepositAddress) == "" { + return "", merrors.Internal("destination wallet missing deposit address") + } + return chainDriver.NormalizeAddress(wallet.DepositAddress) + } + if addr := strings.TrimSpace(dest.ExternalAddress); addr != "" { + return chainDriver.NormalizeAddress(addr) + } + return "", merrors.InvalidArgument("transfer destination address not resolved") +} + +func (s *Service) driverDeps() driver.Deps { + return driver.Deps{ + Logger: s.logger.Named("driver"), + Registry: s.networkRegistry, + TronRegistry: s.tronClients, + KeyManager: s.keyManager, + RPCTimeout: s.settings.rpcTimeout(), + } +} + +func (s *Service) driverForNetwork(network string) (driver.Driver, error) { + if s.drivers == nil { + return nil, merrors.Internal("chain drivers not configured") + } + return s.drivers.Driver(network) +} diff --git a/api/gateway/tron/internal/service/gateway/tronclient/client.go b/api/gateway/tron/internal/service/gateway/tronclient/client.go new file mode 100644 index 00000000..5cb9131a --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/tronclient/client.go @@ -0,0 +1,219 @@ +package tronclient + +import ( + "context" + "crypto/tls" + "encoding/hex" + "fmt" + "math/big" + "net/url" + "strings" + "time" + + "github.com/fbsobreira/gotron-sdk/pkg/client" + "github.com/fbsobreira/gotron-sdk/pkg/proto/api" + "github.com/fbsobreira/gotron-sdk/pkg/proto/core" + "github.com/tech/sendico/pkg/merrors" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" +) + +// Client wraps the gotron-sdk gRPC client with convenience methods. +type Client struct { + grpc *client.GrpcClient + timeout time.Duration +} + +// NewClient creates a new TRON gRPC client connected to the given endpoint. +func NewClient(grpcURL string, timeout time.Duration, authToken string) (*Client, error) { + if grpcURL == "" { + return nil, merrors.InvalidArgument("tronclient: grpc url is required") + } + if timeout <= 0 { + timeout = 30 * time.Second + } + + address, useTLS, err := normalizeGRPCAddress(grpcURL) + if err != nil { + return nil, err + } + + grpcClient := client.NewGrpcClientWithTimeout(address, timeout) + + var transportCreds grpc.DialOption + if useTLS { + transportCreds = grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12})) + } else { + transportCreds = grpc.WithTransportCredentials(insecure.NewCredentials()) + } + + opts := []grpc.DialOption{transportCreds} + if token := strings.TrimSpace(authToken); token != "" { + opts = append(opts, + grpc.WithUnaryInterceptor(grpcTokenUnaryInterceptor(token)), + grpc.WithStreamInterceptor(grpcTokenStreamInterceptor(token)), + ) + } + + if err := grpcClient.Start(opts...); err != nil { + return nil, merrors.Internal(fmt.Sprintf("tronclient: failed to connect to %s: %v", grpcURL, err)) + } + + return &Client{ + grpc: grpcClient, + timeout: timeout, + }, nil +} + +func normalizeGRPCAddress(grpcURL string) (string, bool, error) { + target := strings.TrimSpace(grpcURL) + useTLS := false + if target == "" { + return "", false, merrors.InvalidArgument("tronclient: grpc url is required") + } + if strings.Contains(target, "://") { + u, err := url.Parse(target) + if err != nil { + return "", false, merrors.InvalidArgument("tronclient: invalid grpc url") + } + if u.Scheme == "https" || u.Scheme == "grpcs" { + useTLS = true + } + host := strings.TrimSpace(u.Host) + if host == "" { + return "", false, merrors.InvalidArgument("tronclient: grpc url missing host") + } + if useTLS && u.Port() == "" { + host = host + ":443" + } + return host, useTLS, nil + } + return target, useTLS, nil +} + +func grpcTokenUnaryInterceptor(token string) grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + ctx = metadata.AppendToOutgoingContext(ctx, "x-token", token) + return invoker(ctx, method, req, reply, cc, opts...) + } +} + +func grpcTokenStreamInterceptor(token string) grpc.StreamClientInterceptor { + return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) { + ctx = metadata.AppendToOutgoingContext(ctx, "x-token", token) + return streamer(ctx, desc, cc, method, opts...) + } +} + +// Close closes the gRPC connection. +func (c *Client) Close() { + if c != nil && c.grpc != nil { + c.grpc.Stop() + } +} + +// SetAPIKey configures the TRON-PRO-API-KEY for TronGrid requests. +func (c *Client) SetAPIKey(apiKey string) { + if c != nil && c.grpc != nil { + c.grpc.SetAPIKey(apiKey) + } +} + +// Transfer creates a native TRX transfer transaction. +// Addresses should be in base58 format. +// Amount is in SUN (1 TRX = 1,000,000 SUN). +func (c *Client) Transfer(from, to string, amountSun int64) (*api.TransactionExtention, error) { + if c == nil || c.grpc == nil { + return nil, merrors.Internal("tronclient: client not initialized") + } + return c.grpc.Transfer(from, to, amountSun) +} + +// TRC20Send creates a TRC20 token transfer transaction. +// Addresses should be in base58 format. +// Amount is in the token's smallest unit. +// FeeLimit is in SUN (recommended: 100_000_000 = 100 TRX). +func (c *Client) TRC20Send(from, to, contract string, amount *big.Int, feeLimit int64) (*api.TransactionExtention, error) { + if c == nil || c.grpc == nil { + return nil, merrors.Internal("tronclient: client not initialized") + } + return c.grpc.TRC20Send(from, to, contract, amount, feeLimit) +} + +// Broadcast broadcasts a signed transaction to the network. +func (c *Client) Broadcast(tx *core.Transaction) (*api.Return, error) { + if c == nil || c.grpc == nil { + return nil, merrors.Internal("tronclient: client not initialized") + } + return c.grpc.Broadcast(tx) +} + +// GetTransactionInfoByID retrieves transaction info by its hash. +// The txID should be a hex string (without 0x prefix). +func (c *Client) GetTransactionInfoByID(txID string) (*core.TransactionInfo, error) { + if c == nil || c.grpc == nil { + return nil, merrors.Internal("tronclient: client not initialized") + } + return c.grpc.GetTransactionInfoByID(txID) +} + +// GetTransactionByID retrieves the full transaction by its hash. +func (c *Client) GetTransactionByID(txID string) (*core.Transaction, error) { + if c == nil || c.grpc == nil { + return nil, merrors.Internal("tronclient: client not initialized") + } + return c.grpc.GetTransactionByID(txID) +} + +// TRC20GetDecimals returns the decimals of a TRC20 token. +func (c *Client) TRC20GetDecimals(contract string) (*big.Int, error) { + if c == nil || c.grpc == nil { + return nil, merrors.Internal("tronclient: client not initialized") + } + return c.grpc.TRC20GetDecimals(contract) +} + +// TRC20ContractBalance returns the balance of an address for a TRC20 token. +func (c *Client) TRC20ContractBalance(addr, contract string) (*big.Int, error) { + if c == nil || c.grpc == nil { + return nil, merrors.Internal("tronclient: client not initialized") + } + return c.grpc.TRC20ContractBalance(addr, contract) +} + +// AwaitConfirmation polls for transaction confirmation until ctx is cancelled. +func (c *Client) AwaitConfirmation(ctx context.Context, txID string, pollInterval time.Duration) (*core.TransactionInfo, error) { + if c == nil || c.grpc == nil { + return nil, merrors.Internal("tronclient: client not initialized") + } + if pollInterval <= 0 { + pollInterval = 3 * time.Second + } + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + for { + txInfo, err := c.grpc.GetTransactionInfoByID(txID) + if err == nil && txInfo != nil && txInfo.BlockNumber > 0 { + return txInfo, nil + } + + select { + case <-ticker.C: + continue + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +// TxIDFromExtention extracts the transaction ID hex string from a TransactionExtention. +func TxIDFromExtention(txExt *api.TransactionExtention) string { + if txExt == nil || len(txExt.Txid) == 0 { + return "" + } + return hex.EncodeToString(txExt.Txid) +} diff --git a/api/gateway/tron/internal/service/gateway/tronclient/registry.go b/api/gateway/tron/internal/service/gateway/tronclient/registry.go new file mode 100644 index 00000000..c30f8332 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/tronclient/registry.go @@ -0,0 +1,127 @@ +package tronclient + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/tech/sendico/gateway/tron/internal/service/gateway/shared" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +// Registry manages TRON gRPC clients keyed by network name. +type Registry struct { + logger mlogger.Logger + clients map[string]*Client +} + +// NewRegistry creates an empty registry. +func NewRegistry(logger mlogger.Logger) *Registry { + return &Registry{ + logger: logger.Named("tron_registry"), + clients: make(map[string]*Client), + } +} + +// Prepare initializes TRON gRPC clients for all networks with a configured GRPCUrl. +// Networks without GRPCUrl are skipped (they will fallback to EVM). +func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Network) (*Registry, error) { + if logger == nil { + return nil, merrors.InvalidArgument("tronclient: logger is required") + } + + registry := NewRegistry(logger) + timeout := 30 * time.Second + + for _, network := range networks { + name := network.Name.String() + grpcURL := strings.TrimSpace(network.GRPCUrl) + grpcToken := strings.TrimSpace(network.GRPCToken) + + if !network.Name.IsValid() { + continue + } + + // Skip networks without TRON gRPC URL configured + if grpcURL == "" { + registry.logger.Debug("Skipping network without TRON gRPC URL", + zap.String("network", name), + ) + continue + } + + registry.logger.Info("Initializing TRON gRPC client", + zap.String("network", name), + zap.String("grpc_url", grpcURL), + ) + + client, err := NewClient(grpcURL, timeout, grpcToken) + if err != nil { + registry.Close() + registry.logger.Error("Failed to initialize TRON gRPC client", + zap.String("network", name), + zap.Error(err), + ) + return nil, merrors.Internal(fmt.Sprintf("tronclient: failed to connect to %s: %v", name, err)) + } + + registry.clients[name] = client + registry.logger.Info("TRON gRPC client ready", + zap.String("network", name), + ) + } + + if len(registry.clients) > 0 { + registry.logger.Info("TRON gRPC clients initialized", + zap.Int("count", len(registry.clients)), + ) + } else { + registry.logger.Debug("No TRON gRPC clients were initialized (no networks with grpc_url_env)") + } + + return registry, nil +} + +// Client returns the TRON gRPC client for the given network. +func (r *Registry) Client(networkName string) (*Client, error) { + if r == nil { + return nil, merrors.Internal("tronclient: registry not initialized") + } + + name := strings.ToLower(strings.TrimSpace(networkName)) + client, ok := r.clients[name] + if !ok || client == nil { + return nil, merrors.InvalidArgument(fmt.Sprintf("tronclient: no client for network %s", name)) + } + + return client, nil +} + +// HasClient checks if a TRON gRPC client is available for the given network. +func (r *Registry) HasClient(networkName string) bool { + if r == nil || len(r.clients) == 0 { + return false + } + name := strings.ToLower(strings.TrimSpace(networkName)) + client, ok := r.clients[name] + return ok && client != nil +} + +// Close closes all TRON gRPC connections. +func (r *Registry) Close() { + if r == nil { + return + } + for name, client := range r.clients { + if client != nil { + client.Close() + if r.logger != nil { + r.logger.Info("TRON gRPC client closed", zap.String("network", name)) + } + } + } + r.clients = make(map[string]*Client) +} diff --git a/api/gateway/tron/main.go b/api/gateway/tron/main.go new file mode 100644 index 00000000..deb96a94 --- /dev/null +++ b/api/gateway/tron/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/tech/sendico/gateway/tron/internal/appversion" + si "github.com/tech/sendico/gateway/tron/internal/server" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" + smain "github.com/tech/sendico/pkg/server/main" +) + +func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return si.Create(logger, file, debug) +} + +func main() { + smain.RunServer("gateway", appversion.Create(), factory) +} diff --git a/api/gateway/tron/storage/model/deposit.go b/api/gateway/tron/storage/model/deposit.go new file mode 100644 index 00000000..64db4ea3 --- /dev/null +++ b/api/gateway/tron/storage/model/deposit.go @@ -0,0 +1,54 @@ +package model + +import ( + "strings" + "time" + + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/mservice" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +type DepositStatus string + +const ( + DepositStatusPending DepositStatus = "pending" + DepositStatusConfirmed DepositStatus = "confirmed" + DepositStatusFailed DepositStatus = "failed" +) + +// Deposit records an inbound transfer observed on-chain. +type Deposit struct { + storable.Base `bson:",inline" json:",inline"` + + DepositRef string `bson:"depositRef" json:"depositRef"` + WalletRef string `bson:"walletRef" json:"walletRef"` + Network string `bson:"network" json:"network"` + TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"` + ContractAddress string `bson:"contractAddress" json:"contractAddress"` + Amount *moneyv1.Money `bson:"amount" json:"amount"` + SourceAddress string `bson:"sourceAddress" json:"sourceAddress"` + TxHash string `bson:"txHash" json:"txHash"` + BlockID string `bson:"blockId,omitempty" json:"blockId,omitempty"` + Status DepositStatus `bson:"status" json:"status"` + ObservedAt time.Time `bson:"observedAt" json:"observedAt"` + RecordedAt time.Time `bson:"recordedAt" json:"recordedAt"` + LastStatusAt time.Time `bson:"lastStatusAt" json:"lastStatusAt"` +} + +// Collection implements storable.Storable. +func (*Deposit) Collection() string { + return mservice.ChainDeposits +} + +// Normalize standardizes case-sensitive fields. +func (d *Deposit) Normalize() { + d.DepositRef = strings.TrimSpace(d.DepositRef) + d.WalletRef = strings.TrimSpace(d.WalletRef) + d.Network = strings.TrimSpace(strings.ToLower(d.Network)) + d.TokenSymbol = strings.TrimSpace(strings.ToUpper(d.TokenSymbol)) + d.ContractAddress = strings.TrimSpace(strings.ToLower(d.ContractAddress)) + d.SourceAddress = strings.TrimSpace(strings.ToLower(d.SourceAddress)) + d.TxHash = strings.TrimSpace(strings.ToLower(d.TxHash)) + d.BlockID = strings.TrimSpace(d.BlockID) +} diff --git a/api/gateway/tron/storage/model/transfer.go b/api/gateway/tron/storage/model/transfer.go new file mode 100644 index 00000000..fe4ecf6a --- /dev/null +++ b/api/gateway/tron/storage/model/transfer.go @@ -0,0 +1,93 @@ +package model + +import ( + "strings" + "time" + + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/mservice" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +type TransferStatus string + +const ( + TransferStatusPending TransferStatus = "pending" + TransferStatusSigning TransferStatus = "signing" + TransferStatusSubmitted TransferStatus = "submitted" + TransferStatusConfirmed TransferStatus = "confirmed" + TransferStatusFailed TransferStatus = "failed" + TransferStatusCancelled TransferStatus = "cancelled" +) + +// ServiceFee represents a fee component applied to a transfer. +type ServiceFee struct { + FeeCode string `bson:"feeCode" json:"feeCode"` + Amount *moneyv1.Money `bson:"amount" json:"amount"` + Description string `bson:"description,omitempty" json:"description,omitempty"` +} + +type TransferDestination struct { + ManagedWalletRef string `bson:"managedWalletRef,omitempty" json:"managedWalletRef,omitempty"` + ExternalAddress string `bson:"externalAddress,omitempty" json:"externalAddress,omitempty"` + ExternalAddressOriginal string `bson:"externalAddressOriginal,omitempty" json:"externalAddressOriginal,omitempty"` + Memo string `bson:"memo,omitempty" json:"memo,omitempty"` +} + +// Transfer models an on-chain transfer orchestrated by the gateway. +type Transfer struct { + storable.Base `bson:",inline" json:",inline"` + + TransferRef string `bson:"transferRef" json:"transferRef"` + IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` + OrganizationRef string `bson:"organizationRef" json:"organizationRef"` + SourceWalletRef string `bson:"sourceWalletRef" json:"sourceWalletRef"` + Destination TransferDestination `bson:"destination" json:"destination"` + Network string `bson:"network" json:"network"` + TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"` + ContractAddress string `bson:"contractAddress" json:"contractAddress"` + RequestedAmount *moneyv1.Money `bson:"requestedAmount" json:"requestedAmount"` + NetAmount *moneyv1.Money `bson:"netAmount" json:"netAmount"` + Fees []ServiceFee `bson:"fees,omitempty" json:"fees,omitempty"` + Status TransferStatus `bson:"status" json:"status"` + TxHash string `bson:"txHash,omitempty" json:"txHash,omitempty"` + FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"` + ClientReference string `bson:"clientReference,omitempty" json:"clientReference,omitempty"` + LastStatusAt time.Time `bson:"lastStatusAt" json:"lastStatusAt"` +} + +// Collection implements storable.Storable. +func (*Transfer) Collection() string { + return mservice.ChainTransfers +} + +// TransferFilter describes the parameters for listing transfers. +type TransferFilter struct { + SourceWalletRef string + DestinationWalletRef string + Status TransferStatus + Cursor string + Limit int32 +} + +// TransferList contains paginated transfer results. +type TransferList struct { + Items []*Transfer + NextCursor string +} + +// Normalize trims strings for consistent indexes. +func (t *Transfer) Normalize() { + t.TransferRef = strings.TrimSpace(t.TransferRef) + t.IdempotencyKey = strings.TrimSpace(t.IdempotencyKey) + t.OrganizationRef = strings.TrimSpace(t.OrganizationRef) + t.SourceWalletRef = strings.TrimSpace(t.SourceWalletRef) + t.Network = strings.TrimSpace(strings.ToLower(t.Network)) + t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol)) + t.ContractAddress = strings.TrimSpace(strings.ToLower(t.ContractAddress)) + t.Destination.ManagedWalletRef = strings.TrimSpace(t.Destination.ManagedWalletRef) + t.Destination.ExternalAddress = normalizeWalletAddress(t.Destination.ExternalAddress) + t.Destination.ExternalAddressOriginal = strings.TrimSpace(t.Destination.ExternalAddressOriginal) + t.Destination.Memo = strings.TrimSpace(t.Destination.Memo) + t.ClientReference = strings.TrimSpace(t.ClientReference) +} diff --git a/api/gateway/tron/storage/model/transfer_test.go b/api/gateway/tron/storage/model/transfer_test.go new file mode 100644 index 00000000..f48a38b9 --- /dev/null +++ b/api/gateway/tron/storage/model/transfer_test.go @@ -0,0 +1,50 @@ +package model + +import ( + "strings" + "testing" +) + +func TestTransferNormalizePreservesBase58ExternalAddress(t *testing.T) { + address := "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF" + transfer := &Transfer{ + IdempotencyKey: "idemp", + TransferRef: "ref", + OrganizationRef: "org", + SourceWalletRef: "wallet", + Network: "tron_mainnet", + TokenSymbol: "USDT", + Destination: TransferDestination{ + ExternalAddress: address, + ExternalAddressOriginal: address, + }, + } + + transfer.Normalize() + + if transfer.Destination.ExternalAddress != address { + t.Fatalf("expected external address to preserve case, got %q", transfer.Destination.ExternalAddress) + } + if transfer.Destination.ExternalAddressOriginal != address { + t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal) + } +} + +func TestTransferNormalizeLowercasesHexExternalAddress(t *testing.T) { + address := "0xAABBCCDDEEFF00112233445566778899AABBCCDD" + transfer := &Transfer{ + Destination: TransferDestination{ + ExternalAddress: address, + ExternalAddressOriginal: address, + }, + } + + transfer.Normalize() + + if transfer.Destination.ExternalAddress != strings.ToLower(address) { + t.Fatalf("expected hex external address to be lowercased, got %q", transfer.Destination.ExternalAddress) + } + if transfer.Destination.ExternalAddressOriginal != address { + t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal) + } +} diff --git a/api/gateway/tron/storage/model/wallet.go b/api/gateway/tron/storage/model/wallet.go new file mode 100644 index 00000000..5e6d7347 --- /dev/null +++ b/api/gateway/tron/storage/model/wallet.go @@ -0,0 +1,134 @@ +package model + +import ( + "strings" + "time" + + "github.com/tech/sendico/pkg/db/storable" + pkgmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +type ManagedWalletStatus string + +const ( + ManagedWalletStatusActive ManagedWalletStatus = "active" + ManagedWalletStatusSuspended ManagedWalletStatus = "suspended" + ManagedWalletStatusClosed ManagedWalletStatus = "closed" +) + +// ManagedWallet represents a user-controlled on-chain wallet managed by the service. +type ManagedWallet struct { + storable.Base `bson:",inline" json:",inline"` + pkgmodel.Describable `bson:",inline" json:",inline"` + + IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` + WalletRef string `bson:"walletRef" json:"walletRef"` + OrganizationRef string `bson:"organizationRef" json:"organizationRef"` + OwnerRef string `bson:"ownerRef" json:"ownerRef"` + Network string `bson:"network" json:"network"` + TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"` + ContractAddress string `bson:"contractAddress" json:"contractAddress"` + DepositAddress string `bson:"depositAddress" json:"depositAddress"` + KeyReference string `bson:"keyReference,omitempty" json:"keyReference,omitempty"` + Status ManagedWalletStatus `bson:"status" json:"status"` + Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` +} + +// Collection implements storable.Storable. +func (*ManagedWallet) Collection() string { + return mservice.ChainWallets +} + +// WalletBalance captures computed wallet balances. +type WalletBalance struct { + storable.Base `bson:",inline" json:",inline"` + + WalletRef string `bson:"walletRef" json:"walletRef"` + Available *moneyv1.Money `bson:"available" json:"available"` + NativeAvailable *moneyv1.Money `bson:"nativeAvailable,omitempty" json:"nativeAvailable,omitempty"` + PendingInbound *moneyv1.Money `bson:"pendingInbound,omitempty" json:"pendingInbound,omitempty"` + PendingOutbound *moneyv1.Money `bson:"pendingOutbound,omitempty" json:"pendingOutbound,omitempty"` + CalculatedAt time.Time `bson:"calculatedAt" json:"calculatedAt"` +} + +// Collection implements storable.Storable. +func (*WalletBalance) Collection() string { + return mservice.ChainWalletBalances +} + +// ManagedWalletFilter describes list filters. +type ManagedWalletFilter struct { + OrganizationRef string + // OwnerRefFilter is a 3-state filter: + // - nil: no filter on owner_ref (return all) + // - pointer to empty string: filter for wallets where owner_ref is empty + // - pointer to a value: filter for wallets where owner_ref matches + OwnerRefFilter *string + Network string + TokenSymbol string + Cursor string + Limit int32 +} + +// ManagedWalletList contains paginated wallet results. +type ManagedWalletList struct { + Items []ManagedWallet + NextCursor string +} + +// Normalize trims string fields for consistent indexing. +func (m *ManagedWallet) Normalize() { + m.IdempotencyKey = strings.TrimSpace(m.IdempotencyKey) + m.WalletRef = strings.TrimSpace(m.WalletRef) + m.OrganizationRef = strings.TrimSpace(m.OrganizationRef) + m.OwnerRef = strings.TrimSpace(m.OwnerRef) + m.Name = strings.TrimSpace(m.Name) + if m.Description != nil { + desc := strings.TrimSpace(*m.Description) + if desc == "" { + m.Description = nil + } else { + m.Description = &desc + } + } + m.Network = strings.TrimSpace(strings.ToLower(m.Network)) + m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol)) + m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress)) + m.DepositAddress = normalizeWalletAddress(m.DepositAddress) + m.KeyReference = strings.TrimSpace(m.KeyReference) +} + +// Normalize trims wallet balance identifiers. +func (b *WalletBalance) Normalize() { + b.WalletRef = strings.TrimSpace(b.WalletRef) +} + +func normalizeWalletAddress(address string) string { + trimmed := strings.TrimSpace(address) + if trimmed == "" { + return "" + } + if isHexAddress(trimmed) { + return strings.ToLower(trimmed) + } + return trimmed +} + +func isHexAddress(value string) bool { + trimmed := strings.TrimPrefix(strings.TrimSpace(value), "0x") + if len(trimmed) != 40 && len(trimmed) != 42 { + return false + } + for _, r := range trimmed { + switch { + case r >= '0' && r <= '9': + case r >= 'a' && r <= 'f': + case r >= 'A' && r <= 'F': + default: + return false + } + } + return true +} diff --git a/api/gateway/tron/storage/mongo/repository.go b/api/gateway/tron/storage/mongo/repository.go new file mode 100644 index 00000000..1c5e4e34 --- /dev/null +++ b/api/gateway/tron/storage/mongo/repository.go @@ -0,0 +1,98 @@ +package mongo + +import ( + "context" + "time" + + "github.com/tech/sendico/gateway/tron/storage" + "github.com/tech/sendico/gateway/tron/storage/mongo/store" + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/mongo" + "go.uber.org/zap" +) + +// Store implements storage.Repository backed by MongoDB. +type Store struct { + logger mlogger.Logger + conn *db.MongoConnection + db *mongo.Database + + wallets storage.WalletsStore + transfers storage.TransfersStore + deposits storage.DepositsStore +} + +// New creates a new Mongo-backed repository. +func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) { + if conn == nil { + return nil, merrors.InvalidArgument("mongo connection is nil") + } + client := conn.Client() + if client == nil { + return nil, merrors.Internal("mongo client is not initialised") + } + + result := &Store{ + logger: logger.Named("storage").Named("mongo"), + conn: conn, + db: conn.Database(), + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := result.Ping(ctx); err != nil { + result.logger.Error("Mongo ping failed during repository initialisation", zap.Error(err)) + return nil, err + } + + walletsStore, err := store.NewWallets(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise wallets store", zap.Error(err)) + return nil, err + } + transfersStore, err := store.NewTransfers(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise transfers store", zap.Error(err)) + return nil, err + } + depositsStore, err := store.NewDeposits(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise deposits store", zap.Error(err)) + return nil, err + } + + result.wallets = walletsStore + result.transfers = transfersStore + result.deposits = depositsStore + + result.logger.Info("Chain gateway MongoDB storage initialised") + return result, nil +} + +// Ping verifies the MongoDB connection. +func (s *Store) Ping(ctx context.Context) error { + if s.conn == nil { + return merrors.InvalidArgument("mongo connection is nil") + } + return s.conn.Ping(ctx) +} + +// Wallets returns the wallets store. +func (s *Store) Wallets() storage.WalletsStore { + return s.wallets +} + +// Transfers returns the transfers store. +func (s *Store) Transfers() storage.TransfersStore { + return s.transfers +} + +// Deposits returns the deposits store. +func (s *Store) Deposits() storage.DepositsStore { + return s.deposits +} + +var _ storage.Repository = (*Store)(nil) diff --git a/api/gateway/tron/storage/mongo/store/deposits.go b/api/gateway/tron/storage/mongo/store/deposits.go new file mode 100644 index 00000000..46bc5c9d --- /dev/null +++ b/api/gateway/tron/storage/mongo/store/deposits.go @@ -0,0 +1,161 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/gateway/tron/storage" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/db/repository" + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + "go.mongodb.org/mongo-driver/mongo" + "go.uber.org/zap" +) + +const ( + defaultDepositPageSize int64 = 100 + maxDepositPageSize int64 = 500 +) + +type Deposits struct { + logger mlogger.Logger + repo repository.Repository +} + +// NewDeposits constructs a Mongo-backed deposits store. +func NewDeposits(logger mlogger.Logger, db *mongo.Database) (*Deposits, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + repo := repository.CreateMongoRepository(db, mservice.ChainDeposits) + indexes := []*ri.Definition{ + { + Keys: []ri.Key{{Field: "depositRef", Sort: ri.Asc}}, + Unique: true, + }, + { + Keys: []ri.Key{{Field: "walletRef", Sort: ri.Asc}, {Field: "status", Sort: ri.Asc}}, + }, + { + Keys: []ri.Key{{Field: "txHash", Sort: ri.Asc}}, + Unique: true, + }, + } + for _, def := range indexes { + if err := repo.CreateIndex(def); err != nil { + logger.Error("Failed to ensure deposit index", zap.Error(err), zap.String("collection", repo.Collection())) + return nil, err + } + } + + childLogger := logger.Named("deposits") + childLogger.Debug("Deposits store initialised") + + return &Deposits{logger: childLogger, repo: repo}, nil +} + +func (d *Deposits) Record(ctx context.Context, deposit *model.Deposit) error { + if deposit == nil { + return merrors.InvalidArgument("depositsStore: nil deposit") + } + deposit.Normalize() + if strings.TrimSpace(deposit.DepositRef) == "" { + return merrors.InvalidArgument("depositsStore: empty depositRef") + } + if deposit.Status == "" { + deposit.Status = model.DepositStatusPending + } + if deposit.ObservedAt.IsZero() { + deposit.ObservedAt = time.Now().UTC() + } + if deposit.RecordedAt.IsZero() { + deposit.RecordedAt = time.Now().UTC() + } + if deposit.LastStatusAt.IsZero() { + deposit.LastStatusAt = time.Now().UTC() + } + + existing := &model.Deposit{} + err := d.repo.FindOneByFilter(ctx, repository.Filter("depositRef", deposit.DepositRef), existing) + switch { + case err == nil: + existing.Status = deposit.Status + existing.ObservedAt = deposit.ObservedAt + existing.RecordedAt = deposit.RecordedAt + existing.LastStatusAt = time.Now().UTC() + if deposit.Amount != nil { + existing.Amount = deposit.Amount + } + if deposit.BlockID != "" { + existing.BlockID = deposit.BlockID + } + if deposit.TxHash != "" { + existing.TxHash = deposit.TxHash + } + if deposit.Network != "" { + existing.Network = deposit.Network + } + if deposit.TokenSymbol != "" { + existing.TokenSymbol = deposit.TokenSymbol + } + if deposit.ContractAddress != "" { + existing.ContractAddress = deposit.ContractAddress + } + if deposit.SourceAddress != "" { + existing.SourceAddress = deposit.SourceAddress + } + if err := d.repo.Update(ctx, existing); err != nil { + return err + } + return nil + case errors.Is(err, merrors.ErrNoData): + if err := d.repo.Insert(ctx, deposit, repository.Filter("depositRef", deposit.DepositRef)); err != nil { + return err + } + return nil + default: + return err + } +} + +func (d *Deposits) ListPending(ctx context.Context, network string, limit int32) ([]*model.Deposit, error) { + query := repository.Query().Filter(repository.Field("status"), model.DepositStatusPending) + if net := strings.TrimSpace(network); net != "" { + query = query.Filter(repository.Field("network"), strings.ToLower(net)) + } + pageSize := sanitizeDepositLimit(limit) + query = query.Sort(repository.Field("observedAt"), true).Limit(&pageSize) + + deposits := make([]*model.Deposit, 0, pageSize) + decoder := func(cur *mongo.Cursor) error { + item := &model.Deposit{} + if err := cur.Decode(item); err != nil { + return err + } + deposits = append(deposits, item) + return nil + } + + if err := d.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) { + return nil, err + } + + return deposits, nil +} + +func sanitizeDepositLimit(requested int32) int64 { + if requested <= 0 { + return defaultDepositPageSize + } + if requested > int32(maxDepositPageSize) { + return maxDepositPageSize + } + return int64(requested) +} + +var _ storage.DepositsStore = (*Deposits)(nil) diff --git a/api/gateway/tron/storage/mongo/store/transfers.go b/api/gateway/tron/storage/mongo/store/transfers.go new file mode 100644 index 00000000..9f116720 --- /dev/null +++ b/api/gateway/tron/storage/mongo/store/transfers.go @@ -0,0 +1,200 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/gateway/tron/storage" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/db/repository" + "github.com/tech/sendico/pkg/db/repository/builder" + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.uber.org/zap" +) + +const ( + defaultTransferPageSize int64 = 50 + maxTransferPageSize int64 = 200 +) + +type Transfers struct { + logger mlogger.Logger + repo repository.Repository +} + +// NewTransfers constructs a Mongo-backed transfers store. +func NewTransfers(logger mlogger.Logger, db *mongo.Database) (*Transfers, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + repo := repository.CreateMongoRepository(db, mservice.ChainTransfers) + indexes := []*ri.Definition{ + { + Keys: []ri.Key{{Field: "transferRef", Sort: ri.Asc}}, + Unique: true, + }, + { + Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}}, + Unique: true, + }, + { + Keys: []ri.Key{{Field: "sourceWalletRef", Sort: ri.Asc}, {Field: "status", Sort: ri.Asc}}, + }, + { + Keys: []ri.Key{{Field: "destination.managedWalletRef", Sort: ri.Asc}}, + }, + } + for _, def := range indexes { + if err := repo.CreateIndex(def); err != nil { + logger.Error("Failed to ensure transfer index", zap.Error(err), zap.String("collection", repo.Collection())) + return nil, err + } + } + + childLogger := logger.Named("transfers") + childLogger.Debug("Transfers store initialised") + + return &Transfers{ + logger: childLogger, + repo: repo, + }, nil +} + +func (t *Transfers) Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) { + if transfer == nil { + return nil, merrors.InvalidArgument("transfersStore: nil transfer") + } + transfer.Normalize() + if strings.TrimSpace(transfer.TransferRef) == "" { + return nil, merrors.InvalidArgument("transfersStore: empty transferRef") + } + if strings.TrimSpace(transfer.IdempotencyKey) == "" { + return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey") + } + if transfer.Status == "" { + transfer.Status = model.TransferStatusPending + } + if transfer.LastStatusAt.IsZero() { + transfer.LastStatusAt = time.Now().UTC() + } + if strings.TrimSpace(transfer.IdempotencyKey) == "" { + return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey") + } + if err := t.repo.Insert(ctx, transfer, repository.Filter("idempotencyKey", transfer.IdempotencyKey)); err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + t.logger.Debug("Transfer already exists", zap.String("transfer_ref", transfer.TransferRef), zap.String("idempotency_key", transfer.IdempotencyKey)) + return transfer, nil + } + return nil, err + } + t.logger.Debug("Transfer created", zap.String("transfer_ref", transfer.TransferRef)) + return transfer, nil +} + +func (t *Transfers) Get(ctx context.Context, transferRef string) (*model.Transfer, error) { + transferRef = strings.TrimSpace(transferRef) + if transferRef == "" { + return nil, merrors.InvalidArgument("transfersStore: empty transferRef") + } + transfer := &model.Transfer{} + if err := t.repo.FindOneByFilter(ctx, repository.Filter("transferRef", transferRef), transfer); err != nil { + return nil, err + } + return transfer, nil +} + +func (t *Transfers) List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) { + query := repository.Query() + if src := strings.TrimSpace(filter.SourceWalletRef); src != "" { + query = query.Filter(repository.Field("sourceWalletRef"), src) + } + if dst := strings.TrimSpace(filter.DestinationWalletRef); dst != "" { + query = query.Filter(repository.Field("destination.managedWalletRef"), dst) + } + if status := strings.TrimSpace(string(filter.Status)); status != "" { + query = query.Filter(repository.Field("status"), status) + } + + if cursor := strings.TrimSpace(filter.Cursor); cursor != "" { + if oid, err := primitive.ObjectIDFromHex(cursor); err == nil { + query = query.Comparison(repository.IDField(), builder.Gt, oid) + } else { + t.logger.Warn("Ignoring invalid transfer cursor", zap.String("cursor", cursor), zap.Error(err)) + } + } + + limit := sanitizeTransferLimit(filter.Limit) + fetchLimit := limit + 1 + query = query.Sort(repository.IDField(), true).Limit(&fetchLimit) + + transfers := make([]*model.Transfer, 0, fetchLimit) + decoder := func(cur *mongo.Cursor) error { + item := &model.Transfer{} + if err := cur.Decode(item); err != nil { + return err + } + transfers = append(transfers, item) + return nil + } + + if err := t.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) { + return nil, err + } + + nextCursor := "" + if int64(len(transfers)) == fetchLimit { + last := transfers[len(transfers)-1] + nextCursor = last.ID.Hex() + transfers = transfers[:len(transfers)-1] + } + + return &model.TransferList{ + Items: transfers, + NextCursor: nextCursor, + }, nil +} + +func (t *Transfers) UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error) { + transferRef = strings.TrimSpace(transferRef) + if transferRef == "" { + return nil, merrors.InvalidArgument("transfersStore: empty transferRef") + } + transfer := &model.Transfer{} + if err := t.repo.FindOneByFilter(ctx, repository.Filter("transferRef", transferRef), transfer); err != nil { + return nil, err + } + + transfer.Status = status + if status == model.TransferStatusFailed { + transfer.FailureReason = strings.TrimSpace(failureReason) + } else { + transfer.FailureReason = "" + } + if hash := strings.TrimSpace(txHash); hash != "" { + transfer.TxHash = strings.ToLower(hash) + } + transfer.LastStatusAt = time.Now().UTC() + if err := t.repo.Update(ctx, transfer); err != nil { + return nil, err + } + return transfer, nil +} + +func sanitizeTransferLimit(requested int32) int64 { + if requested <= 0 { + return defaultTransferPageSize + } + if requested > int32(maxTransferPageSize) { + return maxTransferPageSize + } + return int64(requested) +} + +var _ storage.TransfersStore = (*Transfers)(nil) diff --git a/api/gateway/tron/storage/mongo/store/wallets.go b/api/gateway/tron/storage/mongo/store/wallets.go new file mode 100644 index 00000000..19c9865d --- /dev/null +++ b/api/gateway/tron/storage/mongo/store/wallets.go @@ -0,0 +1,288 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/tech/sendico/gateway/tron/storage" + "github.com/tech/sendico/gateway/tron/storage/model" + "github.com/tech/sendico/pkg/db/repository" + "github.com/tech/sendico/pkg/db/repository/builder" + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + mutil "github.com/tech/sendico/pkg/mutil/db" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.uber.org/zap" +) + +const ( + defaultWalletPageSize int64 = 50 + maxWalletPageSize int64 = 200 +) + +type Wallets struct { + logger mlogger.Logger + walletRepo repository.Repository + balanceRepo repository.Repository +} + +// NewWallets constructs a Mongo-backed wallets store. +func NewWallets(logger mlogger.Logger, db *mongo.Database) (*Wallets, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + + walletRepo := repository.CreateMongoRepository(db, mservice.ChainWallets) + walletIndexes := []*ri.Definition{ + { + Keys: []ri.Key{{Field: "walletRef", Sort: ri.Asc}}, + Unique: true, + }, + { + Keys: []ri.Key{{Field: "idempotencyKey", Sort: ri.Asc}}, + Unique: true, + }, + { + Keys: []ri.Key{{Field: "depositAddress", Sort: ri.Asc}}, + Unique: true, + }, + { + Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}, {Field: "ownerRef", Sort: ri.Asc}}, + }, + } + for _, def := range walletIndexes { + if err := walletRepo.CreateIndex(def); err != nil { + logger.Error("Failed to ensure wallet index", zap.String("collection", walletRepo.Collection()), zap.Error(err)) + return nil, err + } + } + + balanceRepo := repository.CreateMongoRepository(db, mservice.ChainWalletBalances) + balanceIndexes := []*ri.Definition{ + { + Keys: []ri.Key{{Field: "walletRef", Sort: ri.Asc}}, + Unique: true, + }, + } + for _, def := range balanceIndexes { + if err := balanceRepo.CreateIndex(def); err != nil { + logger.Error("Failed to ensure wallet balance index", zap.String("collection", balanceRepo.Collection()), zap.Error(err)) + return nil, err + } + } + + childLogger := logger.Named("wallets") + childLogger.Debug("Wallet stores initialised") + + return &Wallets{ + logger: childLogger, + walletRepo: walletRepo, + balanceRepo: balanceRepo, + }, nil +} + +func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*model.ManagedWallet, error) { + if wallet == nil { + return nil, merrors.InvalidArgument("walletsStore: nil wallet") + } + wallet.Normalize() + if strings.TrimSpace(wallet.WalletRef) == "" { + return nil, merrors.InvalidArgument("walletsStore: empty walletRef") + } + if wallet.Status == "" { + wallet.Status = model.ManagedWalletStatusActive + } + if strings.TrimSpace(wallet.IdempotencyKey) == "" { + return nil, merrors.InvalidArgument("walletsStore: empty idempotencyKey") + } + fields := []zap.Field{ + zap.String("wallet_ref", wallet.WalletRef), + zap.String("idempotency_key", wallet.IdempotencyKey), + } + if wallet.OrganizationRef != "" { + fields = append(fields, zap.String("organization_ref", wallet.OrganizationRef)) + } + if wallet.OwnerRef != "" { + fields = append(fields, zap.String("owner_ref", wallet.OwnerRef)) + } + if wallet.Network != "" { + fields = append(fields, zap.String("network", wallet.Network)) + } + if wallet.TokenSymbol != "" { + fields = append(fields, zap.String("token_symbol", wallet.TokenSymbol)) + } + if err := w.walletRepo.Insert(ctx, wallet, repository.Filter("idempotencyKey", wallet.IdempotencyKey)); err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + w.logger.Debug("Wallet already exists", fields...) + return wallet, nil + } + w.logger.Warn("Wallet create failed", append(fields, zap.Error(err))...) + return nil, err + } + w.logger.Debug("Wallet created", fields...) + return wallet, nil +} + +func (w *Wallets) Get(ctx context.Context, walletID string) (*model.ManagedWallet, error) { + walletID = strings.TrimSpace(walletID) + if walletID == "" { + return nil, merrors.InvalidArgument("walletsStore: empty walletRef") + } + fields := []zap.Field{ + zap.String("wallet_id", walletID), + } + wallet := &model.ManagedWallet{} + if err := w.walletRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), wallet); err != nil { + if errors.Is(err, merrors.ErrNoData) { + w.logger.Debug("Wallet not found", fields...) + } else { + w.logger.Warn("Wallet lookup failed", append(fields, zap.Error(err))...) + } + return nil, err + } + return wallet, nil +} + +func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) { + query := repository.Query() + fields := make([]zap.Field, 0, 6) + + if org := strings.TrimSpace(filter.OrganizationRef); org != "" { + query = query.Filter(repository.Field("organizationRef"), org) + fields = append(fields, zap.String("organization_ref", org)) + } + if filter.OwnerRefFilter != nil { + ownerRef := strings.TrimSpace(*filter.OwnerRefFilter) + query = query.Filter(repository.Field("ownerRef"), ownerRef) + fields = append(fields, zap.String("owner_ref_filter", ownerRef)) + } + if network := strings.TrimSpace(filter.Network); network != "" { + normalized := strings.ToLower(network) + query = query.Filter(repository.Field("network"), normalized) + fields = append(fields, zap.String("network", normalized)) + } + if token := strings.TrimSpace(filter.TokenSymbol); token != "" { + normalized := strings.ToUpper(token) + query = query.Filter(repository.Field("tokenSymbol"), normalized) + fields = append(fields, zap.String("token_symbol", normalized)) + } + + if cursor := strings.TrimSpace(filter.Cursor); cursor != "" { + if oid, err := primitive.ObjectIDFromHex(cursor); err == nil { + query = query.Comparison(repository.IDField(), builder.Gt, oid) + fields = append(fields, zap.String("cursor", cursor)) + } else { + w.logger.Warn("Ignoring invalid wallet cursor", zap.String("cursor", cursor), zap.Error(err)) + } + } + + limit := sanitizeWalletLimit(filter.Limit) + fields = append(fields, zap.Int64("limit", limit)) + fetchLimit := limit + 1 + query = query.Sort(repository.IDField(), true).Limit(&fetchLimit) + + wallets, listErr := mutil.GetObjects[model.ManagedWallet](ctx, w.logger, query, nil, w.walletRepo) + if listErr != nil && !errors.Is(listErr, merrors.ErrNoData) { + w.logger.Warn("Wallet list failed", append(fields, zap.Error(listErr))...) + return nil, listErr + } + + nextCursor := "" + if int64(len(wallets)) == fetchLimit { + last := wallets[len(wallets)-1] + nextCursor = last.ID.Hex() + wallets = wallets[:len(wallets)-1] + } + + result := &model.ManagedWalletList{ + Items: wallets, + NextCursor: nextCursor, + } + + fields = append(fields, + zap.Int("count", len(result.Items)), + zap.String("next_cursor", result.NextCursor), + ) + if errors.Is(listErr, merrors.ErrNoData) { + w.logger.Debug("Wallet list empty", fields...) + } else { + w.logger.Debug("Wallet list fetched", fields...) + } + return result, nil +} + +func (w *Wallets) SaveBalance(ctx context.Context, balance *model.WalletBalance) error { + if balance == nil { + return merrors.InvalidArgument("walletsStore: nil balance") + } + balance.Normalize() + if strings.TrimSpace(balance.WalletRef) == "" { + return merrors.InvalidArgument("walletsStore: empty walletRef for balance") + } + if balance.CalculatedAt.IsZero() { + balance.CalculatedAt = time.Now().UTC() + } + fields := []zap.Field{zap.String("wallet_ref", balance.WalletRef)} + + existing := &model.WalletBalance{} + err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", balance.WalletRef), existing) + switch { + case err == nil: + existing.Available = balance.Available + existing.PendingInbound = balance.PendingInbound + existing.PendingOutbound = balance.PendingOutbound + existing.CalculatedAt = balance.CalculatedAt + if err := w.balanceRepo.Update(ctx, existing); err != nil { + w.logger.Warn("Wallet balance update failed", append(fields, zap.Error(err))...) + return err + } + w.logger.Debug("Wallet balance updated", fields...) + return nil + case errors.Is(err, merrors.ErrNoData): + if err := w.balanceRepo.Insert(ctx, balance, repository.Filter("walletRef", balance.WalletRef)); err != nil { + w.logger.Warn("Wallet balance create failed", append(fields, zap.Error(err))...) + return err + } + w.logger.Debug("Wallet balance created", fields...) + return nil + default: + w.logger.Warn("Wallet balance lookup failed", append(fields, zap.Error(err))...) + return err + } +} + +func (w *Wallets) GetBalance(ctx context.Context, walletID string) (*model.WalletBalance, error) { + walletID = strings.TrimSpace(walletID) + if walletID == "" { + return nil, merrors.InvalidArgument("walletsStore: empty walletRef") + } + fields := []zap.Field{zap.String("wallet_ref", walletID)} + balance := &model.WalletBalance{} + if err := w.balanceRepo.FindOneByFilter(ctx, repository.Filter("walletRef", walletID), balance); err != nil { + if errors.Is(err, merrors.ErrNoData) { + w.logger.Debug("Wallet balance not found", fields...) + } else { + w.logger.Warn("Wallet balance lookup failed", append(fields, zap.Error(err))...) + } + return nil, err + } + w.logger.Debug("Wallet balance fetched", fields...) + return balance, nil +} + +func sanitizeWalletLimit(requested int32) int64 { + if requested <= 0 { + return defaultWalletPageSize + } + if requested > int32(maxWalletPageSize) { + return maxWalletPageSize + } + return int64(requested) +} + +var _ storage.WalletsStore = (*Wallets)(nil) diff --git a/api/gateway/tron/storage/storage.go b/api/gateway/tron/storage/storage.go new file mode 100644 index 00000000..28d71d3c --- /dev/null +++ b/api/gateway/tron/storage/storage.go @@ -0,0 +1,53 @@ +package storage + +import ( + "context" + + "github.com/tech/sendico/gateway/tron/storage/model" +) + +type storageError string + +func (e storageError) Error() string { + return string(e) +} + +var ( + // ErrWalletNotFound indicates that a wallet record was not found. + ErrWalletNotFound = storageError("chain.gateway.storage: wallet not found") + // ErrTransferNotFound indicates that a transfer record was not found. + ErrTransferNotFound = storageError("chain.gateway.storage: transfer not found") + // ErrDepositNotFound indicates that a deposit record was not found. + ErrDepositNotFound = storageError("chain.gateway.storage: deposit not found") +) + +// Repository represents the root storage contract for the chain gateway module. +type Repository interface { + Ping(ctx context.Context) error + Wallets() WalletsStore + Transfers() TransfersStore + Deposits() DepositsStore +} + +// WalletsStore exposes persistence operations for managed wallets. +type WalletsStore interface { + Create(ctx context.Context, wallet *model.ManagedWallet) (*model.ManagedWallet, error) + Get(ctx context.Context, walletRef string) (*model.ManagedWallet, error) + List(ctx context.Context, filter model.ManagedWalletFilter) (*model.ManagedWalletList, error) + SaveBalance(ctx context.Context, balance *model.WalletBalance) error + GetBalance(ctx context.Context, walletRef string) (*model.WalletBalance, error) +} + +// TransfersStore exposes persistence operations for transfers. +type TransfersStore interface { + Create(ctx context.Context, transfer *model.Transfer) (*model.Transfer, error) + Get(ctx context.Context, transferRef string) (*model.Transfer, error) + List(ctx context.Context, filter model.TransferFilter) (*model.TransferList, error) + UpdateStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason string, txHash string) (*model.Transfer, error) +} + +// DepositsStore exposes persistence operations for observed deposits. +type DepositsStore interface { + Record(ctx context.Context, deposit *model.Deposit) error + ListPending(ctx context.Context, network string, limit int32) ([]*model.Deposit, error) +}