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() {
No alerts yet