diff --git a/src/components/dashboard/limits-card.tsx b/src/components/dashboard/limits-card.tsx
new file mode 100644
index 0000000..1553acc
--- /dev/null
+++ b/src/components/dashboard/limits-card.tsx
@@ -0,0 +1,229 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { AlertTriangle, ShieldCheck } from 'lucide-react';
+
+import { formatUnitsTrimmed } from '@/features/agents/lib/amount';
+import { TON_ASSET_KEY } from '@/features/agents/lib/limits-codec';
+import { usageKey } from '@/features/agents/lib/limits-constants';
+import type { AssetLimitView, LimitsUsageMap, LimitsView, WindowLimitView } from '@/features/agents/lib/limits-types';
+
+interface LimitsCardProps {
+ limits: LimitsView | null;
+ usage?: LimitsUsageMap;
+ isLoading: boolean;
+ isUsageLoading?: boolean;
+ isOwner: boolean;
+ hashMismatch?: boolean;
+ onEdit: () => void;
+}
+
+function spendRatio(spent: bigint, limit: bigint): number {
+ if (limit <= 0n) {
+ return 0;
+ }
+ const scaled = Number((spent * 10_000n) / limit) / 10_000;
+ return scaled < 0 ? 0 : scaled;
+}
+
+export function LimitsCard({
+ limits,
+ usage,
+ isLoading,
+ isUsageLoading = false,
+ isOwner,
+ hashMismatch = false,
+ onEdit,
+}: LimitsCardProps) {
+ const hasLimits = !!limits && limits.assets.length > 0;
+
+ return (
+
+
+
+
Transaction Limits
+
+ Rolling per-asset spend caps enforced by the agent's MCP before every transaction.
+
+
+ {isOwner && (
+
+ {hasLimits ? 'Edit limits' : 'Set limits'}
+
+ )}
+
+
+ {hashMismatch && (
+
+
+
+ Limits are set on-chain, but we couldn't verify them against a limits transaction in recent
+ history. The displayed values may be stale — re-set them to be safe.
+
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : !hasLimits ? (
+
+ ) : (
+
+ {limits.assets.map((asset) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function AssetLimitGroup({
+ asset,
+ usage,
+ isUsageLoading,
+}: {
+ asset: AssetLimitView;
+ usage?: LimitsUsageMap;
+ isUsageLoading: boolean;
+}) {
+ return (
+
+
+ {asset.assetKey === TON_ASSET_KEY ? (
+
+ ) : asset.imageUrl ? (
+
+ ) : (
+
+ {asset.symbol.slice(0, 2)}
+
+ )}
+
{asset.symbol}
+
+
+
+ {asset.windows.map((window) => (
+
+ ))}
+
+
+ );
+}
+
+function WindowLimitRow({
+ asset,
+ window,
+ usage,
+ isUsageLoading,
+}: {
+ asset: AssetLimitView;
+ window: WindowLimitView;
+ usage?: LimitsUsageMap;
+ isUsageLoading: boolean;
+}) {
+ const limitText = `${formatUnitsTrimmed(window.limit, asset.decimals)} ${asset.symbol}`;
+
+ // Per-transaction limits (window 0) are not rolling-window metered; show the cap only.
+ if (window.windowSeconds === 0) {
+ return (
+
+ {window.label}
+ {limitText}
+
+ );
+ }
+
+ const spent = usage?.[usageKey(asset.assetKey, window.windowSeconds)];
+ const hasUsage = spent !== undefined;
+ const ratio = hasUsage ? spendRatio(spent, window.limit) : 0;
+ const pct = Math.min(100, ratio * 100);
+ const over = ratio >= 1;
+ const barColor = over ? 'bg-red-500' : ratio >= 0.8 ? 'bg-amber-400' : 'bg-amber-500';
+
+ return (
+
+
+ {window.label}
+
+ {isUsageLoading && !hasUsage ? (
+ checking…
+ ) : (
+ <>
+
+ {formatUnitsTrimmed(hasUsage ? spent : 0n, asset.decimals)}
+
+ / {limitText}
+ >
+ )}
+
+
+
+
+ );
+}
+
+function EmptyLimits({
+ isOwner,
+ onEdit,
+ hashMismatch,
+}: {
+ isOwner: boolean;
+ onEdit: () => void;
+ hashMismatch: boolean;
+}) {
+ return (
+
+
+
+ {hashMismatch
+ ? 'Limits are set on-chain but could not be decoded from recent history.'
+ : 'No limits set — this agent can spend without restriction.'}
+
+ {isOwner && (
+
+ {hashMismatch ? 'Re-set limits' : 'Set limits'}
+
+ )}
+
+ );
+}
+
+function LimitsSkeleton() {
+ return (
+
+ {[0, 1].map((row) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/modals/fund-modal.tsx b/src/components/modals/fund-modal.tsx
index b440768..ef95c50 100644
--- a/src/components/modals/fund-modal.tsx
+++ b/src/components/modals/fund-modal.tsx
@@ -674,13 +674,7 @@ export function FundModal({ agent, onClose, onSuccess }: FundModalProps) {
function AssetIcon({ asset }: { asset: AssetItem }) {
if (asset.kind === 'ton') {
- return (
-
- );
+ return ;
}
if (asset.imageUrl) {
diff --git a/src/components/modals/limits-modal.tsx b/src/components/modals/limits-modal.tsx
new file mode 100644
index 0000000..7cb5a75
--- /dev/null
+++ b/src/components/modals/limits-modal.tsx
@@ -0,0 +1,532 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import type { ReactNode } from 'react';
+import { createPortal } from 'react-dom';
+import { toast } from 'sonner';
+import { useJettonsByAddress, useNetwork } from '@ton/appkit-react';
+import { Plus, Trash2 } from 'lucide-react';
+
+import { Modal } from './modal';
+
+import type { AgentWallet } from '@/features/agents';
+import { useAgentOperations } from '@/features/agents';
+import { storedToLimitsDict, TON_ASSET_KEY } from '@/features/agents/lib/limits-codec';
+import type { LimitsView, StoredLimits } from '@/features/agents/lib/limits-types';
+import { shortenAssetKey, WINDOW_PRESETS } from '@/features/agents/lib/limits-constants';
+import { formatUnitsTrimmed, parseUiAmountToUnits } from '@/features/agents/lib/amount';
+
+interface LimitsModalProps {
+ agent: AgentWallet | null;
+ currentLimits?: LimitsView | null;
+ onClose: () => void;
+ onSuccess?: () => void | Promise;
+}
+
+interface AssetOption {
+ key: string;
+ symbol: string;
+ decimals: number;
+ imageUrl?: string;
+}
+
+interface LimitRow {
+ id: number;
+ assetKey: string;
+ windowPreset: string; // preset seconds as string, or 'custom'
+ customSeconds: string;
+ amount: string;
+}
+
+const MAX_UINT32 = 0xffffffff;
+
+let rowIdSeq = 0;
+function nextRowId(): number {
+ rowIdSeq += 1;
+ return rowIdSeq;
+}
+
+function emptyRow(assetKey: string): LimitRow {
+ return { id: nextRowId(), assetKey, windowPreset: '86400', customSeconds: '', amount: '' };
+}
+
+/** Order-sensitive signature of the editable row fields, used to detect changes. */
+function rowsSignature(rows: LimitRow[]): string {
+ return rows
+ .map((row) => `${row.assetKey}|${row.windowPreset}|${row.customSeconds.trim()}|${row.amount.trim()}`)
+ .join(';');
+}
+
+function rowsFromLimits(limits: LimitsView | null | undefined): LimitRow[] {
+ if (!limits) {
+ return [];
+ }
+ const rows: LimitRow[] = [];
+ for (const asset of limits.assets) {
+ for (const window of asset.windows) {
+ const isPreset = WINDOW_PRESETS.some((preset) => preset.seconds === window.windowSeconds);
+ rows.push({
+ id: nextRowId(),
+ assetKey: asset.assetKey,
+ windowPreset: isPreset ? String(window.windowSeconds) : 'custom',
+ customSeconds: isPreset ? '' : String(window.windowSeconds),
+ amount: formatUnitsTrimmed(window.limit, asset.decimals),
+ });
+ }
+ }
+ return rows;
+}
+
+function windowLabel(windowPreset: string): string {
+ if (windowPreset === 'custom') {
+ return 'Custom (seconds)';
+ }
+ const preset = WINDOW_PRESETS.find((candidate) => String(candidate.seconds) === windowPreset);
+ return preset?.label ?? 'Custom (seconds)';
+}
+
+function normalizeError(error: unknown): string {
+ const message = error instanceof Error ? error.message : 'Failed to update limits';
+ const lower = message.toLowerCase();
+ if (lower.includes('unsupported metadata format')) {
+ return 'Unsupported metadata format for this wallet. Limits require on-chain metadata (0x00).';
+ }
+ if (lower.includes('insufficient')) {
+ return 'Insufficient gas for owner operation.';
+ }
+ if (lower.includes('rejected')) {
+ return 'Transaction was rejected.';
+ }
+ return message;
+}
+
+export function LimitsModal({ agent, currentLimits, onClose, onSuccess }: LimitsModalProps) {
+ const network = useNetwork();
+ const { setAgentLimits, clearAgentLimits, isPending } = useAgentOperations();
+ const [rows, setRows] = useState([]);
+ const [initialSignature, setInitialSignature] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { data: jettonsResponse } = useJettonsByAddress({
+ address: agent?.address ?? '',
+ network: network ?? undefined,
+ query: { enabled: !!agent },
+ });
+
+ const assetOptions = useMemo(() => {
+ const byKey = new Map();
+ byKey.set(TON_ASSET_KEY, { key: TON_ASSET_KEY, symbol: 'TON', decimals: 9 });
+
+ for (const jetton of jettonsResponse?.jettons ?? []) {
+ if (!jetton.address) {
+ continue;
+ }
+ byKey.set(jetton.address, {
+ key: jetton.address,
+ symbol: jetton.info?.symbol ?? shortenAssetKey(jetton.address),
+ decimals: jetton.decimalsNumber ?? 9,
+ imageUrl: jetton.info?.image?.url,
+ });
+ }
+
+ // Keep assets already configured even if no longer held.
+ for (const asset of currentLimits?.assets ?? []) {
+ if (!byKey.has(asset.assetKey)) {
+ byKey.set(asset.assetKey, { key: asset.assetKey, symbol: asset.symbol, decimals: asset.decimals });
+ }
+ }
+
+ return Array.from(byKey.values());
+ }, [jettonsResponse?.jettons, currentLimits]);
+
+ useEffect(() => {
+ if (agent) {
+ const seeded = rowsFromLimits(currentLimits);
+ const nextRows = seeded.length > 0 ? seeded : [emptyRow(TON_ASSET_KEY)];
+ setRows(nextRows);
+ setInitialSignature(rowsSignature(nextRows));
+ }
+ }, [agent, currentLimits]);
+
+ if (!agent) {
+ return null;
+ }
+
+ const optionForAsset = (assetKey: string): AssetOption | undefined =>
+ assetOptions.find((option) => option.key === assetKey);
+
+ const symbolForAsset = (assetKey: string): string => optionForAsset(assetKey)?.symbol ?? 'units';
+
+ const updateRow = (id: number, patch: Partial) => {
+ setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
+ };
+
+ const addRow = () => setRows((current) => [...current, emptyRow(TON_ASSET_KEY)]);
+ const removeRow = (id: number) => setRows((current) => current.filter((row) => row.id !== id));
+
+ const buildStoredLimits = (): StoredLimits => {
+ if (rows.length === 0) {
+ throw new Error('Add at least one limit, or use “Clear all limits”.');
+ }
+
+ const assets: StoredLimits['assets'] = {};
+ const seen = new Set();
+
+ for (const row of rows) {
+ const option = assetOptions.find((candidate) => candidate.key === row.assetKey);
+ if (!option) {
+ throw new Error('Select a valid asset for every limit.');
+ }
+
+ let windowSeconds: number;
+ if (row.windowPreset === 'custom') {
+ const parsed = Number(row.customSeconds.trim());
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > MAX_UINT32) {
+ throw new Error('Custom window must be a whole number of seconds between 1 and 4294967295.');
+ }
+ windowSeconds = parsed;
+ } else {
+ windowSeconds = Number(row.windowPreset);
+ }
+
+ const dedupeKey = `${row.assetKey}|${windowSeconds}`;
+ if (seen.has(dedupeKey)) {
+ throw new Error(`Duplicate window for ${option.symbol}. Each asset/window pair must be unique.`);
+ }
+ seen.add(dedupeKey);
+
+ const amountUnits = parseUiAmountToUnits(row.amount, option.decimals, `${option.symbol} amount`);
+ if (amountUnits <= 0n) {
+ throw new Error(`${option.symbol} amount must be greater than zero.`);
+ }
+
+ const assetEntry = assets[row.assetKey] ?? { windows: {} };
+ assetEntry.windows[String(windowSeconds)] = amountUnits.toString();
+ assets[row.assetKey] = assetEntry;
+ }
+
+ return { assets };
+ };
+
+ const uiPending = isPending || isSubmitting;
+ const isDirty = rowsSignature(rows) !== initialSignature;
+
+ const handleSave = async () => {
+ let limitsDict;
+ try {
+ const stored = buildStoredLimits();
+ limitsDict = storedToLimitsDict(stored);
+ } catch (error) {
+ toast.error(normalizeError(error));
+ return;
+ }
+
+ try {
+ setIsSubmitting(true);
+ await setAgentLimits(agent, limitsDict);
+ await onSuccess?.();
+ toast.success('Transaction limits updated');
+ onClose();
+ } catch (error) {
+ toast.error(normalizeError(error));
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleClear = async () => {
+ try {
+ setIsSubmitting(true);
+ await clearAgentLimits(agent);
+ await onSuccess?.();
+ toast.success('Transaction limits cleared');
+ onClose();
+ } catch (error) {
+ toast.error(normalizeError(error));
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ Set rolling spend caps per asset. Window Per transaction {' '}
+ caps a single transfer; time windows cap total spend over the trailing period.
+
+
+
+ {rows.map((row) => {
+ const selectedAsset = optionForAsset(row.assetKey);
+
+ return (
+
+
+
updateRow(row.id, { assetKey: value })}
+ items={assetOptions.map((option) => ({
+ value: option.key,
+ content: (
+
+
+ {option.symbol}
+
+ ),
+ }))}
+ >
+
+
+ {selectedAsset?.symbol ?? 'Select asset'}
+
+
+
removeRow(row.id)}
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-red-500/30 bg-red-500/10 text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300"
+ aria-label="Remove limit"
+ title="Remove limit"
+ >
+
+
+
+
+
+ updateRow(row.id, { windowPreset: value })}
+ items={[
+ ...WINDOW_PRESETS.map((preset) => ({
+ value: String(preset.seconds),
+ content: preset.label,
+ })),
+ { value: 'custom', content: 'Custom (seconds)' },
+ ]}
+ >
+ {windowLabel(row.windowPreset)}
+
+ {row.windowPreset === 'custom' && (
+ {
+ const next = event.target.value;
+ if (next === '' || /^\d+$/.test(next)) {
+ updateRow(row.id, { customSeconds: next });
+ }
+ }}
+ placeholder="seconds"
+ className="w-28 rounded-lg border border-white/[0.08] bg-white/[0.03] px-2.5 py-2 text-sm text-white placeholder-neutral-700 outline-none transition-colors focus:border-amber-500/50"
+ />
+ )}
+
+
+
updateRow(row.id, { amount: event.target.value })}
+ placeholder={`Max amount (e.g. 12.34 ${symbolForAsset(row.assetKey)})`}
+ className="mt-2 w-full rounded-lg border border-white/[0.08] bg-white/[0.03] px-3 py-2 text-sm text-white placeholder-neutral-700 outline-none transition-colors focus:border-amber-500/50"
+ />
+
+ );
+ })}
+
+
+
+
+ Add limit
+
+
+
+
+ Cancel
+
+ {currentLimits && (
+ void handleClear()}
+ disabled={uiPending}
+ className="rounded-full border border-red-500/25 bg-red-500/10 px-5 py-3 text-sm text-red-300 transition-colors hover:border-red-500/50 hover:bg-red-500/20 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-40"
+ >
+ Clear all limits
+
+ )}
+ void handleSave()}
+ disabled={uiPending || rows.length === 0 || !isDirty}
+ className="flex-1 rounded-full bg-amber-500 py-3 text-sm font-medium text-on-accent transition-colors hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
+ >
+ {uiPending ? (
+
+
+ Saving…
+
+ ) : (
+ 'Save limits'
+ )}
+
+
+
+
+ );
+}
+
+interface DropdownItem {
+ value: string;
+ content: ReactNode;
+}
+
+/**
+ * Select-style dropdown whose menu renders in a portal with fixed positioning,
+ * so it escapes the modal's `overflow-hidden` and the scroll container's clipping.
+ */
+function Dropdown({
+ value,
+ items,
+ onSelect,
+ className,
+ children,
+}: {
+ value: string;
+ items: DropdownItem[];
+ onSelect: (value: string) => void;
+ className?: string;
+ children: ReactNode;
+}) {
+ const [open, setOpen] = useState(false);
+ const triggerRef = useRef(null);
+ const menuRef = useRef(null);
+ const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null);
+
+ const updatePosition = useCallback(() => {
+ const element = triggerRef.current;
+ if (!element) {
+ return;
+ }
+ const rect = element.getBoundingClientRect();
+ setPosition({ left: rect.left, top: rect.bottom + 4, width: rect.width });
+ }, []);
+
+ useLayoutEffect(() => {
+ if (!open) {
+ return;
+ }
+ updatePosition();
+ const handle = () => updatePosition();
+ window.addEventListener('scroll', handle, true);
+ window.addEventListener('resize', handle);
+ return () => {
+ window.removeEventListener('scroll', handle, true);
+ window.removeEventListener('resize', handle);
+ };
+ }, [open, updatePosition]);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ const handlePointer = (event: MouseEvent) => {
+ const target = event.target as Node;
+ if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) {
+ return;
+ }
+ setOpen(false);
+ };
+ window.addEventListener('mousedown', handlePointer);
+ return () => window.removeEventListener('mousedown', handlePointer);
+ }, [open]);
+
+ return (
+ <>
+ setOpen((current) => !current)}
+ className={`flex items-center justify-between rounded-lg border border-white/[0.08] bg-white/[0.03] px-2.5 py-2 text-left text-sm text-white outline-none transition-colors hover:border-white/[0.15] focus:border-amber-500/50 ${className ?? ''}`}
+ >
+ {children}
+
+
+ {open &&
+ position &&
+ createPortal(
+
+ {items.map((item) => (
+ {
+ onSelect(item.value);
+ setOpen(false);
+ }}
+ className={`flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm transition-colors hover:bg-white/[0.06] ${
+ value === item.value ? 'text-amber-500' : 'text-white'
+ }`}
+ >
+ {item.content}
+
+ ))}
+
,
+ document.body,
+ )}
+ >
+ );
+}
+
+function Chevron({ open }: { open: boolean }) {
+ return (
+
+
+
+ );
+}
+
+function AssetIcon({ asset }: { asset: AssetOption | undefined }) {
+ if (asset?.key === TON_ASSET_KEY) {
+ return ;
+ }
+
+ if (asset?.imageUrl) {
+ return ;
+ }
+
+ return (
+
+ {(asset?.symbol ?? '?').charAt(0)}
+
+ );
+}
diff --git a/src/core/configs/env.ts b/src/core/configs/env.ts
index b890f38..d4db568 100644
--- a/src/core/configs/env.ts
+++ b/src/core/configs/env.ts
@@ -21,5 +21,5 @@ export const ENV_AGENTIC_COLLECTION_TESTNET =
const DEFAULT_AGENTIC_WALLET_CODE_BOC =
'b5ee9c72410229010006aa000114ff00f4a413f4bcf2c80b01020120022402014803130202ce0412020120051104e93b68bb7efb513434fffe92348034c7fd013d01481bb8c089b5cb089479512b38c0b5cb09db0997e138c0b5cb09d49c6d9f238e4df43e923d0135bff4ffcc7e248931c17cac8274cfcc75c2ffc0f23e9484bd0033b2ffc5b3b2413232ffc4fe94b28032c7c4bd003d00327b553835cb0834173aa32006080b0d01fe06d72c20304f23dcf2acd33f31d4d3ffd1f89222d0269525c000c3009170e294246ec3009170e2f2e097fa48f404d3ffd3ffd70a005312baf2e0972c515c515c515c515c0504111204103a56124b135202542c0ef00228c8fa52cbffca00f91626baf2e09506fa4430f828fa4430baf2e09604c8cbff13fa52ca00cb1ff40007000af400c9ed5402fe27d749810362be925f08e1278308d722088308d72322d0fa4831f401d3ff31d70bff20c000935b377f9a01f9014099f910b3c300e2925f07e0216e14b1f2e08405d3ffd31fd31ff404f4055125baf2e0855137baf2e08601f823bcf2e08802a405c8cbff14fa52cf8314cb1f14f40013f400c9ed54f80f70226e9132e30e20090a00687023d739308e2220d74bc002f2e093c028f2e093d72c20761e436cf2e093d74cd7393001a421c70012e6308407bbf2e09302ed5501146e915b8e84d001db3ce22602c050675f06d0fa4830f892c7058e2af892fa44f828fa443058bd935bdb31e0ed44d0d3ff31fa4831d32031f4058307f40e6fa1319330db31e1df6d01d33f31f404d20001966c12c8cec9019130e27f216e9131e30e216e915b8e8501d001db3ce20c2600687022d739308e2220d74bc002f2e093c028f2e093d72c20761e436cf2e093d74cd7393001a421c70012e6308407bbf2e09301ed5503a48e3037d0fa48f40431f89222c705f2b207d33f31f40501c8fa52f40016cec904c8cbff13fa52ca00cb1f12f400f400c9ed54e06c42d72c2026f68a44e302d72c26861dff54e3025f0320d749c21f9130e30d0e0f10008c33d0fa48f405f89222c705f2b203d33ffa48d4d70a0050066de304c8cf9014931eba13cb3f14cbff12fa5213cc70cf0b3f12f400c9c8cf858812fa5271cf0b6eccc98040fb00008a33d002d33ffa48d4d70a0005fa48f405f89250776de304c8cf9037581f8e15cb3f15cbff15fa5213fa5213cc70cf0b3f12f400c9c8cf858812fa5271cf0b6eccc98040fb000074d70b1f840f2182101f04537abd9a2182106f89f5e3bdc3009170e29a218210d136d3b3bdc3009170e29a0182105fcc3d14bdc300923170e2f2f400391b14481ba5cc1b5b5b5b5b5c38343e923d0134fff4fff4803460402060009d450785f0534343520c000983032c705f2b2f2b2e004f272c8fa5213cbffcf83f916f82a6d6d03c8cbff14fa52cf920000000113f400f400c901c8cf84d0ccccf916c8cf8a0040cbffcf50c705f2b280201201421020120151802016e16170039adce76a26869fffd246900698ffa027a02f80098b619600049183ff0400011af1df6a2686b85ffc0020120191e0201201a1d0201581b1c0008ab188b020022a897ed44d0d3ff31fa4831d30031d70b1f001db262fb513434ffcc7e920c75c280200201581f200039afcaf6a26869fffd246900698ffa027a02f8009a2d99600049183ff0400007aec1b8400201202223004db8fcfed44d0d3fffa48d200d31ff404f4052551454434f0016c31c000955b70596d6de07f553080027b8be1ed44d0d3ff31fa4831d32031f405810086801f8f2208308d722018308d72320d72c25f91a9024f2e08ad3ffd31fd31ff404f404d1ed44d0d3fffa48d200d31f20f404f405206ef26cd0fa4831f40431d3ff31d3ffd20031d120c300990cf90140dcf910c30094303c3a70e2f2e0870a6eb1f2e0845148baf2e0855155baf2e08603f823bcf2e088f80005a403c8cbff2501b4fa52cf8312cb1f12cec9ed54f80f70226e91328e3b7023d739308e2920d74bc002f2e093c028f2e093d72c20761e436cf2e093d4d70b0772b0f2e089d7393001a421c70012e6308407bbf2e09302ed55e2206e915be0d001db3c2602c0eda2edfbeb21d72c08148ec5d72c081c8e3dd72c082493f2c08de121f2e092ed44d001d70a0001d6fffa48d20020d31f31f4055125bdf2e08f24913195016ef2d08ee202c8cefa5212ca00cec9ed54e30de30d21d74a935bdb31e101d74cd00127280078fa4830fa44f828fa443058baf2e091ed44d0d6fffa48d200d61ff40450668307f45bf2e08c2295206ef2d090df04c8ce13fa52ca00cef400cec9ed540066fa4830fa44f828fa443058baf2e091ed44d0d6fffa48d620f404c8cf8350628307f453f2e08b03c8ce12fa52cef400cec9ed5478a46a86'
export const ENV_AGENTIC_WALLET_CODE_BOC = import.meta.env.VITE_AGENTIC_WALLET_CODE_BOC ?? DEFAULT_AGENTIC_WALLET_CODE_BOC;
-export const ENV_AGENTIC_OWNER_OP_GAS = import.meta.env.VITE_AGENTIC_OWNER_OP_GAS ?? '10000000';
+export const ENV_AGENTIC_OWNER_OP_GAS = import.meta.env.VITE_AGENTIC_OWNER_OP_GAS ?? '2000000';
export const ENV_AGENTIC_ACTIVITY_POLL_MS = Number(import.meta.env.VITE_AGENTIC_ACTIVITY_POLL_MS ?? '2000');
diff --git a/src/features/agents/hooks/use-agent-activity.ts b/src/features/agents/hooks/use-agent-activity.ts
index fa4eb5c..85e0e1b 100644
--- a/src/features/agents/hooks/use-agent-activity.ts
+++ b/src/features/agents/hooks/use-agent-activity.ts
@@ -11,9 +11,16 @@
import { useQuery } from '@tanstack/react-query';
import { useAppKit, useNetwork } from '@ton/appkit-react';
+import { Cell } from '@ton/core';
+
import { ENV_AGENTIC_ACTIVITY_POLL_MS } from '@/core/configs/env';
import { isSameTonAddress } from '@/features/agents/lib/address';
import { mapWithConcurrency } from '@/features/agents/lib/async';
+import {
+ fetchAccountTransactionsWithBody,
+ normalizeTxHash,
+} from '@/features/agents/lib/account-transactions';
+import { parseLimitsDictFromMessageBody } from '@/features/agents/lib/limits-codec';
type ActivityDirection = 'incoming' | 'outgoing' | 'neutral';
type SwapProtocol = 'stonfi' | 'dedust' | 'other';
@@ -463,6 +470,43 @@ function secondarySortWeight(item: AgentActivityItem): number {
return 1;
}
+/** Newest pages to scan when refining ChangeNftContent labels (set-limits vs rename). */
+const LIMITS_LABEL_SCAN_LIMIT = 50;
+
+/**
+ * Set-limits and rename share the ChangeNftContent opcode (0x1a0b9d51), so opcode
+ * alone can't tell them apart in the events feed (which omits the message body).
+ * Fetch the recent transactions with bodies and flag, by transaction hash, those
+ * whose body carries a non-empty `limitsDict` — those are limits updates.
+ */
+async function findLimitsUpdateTxHashes(
+ client: Parameters[0],
+ network: { chainId: string },
+ address: string,
+): Promise> {
+ const limitsTxHashes = new Set();
+ try {
+ const transactions = await fetchAccountTransactionsWithBody(client, network, address, LIMITS_LABEL_SCAN_LIMIT, 0);
+ for (const transaction of transactions) {
+ const body = transaction.inMessage?.messageContent?.body;
+ if (!body || !transaction.hash) {
+ continue;
+ }
+ try {
+ const dict = parseLimitsDictFromMessageBody(Cell.fromBase64(body));
+ if (dict && dict.size > 0) {
+ limitsTxHashes.add(transaction.hash);
+ }
+ } catch {
+ // not a parseable ChangeNftContent-with-limits body; leave as a rename
+ }
+ }
+ } catch {
+ // best-effort label refinement; fall back to the default "Set NFT content" label
+ }
+ return limitsTxHashes;
+}
+
export function useAgentActivity(agentAddress: string | null, ownerAddress: string | null = null) {
const appKit = useAppKit();
const network = useNetwork();
@@ -485,6 +529,19 @@ export function useAgentActivity(agentAddress: string | null, ownerAddress: stri
const events = response.events ?? [];
const items: AgentActivityItem[] = [];
+ // ChangeNftContent (rename) and set-limits share an opcode; refine the
+ // label only when a ChangeNftContent action is actually present.
+ const hasChangeNftContent = (events as any[]).some((event) =>
+ (Array.isArray(event?.actions) ? event.actions : []).some(
+ (action: any) =>
+ (normalizeOpcode(action?.SmartContractExec?.operation) ||
+ normalizeOpcode(action?.ContractDeploy?.operation)) === OP_CHANGE_NFT_CONTENT,
+ ),
+ );
+ const limitsUpdateTxHashes = hasChangeNftContent
+ ? await findLimitsUpdateTxHashes(client, network, agentAddress)
+ : new Set();
+
const nftImageCache = new Map();
const loadNftThumbnail = async (nftAddress: string): Promise => {
@@ -739,7 +796,11 @@ export function useAgentActivity(agentAddress: string | null, ownerAddress: stri
}
isAgentOperation = true;
} else if (opcode === OP_CHANGE_NFT_CONTENT) {
- actionLabel = 'Rename agent';
+ const normalizedTxHash = normalizeTxHash(baseTxHash);
+ actionLabel =
+ normalizedTxHash && limitsUpdateTxHashes.has(normalizedTxHash)
+ ? 'Update transaction limits'
+ : 'Set NFT content';
summary = actionLabel;
actor = 'user';
isAgentOperation = true;
diff --git a/src/features/agents/hooks/use-agent-limits-usage.ts b/src/features/agents/hooks/use-agent-limits-usage.ts
new file mode 100644
index 0000000..d0f9437
--- /dev/null
+++ b/src/features/agents/hooks/use-agent-limits-usage.ts
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { AgentWallet } from '../types';
+import type { LimitsUsageMap, LimitsView } from '../lib/limits-types';
+import { useAgentLimitsChainData } from './use-agent-limits';
+
+export interface UseAgentLimitsUsageResult {
+ usage: LimitsUsageMap;
+ isLoading: boolean;
+}
+
+/**
+ * Live rolling-window spend for the configured limits, reproducing the MCP's
+ * per-transaction accounting so the bars match what the MCP will enforce.
+ *
+ * Shares the single account-history fetch performed by `useAgentLimits` (same
+ * query key), so the spend bars and the decoded limits are always derived from the
+ * same on-chain transactions — there is no second fetch.
+ */
+export function useAgentLimitsUsage(agent: AgentWallet | null, limits: LimitsView | null): UseAgentLimitsUsageResult {
+ const query = useAgentLimitsChainData(agent);
+ const hasWindowedLimits = (limits?.maxWindowSeconds ?? 0) > 0;
+
+ return {
+ usage: query.data?.usage ?? {},
+ isLoading: hasWindowedLimits && query.isLoading,
+ };
+}
diff --git a/src/features/agents/hooks/use-agent-limits.ts b/src/features/agents/hooks/use-agent-limits.ts
new file mode 100644
index 0000000..1db6c17
--- /dev/null
+++ b/src/features/agents/hooks/use-agent-limits.ts
@@ -0,0 +1,156 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { useMemo } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import type { UseQueryResult } from '@tanstack/react-query';
+import { useAppKit, useJettonsByAddress, useNetwork } from '@ton/appkit-react';
+
+import type { AgentWallet } from '../types';
+import { extractLimitsHashFromMetadata } from '../lib/metadata';
+import { fetchLimitsChainData } from '../lib/limits-chain-data';
+import type { LimitsChainData } from '../lib/limits-chain-data';
+import { TON_ASSET_KEY } from '../lib/limits-codec';
+import { formatWindowLabel, shortenAssetKey } from '../lib/limits-constants';
+import type { AssetLimitView, LimitsView, StoredLimits, WindowLimitView } from '../lib/limits-types';
+
+interface JettonMeta {
+ symbol: string;
+ decimals: number;
+ imageUrl?: string;
+}
+
+function buildLimitsView(stored: StoredLimits, hashHex: string, jettonMeta: Map): LimitsView {
+ const assets: AssetLimitView[] = [];
+ let maxWindowSeconds = 0;
+
+ for (const [assetKey, assetLimit] of Object.entries(stored.assets)) {
+ const meta: JettonMeta =
+ assetKey === TON_ASSET_KEY
+ ? { symbol: 'TON', decimals: 9 }
+ : (jettonMeta.get(assetKey) ?? { symbol: shortenAssetKey(assetKey), decimals: 9 });
+
+ const windows: WindowLimitView[] = Object.entries(assetLimit.windows)
+ .map(([seconds, amount]) => ({
+ windowSeconds: Number(seconds),
+ label: formatWindowLabel(Number(seconds)),
+ limit: BigInt(amount),
+ }))
+ .sort((a, b) => a.windowSeconds - b.windowSeconds);
+
+ for (const window of windows) {
+ if (window.windowSeconds > maxWindowSeconds) {
+ maxWindowSeconds = window.windowSeconds;
+ }
+ }
+
+ assets.push({
+ assetKey,
+ symbol: meta.symbol,
+ decimals: meta.decimals,
+ imageUrl: meta.imageUrl,
+ windows,
+ });
+ }
+
+ assets.sort((a, b) => {
+ if (a.assetKey === TON_ASSET_KEY) return -1;
+ if (b.assetKey === TON_ASSET_KEY) return 1;
+ return a.symbol.localeCompare(b.symbol);
+ });
+
+ return { hashHex, assets, maxWindowSeconds };
+}
+
+/**
+ * Shared query that fetches account history once and derives both the decoded
+ * limits and the rolling-window spend from the same transactions. `useAgentLimits`
+ * and `useAgentLimitsUsage` both subscribe to this with an identical query key, so
+ * React Query performs a single fetch+compute no matter how many limits hooks are
+ * mounted — the spend bars and the decoded config can never disagree on history.
+ */
+export function useAgentLimitsChainData(agent: AgentWallet | null): UseQueryResult {
+ const appKit = useAppKit();
+ const network = useNetwork();
+
+ const hashHex = agent ? extractLimitsHashFromMetadata(agent.nftItemContent) : null;
+ const address = agent?.address ?? null;
+
+ return useQuery({
+ queryKey: ['agent-limits-data', network?.chainId ?? null, address, hashHex],
+ enabled: !!network && !!address && !!hashHex,
+ staleTime: 15_000,
+ retry: false,
+ refetchOnWindowFocus: false,
+ queryFn: async (): Promise => {
+ if (!network || !address) {
+ return { decoded: null, usage: {} };
+ }
+ const client = appKit.networkManager.getClient(network);
+ return fetchLimitsChainData(client, network, address);
+ },
+ });
+}
+
+export interface UseAgentLimitsResult {
+ limits: LimitsView | null;
+ isLoading: boolean;
+ /** On-chain hash present but the decoded dict couldn't be found/verified. */
+ hashMismatch: boolean;
+}
+
+/**
+ * Read an agent's transaction limits. The `limits_hash` is read from the wallet's
+ * NFT content with no network call; when present, the dict is decoded from the
+ * latest limits-change transaction and verified against the hash.
+ */
+export function useAgentLimits(agent: AgentWallet | null): UseAgentLimitsResult {
+ const network = useNetwork();
+
+ const hashHex = agent ? extractLimitsHashFromMetadata(agent.nftItemContent) : null;
+ const address = agent?.address ?? null;
+
+ const { data: jettonsResponse } = useJettonsByAddress({
+ address: address ?? '',
+ network: network ?? undefined,
+ query: { enabled: !!address && !!hashHex },
+ });
+
+ const query = useAgentLimitsChainData(agent);
+
+ const jettonMeta = useMemo(() => {
+ const map = new Map();
+ for (const jetton of jettonsResponse?.jettons ?? []) {
+ if (!jetton.address) {
+ continue;
+ }
+ map.set(jetton.address, {
+ symbol: jetton.info?.symbol ?? shortenAssetKey(jetton.address),
+ decimals: jetton.decimalsNumber ?? 9,
+ imageUrl: jetton.info?.image?.url,
+ });
+ }
+ return map;
+ }, [jettonsResponse?.jettons]);
+
+ const limits = useMemo(() => {
+ if (!hashHex || !query.data?.decoded) {
+ return null;
+ }
+ return buildLimitsView(query.data.decoded.stored, hashHex, jettonMeta);
+ }, [hashHex, query.data, jettonMeta]);
+
+ const hashMismatch =
+ Boolean(hashHex) && query.isSuccess && (!query.data?.decoded || query.data.decoded.hash !== hashHex);
+
+ return {
+ limits,
+ isLoading: Boolean(hashHex) && query.isLoading,
+ hashMismatch,
+ };
+}
diff --git a/src/features/agents/hooks/use-agent-operations.ts b/src/features/agents/hooks/use-agent-operations.ts
index d6beb44..9d14e76 100644
--- a/src/features/agents/hooks/use-agent-operations.ts
+++ b/src/features/agents/hooks/use-agent-operations.ts
@@ -18,6 +18,8 @@ import { useAgentsStore } from '../store/agents-store';
import {
cellToBase64,
buildRenameAgentTransaction,
+ buildSetLimitsTransaction,
+ buildClearLimitsTransaction,
createChangeOperatorBody,
createExtensionActionRequestBody,
createRemoveExtensionsRequestBody,
@@ -26,7 +28,8 @@ import {
getAgentWalletState,
} from '../lib/agentic-wallet';
import type { WithdrawJettonAction, WithdrawNftAction } from '../lib/agentic-wallet';
-import { buildUpdatedMetadataCell, extractNameFromMetadata } from '../lib/metadata';
+import type { LimitsDict } from '../lib/limits-types';
+import { buildUpdatedMetadataCell, extractLimitsHashFromMetadata, extractNameFromMetadata } from '../lib/metadata';
import { fetchNftInterfaces, mergeAddressBookInterfaces } from '../lib/nft-interfaces';
import { isEligibleFundingNft } from '../lib/nft-trust';
import { parseUint256PublicKey } from '../lib/public-key';
@@ -222,6 +225,23 @@ export function useAgentOperations() {
throw new Error('Extension removal transaction sent, but on-chain state is not updated yet. Please refresh shortly.');
};
+ const waitForLimitsHash = async (agentAddress: string, expectedHash: string | null) => {
+ if (!network) {
+ return;
+ }
+
+ const client = appKit.networkManager.getClient(network);
+ for (let attempt = 0; attempt < OPERATION_RETRY_ATTEMPTS; attempt += 1) {
+ const state = await getAgentWalletState(client, agentAddress);
+ if (extractLimitsHashFromMetadata(state.nftItemContent) === expectedHash) {
+ return;
+ }
+ await delay(OPERATION_RETRY_DELAY_MS);
+ }
+
+ throw new Error('Limits transaction sent, but on-chain state is not updated yet. Please refresh shortly.');
+ };
+
const normalizeExtensionAddresses = (extensionAddresses: string[]) =>
Array.from(new Set(extensionAddresses.map((address) => Address.parse(address).toString())));
@@ -475,6 +495,45 @@ export function useAgentOperations() {
throw new Error('Rename transaction sent, but metadata update is not visible yet. Please refresh shortly.');
});
+ const setAgentLimits = async (agent: AgentWallet, limitsDict: LimitsDict) =>
+ runWithPending(async () => {
+ if (!network) {
+ throw new Error('Network is not selected');
+ }
+
+ const client = appKit.networkManager.getClient(network);
+ const state = await getAgentWalletState(client, agent.address);
+ const { request, limitsHash } = buildSetLimitsTransaction({
+ agentAddress: agent.address,
+ queryId: createQueryId(),
+ gasAmountNano: gasAmount,
+ currentContent: state.nftItemContent,
+ limitsDict,
+ networkChainId: network.chainId,
+ });
+ await sendTransaction(request);
+ await waitForLimitsHash(agent.address, limitsHash);
+ });
+
+ const clearAgentLimits = async (agent: AgentWallet) =>
+ runWithPending(async () => {
+ if (!network) {
+ throw new Error('Network is not selected');
+ }
+
+ const client = appKit.networkManager.getClient(network);
+ const state = await getAgentWalletState(client, agent.address);
+ const request = buildClearLimitsTransaction({
+ agentAddress: agent.address,
+ queryId: createQueryId(),
+ gasAmountNano: gasAmount,
+ currentContent: state.nftItemContent,
+ networkChainId: network.chainId,
+ });
+ await sendTransaction(request);
+ await waitForLimitsHash(agent.address, null);
+ });
+
return {
isPending: isSendTransactionPending || activeOperations > 0,
revokeAgentWallet,
@@ -482,5 +541,7 @@ export function useAgentOperations() {
withdrawAllFromAgentWallet,
removeAgentExtensions,
renameAgentWallet,
+ setAgentLimits,
+ clearAgentLimits,
};
}
diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts
index 82b5b9e..ecbb6b3 100644
--- a/src/features/agents/index.ts
+++ b/src/features/agents/index.ts
@@ -11,6 +11,9 @@ export { useAgents } from './hooks/use-agents';
export { useAgent } from './hooks/use-agent';
export { useAgentOperations } from './hooks/use-agent-operations';
export { useAgentActivity } from './hooks/use-agent-activity';
+export { useAgentLimits } from './hooks/use-agent-limits';
+export { useAgentLimitsUsage } from './hooks/use-agent-limits-usage';
export { nftToAgent, nftsToAgents } from './lib/nft-to-agent';
export type { AgentWallet, AgentStatus, PendingAgentWallet } from './types';
export type { AgentActivityItem } from './hooks/use-agent-activity';
+export type { LimitsView, AssetLimitView, WindowLimitView, LimitsUsageMap, StoredLimits, StoredAssetLimit } from './lib/limits-types';
diff --git a/src/features/agents/lib/__tests__/limits-codec.spec.ts b/src/features/agents/lib/__tests__/limits-codec.spec.ts
new file mode 100644
index 0000000..b5d082e
--- /dev/null
+++ b/src/features/agents/lib/__tests__/limits-codec.spec.ts
@@ -0,0 +1,145 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+/**
+ * Parity gate for the transaction-limits codec. The dashboard is the only writer
+ * of limits, so its serialization must match the MCP reader/verifier byte-for-byte.
+ * These cases mirror the MCP's `__tests__/limits-codec.spec.ts`, plus a pinned
+ * canonical hash and the rename<->limits metadata-coexistence checks.
+ *
+ * Run with: `pnpm add -D vitest && pnpm exec vitest run` (test files are excluded
+ * from `tsconfig.app.json` so the app typecheck does not require vitest).
+ */
+
+import { Address, beginCell } from '@ton/core';
+import { describe, expect, it } from 'vitest';
+
+import {
+ CHANGE_NFT_CONTENT_OP,
+ TON_ASSET_KEY,
+ assetKeyForAddress,
+ computeLimitsHash,
+ limitsDictToStored,
+ normalizeAssetKey,
+ parseLimitsDictFromMessageBody,
+ storedToLimitsDict,
+} from '../limits-codec';
+import type { LimitsDict, StoredLimits } from '../limits-types';
+import {
+ buildContentWithLimitsHash,
+ buildUpdatedMetadataCell,
+ extractLimitsHashFromMetadata,
+ extractNameFromMetadata,
+} from '../metadata';
+
+const SENTINEL = new Address(0, Buffer.alloc(32));
+const JETTON = new Address(0, Buffer.alloc(32, 7));
+
+const STORED: StoredLimits = {
+ assets: {
+ [TON_ASSET_KEY]: { windows: { '0': '5000000000', '3600': '20000000000' } },
+ [JETTON.toString()]: { windows: { '86400': '1000' } },
+ },
+};
+
+/**
+ * The on-chain `limits_hash` the MCP computes for {@link STORED}, derived from the
+ * canonical `beginCell().storeDictDirect(dict).endCell().hash()` serialization both
+ * implementations share. A change here means the dashboard and MCP have diverged.
+ */
+const STORED_LIMITS_HASH = '9b4b54aa6d31eb3bcfb7bfd6b3e384363f1efa3b319bdee091186ed0a619cafe';
+
+/** A ChangeNftContentMsg body carrying `dict` after the (here empty) NFT content. */
+function changeContentBody(dict: LimitsDict, op = CHANGE_NFT_CONTENT_OP) {
+ return beginCell().storeUint(op, 32).storeUint(1n, 64).storeMaybeRef(null).storeDict(dict).endCell();
+}
+
+describe('limits-codec asset keys', () => {
+ it('maps the zero address to the TON sentinel and jettons to their master', () => {
+ expect(assetKeyForAddress(SENTINEL)).toBe(TON_ASSET_KEY);
+ expect(assetKeyForAddress(JETTON)).toBe(JETTON.toString());
+ });
+
+ it('normalizes keys to a comparable form and rejects non-addresses', () => {
+ expect(normalizeAssetKey(TON_ASSET_KEY)).toBe(TON_ASSET_KEY);
+ expect(normalizeAssetKey(JETTON.toString())).toBe(JETTON.toRawString());
+ expect(normalizeAssetKey('not-an-address')).toBeNull();
+ });
+});
+
+describe('limits-codec round-trip', () => {
+ it('round-trips StoredLimits -> dict -> StoredLimits', () => {
+ expect(limitsDictToStored(storedToLimitsDict(STORED))).toEqual(STORED);
+ });
+
+ it('matches the MCP canonical limits_hash (byte-for-byte parity)', () => {
+ expect(computeLimitsHash(storedToLimitsDict(STORED))).toBe(STORED_LIMITS_HASH);
+ });
+
+ it('computes a hash invariant under asset- and window-key insertion order', () => {
+ const reordered: StoredLimits = {
+ assets: {
+ [JETTON.toString()]: { windows: { '86400': '1000' } },
+ [TON_ASSET_KEY]: { windows: { '3600': '20000000000', '0': '5000000000' } },
+ },
+ };
+ expect(computeLimitsHash(storedToLimitsDict(reordered))).toBe(STORED_LIMITS_HASH);
+ });
+
+ it('computes a hash invariant under friendly-vs-raw address form', () => {
+ const rawForm: StoredLimits = {
+ assets: {
+ [TON_ASSET_KEY]: STORED.assets[TON_ASSET_KEY],
+ [JETTON.toRawString()]: { windows: { '86400': '1000' } },
+ },
+ };
+ expect(computeLimitsHash(storedToLimitsDict(rawForm))).toBe(STORED_LIMITS_HASH);
+ });
+
+ it('parses the limitsDict back out of a ChangeNftContentMsg body', () => {
+ const dict = storedToLimitsDict(STORED);
+ const parsed = parseLimitsDictFromMessageBody(changeContentBody(dict));
+ expect(parsed).not.toBeNull();
+ expect(limitsDictToStored(parsed!)).toEqual(STORED);
+ expect(computeLimitsHash(parsed!)).toBe(STORED_LIMITS_HASH);
+ });
+
+ it('returns null for a non-ChangeNftContentMsg opcode', () => {
+ expect(parseLimitsDictFromMessageBody(changeContentBody(storedToLimitsDict(STORED), 0x12345678))).toBeNull();
+ });
+
+ it('returns null for a ChangeNftContentMsg with no trailing limitsDict (a rename)', () => {
+ const body = beginCell().storeUint(CHANGE_NFT_CONTENT_OP, 32).storeUint(1n, 64).storeMaybeRef(null).endCell();
+ expect(parseLimitsDictFromMessageBody(body)).toBeNull();
+ });
+});
+
+describe('limits_hash metadata coexistence', () => {
+ it('set-limits stores the hash and preserves the name', () => {
+ const named = buildUpdatedMetadataCell(null, 'My Agent');
+ const withLimits = buildContentWithLimitsHash(named, STORED_LIMITS_HASH);
+ expect(extractLimitsHashFromMetadata(withLimits)).toBe(STORED_LIMITS_HASH);
+ expect(extractNameFromMetadata(withLimits)).toBe('My Agent');
+ });
+
+ it('rename preserves an existing limits_hash', () => {
+ const named = buildUpdatedMetadataCell(null, 'My Agent');
+ const withLimits = buildContentWithLimitsHash(named, STORED_LIMITS_HASH);
+ const renamed = buildUpdatedMetadataCell(withLimits, 'New Name');
+ expect(extractLimitsHashFromMetadata(renamed)).toBe(STORED_LIMITS_HASH);
+ expect(extractNameFromMetadata(renamed)).toBe('New Name');
+ });
+
+ it('clear-limits drops the hash and preserves the name', () => {
+ const named = buildUpdatedMetadataCell(null, 'My Agent');
+ const withLimits = buildContentWithLimitsHash(named, STORED_LIMITS_HASH);
+ const cleared = buildContentWithLimitsHash(withLimits, null);
+ expect(extractLimitsHashFromMetadata(cleared)).toBeNull();
+ expect(extractNameFromMetadata(cleared)).toBe('My Agent');
+ });
+});
diff --git a/src/features/agents/lib/account-transactions.ts b/src/features/agents/lib/account-transactions.ts
new file mode 100644
index 0000000..663630d
--- /dev/null
+++ b/src/features/agents/lib/account-transactions.ts
@@ -0,0 +1,179 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+/**
+ * Provider-aware account-transactions fetch that always carries message bodies.
+ *
+ * The limits feature recovers the off-chain `limitsDict` from a transaction's
+ * message body, but walletkit's **tonapi** client hardcodes
+ * `messageContent.body = undefined` (it only maps the toncenter
+ * `message_content.body`). tonapi instead returns the body as `in_msg.raw_body`
+ * (a hex BOC), which the typed `Transaction` never surfaces. Since this app runs
+ * on the tonapi provider, the limits decode / spend-usage would otherwise see no
+ * body at all.
+ *
+ * This module fetches transactions in a provider-aware way and normalizes the
+ * body to a base64 BOC so the existing `Cell.fromBase64(body)` consumers work
+ * unchanged: tonapi via a direct REST call (mirroring walletkit's own field
+ * mapping, plus `raw_body`), toncenter via walletkit (its body is already
+ * populated).
+ */
+
+import { Cell } from '@ton/core';
+
+import { ENV_TON_API_KEY_MAINNET, ENV_TON_API_KEY_TESTNET, ENV_TON_API_PROVIDER } from '@/core/configs/env';
+
+/** testnet masterchain workchain id, as exposed by `Network.testnet().chainId`. */
+const TESTNET_CHAIN_ID = '-3';
+
+/** A transaction message with its body normalized to a base64 BOC. */
+export interface AccountTxMessage {
+ source?: string;
+ destination?: string;
+ value?: string;
+ /** Base64 BOC of the message body (normalized from tonapi hex `raw_body`). */
+ messageContent?: { body?: string };
+}
+
+/**
+ * The subset of a transaction the limits decode and spend-window need, with
+ * message bodies guaranteed present regardless of provider. Structurally a subset
+ * of walletkit's `Transaction`, so toncenter results assign without remapping.
+ */
+export interface AccountTx {
+ /** Lowercased hex transaction hash, no `0x` prefix (matches tonapi event `base_transactions`). */
+ hash?: string;
+ now: number;
+ description?: { computePhase?: { isSuccess?: boolean } };
+ inMessage?: AccountTxMessage;
+ outMessages: AccountTxMessage[];
+}
+
+interface WalletkitTransactionsClient {
+ getAccountTransactions: (request: {
+ address: string[];
+ limit: number;
+ offset: number;
+ }) => Promise<{ transactions?: AccountTx[] }>;
+}
+
+interface NetworkLike {
+ chainId: string;
+}
+
+/** Normalize any hash form to lowercased hex without a `0x` prefix. */
+export function normalizeTxHash(value: string | undefined | null): string | undefined {
+ if (!value) {
+ return undefined;
+ }
+ const trimmed = value.trim().toLowerCase();
+ return trimmed.startsWith('0x') ? trimmed.slice(2) : trimmed;
+}
+
+function tonapiBaseUrl(chainId: string): string {
+ return chainId === TESTNET_CHAIN_ID ? 'https://testnet.tonapi.io' : 'https://tonapi.io';
+}
+
+function apiKeyForNetwork(chainId: string): string {
+ return chainId === TESTNET_CHAIN_ID ? ENV_TON_API_KEY_TESTNET : ENV_TON_API_KEY_MAINNET;
+}
+
+/** Convert a tonapi `raw_body` hex BOC to the base64 BOC the consumers parse. */
+function rawBodyToBase64(rawBody: unknown): string | undefined {
+ if (typeof rawBody !== 'string' || rawBody.length === 0) {
+ return undefined;
+ }
+ try {
+ return Cell.fromHex(rawBody).toBoc().toString('base64');
+ } catch {
+ return undefined;
+ }
+}
+
+interface TonApiRawMessage {
+ source?: { address?: string };
+ destination?: { address?: string };
+ value?: number | string;
+ raw_body?: string;
+}
+
+function mapTonApiMessage(raw: TonApiRawMessage | undefined): AccountTxMessage | undefined {
+ if (!raw) {
+ return undefined;
+ }
+ return {
+ source: raw.source?.address,
+ destination: raw.destination?.address,
+ value: raw.value !== undefined && raw.value !== null ? String(raw.value) : undefined,
+ messageContent: { body: rawBodyToBase64(raw.raw_body) },
+ };
+}
+
+interface TonApiRawTransaction {
+ hash?: string;
+ utime?: number;
+ success?: boolean;
+ compute_phase?: { success?: boolean };
+ in_msg?: TonApiRawMessage;
+ out_msgs?: TonApiRawMessage[];
+}
+
+async function fetchTonApiTransactions(
+ network: NetworkLike,
+ address: string,
+ limit: number,
+ offset: number,
+): Promise {
+ const url = new URL(`/v2/blockchain/accounts/${address}/transactions`, tonapiBaseUrl(network.chainId));
+ url.searchParams.set('limit', String(limit));
+ url.searchParams.set('offset', String(offset));
+ url.searchParams.set('sort_order', 'desc');
+
+ const apiKey = apiKeyForNetwork(network.chainId);
+ const response = await fetch(url.toString(), {
+ headers: {
+ accept: 'application/json',
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
+ },
+ });
+ if (!response.ok) {
+ throw new Error(`tonapi transactions request failed (${response.status})`);
+ }
+
+ const data = (await response.json()) as { transactions?: TonApiRawTransaction[] };
+ return (data.transactions ?? []).map((raw) => ({
+ hash: normalizeTxHash(raw.hash),
+ now: Number(raw.utime ?? 0),
+ description: { computePhase: { isSuccess: raw.compute_phase?.success ?? raw.success ?? true } },
+ inMessage: mapTonApiMessage(raw.in_msg),
+ outMessages: (raw.out_msgs ?? []).map((message) => mapTonApiMessage(message)).filter(Boolean) as AccountTxMessage[],
+ }));
+}
+
+/**
+ * Fetch one page of account transactions with message bodies populated. On the
+ * tonapi provider this issues a direct REST call (walletkit drops the body); on
+ * toncenter it delegates to the walletkit client whose body is already present.
+ */
+export async function fetchAccountTransactionsWithBody(
+ client: WalletkitTransactionsClient,
+ network: NetworkLike,
+ address: string,
+ limit: number,
+ offset: number,
+): Promise {
+ if (ENV_TON_API_PROVIDER === 'tonapi') {
+ return fetchTonApiTransactions(network, address, limit, offset);
+ }
+
+ const response = await client.getAccountTransactions({ address: [address], limit, offset });
+ return (response.transactions ?? []).map((transaction) => ({
+ ...transaction,
+ hash: normalizeTxHash(transaction.hash),
+ }));
+}
diff --git a/src/features/agents/lib/address.ts b/src/features/agents/lib/address.ts
index 80fbc73..cb3dfba 100644
--- a/src/features/agents/lib/address.ts
+++ b/src/features/agents/lib/address.ts
@@ -20,6 +20,16 @@ export function normalizeTonAddress(address: string | undefined | null): string
}
}
+/**
+ * Reduce an address to its canonical raw form (`workchain:hash`) for equality
+ * comparison, or `null` when the input is not a valid address. Matches the MCP
+ * `utils/address.ts` `normalizeAddressForComparison`, which the ported limits
+ * codec / spend-window modules depend on.
+ */
+export function normalizeAddressForComparison(value: string | undefined | null): string | null {
+ return normalizeTonAddress(value);
+}
+
export function isSameTonAddress(a: string | undefined | null, b: string | undefined | null): boolean {
const na = normalizeTonAddress(a);
const nb = normalizeTonAddress(b);
diff --git a/src/features/agents/lib/agentic-wallet.ts b/src/features/agents/lib/agentic-wallet.ts
index 53afd54..fcfc2c6 100644
--- a/src/features/agents/lib/agentic-wallet.ts
+++ b/src/features/agents/lib/agentic-wallet.ts
@@ -15,6 +15,10 @@ import {
} from '@ton/walletkit';
import type { TransactionRequest } from '@ton/appkit';
+import { computeLimitsHash } from './limits-codec';
+import type { LimitsDict } from './limits-types';
+import { buildContentWithLimitsHash } from './metadata';
+
const OP_EXTENSION_ACTION_REQUEST = 0xed84cbf0;
const OP_REMOVE_EXTENSION_EXTRA_ACTION = 0x03;
const OP_DEPLOY_WALLET = 0x0609e47b;
@@ -420,27 +424,116 @@ export async function getAgentWalletState(
return parseAgentWalletStateData(parseCellFromBase64Boc(state.data), walletAddress);
}
-export function buildRenameAgentTransaction(params: {
+/** Seconds an owner-signed operation request stays valid after it is built. */
+const OWNER_OP_VALID_UNTIL_SECONDS = 600;
+
+/**
+ * Assemble a single-message owner operation request: one internal message to the
+ * agent carrying `payload`, funded with `gasAmountNano`. Shared by the rename /
+ * set-limits / clear-limits builders, which differ only in the payload they carry.
+ */
+function buildOwnerOpRequest(params: {
agentAddress: string;
- queryId: bigint;
gasAmountNano: bigint;
- updatedNftItemContent: Cell;
networkChainId: string;
+ payload: Cell;
}): TransactionRequest {
- const payload = createChangeNftContentBody(params.queryId, params.updatedNftItemContent);
return {
network: { chainId: params.networkChainId },
- validUntil: Math.floor(Date.now() / 1000) + 600,
+ validUntil: Math.floor(Date.now() / 1000) + OWNER_OP_VALID_UNTIL_SECONDS,
messages: [
{
address: params.agentAddress,
amount: params.gasAmountNano.toString(),
- payload: cellToBase64(payload),
+ payload: cellToBase64(params.payload),
},
],
};
}
+export function buildRenameAgentTransaction(params: {
+ agentAddress: string;
+ queryId: bigint;
+ gasAmountNano: bigint;
+ updatedNftItemContent: Cell;
+ networkChainId: string;
+}): TransactionRequest {
+ return buildOwnerOpRequest({
+ agentAddress: params.agentAddress,
+ gasAmountNano: params.gasAmountNano,
+ networkChainId: params.networkChainId,
+ payload: createChangeNftContentBody(params.queryId, params.updatedNftItemContent),
+ });
+}
+
+/**
+ * ChangeNftContent body that also carries the off-chain `limitsDict` after the
+ * content cell: `op(32) | queryId(64) | maybeRef(content) | storeDict(dict)`. The
+ * contract only reads up to the content; the trailing dict is recovered off-chain
+ * (and its hash is anchored in the content's `limits_hash` attribute).
+ */
+export function createChangeNftContentWithLimitsBody(
+ queryId: bigint,
+ newNftItemContent: Cell | null,
+ limitsDict: LimitsDict,
+): Cell {
+ return beginCell()
+ .storeUint(OP_CHANGE_NFT_CONTENT, 32)
+ .storeUint(queryId, 64)
+ .storeMaybeRef(newNftItemContent)
+ .storeDict(limitsDict)
+ .endCell();
+}
+
+/**
+ * Build the owner-signed set-limits transaction. Computes the canonical
+ * `limits_hash`, writes it into the wallet's NFT content (preserving name/date),
+ * and appends the `limitsDict` to the body. Returns the request plus the hash so
+ * the caller can poll on-chain for it.
+ */
+export function buildSetLimitsTransaction(params: {
+ agentAddress: string;
+ queryId: bigint;
+ gasAmountNano: bigint;
+ currentContent: Cell | null;
+ limitsDict: LimitsDict;
+ networkChainId: string;
+}): { request: TransactionRequest; limitsHash: string } {
+ const limitsHash = computeLimitsHash(params.limitsDict);
+ const content = buildContentWithLimitsHash(params.currentContent, limitsHash);
+ const payload = createChangeNftContentWithLimitsBody(params.queryId, content, params.limitsDict);
+ return {
+ limitsHash,
+ request: buildOwnerOpRequest({
+ agentAddress: params.agentAddress,
+ gasAmountNano: params.gasAmountNano,
+ networkChainId: params.networkChainId,
+ payload,
+ }),
+ };
+}
+
+/**
+ * Build the owner-signed clear-limits transaction: drops the `limits_hash`
+ * attribute (preserving name/date) and sends no dict, so the MCP treats the
+ * wallet as unlimited.
+ */
+export function buildClearLimitsTransaction(params: {
+ agentAddress: string;
+ queryId: bigint;
+ gasAmountNano: bigint;
+ currentContent: Cell | null;
+ networkChainId: string;
+}): TransactionRequest {
+ const content = buildContentWithLimitsHash(params.currentContent, null);
+ return buildOwnerOpRequest({
+ agentAddress: params.agentAddress,
+ gasAmountNano: params.gasAmountNano,
+ networkChainId: params.networkChainId,
+ payload: createChangeNftContentBody(params.queryId, content),
+ });
+}
+
export async function getPublicKey(client: ToncenterLikeClient, walletAddress: string): Promise {
try {
const state = await getAgentWalletState(client, walletAddress);
diff --git a/src/features/agents/lib/limits-chain-data.ts b/src/features/agents/lib/limits-chain-data.ts
new file mode 100644
index 0000000..b33832e
--- /dev/null
+++ b/src/features/agents/lib/limits-chain-data.ts
@@ -0,0 +1,205 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { Cell } from '@ton/core';
+import type { ApiClient } from '@ton/walletkit';
+
+import type { AccountTx } from './account-transactions';
+import { fetchAccountTransactionsWithBody } from './account-transactions';
+import { normalizeAddressForComparison } from './address';
+import {
+ computeLimitsHash,
+ limitsDictToStored,
+ parseLimitsDictFromMessageBody,
+ TON_ASSET_KEY,
+} from './limits-codec';
+import { usageKey } from './limits-constants';
+import { getJettonWalletInfoFromClient } from './limits-jetton';
+import type { LimitsUsageMap, SpendEntry, StoredLimits } from './limits-types';
+import { sumSpendWithinWindow, transactionsToSpend } from './spend-window';
+
+/**
+ * One page of 50 transactions; up to 20 pages (~1000 transactions) cover both the
+ * latest limits-change lookup and the longest rolling window we meter.
+ */
+const LIMITS_PAGE = 50;
+const LIMITS_MAX_PAGES = 20;
+
+interface NetworkLike {
+ chainId: string;
+}
+
+/** Decoded mirror of the on-chain limits, plus the dict hash to verify against `limits_hash`. */
+export interface DecodedLimits {
+ stored: StoredLimits;
+ /** Hash of the decoded dict, to verify against the on-chain `limits_hash`. */
+ hash: string;
+}
+
+/**
+ * Everything the limits UI derives from account history, computed from a single
+ * fetch so the decoded limits and the spend bars always agree on the same
+ * transaction objects.
+ */
+export interface LimitsChainData {
+ /** Decoded limits from the latest limits-change transaction, or `null` if none found. */
+ decoded: DecodedLimits | null;
+ /** Rolling-window spend per `${assetKey}|${windowSeconds}`; empty when no windows are metered. */
+ usage: LimitsUsageMap;
+}
+
+/** Largest configured rolling window across all assets, in seconds (0 if only per-transaction caps). */
+function maxWindowSecondsOf(stored: StoredLimits): number {
+ let max = 0;
+ for (const asset of Object.values(stored.assets)) {
+ for (const seconds of Object.keys(asset.windows)) {
+ const value = Number(seconds);
+ if (value > max) {
+ max = value;
+ }
+ }
+ }
+ return max;
+}
+
+/** Decode the first (newest) transaction in a page whose body carries a non-empty limitsDict. */
+function decodeLimitsFromPage(transactions: AccountTx[]): DecodedLimits | null {
+ for (const transaction of transactions) {
+ const body = transaction.inMessage?.messageContent?.body;
+ if (!body) {
+ continue;
+ }
+ let dict;
+ try {
+ dict = parseLimitsDictFromMessageBody(Cell.fromBase64(body));
+ } catch {
+ continue;
+ }
+ if (dict && dict.size > 0) {
+ return { stored: limitsDictToStored(dict), hash: computeLimitsHash(dict) };
+ }
+ }
+ return null;
+}
+
+/**
+ * Compute spend per configured window from already-fetched transactions, mirroring
+ * MCP enforcement: net TON outflow per transaction plus jetton transfers (resolved
+ * jetton-wallet -> master via `get_wallet_data`), summed over each rolling window
+ * ending now.
+ */
+async function computeUsageFromTransactions(
+ client: ApiClient,
+ transactions: AccountTx[],
+ stored: StoredLimits,
+ address: string,
+ now: number,
+): Promise {
+ const { tonEntries, jettonProbes } = transactionsToSpend(transactions, address);
+ const entries: SpendEntry[] = [...tonEntries];
+
+ const uniqueWallets = [...new Set(jettonProbes.map((probe) => probe.jettonWalletAddress))];
+ const walletToMaster = new Map();
+ await Promise.all(
+ uniqueWallets.map(async (walletAddress) => {
+ const info = await getJettonWalletInfoFromClient(client, walletAddress);
+ walletToMaster.set(walletAddress, info?.master ? normalizeAddressForComparison(info.master) : null);
+ }),
+ );
+
+ for (const probe of jettonProbes) {
+ const master = walletToMaster.get(probe.jettonWalletAddress);
+ if (!master) {
+ continue;
+ }
+ entries.push({ timestamp: probe.timestamp, asset: master, amount: probe.amount });
+ }
+
+ const usage: LimitsUsageMap = {};
+ for (const [assetKey, assetLimit] of Object.entries(stored.assets)) {
+ const normalizedKey =
+ assetKey === TON_ASSET_KEY ? TON_ASSET_KEY : (normalizeAddressForComparison(assetKey) ?? assetKey);
+ for (const seconds of Object.keys(assetLimit.windows)) {
+ const windowSeconds = Number(seconds);
+ if (windowSeconds === 0) {
+ continue; // per-transaction caps are not rolling-window metered
+ }
+ usage[usageKey(assetKey, windowSeconds)] = sumSpendWithinWindow(
+ entries,
+ normalizedKey,
+ now,
+ windowSeconds,
+ );
+ }
+ }
+ return usage;
+}
+
+/**
+ * Fetch account history once and derive everything the limits UI needs from the
+ * same transaction objects: the decoded `limitsDict` (+hash) from the latest
+ * limits-change transaction, and the rolling-window spend for each configured
+ * window.
+ *
+ * Paging (newest first) stops as soon as both needs are met — the latest
+ * limits-change transaction has been found and history reaches back past the
+ * longest window — or when history is exhausted / the page cap is hit.
+ */
+export async function fetchLimitsChainData(
+ client: ApiClient,
+ network: NetworkLike,
+ address: string,
+): Promise {
+ const now = Math.floor(Date.now() / 1000);
+ const transactions: AccountTx[] = [];
+ let decoded: DecodedLimits | null = null;
+ let maxWindowSeconds = 0;
+
+ for (let page = 0; page < LIMITS_MAX_PAGES; page += 1) {
+ const pageTransactions = await fetchAccountTransactionsWithBody(
+ client,
+ network,
+ address,
+ LIMITS_PAGE,
+ page * LIMITS_PAGE,
+ );
+ if (pageTransactions.length === 0) {
+ break;
+ }
+ transactions.push(...pageTransactions);
+
+ if (!decoded) {
+ decoded = decodeLimitsFromPage(pageTransactions);
+ if (decoded) {
+ maxWindowSeconds = maxWindowSecondsOf(decoded.stored);
+ }
+ }
+
+ if (decoded) {
+ // Only per-transaction caps configured: no rolling window needs older history.
+ if (maxWindowSeconds === 0) {
+ break;
+ }
+ const oldest = pageTransactions[pageTransactions.length - 1];
+ if (oldest.now < now - maxWindowSeconds) {
+ break; // history now reaches back past the longest window
+ }
+ }
+
+ if (pageTransactions.length < LIMITS_PAGE) {
+ break;
+ }
+ }
+
+ const usage =
+ decoded && maxWindowSeconds > 0
+ ? await computeUsageFromTransactions(client, transactions, decoded.stored, address, now)
+ : {};
+
+ return { decoded, usage };
+}
diff --git a/src/features/agents/lib/limits-codec.ts b/src/features/agents/lib/limits-codec.ts
new file mode 100644
index 0000000..d235225
--- /dev/null
+++ b/src/features/agents/lib/limits-codec.ts
@@ -0,0 +1,149 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+/**
+ * Canonical codec for the on-chain `limitsDict` (`map>`).
+ *
+ * Ported verbatim from the MCP (`@ton/mcp` `src/limits/limits-codec.ts`) so the
+ * dashboard (the only writer of limits) serializes byte-for-byte what the MCP
+ * reader/verifier expects. The contract never parses or stores this dictionary:
+ * limits are carried in the `ChangeNftContentMsg` (opcode 0x1a0b9d51) body and
+ * recovered off-chain. The integrity anchor is `limits_hash` = the
+ * cell-representation hash of `beginCell().storeDictDirect(limitsDict).endCell()`.
+ */
+
+import { Address, beginCell, Dictionary } from '@ton/core';
+import type { Slice, Cell } from '@ton/core';
+
+import { normalizeAddressForComparison } from './address';
+import type { LimitsDict, StoredLimits } from './limits-types';
+
+/** Opcode of the owner-signed ChangeNftContentMsg that carries the limitsDict. */
+export const CHANGE_NFT_CONTENT_OP = 0x1a0b9d51;
+
+/** Length-prefix width of TON `coins` (VarUInteger 16). */
+const COINS_VARUINT_BITS = 4;
+
+/** Asset key used for native TON in `StoredLimits` and `SpendEntry`. */
+export const TON_ASSET_KEY = 'TON';
+
+/** TON sentinel asset address: workchain 0, all-zero hash (`0:00..00`). */
+export const TON_SENTINEL_ADDRESS = new Address(0, Buffer.alloc(32));
+
+function isTonSentinel(address: Address): boolean {
+ return address.workChain === 0 && address.hash.equals(Buffer.alloc(32));
+}
+
+/** Stored/limits asset key for an on-chain asset address. */
+export function assetKeyForAddress(address: Address): string {
+ return isTonSentinel(address) ? TON_ASSET_KEY : address.toString();
+}
+
+/**
+ * Normalize an asset key for comparison: `'TON'` is preserved; an address is reduced
+ * to its raw form. Returns `null` for a non-TON key that is not a valid address, so
+ * callers can reject corrupt config rather than silently storing an unmatchable key.
+ */
+export function normalizeAssetKey(key: string): string | null {
+ if (key === TON_ASSET_KEY) {
+ return TON_ASSET_KEY;
+ }
+ return normalizeAddressForComparison(key);
+}
+
+/** The inner `map` value serializer for the outer asset dictionary. */
+function innerWindowsValue() {
+ return Dictionary.Values.Dictionary(Dictionary.Keys.Uint(32), Dictionary.Values.BigVarUint(COINS_VARUINT_BITS));
+}
+
+function emptyWindows(): Dictionary {
+ return Dictionary.empty(Dictionary.Keys.Uint(32), Dictionary.Values.BigVarUint(COINS_VARUINT_BITS));
+}
+
+/** An empty `limitsDict` keyed by asset address. */
+export function emptyLimitsDict(): LimitsDict {
+ return Dictionary.empty(Dictionary.Keys.Address(), innerWindowsValue());
+}
+
+/** Canonical cell of the limitsDict; its hash is the on-chain `limits_hash`. */
+export function serializeLimitsDict(dict: LimitsDict): Cell {
+ return beginCell().storeDictDirect(dict).endCell();
+}
+
+/** Hex-encoded canonical hash of the limitsDict (matches the on-chain `limits_hash`). */
+export function computeLimitsHash(dict: LimitsDict): string {
+ return serializeLimitsDict(dict).hash().toString('hex');
+}
+
+/**
+ * Parse the `limitsDict` from a ChangeNftContentMsg body.
+ *
+ * Body layout: `op:uint32, queryId:uint64, newNftItemContent:Maybe ^Cell, limitsDict`.
+ * The contract only reads up to `newNftItemContent`; the trailing dictionary is the
+ * off-chain limits payload appended by the setter.
+ *
+ * Returns `null` when the body is not a ChangeNftContentMsg or carries no dictionary.
+ */
+export function parseLimitsDictFromMessageBody(body: Cell): LimitsDict | null {
+ try {
+ const slice: Slice = body.beginParse();
+ if (slice.remainingBits < 32 + 64) {
+ return null;
+ }
+ if (slice.loadUint(32) !== CHANGE_NFT_CONTENT_OP) {
+ return null;
+ }
+ slice.loadUintBig(64); // queryId
+ slice.loadMaybeRef(); // newNftItemContent
+ if (slice.remainingBits < 1) {
+ return null; // no trailing limitsDict (e.g. a plain rename)
+ }
+ return slice.loadDict(Dictionary.Keys.Address(), innerWindowsValue());
+ } catch {
+ return null;
+ }
+}
+
+/** Decode a parsed `limitsDict` into the JSON-friendly `StoredLimits` config shape. */
+export function limitsDictToStored(dict: LimitsDict): StoredLimits {
+ const assets: StoredLimits['assets'] = {};
+ for (const assetAddress of dict.keys()) {
+ const windowsDict = dict.get(assetAddress);
+ if (!windowsDict) {
+ continue;
+ }
+ const windows: Record = {};
+ for (const windowSeconds of windowsDict.keys()) {
+ const amount = windowsDict.get(windowSeconds);
+ if (amount === undefined) {
+ continue;
+ }
+ windows[String(windowSeconds)] = amount.toString();
+ }
+ assets[assetKeyForAddress(assetAddress)] = { windows };
+ }
+ return { assets };
+}
+
+/**
+ * Re-encode `StoredLimits` into a `limitsDict`. The resulting cell hash is
+ * deterministic regardless of key insertion order (TON serializes dictionaries
+ * canonically), so it round-trips with {@link computeLimitsHash}.
+ */
+export function storedToLimitsDict(stored: StoredLimits): LimitsDict {
+ const dict = emptyLimitsDict();
+ for (const [assetKey, assetLimit] of Object.entries(stored.assets)) {
+ const windows = emptyWindows();
+ for (const [windowSeconds, amount] of Object.entries(assetLimit.windows)) {
+ windows.set(Number(windowSeconds), BigInt(amount));
+ }
+ const address = assetKey === TON_ASSET_KEY ? TON_SENTINEL_ADDRESS : Address.parse(assetKey);
+ dict.set(address, windows);
+ }
+ return dict;
+}
diff --git a/src/features/agents/lib/limits-constants.ts b/src/features/agents/lib/limits-constants.ts
new file mode 100644
index 0000000..dfb034e
--- /dev/null
+++ b/src/features/agents/lib/limits-constants.ts
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+export interface WindowPreset {
+ /** Rolling window in seconds; `0` means a per-transaction cap. */
+ seconds: number;
+ /** Human label shown in dropdowns and rows. */
+ label: string;
+}
+
+/**
+ * UI presets for common windows. The on-chain format allows any uint32 window,
+ * so the modal also offers a "Custom (seconds)" option.
+ */
+export const WINDOW_PRESETS: WindowPreset[] = [
+ { seconds: 0, label: 'Per transaction' },
+ { seconds: 3600, label: 'Per hour' },
+ { seconds: 86400, label: 'Per day' },
+ { seconds: 604800, label: 'Per week' },
+];
+
+/** Stable key for the live-usage map shared by the usage hook and the card. */
+export function usageKey(assetKey: string, windowSeconds: number): string {
+ return `${assetKey}|${windowSeconds}`;
+}
+
+/** Compact fallback symbol for an asset address with no known jetton metadata (e.g. `EQAb…1xYz`). */
+export function shortenAssetKey(assetKey: string): string {
+ return assetKey.length > 12 ? `${assetKey.slice(0, 4)}…${assetKey.slice(-4)}` : assetKey;
+}
+
+const PRESET_LABEL = new Map(WINDOW_PRESETS.map((preset) => [preset.seconds, preset.label]));
+
+/** Human label for any window length (presets, or a friendly fallback). */
+export function formatWindowLabel(seconds: number): string {
+ const preset = PRESET_LABEL.get(seconds);
+ if (preset) {
+ return preset;
+ }
+ if (seconds % 86400 === 0) {
+ return `Per ${seconds / 86400} days`;
+ }
+ if (seconds % 3600 === 0) {
+ return `Per ${seconds / 3600} hours`;
+ }
+ if (seconds % 60 === 0) {
+ return `Per ${seconds / 60} minutes`;
+ }
+ return `Per ${seconds}s`;
+}
diff --git a/src/features/agents/lib/limits-jetton.ts b/src/features/agents/lib/limits-jetton.ts
new file mode 100644
index 0000000..a75784a
--- /dev/null
+++ b/src/features/agents/lib/limits-jetton.ts
@@ -0,0 +1,77 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import { Cell } from '@ton/core';
+import { ParseStack } from '@ton/walletkit';
+import type { ApiClient } from '@ton/walletkit';
+
+// TEP-74 transfer (0x0f8a7ea5) and burn (0x595f07bc) share the same
+// `op:uint32, query_id:uint64, amount:VarUInteger 16` prefix.
+const JETTON_TRANSFER_OP = 0x0f8a7ea5;
+const JETTON_BURN_OP = 0x595f07bc;
+
+/**
+ * Parse the jetton amount leaving the wallet from a message payload, or `null`
+ * when the payload is absent or is not a TEP-74 transfer/burn.
+ */
+export function parseJettonOutflowAmount(payloadBase64: string | null | undefined): bigint | null {
+ if (!payloadBase64) {
+ return null;
+ }
+ try {
+ const slice = Cell.fromBase64(payloadBase64).beginParse();
+ if (slice.remainingBits < 96) {
+ return null;
+ }
+ const op = slice.loadUint(32);
+ if (op !== JETTON_TRANSFER_OP && op !== JETTON_BURN_OP) {
+ return null;
+ }
+ slice.loadUintBig(64); // query_id
+ return slice.loadCoins();
+ } catch {
+ return null;
+ }
+}
+
+export interface JettonWalletInfo {
+ owner: string;
+ master: string;
+}
+
+/**
+ * Resolve a jetton wallet's (owner, master) via `get_wallet_data`. Returns `null`
+ * on any failure; callers fall back to TON-only metering.
+ */
+export async function getJettonWalletInfoFromClient(
+ client: ApiClient,
+ jettonWalletAddress: string,
+): Promise {
+ try {
+ const result = await client.runGetMethod(jettonWalletAddress, 'get_wallet_data');
+ if (result.exitCode !== 0) {
+ return null;
+ }
+ const stack = ParseStack(result.stack);
+ const owner = loadAddressFromStackItem(stack[1]);
+ const master = loadAddressFromStackItem(stack[2]);
+ if (!owner || !master) {
+ return null;
+ }
+ return { owner: owner.toString(), master: master.toString() };
+ } catch {
+ return null;
+ }
+}
+
+function loadAddressFromStackItem(item: ReturnType[number] | undefined) {
+ if (!item || (item.type !== 'slice' && item.type !== 'cell')) {
+ return null;
+ }
+ return item.cell.asSlice().loadAddress();
+}
diff --git a/src/features/agents/lib/limits-types.ts b/src/features/agents/lib/limits-types.ts
new file mode 100644
index 0000000..c3ccc8e
--- /dev/null
+++ b/src/features/agents/lib/limits-types.ts
@@ -0,0 +1,115 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { Address, Dictionary } from '@ton/core';
+
+/**
+ * On-chain wire shape of the limits, recovered from a ChangeNftContentMsg body:
+ * `map>` = {asset: {window_seconds: max_spend}}.
+ *
+ * - Outer key: the asset address. TON is the sentinel zero address; jettons use
+ * their jetton-master address.
+ * - Inner key: rolling window in seconds. The special value `0` is a
+ * per-transaction limit.
+ * - Inner value: maximum spend in the asset's base units.
+ */
+export type LimitsDict = Dictionary>;
+
+/**
+ * A single outgoing-spend observation derived from on-chain history, already
+ * netted per asset. Amounts are strictly positive base units.
+ */
+export interface SpendEntry {
+ /** Unix timestamp in seconds. */
+ timestamp: number;
+ /** Normalized asset key: `'TON'` or a normalized jetton-master address. */
+ asset: string;
+ /** Spent amount in the asset's base units (always > 0). */
+ amount: bigint;
+}
+
+/**
+ * A single outgoing jetton transfer recovered from a transaction's out-messages,
+ * before its jetton-wallet address is resolved to a master. Resolution and
+ * per-master aggregation happen in the service (they require a `get_wallet_data`
+ * call), keeping the transaction parser pure and synchronous.
+ */
+export interface JettonSpendProbe {
+ /** Unix timestamp in seconds of the transaction that emitted the transfer. */
+ timestamp: number;
+ /** Destination of the transfer message: this wallet's jetton-wallet address. */
+ jettonWalletAddress: string;
+ /** Transferred (or burned) amount in base jetton units (always > 0). */
+ amount: bigint;
+}
+
+/**
+ * Outgoing spend recovered from a page of account transactions: netted TON
+ * entries ready to use, and unresolved jetton outflows awaiting master lookup.
+ */
+export interface TransactionSpend {
+ /** Net TON spend entries (one per transaction with positive net outflow). */
+ tonEntries: SpendEntry[];
+ /** Outgoing jetton transfers, keyed by jetton-wallet address (unresolved). */
+ jettonProbes: JettonSpendProbe[];
+}
+
+/**
+ * Decoded mirror of the on-chain limitsDict, JSON-friendly. Mirrors the MCP
+ * `StoredLimits` config shape so the dashboard and MCP agree on the decode
+ * target (MCP `registry/config.ts`).
+ */
+export interface StoredLimits {
+ /** Keyed by asset address (`'TON'` sentinel for native TON). */
+ assets: Record;
+}
+
+export interface StoredAssetLimit {
+ /** Rolling windows: window seconds -> max spend in base units, as a decimal string. */
+ windows: Record;
+}
+
+/** One configured window within an asset, decoded for display. */
+export interface WindowLimitView {
+ /** Rolling window in seconds; `0` is a per-transaction cap. */
+ windowSeconds: number;
+ /** Human label, e.g. "Per transaction", "Per day", "Per 7200s". */
+ label: string;
+ /** Max spend in the asset's base units. */
+ limit: bigint;
+}
+
+/** One asset's limits, decoded and enriched with display metadata. */
+export interface AssetLimitView {
+ /** Normalized asset key: `'TON'` or the jetton-master address. */
+ assetKey: string;
+ /** Display symbol: `'TON'`, the jetton symbol, or a shortened address. */
+ symbol: string;
+ /** Decimals for base-unit <-> UI conversion (9 for TON). */
+ decimals: number;
+ /** Optional token icon URL. */
+ imageUrl?: string;
+ /** Configured windows, sorted (per-tx first, then ascending). */
+ windows: WindowLimitView[];
+}
+
+/** Decoded, display-ready limits for a wallet. */
+export interface LimitsView {
+ /** The on-chain `limits_hash` these limits correspond to. */
+ hashHex: string;
+ /** Per-asset limits. */
+ assets: AssetLimitView[];
+ /** Largest configured window across all assets, in seconds (0 if only per-tx). */
+ maxWindowSeconds: number;
+}
+
+/**
+ * Live usage per `${assetKey}|${windowSeconds}`, in base units, for the rolling
+ * window ending now. Per-transaction (window 0) entries are not metered here.
+ */
+export type LimitsUsageMap = Record;
diff --git a/src/features/agents/lib/metadata.ts b/src/features/agents/lib/metadata.ts
index be8076a..237ffe8 100644
--- a/src/features/agents/lib/metadata.ts
+++ b/src/features/agents/lib/metadata.ts
@@ -151,3 +151,38 @@ export function buildUpdatedMetadataCell(currentContent: Cell | null, newNameRaw
return beginCell().storeUint(ONCHAIN_CONTENT_PREFIX, 8).storeDict(dict).endCell();
}
+
+/** TEP-64 onchain attribute key under which the limits integrity anchor is stored. */
+export const LIMITS_HASH_KEY = 'limits_hash';
+
+/**
+ * Read the on-chain `limits_hash` hex anchor from a wallet's NFT content cell, or
+ * `null` when no limits are set. Trimmed to match how the MCP reads it
+ * (`readOnchainMetadataValue(content, 'limits_hash')` trims the snake value).
+ */
+export function extractLimitsHashFromMetadata(content: Cell | null): string | null {
+ const value = extractStringFromMetadata(content, LIMITS_HASH_KEY);
+ if (value === null) {
+ return null;
+ }
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+/**
+ * Clone the wallet's onchain metadata dict and set (or, when `hashHex` is null,
+ * delete) the `limits_hash` attribute, preserving every other key (name,
+ * creation_date, ...). Setting limits writes the new hash; clearing limits drops
+ * the key so the MCP treats the wallet as unlimited.
+ */
+export function buildContentWithLimitsHash(currentContent: Cell | null, hashHex: string | null): Cell {
+ const dict = parseOnchainMetadataDict(currentContent);
+ const key = onchainMetadataKey(LIMITS_HASH_KEY);
+ if (hashHex && hashHex.trim()) {
+ dict.set(key, buildOnchainMetadataValue(hashHex.trim()));
+ } else {
+ dict.delete(key);
+ }
+
+ return beginCell().storeUint(ONCHAIN_CONTENT_PREFIX, 8).storeDict(dict).endCell();
+}
diff --git a/src/features/agents/lib/spend-window.ts b/src/features/agents/lib/spend-window.ts
new file mode 100644
index 0000000..fc53a7b
--- /dev/null
+++ b/src/features/agents/lib/spend-window.ts
@@ -0,0 +1,104 @@
+/**
+ * Copyright (c) TonTech.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type { AccountTx } from './account-transactions';
+
+import { normalizeAddressForComparison } from './address';
+import { TON_ASSET_KEY } from './limits-codec';
+import { parseJettonOutflowAmount } from './limits-jetton';
+import type { JettonSpendProbe, SpendEntry, TransactionSpend } from './limits-types';
+
+/**
+ * Reduce a page of account transactions into per-transaction net outgoing-spend
+ * for `walletAddress`, parsing jetton transfers directly from message bodies.
+ *
+ * - TON: per transaction, `net = sum(out-message values) - in-message value` so a
+ * contract call that forwards funds is metered at its net cost (e.g. receive 0.1
+ * then send 1 counts as 0.9). Only positive net is recorded as spend.
+ * - Jettons: every out-message carrying a TEP-74 transfer/burn op yields a probe
+ * (amount + jetton-wallet address); the caller resolves the wallet to a master
+ * and aggregates. Incoming jettons arrive as transfer-notifications, never as a
+ * transfer/burn op, so they are not picked up here.
+ *
+ * Transactions whose compute phase explicitly failed are skipped; their actions
+ * were reverted and moved no funds.
+ */
+export function transactionsToSpend(transactions: AccountTx[], walletAddress: string): TransactionSpend {
+ const walletRaw = normalizeAddressForComparison(walletAddress);
+ if (!walletRaw) {
+ return { tonEntries: [], jettonProbes: [] };
+ }
+
+ const tonEntries: SpendEntry[] = [];
+ const jettonProbes: JettonSpendProbe[] = [];
+
+ for (const transaction of transactions) {
+ if (transaction.description?.computePhase?.isSuccess === false) {
+ continue;
+ }
+
+ let tonOut = 0n;
+ for (const message of transaction.outMessages) {
+ if (!isFromWallet(message.source, walletRaw)) {
+ continue;
+ }
+ tonOut += toBigInt(message.value);
+ const outflow = parseJettonOutflowAmount(message.messageContent?.body);
+ if (outflow && outflow > 0n && message.destination) {
+ jettonProbes.push({
+ timestamp: transaction.now,
+ jettonWalletAddress: message.destination,
+ amount: outflow,
+ });
+ }
+ }
+
+ const tonNet = tonOut - toBigInt(transaction.inMessage?.value);
+ if (tonNet > 0n) {
+ tonEntries.push({ timestamp: transaction.now, asset: TON_ASSET_KEY, amount: tonNet });
+ }
+ }
+
+ return { tonEntries, jettonProbes };
+}
+
+/** Sum recorded spend for `asset` within the last `windowSeconds` (inclusive of `now - window`). */
+export function sumSpendWithinWindow(entries: SpendEntry[], asset: string, now: number, windowSeconds: number): bigint {
+ const cutoff = now - windowSeconds;
+ let total = 0n;
+ for (const entry of entries) {
+ if (entry.asset === asset && entry.timestamp >= cutoff) {
+ total += entry.amount;
+ }
+ }
+ return total;
+}
+
+/**
+ * Whether an out-message was emitted by this wallet. Messages in the `outMessages`
+ * list of a transaction on the wallet's account are emitted by it, but the indexer
+ * may omit `source`; an absent source is treated as the wallet, a present one must
+ * match.
+ */
+function isFromWallet(source: string | undefined, walletRaw: string): boolean {
+ if (!source) {
+ return true;
+ }
+ return normalizeAddressForComparison(source) === walletRaw;
+}
+
+function toBigInt(value: bigint | string | number | undefined): bigint {
+ if (value === undefined) {
+ return 0n;
+ }
+ try {
+ return typeof value === 'bigint' ? value : BigInt(value);
+ } catch {
+ return 0n;
+ }
+}
diff --git a/src/pages/agent-detail-page.tsx b/src/pages/agent-detail-page.tsx
index 6c61a43..6568ba3 100644
--- a/src/pages/agent-detail-page.tsx
+++ b/src/pages/agent-detail-page.tsx
@@ -20,7 +20,7 @@ import { useAddress, useAppKit, useBalanceByAddress, useNetwork } from '@ton/app
import { ArrowLeft, AlertTriangle, Check, CheckCircle2, Copy, Pencil, X } from 'lucide-react';
import { toast } from 'sonner';
-import { useAgentActivity, useAgentOperations, useAgents, useAgentsStore } from '@/features/agents';
+import { useAgentActivity, useAgentLimits, useAgentLimitsUsage, useAgentOperations, useAgents, useAgentsStore } from '@/features/agents';
import type { AgentWallet } from '@/features/agents';
import { StatusDot } from '@/components/shared/status-dot';
import { CopyableAddress, CopyableValue } from '@/components/shared/copyable-address';
@@ -30,6 +30,8 @@ import { FundModal } from '@/components/modals/fund-modal';
import { WithdrawModal } from '@/components/modals/withdraw-modal';
import { RevokeModal } from '@/components/modals/revoke-modal';
import { RenameModal } from '@/components/modals/rename-modal';
+import { LimitsModal } from '@/components/modals/limits-modal';
+import { LimitsCard } from '@/components/dashboard/limits-card';
import { ChangePublicKeyModal } from '@/components/modals/change-public-key-modal';
import { UnexpectedActivityModal } from '@/components/modals/unexpected-activity-modal';
import { RemoveExtensionsModal } from '@/components/modals/remove-extensions-modal';
@@ -158,12 +160,15 @@ export function AgentDetailPage() {
agent?.address ?? null,
agent?.ownerAddress ?? null,
);
+ const { limits, isLoading: isLimitsLoading, hashMismatch: limitsHashMismatch } = useAgentLimits(agent);
+ const { usage: limitsUsage, isLoading: isLimitsUsageLoading } = useAgentLimitsUsage(agent, limits);
const lastActivityMarkerRef = useRef(null);
const [showFund, setShowFund] = useState(false);
const [showWithdraw, setShowWithdraw] = useState(false);
const [showRevoke, setShowRevoke] = useState(false);
const [showRename, setShowRename] = useState(false);
+ const [showLimits, setShowLimits] = useState(false);
const [showChangePublicKey, setShowChangePublicKey] = useState(false);
const [showRemoveExtensions, setShowRemoveExtensions] = useState(false);
const [showUnexpected, setShowUnexpected] = useState(false);
@@ -260,6 +265,13 @@ export function AgentDetailPage() {
queryKey: getNFTsQueryOptions(appKit, queryScope).queryKey,
exact: true,
});
+ // Limits + spend bars are derived from transaction history (no
+ // refetchInterval of their own), so refresh them whenever new activity
+ // lands. Prefix match covers the hash tail of the shared query key; the
+ // single query feeds both the decoded limits and the spend usage.
+ void queryClient.invalidateQueries({
+ queryKey: ['agent-limits-data', network?.chainId ?? null, agent.address],
+ });
}, [agent, appKit, latestActivityMarker, network, queryClient]);
useEffect(() => {
@@ -516,6 +528,16 @@ export function AgentDetailPage() {
{agent.source}
+ setShowLimits(true)}
+ />
+
{hasExtensions && (
@@ -586,6 +608,14 @@ export function AgentDetailPage() {
/>
setShowRevoke(false)} onSuccess={refresh} />
setShowRename(false)} onSuccess={refresh} />
+ setShowLimits(false)}
+ onSuccess={async () => {
+ await Promise.all([refresh(), refetchFallbackAgent()]);
+ }}
+ />