Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ Chainly is an AI agent for **Ethereum Sepolia** (chain ID `11155111`). You ask q

- 💬 **Conversational agent:** a streaming chat with Markdown answers. Each tool call shows an expandable step with its raw JSON input and output, plus a rich result card.
- 🔎 **Read:** ETH balances, ERC-20 balances (USDC, WETH and LINK by symbol, or any contract), transaction details, gas prices, block info.
- 🧾 **Decode transactions:** what a transaction actually did, in plain English: the function it called with its arguments, token transfers, mints, approvals (unlimited ones are flagged), NFT moves and WETH wraps.
- 📜 **Explain contracts:** name, verification, token standards, proxy and upgradeability, owner, paused state, functions, and the privileged ones (mint, pause, upgrade…) that give someone special powers.
- 📊 **Analyze:** explorer history turned into money in and out, net flow, gas spent, counterparties, contract usage, a daily chart and insights backed by the data.
- ✍️ **Prepare:** validates the address, amount, balance and network, estimates gas, and shows a preview card. **You** sign in your wallet.
- ✍️ **Prepare:** ETH and ERC-20 token transfers. Validates the address, amount, balance and network, estimates gas, and shows a preview card. **You** sign in your wallet.
- 🏷️ **ENS names:** use `alice.eth` anywhere an address is expected. Names resolve on Sepolia's own ENS registry (mainnet names may not exist there), and previews show the name next to the resolved address that your wallet signs.
- 🛡️ **Safety:**
- No signing tools.
Expand Down Expand Up @@ -89,7 +91,7 @@ Explorer links point to [sepolia.etherscan.io](https://sepolia.etherscan.io). Ge
│ │ SSE stream │ tools, stopWhen=8 steps) │
│ tool steps + result cards ◀───┼◀────────────┤ │ ▲ │
│ │ │ ▼ │ JSON │
│ TransactionPreviewCard │ │ lib/agent/tools.ts (7 typed tools) │
│ TransactionPreviewCard │ │ lib/agent/tools.ts (10 typed tools) │
│ └ signing-guard (re-check) │ │ │ │
│ └ wallet.sendTransaction ◀── user click │ ▼ │
└──────────────┬────────────────┘ │ lib/blockchain/* (pure, tested) │
Expand All @@ -109,13 +111,14 @@ src/
├─ lib/
│ ├─ agent/ ← model provider, tool definitions, instructions, types
│ ├─ blockchain/ ← chain config, reader, explorer, activity, transfer,
│ │ token-transfer, contracts, decode, ens,
│ │ signing-guard, tx-records, validation, errors
│ ├─ web3/wagmi.ts ← browser wallet config (injected wallets, Sepolia)
│ └─ chat-store.ts ← localStorage chat history
└─ components/
├─ landing/ ← hero, product preview, sections
├─ chat/ ← workspace, sidebar, chat view, composer, messages, tool steps
├─ cards/ ← balance, token, tx, network, block, activity, tx preview
├─ cards/ ← balance, token, tx, decoded tx, contract, network, block, activity, tx preview
├─ wallet/ ← wallet button + menu, network indicator, identicon
└─ ui/ ← shadcn/ui-style primitives
```
Expand All @@ -129,10 +132,13 @@ The network lives in one file, `src/lib/blockchain/chain.ts`. Changing chains me
| `getEthBalance` | `address?` (defaults to connected wallet) | RPC | balance, EOA/contract, block |
| `getTokenBalance` | `address?`, `token` (symbol or contract) | RPC (`erc20Abi`) | balance with decimals, token metadata |
| `getTransaction` | `hash` | RPC tx + receipt + block | status, value, fee, gas, confirmations |
| `decodeTransaction` | `hash` | RPC tx + receipt, verified ABI from the explorer, standard ABIs | decoded call, events with plain-English summaries, flags |
| `explainContract` | `address` (or ENS name) | RPC (code, EIP-1967 slot, probes), verified ABI from the explorer | standards, proxy, owner, functions, privileged functions |
| `getNetworkStatus` | none | RPC | latest block, gas price, base/priority fee, utilization |
| `getBlock` | `blockNumber?` | RPC | block stats |
| `analyzeWallet` | `address?`, `limit` | Blockscout / Etherscan V2 | stats, counterparties, daily chart, insights |
| `prepareTransaction` | `to`, `amountEth` | RPC (fees, balance, estimateGas, getCode) | a **preview** with checks, `readyToSign` |
| `prepareTokenTransfer` | `token`, `to`, `amount` | RPC (token metadata, balances, fees, estimateGas) | a **preview** with `transfer` calldata and checks, `readyToSign` |

### Transaction safety model

Expand All @@ -142,11 +148,13 @@ The network lives in one file, `src/lib/blockchain/chain.ts`. Changing chains me
- checksummed addresses and a zero-address block
- locale-safe amounts (`"1,5"` is rejected, not guessed)
- a 10 ETH ceiling
- a balance check covering amount plus max gas
- a balance check covering amount plus max gas (for tokens: the token balance, plus ETH for gas)
- no token transfers to the token's own contract
- a warning when the recipient is a contract
4. **Human in the loop:** only a click on **Approve in wallet** opens the wallet.
5. **Defense in depth:** `signing-guard.ts` re-checks the chain ID, sender, amount and gas bounds, and a 10-minute expiry. `tx-records.ts` stops a submitted preview from being re-signed when a chat is reopened.
5. **Defense in depth:** `signing-guard.ts` re-checks the chain ID, sender, amount and gas bounds, and a 10-minute expiry. For token transfers it decodes the calldata itself and refuses anything but `transfer(recipient, amount)` matching the card. `tx-records.ts` stops a submitted preview from being re-signed when a chat is reopened.
6. **No leaks:** viem errors (which can include RPC URLs with keys) are mapped to clean messages.
7. **Untrusted contract data:** `explainContract` never passes raw source code to the model, and names from ABIs are filtered to plain identifiers, so text planted in a contract can't easily steer the agent.

## Design system

Expand Down
7 changes: 7 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
--warning: oklch(0.65 0.14 70);
--destructive: oklch(0.56 0.2 25);

/* Chart series color, validated (lightness band, chroma, 3:1 contrast) against the light background. */
--chart-1: oklch(0.53 0.2 285);

--ring: oklch(0.53 0.2 285 / 45%);
}

Expand All @@ -49,6 +52,9 @@
--warning: oklch(0.82 0.13 80);
--destructive: oklch(0.7 0.17 25);

/* Its own dark step (not the accent): the dark chart band is L 0.48–0.67. */
--chart-1: oklch(0.66 0.15 285);

--ring: oklch(0.72 0.15 285 / 50%);
}

Expand All @@ -67,6 +73,7 @@
--color-success: var(--success);
--color-warning: var(--warning);
--color-destructive: var(--destructive);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);

--radius-sm: calc(var(--radius) - 4px);
Expand Down
17 changes: 15 additions & 2 deletions src/components/cards/activity-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,21 @@ import { Badge } from "@/components/ui/badge";
import type { ChainlyUITools } from "@/lib/agent/tools";
import { explorerAddressUrl } from "@/lib/blockchain/chain";
import { cn } from "@/lib/utils";
import { FlowChart, GasChart } from "./activity-charts";
import { AddressLink, ExplorerLink, HashLink, ResultCard, ResultHeader, Stat, StatGrid, timeAgo } from "./primitives";

type Data = ChainlyUITools["analyzeWallet"]["output"];
type Tab = "insights" | "transactions" | "counterparties";
type Tab = "insights" | "charts" | "transactions" | "counterparties";

export function ActivityCard({ data }: { data: Data }) {
const s = data.summary;
const [tab, setTab] = useState<Tab>("insights");
const total = s.incomingCount + s.outgoingCount || 1;
// Older saved chats predate the chart fields.
const flowSeries = data.flowSeries ?? [];
const gasBreakdown = data.gasBreakdown ?? [];
const hasCharts = flowSeries.length > 1 || gasBreakdown.length > 0;
const tabs: Tab[] = hasCharts ? ["insights", "charts", "transactions", "counterparties"] : ["insights", "transactions", "counterparties"];

return (
<ResultCard>
Expand Down Expand Up @@ -57,7 +63,7 @@ export function ActivityCard({ data }: { data: Data }) {
</div>

<div className="flex gap-1 border-t px-3 pt-2" role="tablist">
{(["insights", "transactions", "counterparties"] as const).map((t) => (
{tabs.map((t) => (
<button
key={t}
type="button"
Expand Down Expand Up @@ -96,6 +102,13 @@ export function ActivityCard({ data }: { data: Data }) {
</ul>
)}

{tab === "charts" && (
<div className="space-y-6 py-1">
<FlowChart series={flowSeries} />
<GasChart breakdown={gasBreakdown} />
</div>
)}

{tab === "transactions" && (
<div className="divide-y">
{data.recentTransactions.map((tx) => (
Expand Down
181 changes: 181 additions & 0 deletions src/components/cards/activity-charts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"use client";

import { useId, useMemo, useState, type KeyboardEvent, type PointerEvent } from "react";
import type { ChainlyUITools } from "@/lib/agent/tools";
import { cn } from "@/lib/utils";

type Data = ChainlyUITools["analyzeWallet"]["output"];

const W = 600;
const H = 140;

/** Running net ETH flow (received − sent − gas) per day, with a crosshair tooltip. */
export function FlowChart({ series }: { series: Data["flowSeries"] }) {
const [active, setActive] = useState<number | null>(null);
const tableId = useId();

const { points, zeroY, min, max } = useMemo(() => {
const values = series.map((p) => p.cumulativeEth);
const lo = Math.min(0, ...values);
const hi = Math.max(0, ...values);
const pad = (hi - lo || 1) * 0.12;
const [yMin, yMax] = [lo - pad, hi + pad];
const y = (v: number) => H - ((v - yMin) / (yMax - yMin)) * H;
const x = (i: number) => (series.length === 1 ? W / 2 : (i / (series.length - 1)) * W);
return { points: series.map((p, i) => ({ x: x(i), y: y(p.cumulativeEth) })), zeroY: y(0), min: lo, max: hi };
}, [series]);

if (series.length < 2) return null;

const last = series[series.length - 1];
const shown = active ?? series.length - 1;
const point = series[shown];
const pos = points[shown];

function onPointerMove(e: PointerEvent<HTMLDivElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
setActive(Math.round(ratio * (series.length - 1)));
}

function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const step = e.key === "ArrowLeft" ? -1 : 1;
setActive((i) => Math.min(series.length - 1, Math.max(0, (i ?? series.length - 1) + step)));
}

return (
<figure className="space-y-2">
<figcaption className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 text-[11px] text-subtle-foreground">
<span>Net ETH flow, running total</span>
{/* The readout doubles as the tooltip, so it never covers the line. */}
<span className="tabular-nums text-foreground" aria-live="polite">
{active === null ? (
<>
{signed(last.cumulativeEth)} ETH <span className="text-subtle-foreground">over {series.length} active days</span>
</>
) : (
<>
<span className="font-mono text-subtle-foreground">{point.date}</span> · {signed(point.cumulativeEth)} ETH{" "}
<span className="text-subtle-foreground">({signed(point.netEth)} that day)</span>
</>
)}
</span>
</figcaption>

<div
className="relative h-36 touch-none rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
role="img"
aria-label={`Running net ETH flow from ${series[0].date} to ${last.date}, ending at ${signed(last.cumulativeEth)} ETH. Use the arrow keys to read each day.`}
aria-describedby={tableId}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={() => setActive(null)}
onKeyDown={onKeyDown}
onBlur={() => setActive(null)}
>
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible" aria-hidden>
<line x1={0} x2={W} y1={zeroY} y2={zeroY} className="stroke-border-strong" strokeDasharray="4 4" vectorEffect="non-scaling-stroke" />
<polyline
points={points.map((p) => `${p.x},${p.y}`).join(" ")}
fill="none"
className="stroke-chart-1"
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</svg>

{/* Crosshair and marker are HTML so they don't stretch with the SVG. */}
<div className={cn("pointer-events-none absolute inset-y-0 w-px bg-border-strong", active === null && "hidden")} style={{ left: `${(pos.x / W) * 100}%` }} />
<div
className="pointer-events-none absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-chart-1 ring-2 ring-background"
style={{ left: `${(pos.x / W) * 100}%`, top: `${(pos.y / H) * 100}%` }}
/>
</div>

<div className="flex justify-between font-mono text-[10px] text-subtle-foreground">
<span>{series[0].date}</span>
<span>
range {signed(min)} … {signed(max)} ETH
</span>
<span>{last.date}</span>
</div>

<details id={tableId} className="text-[11px] text-muted-foreground">
<summary className="cursor-pointer select-none text-subtle-foreground hover:text-foreground">Show data</summary>
<table className="mt-2 w-full tabular-nums">
<thead className="text-subtle-foreground">
<tr>
<th className="py-1 text-left font-normal">Day</th>
<th className="py-1 text-right font-normal">Net</th>
<th className="py-1 text-right font-normal">Running total</th>
</tr>
</thead>
<tbody>
{series.map((p) => (
<tr key={p.date} className="border-t">
<td className="py-1 font-mono">{p.date}</td>
<td className="py-1 text-right">{signed(p.netEth)}</td>
<td className="py-1 text-right text-foreground">{signed(p.cumulativeEth)}</td>
</tr>
))}
</tbody>
</table>
</details>
<p className="text-[10px] text-subtle-foreground">
Received − sent − gas within the analyzed transactions. Starts at 0, so it is not your balance, and internal contract transfers aren&apos;t included.
</p>
</figure>
);
}

/** Where the gas went: one bar per activity, largest first, values labeled. */
export function GasChart({ breakdown }: { breakdown: Data["gasBreakdown"] }) {
const [active, setActive] = useState<string | null>(null);
if (breakdown.length === 0) return null;
const max = Math.max(...breakdown.map((g) => g.sharePercent), 1);

return (
<figure className="space-y-2">
<figcaption className="text-[11px] text-subtle-foreground">Gas spent by activity</figcaption>
<ul className="space-y-1.5">
{breakdown.map((g) => (
<li
key={g.label}
tabIndex={0}
onPointerEnter={() => setActive(g.label)}
onPointerLeave={() => setActive(null)}
onFocus={() => setActive(g.label)}
onBlur={() => setActive(null)}
aria-label={`${g.label}: ${g.gasEth} ETH, ${g.sharePercent}% of gas, ${g.txCount} transactions`}
className="grid grid-cols-[minmax(0,7.5rem)_1fr_auto] items-center gap-3 rounded-md py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className={cn("truncate", active === g.label ? "text-foreground" : "text-muted-foreground")} title={g.label}>
{g.label}
</span>
<span className="h-2.5 rounded-full bg-surface-2">
<span
className={cn("block h-full rounded-r-[4px] rounded-l-full bg-chart-1 transition-opacity", active && active !== g.label && "opacity-40")}
style={{ width: `${Math.max(2, (g.sharePercent / max) * 100)}%` }}
/>
</span>
<span className="tabular-nums text-foreground">
{g.gasEth} ETH{" "}
<span className="text-subtle-foreground">
· {active === g.label ? `${g.txCount} tx${g.txCount === 1 ? "" : "s"}` : `${Math.round(g.sharePercent)}%`}
</span>
</span>
</li>
))}
</ul>
</figure>
);
}

function signed(n: number) {
const s = Math.abs(n).toLocaleString("en-US", { maximumFractionDigits: 6 });
return n > 0 ? `+${s}` : n < 0 ? `−${s}` : "0";
}
Loading
Loading