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 && ( + + )} +
+ + {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 && ( + + )} +
+ ); +} + +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'} + + + +
+ +
+ 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" + /> +
+ ); + })} +
+ + + +
+ + {currentLimits && ( + + )} + +
+
+
+ ); +} + +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 ( + <> + + {open && + position && + createPortal( +
+ {items.map((item) => ( + + ))} +
, + 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()]); + }} + />