diff --git a/api/chain/gateway/internal/service/gateway/commands/wallet/balance.go b/api/chain/gateway/internal/service/gateway/commands/wallet/balance.go index 1be34ab..40a7ecd 100644 --- a/api/chain/gateway/internal/service/gateway/commands/wallet/balance.go +++ b/api/chain/gateway/internal/service/gateway/commands/wallet/balance.go @@ -9,7 +9,9 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mservice" gatewayv1 "github.com/tech/sendico/pkg/proto/chain/gateway/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" "go.uber.org/zap" + "google.golang.org/protobuf/types/known/timestamppb" ) type getWalletBalanceCommand struct { @@ -34,7 +36,7 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *gatewayv1.Ge c.deps.Logger.Warn("wallet_ref missing") return gsresponse.InvalidArgument[gatewayv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("wallet_ref is required")) } - balance, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef) + 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)) @@ -43,5 +45,33 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *gatewayv1.Ge c.deps.Logger.Warn("storage get failed", zap.Error(err), zap.String("wallet_ref", walletRef)) return gsresponse.Auto[gatewayv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) } - return gsresponse.Success(&gatewayv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(balance)}) + + balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet) + if chainErr != nil { + c.deps.Logger.Warn("on-chain balance fetch failed, falling back to stored 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("stored balance not found", zap.String("wallet_ref", walletRef)) + return gsresponse.NotFound[gatewayv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) + } + return gsresponse.Auto[gatewayv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err) + } + return gsresponse.Success(&gatewayv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(stored)}) + } + + return gsresponse.Success(&gatewayv1.GetWalletBalanceResponse{Balance: onChainBalanceToProto(balance)}) +} + +func onChainBalanceToProto(balance *moneyv1.Money) *gatewayv1.WalletBalance { + if balance == nil { + return nil + } + zero := &moneyv1.Money{Currency: balance.Currency, Amount: "0"} + return &gatewayv1.WalletBalance{ + Available: balance, + PendingInbound: zero, + PendingOutbound: zero, + CalculatedAt: timestamppb.Now(), + } } diff --git a/api/chain/gateway/internal/service/gateway/commands/wallet/onchain_balance.go b/api/chain/gateway/internal/service/gateway/commands/wallet/onchain_balance.go new file mode 100644 index 0000000..76b69b6 --- /dev/null +++ b/api/chain/gateway/internal/service/gateway/commands/wallet/onchain_balance.go @@ -0,0 +1,124 @@ +package wallet + +import ( + "context" + "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/ethclient" + "github.com/shopspring/decimal" + "github.com/tech/sendico/chain/gateway/storage/model" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" +) + +func onChainWalletBalance(ctx context.Context, deps Deps, wallet *model.ManagedWallet) (*moneyv1.Money, error) { + network := deps.Networks[strings.ToLower(strings.TrimSpace(wallet.Network))] + rpcURL := strings.TrimSpace(network.RPCURL) + if rpcURL == "" { + return nil, merrors.Internal("network rpc url is not configured") + } + contract := strings.TrimSpace(wallet.ContractAddress) + if contract == "" || !common.IsHexAddress(contract) { + return nil, merrors.InvalidArgument("invalid contract address") + } + if wallet.DepositAddress == "" || !common.IsHexAddress(wallet.DepositAddress) { + return nil, merrors.InvalidArgument("invalid wallet address") + } + + client, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return nil, merrors.Internal("failed to connect rpc: " + err.Error()) + } + defer client.Close() + + timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + tokenABI, err := abi.JSON(strings.NewReader(erc20ABIJSON)) + if err != nil { + return nil, merrors.Internal("failed to parse erc20 abi: " + err.Error()) + } + tokenAddr := common.HexToAddress(contract) + walletAddr := common.HexToAddress(wallet.DepositAddress) + + decimals, err := readDecimals(timeoutCtx, client, tokenABI, tokenAddr) + if err != nil { + return nil, err + } + + bal, err := readBalanceOf(timeoutCtx, client, tokenABI, tokenAddr, walletAddr) + if err != nil { + return nil, err + } + + dec := decimal.NewFromBigInt(bal, 0).Shift(-int32(decimals)) + return &moneyv1.Money{Currency: wallet.TokenSymbol, Amount: dec.String()}, nil +} + +func readDecimals(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address) (uint8, error) { + data, err := tokenABI.Pack("decimals") + if err != nil { + return 0, merrors.Internal("failed to encode decimals call: " + err.Error()) + } + msg := ethereum.CallMsg{To: &token, Data: data} + out, err := client.CallContract(ctx, msg, nil) + if err != nil { + return 0, merrors.Internal("decimals call failed: " + err.Error()) + } + values, err := tokenABI.Unpack("decimals", out) + if err != nil || len(values) == 0 { + return 0, merrors.Internal("failed to unpack decimals") + } + if val, ok := values[0].(uint8); ok { + return val, nil + } + return 0, merrors.Internal("decimals returned unexpected type") +} + +func readBalanceOf(ctx context.Context, client *ethclient.Client, tokenABI abi.ABI, token common.Address, wallet common.Address) (*big.Int, error) { + data, err := tokenABI.Pack("balanceOf", wallet) + if err != nil { + return nil, merrors.Internal("failed to encode balanceOf: " + err.Error()) + } + msg := ethereum.CallMsg{To: &token, Data: data} + out, err := client.CallContract(ctx, msg, nil) + if err != nil { + return nil, merrors.Internal("balanceOf call failed: " + err.Error()) + } + values, err := tokenABI.Unpack("balanceOf", out) + if err != nil || len(values) == 0 { + return nil, merrors.Internal("failed to unpack balanceOf") + } + raw, ok := values[0].(*big.Int) + if !ok { + return nil, merrors.Internal("balanceOf returned unexpected type") + } + return decimal.NewFromBigInt(raw, 0).BigInt(), nil +} + +const erc20ABIJSON = ` +[ + { + "constant": true, + "inputs": [], + "name": "decimals", + "outputs": [{ "name": "", "type": "uint8" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "name": "_owner", "type": "address" }], + "name": "balanceOf", + "outputs": [{ "name": "balance", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + } +]`