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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Chainly is an AI agent for **Ethereum Sepolia** (chain ID `11155111`). You ask q
- 🔎 **Read:** ETH balances, ERC-20 balances (USDC, WETH and LINK by symbol, or any contract), transaction details, gas prices, block info.
- 📊 **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.
- 🏷️ **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.
- The sender is locked to the connected wallet.
Expand Down
2 changes: 1 addition & 1 deletion src/components/cards/activity-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function ActivityCard({ data }: { data: Data }) {
title="Wallet activity"
meta={
<span>
<AddressLink address={data.address} /> · {s.totalTransactions} txs · {data.source}
<AddressLink address={data.address} name={data.ensName} /> · {s.totalTransactions} txs · {data.source}
</span>
}
right={<ExplorerLink href={explorerAddressUrl(data.address)} />}
Expand Down
12 changes: 9 additions & 3 deletions src/components/cards/primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function Row({ label, children }: { label: string; children: ReactNode })
);
}

export function AddressLink({ address, chars = 4 }: { address: string | null | undefined; chars?: number }) {
export function AddressLink({ address, name, chars = 4 }: { address: string | null | undefined; name?: string | null; chars?: number }) {
const { copied, copy } = useCopy();
if (!address) return <span className="text-subtle-foreground">—</span>;
return (
Expand All @@ -59,9 +59,15 @@ export function AddressLink({ address, chars = 4 }: { address: string | null | u
target="_blank"
rel="noreferrer"
className="underline-offset-2 hover:text-foreground hover:underline"
title={address}
title={name ? `${name} → ${address}` : address}
>
{shortAddress(address, chars)}
{name ? (
<>
<span className="font-sans font-medium">{name}</span> <span className="text-subtle-foreground">{shortAddress(address, chars)}</span>
</>
) : (
shortAddress(address, chars)
)}
</a>
<button
type="button"
Expand Down
4 changes: 2 additions & 2 deletions src/components/cards/read-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function BigValue({ value, unit }: { value: string; unit: string }) {
export function BalanceCard({ data }: { data: Out<"getEthBalance"> }) {
return (
<ResultCard>
<ResultHeader icon={<Wallet />} title="ETH balance" meta={<AddressLink address={data.address} />} right={<ExplorerLink href={data.explorerUrl} />} />
<ResultHeader icon={<Wallet />} title="ETH balance" meta={<AddressLink address={data.address} name={data.ensName} />} right={<ExplorerLink href={data.explorerUrl} />} />
<div className="flex items-end justify-between gap-4 px-4 py-5">
<BigValue value={data.balanceEth} unit="ETH" />
<div className="flex flex-col items-end gap-1.5">
Expand All @@ -43,7 +43,7 @@ export function TokenBalanceCard({ data }: { data: Out<"getTokenBalance"> }) {
<BigValue value={data.balance} unit={data.token.symbol} />
<div className="text-right text-[11px] text-subtle-foreground">
<div>holder</div>
<AddressLink address={data.address} />
<AddressLink address={data.address} name={data.ensName} />
</div>
</div>
</ResultCard>
Expand Down
2 changes: 1 addition & 1 deletion src/components/cards/transaction-preview-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function TransactionPreviewCard({ data, onAskAgent }: Props) {
<div className="mt-3 flex flex-wrap items-center gap-2 text-muted-foreground">
<AddressLink address={data.from} />
<ArrowRight className="size-3.5 text-subtle-foreground" />
<AddressLink address={data.to} />
<AddressLink address={data.to} name={data.toEnsName} />
</div>
</div>

Expand Down
2 changes: 2 additions & 0 deletions src/lib/agent/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ ${connectedAddress ? `The user's connected wallet is ${connectedAddress}. "My wa
- After tools return, give a short, clear answer. The UI already renders a rich card for every tool result, so summarize the key numbers and explain what they mean — don't repeat every field.
- If a tool fails, explain the error in plain language and suggest a fix (e.g. check the address, retry, use a faucet).
- Format amounts with units (ETH, gwei). Shorten addresses like 0x1234…abcd in prose.
- Tools accept ENS names (e.g. alice.eth) wherever they take an address. Pass the name as-is; the tool resolves it on ${NETWORK_NAME}'s ENS. When a result includes ensName, mention both the name and the resolved address.

## Wallet analysis
- Use analyzeWallet, then interpret the stats: overall behaviour, incoming vs outgoing, spending pattern, gas costs, main counterparties, contract usage, notable or risky signals. Base insights only on the returned data.
Expand All @@ -19,6 +20,7 @@ ${connectedAddress ? `The user's connected wallet is ${connectedAddress}. "My wa
- You can NEVER sign, send or broadcast a transaction, and you never ask for or handle private keys or seed phrases. If someone shares one, tell them to treat it as compromised.
- To send ETH, call prepareTransaction. It validates the address, amount, balance and network and returns a preview. The user then reviews it and approves it in their own wallet by clicking the button on the preview card.
- Never claim a transaction was sent. Say it is prepared and waiting for their approval.
- When the recipient is an ENS name, tell the user which address it resolved to so they can confirm it before approving.
- If the user gives no recipient or amount, ask for it. If a check fails (e.g. insufficient balance), explain it.
- Only native ETH transfers on ${NETWORK_NAME} are supported for now.`;
}
9 changes: 5 additions & 4 deletions src/lib/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
KNOWN_TOKENS,
} from "../blockchain/reader";
import { prepareEthTransfer } from "../blockchain/transfer";
import { parseAddress } from "../blockchain/validation";
import { resolveAddress } from "../blockchain/ens";

/**
* The agent's toolbox. Each tool is a thin, typed wrapper around a reusable
Expand All @@ -36,7 +36,7 @@ export type ToolsContext = {

const addressSchema = z
.string()
.describe("A 0x-prefixed EVM address. Use the connected wallet address when the user says 'my wallet'.");
.describe("A 0x-prefixed EVM address or an ENS name (e.g. alice.eth). Use the connected wallet address when the user says 'my wallet'.");

export function createChainlyTools(ctx: ToolsContext) {
const requireAddress = (address: string | undefined) => {
Expand Down Expand Up @@ -89,14 +89,15 @@ export function createChainlyTools(ctx: ToolsContext) {
limit: z.number().int().min(5).max(200).default(50).describe("How many recent transactions to analyze"),
}),
execute: async ({ address, limit }) => {
const target = parseAddress(requireAddress(address));
const { address: target, ensName } = await resolveAddress(ctx.client, requireAddress(address));
const [txs, tokenTransfers] = await Promise.all([
fetchTransactions(target, limit, ctx.explorer),
// Token history is a nice-to-have: don't fail the whole analysis on it.
fetchTokenTransfers(target, limit, ctx.explorer).catch(() => []),
]);
return {
...analyzeWalletActivity(target, txs, tokenTransfers),
ensName,
analyzedLimit: limit,
source: explorerName(ctx.explorer),
};
Expand All @@ -107,7 +108,7 @@ export function createChainlyTools(ctx: ToolsContext) {
description:
"Prepare a native ETH transfer from the user's connected wallet on Ethereum Sepolia. Validates the recipient and amount, checks the balance, estimates gas and returns a preview. This does NOT send anything: the user must review and approve it in their wallet. Also use this to estimate the gas fee of a transfer.",
inputSchema: z.object({
to: z.string().describe("Recipient 0x address"),
to: z.string().describe("Recipient 0x address or ENS name (e.g. alice.eth)"),
amountEth: z.string().describe("Amount of ETH as a decimal string, e.g. '0.001'"),
}),
execute: async ({ to, amountEth }) =>
Expand Down
54 changes: 54 additions & 0 deletions src/lib/blockchain/ens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Address } from "viem";
import { normalize } from "viem/ens";
import type { ChainClient } from "./types";
import { NETWORK_NAME } from "./chain";
import { BlockchainError, toBlockchainError } from "./errors";
import { parseAddress } from "./validation";

/**
* ENS support. Names are resolved on Ethereum Sepolia's own ENS deployment,
* which is separate from mainnet: a name registered on mainnet does not
* automatically exist (or point to the same address) on Sepolia.
*/

export type ResolvedAddress = { address: Address; ensName: string | null };

/** Anything that isn't 0x-prefixed but contains a dot, e.g. "alice.eth". */
export function looksLikeEnsName(input: string) {
const value = input.trim();
return !/^0x/i.test(value) && value.includes(".");
}

/**
* Accept either a 0x address or an ENS name and return a checksummed address.
* The resolved address is what gets used (and shown), never the name alone.
*/
export async function resolveAddress(client: ChainClient, input: string, label = "address"): Promise<ResolvedAddress> {
const value = input?.trim() ?? "";
if (!looksLikeEnsName(value)) return { address: parseAddress(value, label), ensName: null };

let name: string;
try {
name = normalize(value);
} catch {
throw new BlockchainError("INVALID_ADDRESS", `"${truncate(value)}" is not a valid ENS name.`);
}

let resolved: Address | null;
try {
resolved = await client.getEnsAddress({ name });
} catch (error) {
throw toBlockchainError(error);
}
if (!resolved) {
throw new BlockchainError(
"NOT_FOUND",
`The ENS name ${name} does not resolve to an address on ${NETWORK_NAME}. Sepolia has its own ENS registry, so mainnet names may not exist there.`,
);
}
return { address: parseAddress(resolved, label), ensName: name };
}

function truncate(s: string, max = 48) {
return s.length > max ? `${s.slice(0, max)}…` : s;
}
7 changes: 5 additions & 2 deletions src/lib/blockchain/reader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { erc20Abi, type Address } from "viem";
import type { ChainClient } from "./types";
import { CHAIN_ID, NETWORK_NAME, explorerAddressUrl, explorerBlockUrl, explorerTxUrl } from "./chain";
import { resolveAddress } from "./ens";
import { BlockchainError, toBlockchainError } from "./errors";
import { formatEth, formatGweiValue, formatToken } from "./format";
import { parseAddress, parseTxHash } from "./validation";
Expand All @@ -13,7 +14,7 @@ import { parseAddress, parseTxHash } from "./validation";
*/

export async function getEthBalance(client: ChainClient, addressInput: string) {
const address = parseAddress(addressInput);
const { address, ensName } = await resolveAddress(client, addressInput);
try {
const [balance, blockNumber, code] = await Promise.all([
client.getBalance({ address }),
Expand All @@ -22,6 +23,7 @@ export async function getEthBalance(client: ChainClient, addressInput: string) {
]);
return {
address,
ensName,
network: NETWORK_NAME,
balanceWei: balance.toString(),
balanceEth: formatEth(balance, 8),
Expand All @@ -48,8 +50,8 @@ export function resolveToken(tokenInput: string): Address {
}

export async function getTokenBalance(client: ChainClient, addressInput: string, tokenInput: string) {
const address = parseAddress(addressInput);
const token = resolveToken(tokenInput);
const { address, ensName } = await resolveAddress(client, addressInput);
try {
const code = await client.getCode({ address: token });
if (!code || code === "0x") {
Expand All @@ -64,6 +66,7 @@ export async function getTokenBalance(client: ChainClient, addressInput: string,
]);
return {
address,
ensName,
network: NETWORK_NAME,
token: { address: token, symbol, name, decimals },
balanceRaw: balance.toString(),
Expand Down
21 changes: 17 additions & 4 deletions src/lib/blockchain/transfer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Address } from "viem";
import type { ChainClient } from "./types";
import { CHAIN, NETWORK_NAME, explorerAddressUrl } from "./chain";
import { resolveAddress } from "./ens";
import { BlockchainError, toBlockchainError } from "./errors";
import { formatEth, formatGweiValue } from "./format";
import { isZeroAddress, parseAddress, parseEthAmount } from "./validation";
Expand All @@ -21,6 +22,8 @@ export type PreparedTransfer = {
network: string;
from: Address | null;
to: Address;
/** ENS name the recipient was resolved from (absent in previews saved before ENS support). */
toEnsName?: string | null;
valueWei: string;
valueEth: string;
gasLimit: string;
Expand Down Expand Up @@ -51,12 +54,13 @@ const GAS_BUFFER_BPS = 12_000n;

export async function prepareEthTransfer(client: ChainClient, input: PrepareTransferInput): Promise<PreparedTransfer> {
// 1. Validate user intent (throws a friendly BlockchainError on bad input).
const to = parseAddress(input.to, "recipient address");
const value = parseEthAmount(input.amountEth);
const from = input.from ? parseAddress(input.from, "sender address") : null;
// An ENS name is resolved once, here; the preview (and the wallet) only ever use the address.
const { address: to, ensName: toEnsName } = await resolveAddress(client, input.to, "recipient address");
if (isZeroAddress(to)) {
throw new BlockchainError("INVALID_ADDRESS", "Sending to the zero address would burn the funds. Please provide a real recipient.");
}
const value = parseEthAmount(input.amountEth);
const from = input.from ? parseAddress(input.from, "sender address") : null;

try {
// 2. Read live chain state in parallel.
Expand Down Expand Up @@ -85,7 +89,12 @@ export async function prepareEthTransfer(client: ChainClient, input: PrepareTran

// 3. Safety checks — every one must pass before the UI enables signing.
const checks: TransferCheck[] = [
{ id: "recipient", label: "Recipient address", passed: true, detail: `Valid checksummed address ${to}` },
{
id: "recipient",
label: "Recipient address",
passed: true,
detail: toEnsName ? `${toEnsName} resolves to ${to}` : `Valid checksummed address ${to}`,
},
{ id: "amount", label: "Amount", passed: true, detail: `${formatEth(value, 18)} ETH` },
{ id: "network", label: "Network", passed: true, detail: `${NETWORK_NAME} (chain ID ${CHAIN.id}) — testnet ETH only` },
{
Expand All @@ -110,6 +119,9 @@ export async function prepareEthTransfer(client: ChainClient, input: PrepareTran

const warnings: string[] = [];
if (from && from.toLowerCase() === to.toLowerCase()) warnings.push("You are sending ETH to yourself — only the gas fee will be spent.");
if (toEnsName) {
warnings.push(`Recipient resolved from the ENS name ${toEnsName}. Check that ${to} is who you expect: the name's owner can change where it points.`);
}
if (recipientIsContract) warnings.push("The recipient is a smart contract. Make sure it can accept ETH, or the transaction may revert.");
if (typeof gasEstimate !== "bigint" && gasEstimate.error.code !== "INSUFFICIENT_FUNDS" && from) {
warnings.push(`Gas estimation failed (${gasEstimate.error.message}); a default gas limit is used.`);
Expand All @@ -121,6 +133,7 @@ export async function prepareEthTransfer(client: ChainClient, input: PrepareTran
network: NETWORK_NAME,
from,
to,
toEnsName,
valueWei: value.toString(),
valueEth: formatEth(value, 18),
gasLimit: gasLimit.toString(),
Expand Down
73 changes: 73 additions & 0 deletions tests/ens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { parseEther, parseGwei } from "viem";
import { describe, expect, it, vi } from "vitest";
import { looksLikeEnsName, resolveAddress } from "@/lib/blockchain/ens";
import { getEthBalance } from "@/lib/blockchain/reader";
import { prepareEthTransfer } from "@/lib/blockchain/transfer";
import { fakeClient } from "./fake-client";

const ALICE = "0x2222222222222222222222222222222222222222";
const FROM = "0x1111111111111111111111111111111111111111";

describe("ENS names", () => {
it("tells names from addresses", () => {
expect(looksLikeEnsName("alice.eth")).toBe(true);
expect(looksLikeEnsName(" pay.alice.eth ")).toBe(true);
expect(looksLikeEnsName(ALICE)).toBe(false);
expect(looksLikeEnsName("alice")).toBe(false);
});

it("passes 0x addresses through without an RPC call", async () => {
const getEnsAddress = vi.fn();
await expect(resolveAddress(fakeClient({ getEnsAddress }), ALICE.toUpperCase().replace("0X", "0x"))).resolves.toEqual({
address: ALICE,
ensName: null,
});
expect(getEnsAddress).not.toHaveBeenCalled();
});

it("normalizes the name and returns the checksummed address", async () => {
const getEnsAddress = vi.fn(async () => "0xd8da6bf26964af9d7eed9e03e53415d37aa96045");
const result = await resolveAddress(fakeClient({ getEnsAddress } as never), " Alice.ETH ");
expect(getEnsAddress).toHaveBeenCalledWith({ name: "alice.eth" });
expect(result).toEqual({ address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", ensName: "alice.eth" });
});

it("explains names that don't resolve on Sepolia", async () => {
const client = fakeClient({ getEnsAddress: async () => null });
await expect(resolveAddress(client, "nobody.eth")).rejects.toMatchObject({ code: "NOT_FOUND", message: expect.stringMatching(/Sepolia/) });
});

it("rejects invalid names", async () => {
await expect(resolveAddress(fakeClient({}), "bad name.eth")).rejects.toMatchObject({ code: "INVALID_ADDRESS" });
});

it("includes the name in balance results", async () => {
const client = fakeClient({
getEnsAddress: async () => ALICE,
getBalance: async () => parseEther("1"),
getBlockNumber: async () => 1n,
getCode: async () => "0x",
});
await expect(getEthBalance(client, "alice.eth")).resolves.toMatchObject({ address: ALICE, ensName: "alice.eth", balanceEth: "1" });
});

it("prepares a transfer to the resolved address and flags the name", async () => {
const client = fakeClient({
getEnsAddress: async () => ALICE,
estimateFeesPerGas: async () => ({ maxFeePerGas: parseGwei("0.002"), maxPriorityFeePerGas: parseGwei("0.001") }),
getGasPrice: async () => parseGwei("0.0015"),
getCode: async () => undefined,
getBalance: async () => parseEther("0.5"),
estimateGas: async () => 21_000n,
});
const preview = await prepareEthTransfer(client, { from: FROM, to: "alice.eth", amountEth: "0.001" });
expect(preview).toMatchObject({ to: ALICE, toEnsName: "alice.eth", readyToSign: true });
expect(preview.checks.find((c) => c.id === "recipient")?.detail).toBe(`alice.eth resolves to ${ALICE}`);
expect(preview.warnings.some((w) => w.includes("alice.eth") && w.includes(ALICE))).toBe(true);
});

it("refuses a name that resolves to the zero address", async () => {
const client = fakeClient({ getEnsAddress: async () => "0x0000000000000000000000000000000000000000" });
await expect(prepareEthTransfer(client, { from: FROM, to: "burn.eth", amountEth: "0.001" })).rejects.toThrow(/zero address/);
});
});
Loading