new tron gateway
This commit is contained in:
46
api/gateway/tron/.air.toml
Normal file
46
api/gateway/tron/.air.toml
Normal file
@@ -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
|
||||
4
api/gateway/tron/.gitignore
vendored
Normal file
4
api/gateway/tron/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
internal/generated
|
||||
.gocache
|
||||
app
|
||||
tmp
|
||||
792
api/gateway/tron/client/client.go
Normal file
792
api/gateway/tron/client/client.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
68
api/gateway/tron/client/client_test.go
Normal file
68
api/gateway/tron/client/client_test.go
Normal file
@@ -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())
|
||||
}
|
||||
20
api/gateway/tron/client/config.go
Normal file
20
api/gateway/tron/client/config.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
99
api/gateway/tron/client/fake.go
Normal file
99
api/gateway/tron/client/fake.go
Normal file
@@ -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
|
||||
}
|
||||
287
api/gateway/tron/client/rail_gateway.go
Normal file
287
api/gateway/tron/client/rail_gateway.go
Normal file
@@ -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
|
||||
}
|
||||
72
api/gateway/tron/config.dev.yml
Normal file
72
api/gateway/tron/config.dev.yml
Normal file
@@ -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
|
||||
72
api/gateway/tron/config.yml
Normal file
72
api/gateway/tron/config.yml
Normal file
@@ -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
|
||||
15
api/gateway/tron/entrypoint.sh
Normal file
15
api/gateway/tron/entrypoint.sh
Normal file
@@ -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 "$@"
|
||||
1
api/gateway/tron/env/.gitignore
vendored
Normal file
1
api/gateway/tron/env/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env.api
|
||||
99
api/gateway/tron/go.mod
Normal file
99
api/gateway/tron/go.mod
Normal file
@@ -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
|
||||
)
|
||||
400
api/gateway/tron/go.sum
Normal file
400
api/gateway/tron/go.sum
Normal file
@@ -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=
|
||||
27
api/gateway/tron/internal/appversion/version.go
Normal file
27
api/gateway/tron/internal/appversion/version.go
Normal file
@@ -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)
|
||||
}
|
||||
13
api/gateway/tron/internal/keymanager/config.go
Normal file
13
api/gateway/tron/internal/keymanager/config.go
Normal file
@@ -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]
|
||||
26
api/gateway/tron/internal/keymanager/keymanager.go
Normal file
26
api/gateway/tron/internal/keymanager/keymanager.go
Normal file
@@ -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)
|
||||
}
|
||||
341
api/gateway/tron/internal/keymanager/vault/manager.go
Normal file
341
api/gateway/tron/internal/keymanager/vault/manager.go
Normal file
@@ -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)
|
||||
431
api/gateway/tron/internal/server/internal/serverimp.go
Normal file
431
api/gateway/tron/internal/server/internal/serverimp.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
12
api/gateway/tron/internal/server/server.go
Normal file
12
api/gateway/tron/internal/server/server.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
629
api/gateway/tron/internal/service/gateway/connector.go
Normal file
629
api/gateway/tron/internal/service/gateway/connector.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
36
api/gateway/tron/internal/service/gateway/driver/driver.go
Normal file
36
api/gateway/tron/internal/service/gateway/driver/driver.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
734
api/gateway/tron/internal/service/gateway/driver/evm/evm.go
Normal file
734
api/gateway/tron/internal/service/gateway/driver/evm/evm.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
193
api/gateway/tron/internal/service/gateway/driver/tron/address.go
Normal file
193
api/gateway/tron/internal/service/gateway/driver/tron/address.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
304
api/gateway/tron/internal/service/gateway/driver/tron/driver.go
Normal file
304
api/gateway/tron/internal/service/gateway/driver/tron/driver.go
Normal file
@@ -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)
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
349
api/gateway/tron/internal/service/gateway/executor.go
Normal file
349
api/gateway/tron/internal/service/gateway/executor.go
Normal file
@@ -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)
|
||||
}
|
||||
65
api/gateway/tron/internal/service/gateway/metrics.go
Normal file
65
api/gateway/tron/internal/service/gateway/metrics.go
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
99
api/gateway/tron/internal/service/gateway/options.go
Normal file
99
api/gateway/tron/internal/service/gateway/options.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
204
api/gateway/tron/internal/service/gateway/rpcclient/clients.go
Normal file
204
api/gateway/tron/internal/service/gateway/rpcclient/clients.go
Normal file
@@ -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] + "..."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
225
api/gateway/tron/internal/service/gateway/service.go
Normal file
225
api/gateway/tron/internal/service/gateway/service.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
664
api/gateway/tron/internal/service/gateway/service_test.go
Normal file
664
api/gateway/tron/internal/service/gateway/service_test.go
Normal file
@@ -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
|
||||
}
|
||||
43
api/gateway/tron/internal/service/gateway/settings.go
Normal file
43
api/gateway/tron/internal/service/gateway/settings.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
148
api/gateway/tron/internal/service/gateway/shared/helpers.go
Normal file
148
api/gateway/tron/internal/service/gateway/shared/helpers.go
Normal file
@@ -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
|
||||
}
|
||||
50
api/gateway/tron/internal/service/gateway/shared/hex.go
Normal file
50
api/gateway/tron/internal/service/gateway/shared/hex.go
Normal file
@@ -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
|
||||
}
|
||||
16
api/gateway/tron/internal/service/gateway/shared/hex_test.go
Normal file
16
api/gateway/tron/internal/service/gateway/shared/hex_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
142
api/gateway/tron/internal/service/gateway/transfer_execution.go
Normal file
142
api/gateway/tron/internal/service/gateway/transfer_execution.go
Normal file
@@ -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)
|
||||
}
|
||||
219
api/gateway/tron/internal/service/gateway/tronclient/client.go
Normal file
219
api/gateway/tron/internal/service/gateway/tronclient/client.go
Normal file
@@ -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)
|
||||
}
|
||||
127
api/gateway/tron/internal/service/gateway/tronclient/registry.go
Normal file
127
api/gateway/tron/internal/service/gateway/tronclient/registry.go
Normal file
@@ -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)
|
||||
}
|
||||
17
api/gateway/tron/main.go
Normal file
17
api/gateway/tron/main.go
Normal file
@@ -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)
|
||||
}
|
||||
54
api/gateway/tron/storage/model/deposit.go
Normal file
54
api/gateway/tron/storage/model/deposit.go
Normal file
@@ -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)
|
||||
}
|
||||
93
api/gateway/tron/storage/model/transfer.go
Normal file
93
api/gateway/tron/storage/model/transfer.go
Normal file
@@ -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)
|
||||
}
|
||||
50
api/gateway/tron/storage/model/transfer_test.go
Normal file
50
api/gateway/tron/storage/model/transfer_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTransferNormalizePreservesBase58ExternalAddress(t *testing.T) {
|
||||
address := "TGBDXEg9rxSqGFJDcb889zqTjDwx1bmLRF"
|
||||
transfer := &Transfer{
|
||||
IdempotencyKey: "idemp",
|
||||
TransferRef: "ref",
|
||||
OrganizationRef: "org",
|
||||
SourceWalletRef: "wallet",
|
||||
Network: "tron_mainnet",
|
||||
TokenSymbol: "USDT",
|
||||
Destination: TransferDestination{
|
||||
ExternalAddress: address,
|
||||
ExternalAddressOriginal: address,
|
||||
},
|
||||
}
|
||||
|
||||
transfer.Normalize()
|
||||
|
||||
if transfer.Destination.ExternalAddress != address {
|
||||
t.Fatalf("expected external address to preserve case, got %q", transfer.Destination.ExternalAddress)
|
||||
}
|
||||
if transfer.Destination.ExternalAddressOriginal != address {
|
||||
t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransferNormalizeLowercasesHexExternalAddress(t *testing.T) {
|
||||
address := "0xAABBCCDDEEFF00112233445566778899AABBCCDD"
|
||||
transfer := &Transfer{
|
||||
Destination: TransferDestination{
|
||||
ExternalAddress: address,
|
||||
ExternalAddressOriginal: address,
|
||||
},
|
||||
}
|
||||
|
||||
transfer.Normalize()
|
||||
|
||||
if transfer.Destination.ExternalAddress != strings.ToLower(address) {
|
||||
t.Fatalf("expected hex external address to be lowercased, got %q", transfer.Destination.ExternalAddress)
|
||||
}
|
||||
if transfer.Destination.ExternalAddressOriginal != address {
|
||||
t.Fatalf("expected external address original to preserve case, got %q", transfer.Destination.ExternalAddressOriginal)
|
||||
}
|
||||
}
|
||||
134
api/gateway/tron/storage/model/wallet.go
Normal file
134
api/gateway/tron/storage/model/wallet.go
Normal file
@@ -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
|
||||
}
|
||||
98
api/gateway/tron/storage/mongo/repository.go
Normal file
98
api/gateway/tron/storage/mongo/repository.go
Normal file
@@ -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)
|
||||
161
api/gateway/tron/storage/mongo/store/deposits.go
Normal file
161
api/gateway/tron/storage/mongo/store/deposits.go
Normal file
@@ -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)
|
||||
200
api/gateway/tron/storage/mongo/store/transfers.go
Normal file
200
api/gateway/tron/storage/mongo/store/transfers.go
Normal file
@@ -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)
|
||||
288
api/gateway/tron/storage/mongo/store/wallets.go
Normal file
288
api/gateway/tron/storage/mongo/store/wallets.go
Normal file
@@ -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)
|
||||
53
api/gateway/tron/storage/storage.go
Normal file
53
api/gateway/tron/storage/storage.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user