From 25850825b40e01a708b873db602a6d8b88573f82 Mon Sep 17 00:00:00 2001 From: KS Jannette Date: Sun, 1 Mar 2026 20:32:21 -0500 Subject: [PATCH] updated types and poller --- backend-go/internal/domain/types.go | 11 ++ .../internal/protocols/ethereum/jsonrpc.go | 126 ++++++++++++++++++ .../internal/protocols/ethereum/observer.go | 1 + .../internal/protocols/ethereum/tokens.go | 34 +++++ backend-go/internal/services/evaluator.go | 37 +++++ backend-go/internal/services/observer.go | 9 ++ backend-go/internal/wei/converter.go | 51 +++++++ frontend/src/App.jsx | 2 +- frontend/src/components/Navbar.jsx | 2 +- frontend/src/pages/AlertHistory.jsx | 2 +- 10 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 backend-go/internal/protocols/ethereum/tokens.go diff --git a/backend-go/internal/domain/types.go b/backend-go/internal/domain/types.go index e01aa64..dfa5201 100644 --- a/backend-go/internal/domain/types.go +++ b/backend-go/internal/domain/types.go @@ -110,6 +110,17 @@ type NormalizedTx struct { Value string `json:"value"` // Wei as string for precision BlockNumber int `json:"block_number"` //nolint:tagliatelle BlockTimestamp int64 `json:"block_timestamp"` //nolint:tagliatelle + + // ERC-20 token transfer fields (nil for native ETH transfers) + TokenContract *string `json:"token_contract,omitempty"` //nolint:tagliatelle + TokenSymbol *string `json:"token_symbol,omitempty"` //nolint:tagliatelle + TokenDecimals *int `json:"token_decimals,omitempty"` //nolint:tagliatelle + TokenValue *string `json:"token_value,omitempty"` //nolint:tagliatelle +} + +// IsTokenTransfer returns true if this transaction represents an ERC-20 token transfer. +func (tx NormalizedTx) IsTokenTransfer() bool { + return tx.TokenContract != nil } type Direction string diff --git a/backend-go/internal/protocols/ethereum/jsonrpc.go b/backend-go/internal/protocols/ethereum/jsonrpc.go index ae77a55..b8a26f2 100644 --- a/backend-go/internal/protocols/ethereum/jsonrpc.go +++ b/backend-go/internal/protocols/ethereum/jsonrpc.go @@ -242,6 +242,132 @@ func (j *JsonRpcEthereum) GetBalance(ctx context.Context, address string) (strin return hexToDecimalString(hexBalance), nil } +const erc20TransferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + +const minTransferTopics = 3 + +type rpcLog struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Data string `json:"data"` + BlockNumber string `json:"blockNumber"` + TxHash string `json:"transactionHash"` +} + +// GetTokenTransfers fetches ERC-20 Transfer events for a given address +// across a block range, covering both incoming and outgoing transfers. +func (j *JsonRpcEthereum) GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error) { + paddedAddr := padAddress(address) + hexFrom := fmt.Sprintf("0x%x", fromBlock) + hexTo := fmt.Sprintf("0x%x", toBlock) + + incomingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, "", paddedAddr) + if err != nil { + return nil, fmt.Errorf("fetch incoming token transfers: %w", err) + } + + outgoingLogs, err := j.getTransferLogs(ctx, hexFrom, hexTo, paddedAddr, "") + if err != nil { + return nil, fmt.Errorf("fetch outgoing token transfers: %w", err) + } + + seen := make(map[string]bool) + var txs []domain.NormalizedTx + + for _, entry := range append(incomingLogs, outgoingLogs...) { + key := entry.TxHash + "|" + entry.Address + "|" + entry.Data + if seen[key] { + continue + } + seen[key] = true + + tx, parseErr := parseTransferLog(entry) + if parseErr != nil { + log.Printf("Skipping unparseable transfer log in tx %s: %v", entry.TxHash, parseErr) + continue + } + txs = append(txs, tx) + } + + return txs, nil +} + +func (j *JsonRpcEthereum) getTransferLogs(ctx context.Context, fromBlock, toBlock, fromAddr, toAddr string) ([]rpcLog, error) { + topics := make([]interface{}, minTransferTopics) + topics[0] = erc20TransferTopic + + if fromAddr != "" { + topics[1] = fromAddr + } + if toAddr != "" { + topics[2] = toAddr + } + + filter := map[string]interface{}{ + "fromBlock": fromBlock, + "toBlock": toBlock, + "topics": topics, + } + + result, err := j.callRPC(ctx, "eth_getLogs", filter) + if err != nil { + return nil, err + } + + var logs []rpcLog + if err := json.Unmarshal(result, &logs); err != nil { + return nil, fmt.Errorf("unmarshal logs: %w", err) + } + + return logs, nil +} + +func parseTransferLog(entry rpcLog) (domain.NormalizedTx, error) { + if len(entry.Topics) < minTransferTopics { + return domain.NormalizedTx{}, fmt.Errorf("transfer log has %d topics, expected >= 3", len(entry.Topics)) + } + + from := topicToAddress(entry.Topics[1]) + to := topicToAddress(entry.Topics[2]) + tokenValue := hexToDecimalString(entry.Data) + blockNumber, _ := hexToInt(entry.BlockNumber) + + contractAddr := strings.ToLower(entry.Address) + tokenInfo, known := LookupToken(contractAddr) + + tx := domain.NormalizedTx{ + Hash: entry.TxHash, + From: from, + To: &to, + Value: "0", + BlockNumber: blockNumber, + TokenContract: &contractAddr, + TokenValue: &tokenValue, + } + + if known { + tx.TokenSymbol = &tokenInfo.Symbol + tx.TokenDecimals = &tokenInfo.Decimals + } + + return tx, nil +} + +func padAddress(addr string) string { + clean := strings.TrimPrefix(strings.ToLower(addr), "0x") + const addressHexLen = 64 + return "0x" + strings.Repeat("0", addressHexLen-len(clean)) + clean +} + +func topicToAddress(topic string) string { + clean := strings.TrimPrefix(topic, "0x") + const ethAddrLen = 40 + if len(clean) > ethAddrLen { + clean = clean[len(clean)-ethAddrLen:] + } + return "0x" + strings.ToLower(clean) +} + func hexToInt(hex string) (int, error) { hex = strings.TrimPrefix(hex, "0x") n, ok := new(big.Int).SetString(hex, 16) diff --git a/backend-go/internal/protocols/ethereum/observer.go b/backend-go/internal/protocols/ethereum/observer.go index 862b2a8..31f554a 100644 --- a/backend-go/internal/protocols/ethereum/observer.go +++ b/backend-go/internal/protocols/ethereum/observer.go @@ -12,4 +12,5 @@ type EthereumObserver interface { GetLatestBlockNumber(ctx context.Context) (int, error) GetBlockTransactions(ctx context.Context, blockNumber int) ([]domain.NormalizedTx, error) GetBalance(ctx context.Context, address string) (string, error) + GetTokenTransfers(ctx context.Context, fromBlock, toBlock int, address string) ([]domain.NormalizedTx, error) } diff --git a/backend-go/internal/protocols/ethereum/tokens.go b/backend-go/internal/protocols/ethereum/tokens.go new file mode 100644 index 0000000..2149035 --- /dev/null +++ b/backend-go/internal/protocols/ethereum/tokens.go @@ -0,0 +1,34 @@ +package ethereum + +import "strings" + +// TokenInfo holds metadata for a known ERC-20 token contract. +type TokenInfo struct { + Symbol string + Decimals int +} + +//nolint:gochecknoglobals +var wellKnownTokens = map[string]TokenInfo{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": {Symbol: "USDT", Decimals: 6}, + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": {Symbol: "USDC", Decimals: 6}, + "0x6b175474e89094c44da98b954eedeac495271d0f": {Symbol: "DAI", Decimals: 18}, + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2": {Symbol: "WETH", Decimals: 18}, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": {Symbol: "WBTC", Decimals: 8}, + "0x514910771af9ca656af840dff83e8264ecf986ca": {Symbol: "LINK", Decimals: 18}, + "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": {Symbol: "UNI", Decimals: 18}, + "0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9": {Symbol: "AAVE", Decimals: 18}, + "0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce": {Symbol: "SHIB", Decimals: 18}, + "0x6982508145454ce325ddbe47a25d4ec3d2311933": {Symbol: "PEPE", Decimals: 18}, + "0xb8c77482e45f1f44de1745f52c74426c631bdd52": {Symbol: "BNB", Decimals: 18}, + "0x4fabb145d64652a948d72533023f6e7a623c7c53": {Symbol: "BUSD", Decimals: 18}, + "0x75231f58b43240c9718dd58b4967c5114342a86c": {Symbol: "OKB", Decimals: 18}, + "0x582d872a1b094fc48f5de31d3b73f2d9be47def1": {Symbol: "TON", Decimals: 9}, + "0x4d224452801aced8b2f0aebe155379bb5d594381": {Symbol: "APE", Decimals: 18}, +} + +// LookupToken returns metadata for a known token contract, if found. +func LookupToken(contractAddress string) (TokenInfo, bool) { + info, ok := wellKnownTokens[strings.ToLower(contractAddress)] + return info, ok +} diff --git a/backend-go/internal/services/evaluator.go b/backend-go/internal/services/evaluator.go index 02b5079..0f151a7 100644 --- a/backend-go/internal/services/evaluator.go +++ b/backend-go/internal/services/evaluator.go @@ -281,6 +281,10 @@ func (s *EvaluatorService) sendNotification(ctx context.Context, userID, message } func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.ObservedTx) string { + if obs.IsTokenTransfer() { + return s.buildTokenMessage(rule, obs) + } + switch rule.Type { case domain.AlertIncomingTx: ethStr, _ := wei.FormatAsEth(obs.Value, 4) @@ -305,3 +309,36 @@ func (s *EvaluatorService) buildMessage(rule domain.AlertRule, obs domain.Observ return "Alert triggered" } } + +const defaultTokenDecimals = 18 + +func (s *EvaluatorService) buildTokenMessage(rule domain.AlertRule, obs domain.ObservedTx) string { + symbol := "tokens" + if obs.TokenSymbol != nil { + symbol = *obs.TokenSymbol + } + + amount := "unknown" + if obs.TokenValue != nil { + decimals := defaultTokenDecimals + if obs.TokenDecimals != nil { + decimals = *obs.TokenDecimals + } + amount = wei.FormatTokenAmount(*obs.TokenValue, decimals) + } + + switch rule.Type { + case domain.AlertIncomingTx: + return fmt.Sprintf("Incoming transfer: %s %s received", amount, symbol) + case domain.AlertOutgoingTx: + return fmt.Sprintf("Outgoing transfer: %s %s sent", amount, symbol) + case domain.AlertLargeTransfer: + threshold := float64(0) + if rule.Threshold != nil { + threshold = *rule.Threshold + } + return fmt.Sprintf("Large token transfer: %s %s (threshold: %g)", amount, symbol, threshold) + default: + return fmt.Sprintf("Token transfer: %s %s", amount, symbol) + } +} diff --git a/backend-go/internal/services/observer.go b/backend-go/internal/services/observer.go index 66cd127..6449912 100644 --- a/backend-go/internal/services/observer.go +++ b/backend-go/internal/services/observer.go @@ -80,6 +80,15 @@ func (s *ObserverService) observeAddress(ctx context.Context, addr domain.Addres } } + tokenTxs, err := s.eth.GetTokenTransfers(ctx, startBlock, endBlock, addr.Address) + if err != nil { + log.Printf("Error fetching token transfers for %s: %v", addr.Address, err) + } else { + for _, tx := range tokenTxs { + observations = append(observations, createObservedTx(tx, addr)) + } + } + if _, err := s.checkpoint.UpdateLastCheckedBlock(ctx, addr.ID, endBlock); err != nil { return nil, err } diff --git a/backend-go/internal/wei/converter.go b/backend-go/internal/wei/converter.go index 0f998b3..2922b7a 100644 --- a/backend-go/internal/wei/converter.go +++ b/backend-go/internal/wei/converter.go @@ -98,3 +98,54 @@ func FormatAsEth(weiString string, decimals int) (string, error) { } return fmt.Sprintf("%.*f ETH", decimals, eth), nil } + +// FormatTokenAmount formats a raw token amount using the token's decimal places. +// For example, 1000000 USDT (6 decimals) becomes "1". +func FormatTokenAmount(rawValue string, tokenDecimals int) string { + if rawValue == "" || rawValue == "0" { + return "0" + } + + n, ok := new(big.Int).SetString(rawValue, 10) + if !ok { + return "0" + } + + divisor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(tokenDecimals)), nil) //nolint:mnd + whole := new(big.Int).Div(n, divisor) + remainder := new(big.Int).Mod(n, divisor) + + if remainder.Sign() == 0 { + return addThousandsSeparators(whole.String()) + } + + fracStr := fmt.Sprintf("%0*s", tokenDecimals, remainder.String()) + fracStr = strings.TrimRight(fracStr, "0") + const maxDisplayDecimals = 4 + if len(fracStr) > maxDisplayDecimals { + fracStr = fracStr[:maxDisplayDecimals] + } + + return addThousandsSeparators(whole.String()) + "." + fracStr +} + +func addThousandsSeparators(s string) string { + if len(s) <= 3 { //nolint:mnd + return s + } + + var result strings.Builder + offset := len(s) % 3 //nolint:mnd + if offset > 0 { + result.WriteString(s[:offset]) + } + + for i := offset; i < len(s); i += 3 { //nolint:mnd + if result.Len() > 0 { + result.WriteByte(',') + } + result.WriteString(s[i : i+3]) //nolint:mnd + } + + return result.String() +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3f16436..c94c658 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -29,7 +29,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index f5cad23..971a1f1 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -4,7 +4,7 @@ import { useAuth } from "../contexts/AuthContext"; const navLinks = [ { to: "/addresses", label: "Addresses" }, { to: "/alerts", label: "Configure Alerts" }, - { to: "/history", label: "Alert History" }, + { to: "/alertevents", label: "Alert Events" }, ]; export default function Navbar() { diff --git a/frontend/src/pages/AlertHistory.jsx b/frontend/src/pages/AlertHistory.jsx index e2af5a4..b04f0d0 100644 --- a/frontend/src/pages/AlertHistory.jsx +++ b/frontend/src/pages/AlertHistory.jsx @@ -36,7 +36,7 @@ export default function AlertHistory() { return (
-

Recent Alerts

+

Recent Alert Events

{alertEvents.length === 0 ? (

No alerts yet