Skip to content

Commit 6326eec

Browse files
authored
Merge pull request #10 from selimerunkut/feature/wallet-onchain-activity-ui
Show live Base Sepolia wallet activity in the Streamlit UI.
2 parents acca6b9 + 06e8b07 commit 6326eec

8 files changed

Lines changed: 475 additions & 46 deletions

File tree

‎app/streamlit_app.py‎

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -288,15 +288,48 @@ def _load_wallet_panel_cache(api_base: str) -> dict:
288288
return cache
289289

290290

291+
def _render_wallet_transactions(transactions: dict | None, *, limit: int = 8) -> None:
292+
txs = (transactions or {}).get("transactions") or []
293+
if not txs:
294+
st.caption("No on-chain activity yet.")
295+
return
296+
st.markdown("**Recent on-chain activity**")
297+
for tx in txs[:limit]:
298+
label = tx.get("operation") or tx.get("transaction_type") or "transfer"
299+
amount = tx.get("amount_usdc", 0)
300+
state = tx.get("state", "")
301+
tx_hash = tx.get("tx_hash", "")
302+
when = tx.get("create_date", "")
303+
explorer = tx.get("explorer_url") or (blockscout_tx_url(tx_hash) if tx_hash.startswith("0x") else "")
304+
amount_bit = f" · {amount:.4f} USDC" if amount else ""
305+
line = f"**{label}**{amount_bit} · {state}"
306+
if explorer:
307+
short = f"{tx_hash[:10]}…{tx_hash[-6:]}" if tx_hash else "view"
308+
st.markdown(f"{line} · [{short}]({explorer})")
309+
else:
310+
st.markdown(line)
311+
if when:
312+
st.caption(when)
313+
314+
291315
def _render_wallet_balance_bar(api_base: str, backend) -> None:
292-
"""Always-visible USDC balances on the main page."""
316+
"""Always-visible USDC balances and recent on-chain activity on the main page."""
293317
cache = _load_wallet_panel_cache(api_base)
294318
balance = cache.get("balance")
319+
transactions = cache.get("transactions")
295320
error = cache.get("error")
296321

297322
usdc_addr = os.getenv("BASE_SEPOLIA_TEST_USDC_ADDRESS", BASE_SEPOLIA_TEST_USDC_ADDRESS_DEFAULT).strip()
298323
token_link = blockscout_usdc_token_url(usdc_addr)
299324

325+
header_cols = st.columns([5, 1])
326+
with header_cols[0]:
327+
st.markdown("##### Base Sepolia wallet")
328+
with header_cols[1]:
329+
if st.button("Refresh", key="refresh_wallet_main", use_container_width=True):
330+
st.session_state.pop("_wallet_panel_cache", None)
331+
st.rerun()
332+
300333
if error and balance is None:
301334
st.info("Wallet balance unavailable — start the API to see live USDC.")
302335
return
@@ -306,30 +339,38 @@ def _render_wallet_balance_bar(api_base: str, backend) -> None:
306339

307340
simulated = balance.get("simulated", False)
308341
mode_label = "demo" if simulated else "live"
309-
cols = st.columns(3)
342+
cols = st.columns(4)
310343
with cols[0]:
344+
eth = balance.get("eth_balance")
345+
help_text = f"Agent wallet on Base Sepolia ({mode_label})"
311346
st.metric(
312-
"Circle wallet",
313-
f"{balance.get('usdc_total', 0):.4f} USDC",
314-
help=f"Agent wallet on Base Sepolia ({mode_label})",
347+
"Circle wallet USDC",
348+
f"{balance.get('usdc_total', 0):.4f}",
349+
delta=f"{eth:.6f} ETH" if eth is not None else None,
350+
help=help_text,
315351
)
316352
payer_usdc = balance.get("broker_payer_usdc")
317353
payer_addr = balance.get("broker_payer_address", "")
354+
payer_eth = balance.get("broker_payer_eth")
318355
with cols[1]:
319356
if payer_usdc is not None:
320357
st.metric(
321-
"Premium payer",
322-
f"{float(payer_usdc):.4f} USDC",
358+
"Premium payer USDC",
359+
f"{float(payer_usdc):.4f}",
360+
delta=f"{payer_eth:.6f} ETH" if payer_eth is not None else None,
323361
help="Deployer wallet that pays on-chain premiums",
324362
)
325363
else:
326-
st.metric("Premium payer", "—", help="Deployer USDC balance unavailable")
364+
st.metric("Premium payer USDC", "—", help="Deployer USDC balance unavailable")
327365
with cols[2]:
328366
st.metric(
329367
"Demo ledger",
330368
f"{backend.broker_wallet_usdc:.2f} USDC",
331369
help="In-memory broker balance for mock research/premium steps",
332370
)
371+
with cols[3]:
372+
tx_count = len((transactions or {}).get("transactions") or [])
373+
st.metric("On-chain txs", tx_count, help="Token transfers + deployer contract calls")
333374

334375
wallet_id = balance.get("wallet_id", "")
335376
links: list[str] = [f"[USDC token]({token_link})"]
@@ -340,6 +381,7 @@ def _render_wallet_balance_bar(api_base: str, backend) -> None:
340381
if balance.get("provenance"):
341382
links.append(f"`{balance['provenance']}`")
342383
st.caption(" · ".join(links))
384+
_render_wallet_transactions(transactions)
343385

344386

345387
def _render_circle_wallet_panel(api_base: str) -> None:
@@ -379,24 +421,9 @@ def _render_circle_wallet_panel(api_base: str) -> None:
379421

380422
txs = (transactions or {}).get("transactions") or []
381423
if not txs:
382-
st.caption("No Circle CLI transactions yet.")
383-
return
384-
385-
st.markdown("**Recent transactions**")
386-
for tx in txs:
387-
label = tx.get("operation") or tx.get("transaction_type") or "transfer"
388-
amount = tx.get("amount_usdc", 0)
389-
state = tx.get("state", "")
390-
tx_hash = tx.get("tx_hash", "")
391-
explorer = tx.get("explorer_url") or (blockscout_tx_url(tx_hash) if tx_hash.startswith("0x") else "")
392-
line = f"**{label}** · {amount:.4f} USDC · {state}"
393-
if explorer:
394-
short = f"{tx_hash[:10]}…{tx_hash[-6:]}" if tx_hash else "view"
395-
st.markdown(f"{line} · [{short}]({explorer})")
396-
else:
397-
st.markdown(line)
398-
if tx.get("create_date"):
399-
st.caption(tx["create_date"])
424+
st.caption("No on-chain activity yet.")
425+
else:
426+
_render_wallet_transactions(transactions, limit=15)
400427

401428

402429
def build_budget_quote_copy(max_budget_usdc: float) -> tuple[str, str]:

‎backend/schemas.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,10 @@ class WalletBalanceResponse(BaseModel):
9191
balances: list[WalletTokenBalance] = Field(default_factory=list)
9292
simulated: bool = False
9393
provenance: str = ""
94+
eth_balance: float | None = None
9495
broker_payer_address: str = ""
9596
broker_payer_usdc: float | None = None
97+
broker_payer_eth: float | None = None
9698

9799

98100
class WalletTransactionItem(BaseModel):
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""On-chain wallet activity from Base Sepolia Blockscout (token transfers + deployer txs)."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import os
7+
from collections.abc import Mapping
8+
from typing import Any
9+
10+
import httpx
11+
12+
from backend.schemas import WalletTransactionItem
13+
from backend.services.base_sepolia import blockscout_tx_url
14+
15+
logger = logging.getLogger(__name__)
16+
17+
BLOCKSCOUT_API = "https://base-sepolia.blockscout.com/api/v2"
18+
BLOCKSCOUT_PROVENANCE = "base:sepolia:blockscout"
19+
20+
21+
def _env(env: Mapping[str, str] | None = None) -> dict[str, str]:
22+
return dict(os.environ) if env is None else dict(env)
23+
24+
25+
def _env_address(env: Mapping[str, str], key: str) -> str:
26+
raw = env.get(key, "").strip()
27+
if not raw:
28+
return ""
29+
return raw.split("#", 1)[0].strip()
30+
31+
32+
def _amount_usdc(total: dict[str, Any] | None) -> float:
33+
if not isinstance(total, dict):
34+
return 0.0
35+
raw = total.get("value")
36+
decimals = int(total.get("decimals") or 6)
37+
try:
38+
return round(int(raw) / 10**decimals, 6)
39+
except (TypeError, ValueError):
40+
return 0.0
41+
42+
43+
def _address_hash(entry: dict[str, Any] | None) -> str:
44+
if not isinstance(entry, dict):
45+
return ""
46+
return str(entry.get("hash") or "")
47+
48+
49+
def _parse_token_transfer(entry: dict[str, Any], *, wallet_address: str) -> WalletTransactionItem | None:
50+
tx_hash = str(entry.get("transaction_hash") or "")
51+
if not tx_hash.startswith("0x"):
52+
return None
53+
wallet_l = wallet_address.lower()
54+
from_addr = _address_hash(entry.get("from")).lower()
55+
to_addr = _address_hash(entry.get("to")).lower()
56+
amount = _amount_usdc(entry.get("total"))
57+
if to_addr == wallet_l:
58+
operation = "USDC in"
59+
elif from_addr == wallet_l:
60+
operation = "USDC out"
61+
else:
62+
operation = "USDC transfer"
63+
ts = str(entry.get("timestamp") or "")
64+
log_index = entry.get("log_index", 0)
65+
return WalletTransactionItem(
66+
id=f"{tx_hash}:{log_index}",
67+
state="confirmed",
68+
tx_hash=tx_hash,
69+
amount_usdc=amount,
70+
operation=operation,
71+
transaction_type="token_transfer",
72+
create_date=ts.replace("T", " ").replace(".000000Z", " UTC") if ts else "",
73+
explorer_url=blockscout_tx_url(tx_hash),
74+
)
75+
76+
77+
def _parse_address_transaction(entry: dict[str, Any]) -> WalletTransactionItem | None:
78+
tx_hash = str(entry.get("hash") or "")
79+
if not tx_hash.startswith("0x"):
80+
return None
81+
method = str(entry.get("method") or "")
82+
if not method and entry.get("transaction_types"):
83+
types = entry.get("transaction_types") or []
84+
method = str(types[0]) if types else "transaction"
85+
amount = 0.0
86+
token_transfers = entry.get("token_transfers") or []
87+
if isinstance(token_transfers, list) and token_transfers:
88+
amount = _amount_usdc(token_transfers[0].get("total"))
89+
ts = str(entry.get("timestamp") or "")
90+
state = "confirmed" if entry.get("status") == "ok" else str(entry.get("status") or "unknown")
91+
return WalletTransactionItem(
92+
id=tx_hash,
93+
state=state,
94+
tx_hash=tx_hash,
95+
amount_usdc=amount,
96+
operation=method or "transaction",
97+
transaction_type=str(entry.get("type") or "contract_call"),
98+
create_date=ts.replace("T", " ").replace(".000000Z", " UTC") if ts else "",
99+
explorer_url=blockscout_tx_url(tx_hash),
100+
)
101+
102+
103+
def _get_json(path: str, *, timeout: float = 20.0) -> dict[str, Any]:
104+
url = f"{BLOCKSCOUT_API}{path}"
105+
with httpx.Client(timeout=timeout) as client:
106+
response = client.get(url)
107+
response.raise_for_status()
108+
payload = response.json()
109+
return payload if isinstance(payload, dict) else {}
110+
111+
112+
def fetch_blockscout_token_transfers(address: str, *, limit: int = 15) -> list[WalletTransactionItem]:
113+
if not address.startswith("0x"):
114+
return []
115+
try:
116+
payload = _get_json(f"/addresses/{address}/token-transfers")
117+
except Exception:
118+
logger.debug("Blockscout token transfers failed for %s", address, exc_info=True)
119+
return []
120+
items = payload.get("items") or []
121+
parsed: list[WalletTransactionItem] = []
122+
for entry in items[:limit]:
123+
if not isinstance(entry, dict):
124+
continue
125+
item = _parse_token_transfer(entry, wallet_address=address)
126+
if item:
127+
parsed.append(item)
128+
return parsed
129+
130+
131+
def fetch_blockscout_address_transactions(address: str, *, limit: int = 15) -> list[WalletTransactionItem]:
132+
if not address.startswith("0x"):
133+
return []
134+
try:
135+
payload = _get_json(f"/addresses/{address}/transactions")
136+
except Exception:
137+
logger.debug("Blockscout transactions failed for %s", address, exc_info=True)
138+
return []
139+
items = payload.get("items") or []
140+
parsed: list[WalletTransactionItem] = []
141+
for entry in items[:limit]:
142+
if not isinstance(entry, dict):
143+
continue
144+
item = _parse_address_transaction(entry)
145+
if item:
146+
parsed.append(item)
147+
return parsed
148+
149+
150+
def merge_wallet_transactions(
151+
*groups: list[WalletTransactionItem],
152+
limit: int,
153+
) -> list[WalletTransactionItem]:
154+
seen: set[str] = set()
155+
merged: list[WalletTransactionItem] = []
156+
for group in groups:
157+
ordered = sorted(group, key=lambda tx: tx.create_date, reverse=True)
158+
for item in ordered:
159+
key = item.id or item.tx_hash
160+
if not key or key in seen:
161+
continue
162+
seen.add(key)
163+
merged.append(item)
164+
if len(merged) >= limit:
165+
return merged
166+
return merged
167+
168+
169+
def fetch_onchain_wallet_activity(
170+
*,
171+
limit: int = 15,
172+
env: Mapping[str, str] | None = None,
173+
) -> list[WalletTransactionItem]:
174+
"""Circle wallet token transfers plus deployer contract calls (e.g. purchasePolicy)."""
175+
current = _env(env)
176+
wallet = _env_address(current, "CIRCLE_WALLET_ID")
177+
deployer = _env_address(current, "BASE_SEPOLIA_DEPLOYER_ADDRESS")
178+
wallet_txs = fetch_blockscout_token_transfers(wallet, limit=limit) if wallet.startswith("0x") else []
179+
deployer_txs = fetch_blockscout_address_transactions(deployer, limit=limit) if deployer.startswith("0x") else []
180+
return merge_wallet_transactions(wallet_txs, deployer_txs, limit=limit)

0 commit comments

Comments
 (0)