From cc6183dcf8c7fc2e7c222126146ba969e50e6ae1 Mon Sep 17 00:00:00 2001 From: Arkadiy Stena Date: Mon, 1 Jun 2026 11:09:13 +0300 Subject: [PATCH 1/3] add spending limits --- .changeset/mcp-transaction-limits.md | 5 + packages/mcp/README.md | 14 + .../AgenticSetupSessionManager.spec.ts | 12 +- .../__tests__/AgenticWalletAdapter.spec.ts | 1 + packages/mcp/src/__tests__/limits.spec.ts | 561 ++++++++++++++++++ packages/mcp/src/__tests__/tep64.spec.ts | 51 ++ .../agentic_wallet/AgenticWalletAdapter.ts | 6 +- .../src/contracts/agentic_wallet/actions.ts | 53 +- packages/mcp/src/limits/enforce.ts | 280 +++++++++ packages/mcp/src/limits/jetton.ts | 82 +++ packages/mcp/src/limits/limits-codec.ts | 150 +++++ packages/mcp/src/limits/spend-window.ts | 112 ++++ packages/mcp/src/limits/types.ts | 71 +++ packages/mcp/src/registry/config.ts | 78 +++ packages/mcp/src/runtime/wallet-runtime.ts | 25 +- .../services/AgenticSetupSessionManager.ts | 8 +- packages/mcp/src/services/McpWalletService.ts | 396 ++++++++++--- packages/mcp/src/utils/agentic.ts | 43 +- packages/mcp/src/utils/tep64.ts | 79 +++ .../api/models/transactions/Transaction.ts | 5 +- .../src/clients/tonapi/ApiClientTonApi.ts | 2 +- .../clients/toncenter/ApiClientToncenter.ts | 2 +- .../toncenter/mappers/map-transactions.ts | 2 +- 23 files changed, 1863 insertions(+), 175 deletions(-) create mode 100644 .changeset/mcp-transaction-limits.md create mode 100644 packages/mcp/src/__tests__/limits.spec.ts create mode 100644 packages/mcp/src/__tests__/tep64.spec.ts create mode 100644 packages/mcp/src/limits/enforce.ts create mode 100644 packages/mcp/src/limits/jetton.ts create mode 100644 packages/mcp/src/limits/limits-codec.ts create mode 100644 packages/mcp/src/limits/spend-window.ts create mode 100644 packages/mcp/src/limits/types.ts create mode 100644 packages/mcp/src/utils/tep64.ts diff --git a/.changeset/mcp-transaction-limits.md b/.changeset/mcp-transaction-limits.md new file mode 100644 index 000000000..dff9528c1 --- /dev/null +++ b/.changeset/mcp-transaction-limits.md @@ -0,0 +1,5 @@ +--- +'@ton/mcp': patch +--- + +Added on-chain-anchored spend limits for agentic wallets. Limits are set by the owner on-chain (via `ChangeNftContentMsg`) and verified client-side before every transfer against the wallet's rolling transaction history — per-transaction caps and rolling time-window caps for TON and jettons. No local limits file or persisted counters; the on-chain history is the source of truth. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index c48d10ee9..9d7e6ea5f 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -134,6 +134,20 @@ Examples of user requests, approximate corresponding raw CLI commands via `npx @ | `AGENTIC_CALLBACK_HOST` | `127.0.0.1` | Host for the local callback server in stdio mode | | `AGENTIC_CALLBACK_PORT` | random free port | Port for the local callback server in stdio mode | +## Spend limits for agentic wallets + +Agentic wallets can be capped by their **owner**, on-chain. The owner sets limits from the +[dashboard](https://agents.ton.org/); the MCP only reads and enforces them — it never sets limits. + +- Limits are anchored by a `limits_hash` attribute in the wallet's on-chain NFT content and carried as a + `limitsDict` (`{asset: {window_seconds: max_spend}}`) in the owner-signed limits-change transaction. +- Before every transfer the MCP reads the on-chain `limits_hash`; if it changed it re-syncs the limits + from the latest limits-change transaction and verifies the hash. A hash that cannot be verified blocks the send. +- Each limit is checked against the wallet's **rolling on-chain history**: a window of `0` seconds is a + per-transaction cap, and any other window is "max spend within the last N seconds". TON and jettons are + metered independently (jettons matched by master address; TON metered net of incoming value). +- Wallets with no `limits_hash` set are unlimited. Synced limits are cached per wallet in `config.json`. + ## Available Tools In registry mode, wallet-scoped tools below also accept optional `walletSelector`. If omitted, the active wallet is used. diff --git a/packages/mcp/src/__tests__/AgenticSetupSessionManager.spec.ts b/packages/mcp/src/__tests__/AgenticSetupSessionManager.spec.ts index dd1c47b22..0916f6dd1 100644 --- a/packages/mcp/src/__tests__/AgenticSetupSessionManager.spec.ts +++ b/packages/mcp/src/__tests__/AgenticSetupSessionManager.spec.ts @@ -43,7 +43,7 @@ describe('AgenticSetupSessionManager', () => { }); it('accepts dashboard callback payloads', async () => { - const manager = new AgenticSetupSessionManager(); + const manager = await AgenticSetupSessionManager.create(); managers.push(manager); const session = await manager.createSession('setup-1'); @@ -73,7 +73,7 @@ describe('AgenticSetupSessionManager', () => { }); it('answers CORS preflight requests', async () => { - const manager = new AgenticSetupSessionManager(); + const manager = await AgenticSetupSessionManager.create(); managers.push(manager); const session = await manager.createSession('setup-2'); @@ -92,7 +92,7 @@ describe('AgenticSetupSessionManager', () => { }); it('marks sessions as completed and cancelled', async () => { - const manager = new AgenticSetupSessionManager(); + const manager = await AgenticSetupSessionManager.create(); managers.push(manager); await manager.createSession('setup-3'); @@ -106,7 +106,7 @@ describe('AgenticSetupSessionManager', () => { it('restores persisted callback payloads and callback urls from config-backed store', async () => { const store = new ConfigBackedAgenticSetupSessionStore(); - const manager = new AgenticSetupSessionManager({ store }); + const manager = await AgenticSetupSessionManager.create({ store }); managers.push(manager); const session = await manager.createSession('setup-5'); @@ -124,9 +124,7 @@ describe('AgenticSetupSessionManager', () => { }); expect(response.status).toBe(200); - const reopened = new AgenticSetupSessionManager({ - store, - }); + const reopened = await AgenticSetupSessionManager.create({ store }); managers.push(reopened); expect(reopened.getSession('setup-5')).toMatchObject({ diff --git a/packages/mcp/src/__tests__/AgenticWalletAdapter.spec.ts b/packages/mcp/src/__tests__/AgenticWalletAdapter.spec.ts index 620cd649f..d063fdb91 100644 --- a/packages/mcp/src/__tests__/AgenticWalletAdapter.spec.ts +++ b/packages/mcp/src/__tests__/AgenticWalletAdapter.spec.ts @@ -17,6 +17,7 @@ import { Network } from '@ton/walletkit'; import type { ApiClient, Hex, WalletSigner } from '@ton/walletkit'; import { AgenticWalletAdapter } from '../contracts/agentic_wallet/AgenticWalletAdapter.js'; +import { ActionSendMsg } from '../contracts/agentic_wallet/actions.js'; import { DEFAULT_AGENTIC_COLLECTION_ADDRESS, createAgenticWalletRecord, diff --git a/packages/mcp/src/__tests__/limits.spec.ts b/packages/mcp/src/__tests__/limits.spec.ts new file mode 100644 index 000000000..a1b71be18 --- /dev/null +++ b/packages/mcp/src/__tests__/limits.spec.ts @@ -0,0 +1,561 @@ +/** + * 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 { Address, beginCell } from '@ton/core'; +import type { ApiClient, Base64String, Hex, Transaction } from '@ton/walletkit'; +import { describe, expect, it, vi } from 'vitest'; + +import { + CHANGE_NFT_CONTENT_OP, + TON_ASSET_KEY, + assetKeyForAddress, + computeLimitsHash, + limitsDictToStored, + normalizeAssetKey, + parseLimitsDictFromMessageBody, + storedToLimitsDict, +} from '../limits/limits-codec.js'; +import { evaluateLimits, findLimitViolation, formatLimitViolation, maxRelevantWindow } from '../limits/enforce.js'; +import type { LimitsEnv } from '../limits/enforce.js'; +import { getJettonWalletInfoFromClient, parseJettonOutflowAmount } from '../limits/jetton.js'; +import { sumSpendWithinWindow, transactionsToSpend } from '../limits/spend-window.js'; +import type { LimitsDict, PendingSpend, SpendEntry } from '../limits/types.js'; +import type { StoredLimits } from '../registry/config.js'; +import { McpWalletService } from '../services/McpWalletService.js'; + +const SENTINEL = new Address(0, Buffer.alloc(32)); +const JETTON = new Address(0, Buffer.alloc(32, 7)); +const JETTON_KEY = JETTON.toRawString(); + +const WALLET = new Address(0, Buffer.alloc(32, 1)).toString(); +const OTHER = new Address(0, Buffer.alloc(32, 2)).toString(); +const RECIPIENT = OTHER; +const JETTON_WALLET = new Address(0, Buffer.alloc(32, 3)).toString(); + +const JETTON_TRANSFER_OP = 0x0f8a7ea5; +const JETTON_BURN_OP = 0x595f07bc; + +const NOW = 1000; + +// ------------------------------------------------------------------------------------------------- +// limits-codec +// ------------------------------------------------------------------------------------------------- + +const STORED: StoredLimits = { + assets: { + [TON_ASSET_KEY]: { windows: { '0': '5000000000', '3600': '20000000000' } }, + [JETTON.toString()]: { windows: { '86400': '1000' } }, + }, +}; + +/** 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('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(computeLimitsHash(storedToLimitsDict(STORED))); + }); + + 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(computeLimitsHash(dict)); + }); + + 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', () => { + const body = beginCell().storeUint(CHANGE_NFT_CONTENT_OP, 32).storeUint(1n, 64).storeMaybeRef(null).endCell(); + expect(parseLimitsDictFromMessageBody(body)).toBeNull(); + }); +}); + +// ------------------------------------------------------------------------------------------------- +// limits enforcement (findLimitViolation / maxRelevantWindow / formatLimitViolation / evaluateLimits) +// ------------------------------------------------------------------------------------------------- + +const LIMITS: StoredLimits = { + assets: { + [TON_ASSET_KEY]: { windows: { '0': '5', '3600': '20' } }, + [JETTON.toString()]: { windows: { '86400': '1000' } }, + }, +}; + +function ton(amount: bigint): PendingSpend { + return { ton: amount, jettons: new Map() }; +} + +describe('findLimitViolation', () => { + it('blocks a transaction over the per-transaction (window 0) cap', () => { + const v = findLimitViolation(LIMITS, ton(6n), [], NOW); + expect(v).toMatchObject({ asset: TON_ASSET_KEY, windowSeconds: 0, limit: 5n, pending: 6n }); + }); + + it('blocks when prior rolling-window spend plus the pending amount exceeds the cap', () => { + const entries: SpendEntry[] = [{ timestamp: NOW - 100, asset: TON_ASSET_KEY, amount: 18n }]; + const v = findLimitViolation(LIMITS, ton(4n), entries, NOW); + expect(v).toMatchObject({ windowSeconds: 3600, limit: 20n, alreadySpent: 18n, pending: 4n, total: 22n }); + }); + + it('allows a transaction whose rolling total exactly equals the cap', () => { + const entries: SpendEntry[] = [{ timestamp: NOW - 100, asset: TON_ASSET_KEY, amount: 18n }]; + expect(findLimitViolation(LIMITS, ton(2n), entries, NOW)).toBeNull(); + }); + + it('reports the per-transaction cap first when both windows are breached', () => { + const entries: SpendEntry[] = [{ timestamp: NOW - 100, asset: TON_ASSET_KEY, amount: 18n }]; + expect(findLimitViolation(LIMITS, ton(6n), entries, NOW)?.windowSeconds).toBe(0); + }); + + it('meters jettons against their own master cap', () => { + const spend: PendingSpend = { ton: 0n, jettons: new Map([[JETTON_KEY, 600n]]) }; + const entries: SpendEntry[] = [{ timestamp: NOW - 100, asset: JETTON_KEY, amount: 500n }]; + expect(findLimitViolation(LIMITS, spend, entries, NOW)).toMatchObject({ windowSeconds: 86400, total: 1100n }); + }); + + it('ignores assets that have no configured limit', () => { + const spend: PendingSpend = { ton: 0n, jettons: new Map([['0:dead', 10n ** 30n]]) }; + expect(findLimitViolation(LIMITS, spend, [], NOW)).toBeNull(); + }); + + it('fails closed (throws) on malformed config', () => { + expect(() => findLimitViolation({ assets: { TON: { windows: { abc: '5' } } } }, ton(1n), [], NOW)).toThrow(); + expect(() => findLimitViolation({ assets: { xyz: { windows: { '0': '5' } } } }, ton(1n), [], NOW)).toThrow(); + expect(() => findLimitViolation({ assets: { TON: { windows: { '0': '-5' } } } }, ton(1n), [], NOW)).toThrow(); + }); +}); + +describe('maxRelevantWindow', () => { + it('returns the largest rolling window touched by the spend', () => { + expect(maxRelevantWindow(LIMITS, ton(1n))).toBe(3600); + }); + + it('returns 0 when only a per-transaction limit applies', () => { + const perTxOnly: StoredLimits = { assets: { [TON_ASSET_KEY]: { windows: { '0': '5' } } } }; + expect(maxRelevantWindow(perTxOnly, ton(1n))).toBe(0); + }); +}); + +describe('formatLimitViolation', () => { + it('describes per-transaction and rolling breaches distinctly', () => { + expect(formatLimitViolation(findLimitViolation(LIMITS, ton(6n), [], NOW)!)).toContain('per-transaction'); + const rolling = findLimitViolation( + LIMITS, + ton(4n), + [{ timestamp: NOW, asset: TON_ASSET_KEY, amount: 18n }], + NOW, + )!; + expect(formatLimitViolation(rolling)).toContain('rolling 1h'); + }); +}); + +describe('evaluateLimits', () => { + function env(overrides: Partial): LimitsEnv { + return { + now: () => NOW, + readOnchainLimitsHash: async () => 'hash-1', + readCache: () => ({}), + writeCache: async () => {}, + syncLimitsFromChain: async () => ({ limits: LIMITS, hash: 'hash-1' }), + fetchSpendEntries: async () => [], + ...overrides, + }; + } + + it('allows and clears a stale cache when the wallet has no on-chain limits', async () => { + const writeCache = vi.fn(async () => {}); + const decision = await evaluateLimits( + env({ + readOnchainLimitsHash: async () => undefined, + readCache: () => ({ limits_hash: 'old' }), + writeCache, + }), + ton(1n), + ); + expect(decision.allowed).toBe(true); + expect(writeCache).toHaveBeenCalledWith({}); + }); + + it('does not write when there is no on-chain hash and no cache to clear', async () => { + const writeCache = vi.fn(async () => {}); + await evaluateLimits(env({ readOnchainLimitsHash: async () => undefined, writeCache }), ton(1n)); + expect(writeCache).not.toHaveBeenCalled(); + }); + + it('uses the cache without re-syncing when the hash matches', async () => { + const syncLimitsFromChain = vi.fn(async () => ({ limits: LIMITS, hash: 'hash-1' })); + const decision = await evaluateLimits( + env({ readCache: () => ({ limits: LIMITS, limits_hash: 'hash-1' }), syncLimitsFromChain }), + ton(1n), + ); + expect(decision.allowed).toBe(true); + expect(syncLimitsFromChain).not.toHaveBeenCalled(); + }); + + it('re-syncs and persists when the on-chain hash differs from the cache', async () => { + const writeCache = vi.fn(async () => {}); + const decision = await evaluateLimits(env({ readCache: () => ({}), writeCache }), ton(1n)); + expect(decision.allowed).toBe(true); + expect(writeCache).toHaveBeenCalledWith({ limits: LIMITS, limits_hash: 'hash-1' }); + }); + + it('refuses to send when no limits-change transaction can be found', async () => { + const decision = await evaluateLimits(env({ syncLimitsFromChain: async () => null }), ton(1n)); + expect(decision).toMatchObject({ allowed: false }); + expect(decision.allowed === false && decision.message).toContain('no limits-change'); + }); + + it('refuses to send when the synced hash does not match the on-chain hash', async () => { + const decision = await evaluateLimits( + env({ syncLimitsFromChain: async () => ({ limits: LIMITS, hash: 'other' }) }), + ton(1n), + ); + expect(decision).toMatchObject({ allowed: false }); + expect(decision.allowed === false && decision.message).toContain('does not match'); + }); + + it('skips the history fetch when only a per-transaction limit applies', async () => { + const fetchSpendEntries = vi.fn(async () => []); + const perTxOnly: StoredLimits = { assets: { [TON_ASSET_KEY]: { windows: { '0': '5' } } } }; + await evaluateLimits( + env({ syncLimitsFromChain: async () => ({ limits: perTxOnly, hash: 'hash-1' }), fetchSpendEntries }), + ton(1n), + ); + expect(fetchSpendEntries).not.toHaveBeenCalled(); + }); + + it('propagates (fails closed) when the history fetch throws', async () => { + await expect( + evaluateLimits( + env({ + fetchSpendEntries: async () => { + throw new Error('rpc down'); + }, + }), + ton(1n), + ), + ).rejects.toThrow('rpc down'); + }); + + it('blocks an over-limit spend with a descriptive message', async () => { + const decision = await evaluateLimits(env({}), ton(6n)); + expect(decision).toMatchObject({ allowed: false }); + expect(decision.allowed === false && decision.message).toContain('spend limit'); + }); +}); + +// ------------------------------------------------------------------------------------------------- +// limits-jetton (parseJettonOutflowAmount / getJettonWalletInfoFromClient) +// ------------------------------------------------------------------------------------------------- + +const OWNER = new Address(0, Buffer.alloc(32, 1)); +const MASTER = new Address(0, Buffer.alloc(32, 9)); + +function opBody(op: number, amount: bigint): string { + return beginCell().storeUint(op, 32).storeUint(0n, 64).storeCoins(amount).endCell().toBoc().toString('base64'); +} + +function addressStackItem(address: Address) { + return { type: 'cell' as const, value: beginCell().storeAddress(address).endCell().toBoc().toString('base64') }; +} + +function clientWithRunGetMethod(impl: ApiClient['runGetMethod']): ApiClient { + return { runGetMethod: impl } as unknown as ApiClient; +} + +describe('parseJettonOutflowAmount', () => { + it('reads the amount from a transfer or burn op', () => { + expect(parseJettonOutflowAmount(opBody(JETTON_TRANSFER_OP, 1234n))).toBe(1234n); + expect(parseJettonOutflowAmount(opBody(JETTON_BURN_OP, 77n))).toBe(77n); + }); + + it('returns null for a non-transfer op, an empty payload, or garbage', () => { + expect(parseJettonOutflowAmount(opBody(0, 1n))).toBeNull(); + expect(parseJettonOutflowAmount(null)).toBeNull(); + expect(parseJettonOutflowAmount(undefined)).toBeNull(); + expect(parseJettonOutflowAmount('not-base64!!')).toBeNull(); + }); +}); + +describe('getJettonWalletInfoFromClient', () => { + it('resolves owner and master from a successful get_wallet_data', async () => { + const client = clientWithRunGetMethod( + vi.fn(async () => ({ + gasUsed: 0, + exitCode: 0, + stack: [{ type: 'num' as const, value: '100' }, addressStackItem(OWNER), addressStackItem(MASTER)], + })), + ); + await expect(getJettonWalletInfoFromClient(client, OWNER.toString())).resolves.toEqual({ + owner: OWNER.toString(), + master: MASTER.toString(), + }); + }); + + it('returns null when get_wallet_data ran but exited non-zero (not a jetton wallet)', async () => { + const client = clientWithRunGetMethod(vi.fn(async () => ({ gasUsed: 0, exitCode: -13, stack: [] }))); + await expect(getJettonWalletInfoFromClient(client, OWNER.toString())).resolves.toBeNull(); + }); + + it('propagates (fails closed) when the get_wallet_data call itself throws', async () => { + const client = clientWithRunGetMethod( + vi.fn(async () => { + throw new Error('429 Too Many Requests'); + }), + ); + await expect(getJettonWalletInfoFromClient(client, OWNER.toString())).rejects.toThrow('429'); + }); +}); + +// ------------------------------------------------------------------------------------------------- +// limits-spend-window (transactionsToSpend / sumSpendWithinWindow) +// ------------------------------------------------------------------------------------------------- + +function jettonTransferBody(amount: bigint): string { + return beginCell() + .storeUint(JETTON_TRANSFER_OP, 32) + .storeUint(0n, 64) + .storeCoins(amount) + .endCell() + .toBoc() + .toString('base64'); +} + +interface OutMsg { + source?: string; + destination?: string; + value: string; + body?: string; +} + +function makeTx(input: { now: number; inValue?: string; outs?: OutMsg[]; computeSuccess?: boolean }): Transaction { + return { + account: WALLET, + hash: 'tx' as Hex, + logicalTime: '0', + now: input.now, + mcBlockSeqno: 0, + traceExternalHash: 'trace' as Hex, + isEmulated: false, + inMessage: input.inValue === undefined ? undefined : { hash: 'in' as Hex, value: input.inValue }, + outMessages: (input.outs ?? []).map((out) => ({ + hash: 'out' as Hex, + source: out.source, + destination: out.destination, + value: out.value, + messageContent: out.body ? { body: out.body as Base64String } : undefined, + })), + description: { + type: 'ord', + isAborted: false, + isDestroyed: false, + isCreditFirst: false, + isTock: false, + isInstalled: false, + computePhase: { isSuccess: input.computeSuccess ?? true }, + }, + }; +} + +describe('transactionsToSpend (TON)', () => { + it('records the full outflow for an externally-triggered send (value-0 in-message)', () => { + const spend = transactionsToSpend( + [makeTx({ now: 100, outs: [{ source: WALLET, value: '1000000000' }] })], + WALLET, + ); + expect(spend.tonEntries).toEqual([{ timestamp: 100, asset: TON_ASSET_KEY, amount: 1000000000n }]); + }); + + it('meters a forwarding transaction at its net cost', () => { + const spend = transactionsToSpend( + [makeTx({ now: 100, inValue: '100000000', outs: [{ source: WALLET, value: '1000000000' }] })], + WALLET, + ); + expect(spend.tonEntries).toEqual([{ timestamp: 100, asset: TON_ASSET_KEY, amount: 900000000n }]); + }); + + it('records nothing for a purely incoming transaction', () => { + const spend = transactionsToSpend([makeTx({ now: 100, inValue: '1000000000' })], WALLET); + expect(spend.tonEntries).toEqual([]); + }); + + it('skips transactions whose compute phase failed', () => { + const spend = transactionsToSpend( + [makeTx({ now: 100, computeSuccess: false, outs: [{ source: WALLET, value: '1000000000' }] })], + WALLET, + ); + expect(spend.tonEntries).toEqual([]); + }); + + it('counts out-messages with an absent source but excludes a foreign source', () => { + const absent = transactionsToSpend([makeTx({ now: 100, outs: [{ value: '5' }] })], WALLET); + expect(absent.tonEntries).toEqual([{ timestamp: 100, asset: TON_ASSET_KEY, amount: 5n }]); + const foreign = transactionsToSpend([makeTx({ now: 100, outs: [{ source: OTHER, value: '5' }] })], WALLET); + expect(foreign.tonEntries).toEqual([]); + }); +}); + +describe('transactionsToSpend (jettons)', () => { + it('emits a probe for an outgoing TEP-74 transfer', () => { + const spend = transactionsToSpend( + [ + makeTx({ + now: 100, + outs: [ + { + source: WALLET, + destination: JETTON_WALLET, + value: '50000000', + body: jettonTransferBody(500n), + }, + ], + }), + ], + WALLET, + ); + expect(spend.jettonProbes).toEqual([{ timestamp: 100, jettonWalletAddress: JETTON_WALLET, amount: 500n }]); + }); +}); + +describe('sumSpendWithinWindow', () => { + const entries: SpendEntry[] = [ + { timestamp: 940, asset: TON_ASSET_KEY, amount: 5n }, + { timestamp: 939, asset: TON_ASSET_KEY, amount: 7n }, + { timestamp: 1000, asset: 'jetton', amount: 9n }, + ]; + + it('includes the entry exactly at the cutoff and excludes older ones', () => { + expect(sumSpendWithinWindow(entries, TON_ASSET_KEY, 1000, 60)).toBe(5n); + }); + + it('filters by asset', () => { + expect(sumSpendWithinWindow(entries, 'jetton', 1000, 60)).toBe(9n); + }); +}); + +// ------------------------------------------------------------------------------------------------- +// McpWalletService send choke point +// ------------------------------------------------------------------------------------------------- + +function jettonTransferPayload(amount: bigint): string { + return beginCell() + .storeUint(0x0f8a7ea5, 32) + .storeUint(0n, 64) + .storeCoins(amount) + .endCell() + .toBoc() + .toString('base64'); +} + +interface FakeWallet { + version?: string; + getAddress: () => string; + getClient: () => unknown; + sendTransaction: ReturnType; + createTransferTonTransaction?: ReturnType; +} + +function makeService(wallet: FakeWallet): McpWalletService { + const service = Object.create(McpWalletService.prototype) as McpWalletService; + Object.defineProperty(service, 'wallet', { value: wallet, configurable: true }); + Object.defineProperty(service, 'config', { value: {}, configurable: true }); + Object.defineProperty(service, 'limitsCache', { value: null, configurable: true }); + return service; +} + +describe('McpWalletService send choke point', () => { + it('skips limit enforcement for non-agentic wallets', async () => { + const sendTransaction = vi.fn().mockResolvedValue({ normalizedHash: 'hash' }); + const getAccountState = vi.fn(); + const service = makeService({ + version: undefined, + getAddress: () => WALLET, + getClient: () => ({ getAccountState }), + sendTransaction, + createTransferTonTransaction: vi + .fn() + .mockResolvedValue({ messages: [{ address: RECIPIENT, amount: '1000000000' }] }), + }); + + const result = await service.sendTon(RECIPIENT, '1000000000'); + + expect(result.success).toBe(true); + expect(getAccountState).not.toHaveBeenCalled(); + expect(sendTransaction).toHaveBeenCalledOnce(); + }); + + it('fails closed when an agentic wallet cannot read its on-chain limits', async () => { + const sendTransaction = vi.fn(); + const service = makeService({ + version: 'agentic', + getAddress: () => WALLET, + getClient: () => ({ + getAccountState: vi.fn().mockRejectedValue(new Error('indexer down')), + runGetMethod: vi.fn(), + }), + sendTransaction, + createTransferTonTransaction: vi + .fn() + .mockResolvedValue({ messages: [{ address: RECIPIENT, amount: '1000000000' }] }), + }); + + const result = await service.sendTon(RECIPIENT, '1000000000'); + + expect(result.success).toBe(false); + expect(result.message).toContain('Could not verify spend limits'); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it('fails closed when a pending jetton wallet cannot be resolved (no silent TON-only metering)', async () => { + const sendTransaction = vi.fn(); + const runGetMethod = vi.fn().mockRejectedValue(new Error('429 Too Many Requests')); + const service = makeService({ + version: 'agentic', + getAddress: () => WALLET, + getClient: () => ({ runGetMethod, getAccountState: vi.fn() }), + sendTransaction, + }); + + const result = await service.sendRawTransaction({ + messages: [{ address: JETTON_WALLET, amount: '50000000', payload: jettonTransferPayload(500n) }], + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('Could not verify spend limits'); + expect(sendTransaction).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mcp/src/__tests__/tep64.spec.ts b/packages/mcp/src/__tests__/tep64.spec.ts new file mode 100644 index 000000000..3bd3c9f36 --- /dev/null +++ b/packages/mcp/src/__tests__/tep64.spec.ts @@ -0,0 +1,51 @@ +/** + * 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 { Dictionary, beginCell } from '@ton/core'; +import type { Cell } from '@ton/core'; +import { describe, expect, it } from 'vitest'; + +import { onchainMetadataKey, readOnchainMetadataValue } from '../utils/tep64.js'; + +/** Build a TEP-64 onchain content cell (prefix 0x00) from name -> value cell pairs. */ +function onchainContent(values: Record): Cell { + const dict = Dictionary.empty(Dictionary.Keys.BigUint(256), Dictionary.Values.Cell()); + for (const [name, value] of Object.entries(values)) { + dict.set(onchainMetadataKey(name), value); + } + return beginCell().storeUint(0x00, 8).storeDict(dict).endCell(); +} + +function snakeValue(text: string): Cell { + return beginCell().storeUint(0x00, 8).storeStringTail(text).endCell(); +} + +describe('readOnchainMetadataValue', () => { + it('reads a snake-encoded attribute back by name', () => { + const content = onchainContent({ limits_hash: snakeValue('deadbeef'), name: snakeValue('Agent') }); + expect(readOnchainMetadataValue(content, 'limits_hash')).toBe('deadbeef'); + expect(readOnchainMetadataValue(content, 'name')).toBe('Agent'); + }); + + it('joins chunked values in key order', () => { + const chunks = Dictionary.empty(Dictionary.Keys.Uint(32), Dictionary.Values.Cell()); + chunks.set(0, beginCell().storeStringTail('foo').endCell()); + chunks.set(1, beginCell().storeStringTail('bar').endCell()); + const content = onchainContent({ name: beginCell().storeUint(0x01, 8).storeDict(chunks).endCell() }); + expect(readOnchainMetadataValue(content, 'name')).toBe('foobar'); + }); + + it('returns undefined for an absent key, offchain content, or no content', () => { + const content = onchainContent({ name: snakeValue('Agent') }); + expect(readOnchainMetadataValue(content, 'limits_hash')).toBeUndefined(); + expect( + readOnchainMetadataValue(beginCell().storeUint(0x01, 8).storeStringTail('https://x').endCell(), 'name'), + ).toBeUndefined(); + expect(readOnchainMetadataValue(null, 'name')).toBeUndefined(); + }); +}); diff --git a/packages/mcp/src/contracts/agentic_wallet/AgenticWalletAdapter.ts b/packages/mcp/src/contracts/agentic_wallet/AgenticWalletAdapter.ts index 75e05b9d0..21d979361 100644 --- a/packages/mcp/src/contracts/agentic_wallet/AgenticWalletAdapter.ts +++ b/packages/mcp/src/contracts/agentic_wallet/AgenticWalletAdapter.ts @@ -47,7 +47,7 @@ import { } from '@ton/walletkit'; import { AgenticWalletCodeCell } from './AgenticWallet.source.js'; -import { ActionSendMsg, packActionsList } from './actions.js'; +import { ActionSendMsg, packOutActionList } from './actions.js'; export const defaultAgenticWorkchain = 0; @@ -313,7 +313,7 @@ export class AgenticWalletAdapter implements WalletAdapter { options: SignedSendTransactionOptions | undefined, authType: AgenticWalletAuthType, ): Promise { - const actions = packActionsList(input.messages.map((message) => this.createTransferAction(message))); + const outActions = packOutActionList(input.messages.map((message) => this.createTransferAction(message))); let seqno = 0; try { @@ -323,7 +323,7 @@ export class AgenticWalletAdapter implements WalletAdapter { } const walletNftIndex = await this.getWalletNftIndex(); - return this.createSignedBody(seqno, walletNftIndex, actions, { + return this.createSignedBody(seqno, walletNftIndex, outActions, { ...options, authType, validUntil: this.resolveValidUntil(input.validUntil), diff --git a/packages/mcp/src/contracts/agentic_wallet/actions.ts b/packages/mcp/src/contracts/agentic_wallet/actions.ts index 67f81a78d..3d266a2c9 100644 --- a/packages/mcp/src/contracts/agentic_wallet/actions.ts +++ b/packages/mcp/src/contracts/agentic_wallet/actions.ts @@ -92,46 +92,17 @@ function packActionsListOut(actions: (OutAction | ExtendedAction)[]): Cell { return beginCell().storeRef(packActionsListOut(rest)).storeSlice(action.serialize().beginParse()).endCell(); } -function packExtendedActions(extendedActions: ExtendedAction[]): Cell { - const first = extendedActions[0]; - const rest = extendedActions.slice(1); - let builder = beginCell().storeSlice(first.serialize().beginParse()); - if (rest.length > 0) { - builder = builder.storeRef(packExtendedActions(extendedActions.slice(1))); - } - return builder.endCell(); -} - -function packActionsListExtended(actions: (OutAction | ExtendedAction)[]): Cell { - const extendedActions: ExtendedAction[] = []; - const outActions: OutAction[] = []; - actions.forEach((action) => { - if (isExtendedAction(action)) { - extendedActions.push(action); - } else { - outActions.push(action); - } - }); - - let builder = beginCell(); - if (outActions.length === 0) { - builder = builder.storeUint(0, 1); - } else { - builder = builder.storeMaybeRef(packActionsListOut(outActions.slice().reverse())); - } - if (extendedActions.length === 0) { - builder = builder.storeUint(0, 1); - } else { - const first = extendedActions[0]; - const rest = extendedActions.slice(1); - builder = builder.storeUint(1, 1).storeSlice(first.serialize().beginParse()); - if (rest.length > 0) { - builder = builder.storeRef(packExtendedActions(rest)); - } +/** + * Pack send-message actions into the bare `OutList` head cell the agentic wallet + * contract expects in its `outActions` field (see `verifyC5Actions`): each node is + * a single `action_send_msg` (40 bits + 2 refs). This is NOT the w5 combined + * `(outList, extendedActions)` container — passing that container to the agentic + * contract fails on-chain with ERROR_INVALID_C5 (nRefs == 1, not 2). Extended + * actions travel separately in the request's `extraActions` ref. + */ +export function packOutActionList(actions: OutAction[]): Cell | null { + if (actions.length === 0) { + return null; } - return builder.endCell(); -} - -export function packActionsList(actions: (OutAction | ExtendedAction)[]): Cell { - return packActionsListExtended(actions); + return packActionsListOut(actions.slice().reverse()); } diff --git a/packages/mcp/src/limits/enforce.ts b/packages/mcp/src/limits/enforce.ts new file mode 100644 index 000000000..f3a1e975c --- /dev/null +++ b/packages/mcp/src/limits/enforce.ts @@ -0,0 +1,280 @@ +/** + * 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. + * + */ + +/** + * Client-side limit enforcement (the contract does not enforce limits). + * + * {@link findLimitViolation} is the pure rolling-window check; {@link evaluateLimits} + * orchestrates the on-chain reads (limits_hash -> cache or re-sync -> spend history) + * through an injected {@link LimitsEnv} so the IO is testable in isolation. + */ + +import type { StoredLimits } from '../registry/config.js'; +import { TON_ASSET_KEY, normalizeAssetKey } from './limits-codec.js'; +import { sumSpendWithinWindow } from './spend-window.js'; +import type { PendingSpend, SpendEntry } from './types.js'; + +/** The inner-key sentinel that marks a per-transaction (non-rolling) limit. */ +const PER_TX_WINDOW = 0; + +export interface LimitViolation { + /** Human-readable asset key from the limits config (`'TON'` or a jetton master). */ + asset: string; + /** Rolling window in seconds; `0` means a per-transaction limit. */ + windowSeconds: number; + /** The configured maximum, in base units. */ + limit: bigint; + /** Spend already observed in the window (`0` for per-transaction limits). */ + alreadySpent: bigint; + /** The pending transaction's spend for this asset. */ + pending: bigint; + /** `alreadySpent + pending`. */ + total: bigint; +} + +interface IndexedAssetLimit { + displayKey: string; + windows: Map; +} + +/** + * Build a normalized lookup of the limits. Fails closed (throws) on any malformed + * entry — an unparseable asset key, a non-integer/negative window, or a non-integer + * amount — so corrupt config refuses sends rather than silently disabling a cap. + * A well-formed on-chain limitsDict never produces malformed entries. + */ +function indexLimits(limits: StoredLimits): Map { + const byAsset = new Map(); + for (const [displayKey, assetLimit] of Object.entries(limits.assets)) { + const normalizedKey = normalizeAssetKey(displayKey); + if (normalizedKey === null) { + throw new Error(`Invalid spend-limit config: asset key "${displayKey}" is not a valid address.`); + } + const windows = new Map(); + for (const [windowSeconds, amount] of Object.entries(assetLimit.windows)) { + const seconds = Number(windowSeconds); + if (!Number.isInteger(seconds) || seconds < 0) { + throw new Error( + `Invalid spend-limit config: window "${windowSeconds}" for asset "${displayKey}" is not a non-negative integer.`, + ); + } + let max: bigint; + try { + max = BigInt(amount); + } catch { + throw new Error( + `Invalid spend-limit config: amount "${amount}" for asset "${displayKey}" is not an integer.`, + ); + } + if (max < 0n) { + throw new Error( + `Invalid spend-limit config: amount "${amount}" for asset "${displayKey}" is negative.`, + ); + } + windows.set(seconds, max); + } + byAsset.set(normalizedKey, { displayKey, windows }); + } + return byAsset; +} + +/** Normalized-key -> pending amount for every asset this transaction actually spends. */ +function affectedAssets(spend: PendingSpend): Map { + const affected = new Map(); + if (spend.ton > 0n) { + affected.set(TON_ASSET_KEY, spend.ton); + } + for (const [master, amount] of spend.jettons) { + if (amount > 0n) { + affected.set(master, amount); + } + } + return affected; +} + +/** Largest rolling window (seconds > 0) referenced by any asset the tx spends; `0` if none. */ +export function maxRelevantWindow(limits: StoredLimits, spend: PendingSpend): number { + const index = indexLimits(limits); + let maxWindow = 0; + for (const normalizedKey of affectedAssets(spend).keys()) { + const assetLimit = index.get(normalizedKey); + if (!assetLimit) { + continue; + } + for (const windowSeconds of assetLimit.windows.keys()) { + if (windowSeconds > maxWindow) { + maxWindow = windowSeconds; + } + } + } + return maxWindow; +} + +/** + * Pure rolling-window check. Returns the first limit a transaction would breach, + * or `null` when every affected asset stays within its configured windows. + */ +export function findLimitViolation( + limits: StoredLimits, + spend: PendingSpend, + spendEntries: SpendEntry[], + now: number, +): LimitViolation | null { + const index = indexLimits(limits); + + for (const [normalizedKey, pending] of affectedAssets(spend)) { + const assetLimit = index.get(normalizedKey); + if (!assetLimit) { + continue; // asset has no configured limit + } + const windowsAscending = [...assetLimit.windows.entries()].sort((a, b) => a[0] - b[0]); + for (const [windowSeconds, limit] of windowsAscending) { + if (windowSeconds === PER_TX_WINDOW) { + if (pending > limit) { + return { + asset: assetLimit.displayKey, + windowSeconds, + limit, + alreadySpent: 0n, + pending, + total: pending, + }; + } + continue; + } + const alreadySpent = sumSpendWithinWindow(spendEntries, normalizedKey, now, windowSeconds); + const total = alreadySpent + pending; + if (total > limit) { + return { asset: assetLimit.displayKey, windowSeconds, limit, alreadySpent, pending, total }; + } + } + } + + return null; +} + +/** Render a violation as a human-readable, broadcast-blocking error message. */ +export function formatLimitViolation(violation: LimitViolation): string { + const assetLabel = violation.asset === TON_ASSET_KEY ? 'TON' : `jetton ${violation.asset}`; + if (violation.windowSeconds === PER_TX_WINDOW) { + return ( + `Transaction blocked by spend limit: ${assetLabel} per-transaction limit is ` + + `${violation.limit} base units, but this transaction sends ${violation.pending}.` + ); + } + const window = humanizeWindow(violation.windowSeconds); + return ( + `Transaction blocked by spend limit: ${assetLabel} rolling ${window} limit is ${violation.limit} ` + + `base units; already spent ${violation.alreadySpent} in the window, and this transaction would add ` + + `${violation.pending} (total ${violation.total}).` + ); +} + +function humanizeWindow(seconds: number): string { + const units: Array<[number, string]> = [ + [86400, 'd'], + [3600, 'h'], + [60, 'm'], + ]; + for (const [size, suffix] of units) { + if (seconds >= size && seconds % size === 0) { + return `${seconds / size}${suffix} (${seconds}s)`; + } + } + return `${seconds}s`; +} + +/** Cached per-wallet limits mirrored from the config record. */ +export interface CachedLimits { + limits?: StoredLimits; + limits_hash?: string; +} + +/** Read-through/write-through port over a single wallet's cached limits. */ +export interface LimitsCache { + read(): CachedLimits; + write(next: CachedLimits): Promise; +} + +/** Result of re-syncing limits from the latest on-chain limits-change transaction. */ +export interface SyncedLimits { + limits: StoredLimits; + hash: string; +} + +/** IO surface the enforcement flow depends on; implemented by the wallet service. */ +export interface LimitsEnv { + /** Current time in unix seconds. */ + now(): number; + /** The on-chain `limits_hash` attribute, or `undefined` when no limits are set. */ + readOnchainLimitsHash(): Promise; + readCache(): CachedLimits; + writeCache(next: CachedLimits): Promise; + /** Parse + hash the latest on-chain limits-change tx, or `null` when none is found. */ + syncLimitsFromChain(): Promise; + /** + * Outgoing spend entries within `[now - maxWindowSeconds, now]`. Receives the + * same `now` the check uses so the fetch cutoff and the window math agree. + * Must throw rather than return a truncated history (fail closed). + */ + fetchSpendEntries(maxWindowSeconds: number, now: number): Promise; +} + +export type LimitsDecision = { allowed: true } | { allowed: false; message: string }; + +/** + * Decide whether a pending transaction may broadcast: + * 1. read the on-chain limits_hash; absent -> allow and clear any stale cache; + * 2. hash matches cache -> use cached limits, else re-sync and persist; + * 3. a hash that cannot be verified against an on-chain tx is a hard failure; + * 4. measure spend over the largest relevant window and apply the per-asset checks. + */ +export async function evaluateLimits(env: LimitsEnv, spend: PendingSpend): Promise { + const onchainHash = await env.readOnchainLimitsHash(); + + if (!onchainHash) { + const cached = env.readCache(); + if (cached.limits || cached.limits_hash) { + await env.writeCache({}); + } + return { allowed: true }; + } + + let limits: StoredLimits; + const cached = env.readCache(); + if (cached.limits && cached.limits_hash === onchainHash) { + limits = cached.limits; + } else { + const synced = await env.syncLimitsFromChain(); + if (!synced) { + return { + allowed: false, + message: + `Wallet has on-chain limits (limits_hash=${onchainHash}) but no limits-change ` + + `transaction was found to verify them; refusing to send.`, + }; + } + if (synced.hash !== onchainHash) { + return { + allowed: false, + message: + `On-chain limits_hash (${onchainHash}) does not match the hash of the latest ` + + `limits-change transaction (${synced.hash}); refusing to send.`, + }; + } + limits = synced.limits; + await env.writeCache({ limits, limits_hash: onchainHash }); + } + + // Capture `now` once so the history-fetch cutoff and the window math share a boundary. + const now = env.now(); + const maxWindow = maxRelevantWindow(limits, spend); + const spendEntries = maxWindow > 0 ? await env.fetchSpendEntries(maxWindow, now) : []; + const violation = findLimitViolation(limits, spend, spendEntries, now); + return violation ? { allowed: false, message: formatLimitViolation(violation) } : { allowed: true }; +} diff --git a/packages/mcp/src/limits/jetton.ts b/packages/mcp/src/limits/jetton.ts new file mode 100644 index 000000000..29f206d8a --- /dev/null +++ b/packages/mcp/src/limits/jetton.ts @@ -0,0 +1,82 @@ +/** + * 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` only when `get_wallet_data` *ran* but the address is not a usable + * jetton wallet (non-zero exit, or a stack that does not decode to owner+master) — + * a definitive "not owned by us" answer the caller may safely drop. An RPC failure + * (network error, timeout, 4xx/5xx) is *not* swallowed: it propagates so spend + * metering fails closed rather than silently under-counting an unresolvable wallet. + */ +export async function getJettonWalletInfoFromClient( + client: ApiClient, + jettonWalletAddress: string, +): Promise { + const result = await client.runGetMethod(jettonWalletAddress, 'get_wallet_data'); + if (result.exitCode !== 0) { + return null; + } + try { + 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/packages/mcp/src/limits/limits-codec.ts b/packages/mcp/src/limits/limits-codec.ts new file mode 100644 index 000000000..c31ea549a --- /dev/null +++ b/packages/mcp/src/limits/limits-codec.ts @@ -0,0 +1,150 @@ +/** + * 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>`). + * + * 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()`. The setter (dashboard) and + * this verifier agree by using the canonical TON dictionary serialization for the + * declared TL-B type, so no separate canonicalization spec is needed. + */ + +import { Address, beginCell, Dictionary } from '@ton/core'; +import type { Slice, Cell } from '@ton/core'; + +import { normalizeAddressForComparison } from '../utils/address.js'; +import type { StoredLimits } from '../registry/config.js'; +import type { LimitsDict } from './types.js'; + +/** 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/packages/mcp/src/limits/spend-window.ts b/packages/mcp/src/limits/spend-window.ts new file mode 100644 index 000000000..20f52e15c --- /dev/null +++ b/packages/mcp/src/limits/spend-window.ts @@ -0,0 +1,112 @@ +/** + * 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 { Transaction } from '@ton/walletkit'; + +import { normalizeAddressForComparison } from '../utils/address.js'; +import { TON_ASSET_KEY } from './limits-codec.js'; +import { parseJettonOutflowAmount } from './jetton.js'; +import type { JettonSpendProbe, SpendEntry, TransactionSpend } from './types.js'; + +/** + * Reduce a page of account transactions into per-transaction net outgoing-spend + * for `walletAddress`, parsing jetton transfers directly from message bodies + * (`getAccountTransactions` carries the raw messages, where `getEvents` returned + * pre-decoded — but unreliable and capped — trace actions). + * + * - TON: per transaction, `net = sum(out-message values) - in-message value`, and + * only positive net is recorded. For an operator-signed agentic send the trigger + * is an external message (value 0), so net equals the gross outflow — the same + * amount the pending-spend estimate meters. The subtraction only bites when one + * transaction both receives an internal credit and forwards funds (a contract + * call metered at its net cost, e.g. receive 0.1 then send 1 counts as 0.9), and + * it accounts for the forward/gas TON attached to a jetton-transfer message. + * A send that later bounces is not refunded here: its outflow stays counted until + * the rolling window rolls past it (conservative — it over-blocks, never bypasses). + * - Jettons: every out-message carrying a TEP-74 transfer/burn op yields a probe + * (amount + jetton-wallet address); the service 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: Transaction[], 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/packages/mcp/src/limits/types.ts b/packages/mcp/src/limits/types.ts new file mode 100644 index 000000000..f6e3b4afc --- /dev/null +++ b/packages/mcp/src/limits/types.ts @@ -0,0 +1,71 @@ +/** + * 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[]; +} + +/** + * Amounts the pending (not-yet-broadcast) transaction will spend, grouped by + * normalized asset key. + */ +export interface PendingSpend { + /** Total TON outflow across all messages, in nanotons. */ + ton: bigint; + /** Normalized jetton-master address -> outflow amount in base jetton units. */ + jettons: Map; +} diff --git a/packages/mcp/src/registry/config.ts b/packages/mcp/src/registry/config.ts index ef0074e19..8881819ee 100644 --- a/packages/mcp/src/registry/config.ts +++ b/packages/mcp/src/registry/config.ts @@ -62,6 +62,21 @@ export interface StoredAgenticWallet extends StoredWalletBase { wallet_nft_index?: string; origin_operator_public_key?: string; deployed_by_user?: boolean; + /** Cached limits, decoded from the latest on-chain limits-change tx. Absent = no limits set. */ + limits?: StoredLimits; + /** Hex of the on-chain `limits_hash` the cached `limits` correspond to. */ + limits_hash?: string; +} + +/** Decoded mirror of the on-chain limitsDict, JSON-friendly. */ +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; } export type StoredWallet = StoredStandardWallet | StoredAgenticWallet; @@ -188,6 +203,9 @@ function normalizeConfig(raw: TonConfig): TonConfig { collection_address: formatAssetAddress(wallet.collection_address, wallet.network), } : {}), + // Carry limits explicitly so they survive normalization round-trips. + ...(wallet.limits ? { limits: wallet.limits } : {}), + ...(wallet.limits_hash ? { limits_hash: wallet.limits_hash } : {}), }; const normalizeSetupSession = (session: StoredAgenticSetupSession): StoredAgenticSetupSession => ({ ...session, @@ -589,6 +607,62 @@ export async function persistAgenticWalletNftIndex(walletId: string, walletNftIn return true; } +/** + * Set or clear the cached limits for an agentic wallet. Identity-keyed on + * `limits_hash`: passing the hash already stored is a no-op; passing `undefined` + * clears both `limits` and `limits_hash`. + */ +export function updateAgenticWalletLimits( + config: TonConfig, + walletId: string, + limits: StoredLimits | undefined, + limitsHash: string | undefined, +): TonConfig { + let changed = false; + const nextWallets = config.wallets.map((item) => { + if (item.id !== walletId || item.type !== 'agentic' || isWalletRemoved(item)) { + return item; + } + if (item.limits_hash === limitsHash) { + return item; + } + changed = true; + const { limits: _limits, limits_hash: _limitsHash, ...rest } = item; + return { + ...rest, + ...(limits ? { limits } : {}), + ...(limitsHash ? { limits_hash: limitsHash } : {}), + updated_at: nowIso(), + }; + }); + + if (!changed) { + return config; + } + + return { + ...config, + wallets: nextWallets, + }; +} + +export async function persistAgenticWalletLimits( + walletId: string, + limits: StoredLimits | undefined, + limitsHash: string | undefined, +): Promise { + const config = await loadConfigWithMigration(); + if (!config) { + return false; + } + const nextConfig = updateAgenticWalletLimits(config, walletId, limits, limitsHash); + if (nextConfig === config) { + return false; + } + saveConfig(nextConfig); + return true; +} + export function setActiveWallet( config: TonConfig, selector: string, @@ -918,6 +992,8 @@ export function createAgenticWalletRecord(input: { walletNftIndex?: string; originOperatorPublicKey?: string; deployedByUser?: boolean; + limits?: StoredLimits; + limitsHash?: string; idPrefix?: string; }): StoredAgenticWallet { const now = nowIso(); @@ -937,6 +1013,8 @@ export function createAgenticWalletRecord(input: { ...(input.walletNftIndex ? { wallet_nft_index: input.walletNftIndex } : {}), ...(input.originOperatorPublicKey ? { origin_operator_public_key: input.originOperatorPublicKey } : {}), ...(typeof input.deployedByUser === 'boolean' ? { deployed_by_user: input.deployedByUser } : {}), + ...(input.limits ? { limits: input.limits } : {}), + ...(input.limitsHash ? { limits_hash: input.limitsHash } : {}), created_at: now, updated_at: now, }; diff --git a/packages/mcp/src/runtime/wallet-runtime.ts b/packages/mcp/src/runtime/wallet-runtime.ts index a492f0f6d..b95fb717f 100644 --- a/packages/mcp/src/runtime/wallet-runtime.ts +++ b/packages/mcp/src/runtime/wallet-runtime.ts @@ -21,7 +21,8 @@ import type { TonWalletKit as TonWalletKitType, Wallet, WalletAdapter, WalletSig import { AgenticWalletAdapter } from '../contracts/agentic_wallet/AgenticWalletAdapter.js'; import type { IContactResolver } from '../types/contacts.js'; import { McpWalletService } from '../services/McpWalletService.js'; -import { persistAgenticWalletNftIndex } from '../registry/config.js'; +import { persistAgenticWalletLimits, persistAgenticWalletNftIndex } from '../registry/config.js'; +import type { CachedLimits, LimitsCache } from '../limits/enforce.js'; import type { StandardWalletVersion, StoredAgenticWallet, @@ -155,6 +156,27 @@ function parseStoredWalletNftIndex(value: string | undefined): bigint | undefine } } +/** + * Per-wallet limits cache backed by the config record. Reads serve an in-memory + * snapshot seeded from the stored wallet; writes update the snapshot and persist + * to `config.json` (mirroring `persistAgenticWalletNftIndex`). + */ +function createConfigBackedLimitsCache(wallet: StoredAgenticWallet): LimitsCache { + let snapshot: CachedLimits = { + ...(wallet.limits ? { limits: wallet.limits } : {}), + ...(wallet.limits_hash ? { limits_hash: wallet.limits_hash } : {}), + }; + return { + read: () => snapshot, + write: async (next) => { + // Persist first, then update the in-memory snapshot, so a failed (or + // out-of-order concurrent) write never leaves memory ahead of disk. + await persistAgenticWalletLimits(wallet.id, next.limits, next.limits_hash); + snapshot = next; + }, + }; +} + async function createServiceFromStoredAgentic( wallet: StoredAgenticWallet, contacts: IContactResolver | undefined, @@ -193,6 +215,7 @@ async function createServiceFromStoredAgentic( networks: { [wallet.network]: toncenterApiKey ? { apiKey: toncenterApiKey } : undefined, }, + limitsCache: createConfigBackedLimitsCache(wallet), }); return { service, diff --git a/packages/mcp/src/services/AgenticSetupSessionManager.ts b/packages/mcp/src/services/AgenticSetupSessionManager.ts index 4da440fcc..b8385acfb 100644 --- a/packages/mcp/src/services/AgenticSetupSessionManager.ts +++ b/packages/mcp/src/services/AgenticSetupSessionManager.ts @@ -121,8 +121,10 @@ export class AgenticSetupSessionManager { return; } + // Snapshot first, then swap, so unawaited concurrent callers don't see an empty map. + const stored = await this.store.listSessions(); this.sessions.clear(); - for (const session of await this.store.listSessions()) { + for (const session of stored) { this.sessions.set(session.setup_id, this.fromStoredSession(session)); } } @@ -162,7 +164,9 @@ export class AgenticSetupSessionManager { } private cleanupExpiredSessions(): void { - this.syncFromStore(); + // Don't re-read the store here: persistSession is fire-and-forget, + // and a syncFromStore racing with an in-flight upsert would wipe sessions + // this manager just registered. const now = Date.now(); for (const [_setupId, session] of this.sessions.entries()) { if (new Date(session.expiresAt).getTime() <= now && session.status === 'pending') { diff --git a/packages/mcp/src/services/McpWalletService.ts b/packages/mcp/src/services/McpWalletService.ts index eee7d9155..4c3aea1d6 100644 --- a/packages/mcp/src/services/McpWalletService.ts +++ b/packages/mcp/src/services/McpWalletService.ts @@ -16,8 +16,6 @@ * with user-specific wallet instances. */ -import { createHash } from 'node:crypto'; - import { TonWalletKit, MemoryStorageAdapter, @@ -50,12 +48,38 @@ import type { NetworkType } from '../types/config.js'; import { AgenticWalletCodeCell } from '../contracts/agentic_wallet/AgenticWallet.source.js'; import { createApiClient } from '../utils/ton-client.js'; import { UINT_256_MAX } from '../utils/math.js'; +import { onchainMetadataKey } from '../utils/tep64.js'; +import { readAgenticLimitsHash } from '../utils/agentic.js'; +import { normalizeAddressForComparison } from '../utils/address.js'; +import { evaluateLimits } from '../limits/enforce.js'; +import type { CachedLimits, LimitsCache, LimitsEnv, SyncedLimits } from '../limits/enforce.js'; +import { getJettonWalletInfoFromClient, parseJettonOutflowAmount } from '../limits/jetton.js'; +import { computeLimitsHash, limitsDictToStored, parseLimitsDictFromMessageBody } from '../limits/limits-codec.js'; +import { transactionsToSpend } from '../limits/spend-window.js'; +import type { JettonSpendProbe, PendingSpend, SpendEntry } from '../limits/types.js'; + +/** History fetch bounds for limit enforcement. */ +const LIMITS_HISTORY_PAGE = 1000; +const LIMITS_HISTORY_MAX_PAGES = 10; const OP_DEPLOY_WALLET = 0x0609e47b; const AGENTIC_DEFAULT_VALID_UNTIL = 600; const TEP64_ONCHAIN_CONTENT_PREFIX = 0x00; const TEP64_SNAKE_CONTENT_PREFIX = 0x00; +/** Parse a message `amount` (nanotons) into a non-negative bigint; 0 on absence/garbage. */ +function parseMessageAmount(amount: string | undefined): bigint { + if (!amount) { + return 0n; + } + try { + const value = BigInt(amount); + return value > 0n ? value : 0n; + } catch { + return 0n; + } +} + /** * Jetton information */ @@ -217,6 +241,8 @@ export interface McpWalletServiceConfig { mainnet?: NetworkConfig; testnet?: NetworkConfig; }; + /** Per-wallet limits cache (config-backed). Absent = re-sync limits on every send. */ + limitsCache?: LimitsCache; } interface McpWalletServiceInternalConfig { @@ -227,6 +253,7 @@ interface McpWalletServiceInternalConfig { mainnet?: NetworkConfig; testnet?: NetworkConfig; }; + limitsCache?: LimitsCache; } interface DeployAgenticSubwalletParams { @@ -248,11 +275,13 @@ interface AgenticRootWalletState { export class McpWalletService { private readonly config: McpWalletServiceConfig; private readonly wallet: Wallet; + private readonly limitsCache: LimitsCache | null; private kit: TonWalletKit | null = null; private constructor(config: McpWalletServiceInternalConfig) { this.config = config; this.wallet = config.wallet; + this.limitsCache = config.limitsCache ?? null; } private static parseUint256(input: string, fieldName: string): bigint { @@ -281,11 +310,6 @@ export class McpWalletService { return (now << 16n) | rand; } - private static onchainMetadataKey(key: string): bigint { - const hashHex = createHash('sha256').update(key, 'utf8').digest('hex'); - return BigInt(`0x${hashHex}`); - } - private static buildOnchainMetadataValue(value: string | number | boolean): Cell { const stringValue = typeof value === 'string' ? value : value.toString(); return beginCell().storeUint(TEP64_SNAKE_CONTENT_PREFIX, 8).storeStringTail(stringValue).endCell(); @@ -294,7 +318,7 @@ export class McpWalletService { private static buildOnchainMetadata(metadata: Record): Cell { const dict = Dictionary.empty(Dictionary.Keys.BigUint(256), Dictionary.Values.Cell()); for (const [key, value] of Object.entries(metadata)) { - dict.set(McpWalletService.onchainMetadataKey(key), McpWalletService.buildOnchainMetadataValue(value)); + dict.set(onchainMetadataKey(key), McpWalletService.buildOnchainMetadataValue(value)); } const metadataDictCell = beginCell().storeDictDirect(dict).endCell(); @@ -354,9 +378,13 @@ export class McpWalletService { return slice.loadMaybeRef() !== null; } + /** The adapter's wallet version. `Wallet` does not type it, so it is read defensively. */ + private get walletVersion(): string | undefined { + return (this.wallet as unknown as { version?: string }).version; + } + private assertAgenticWalletVersion(): void { - const version = (this.wallet as unknown as { version?: string }).version; - if (version !== 'agentic') { + if (this.walletVersion !== 'agentic') { throw new Error('deploy_agentic_subwallet is available only for WALLET_VERSION=agentic'); } } @@ -676,29 +704,249 @@ export class McpWalletService { } /** - * Send TON + * Build → enforce on-chain-anchored spend limits → broadcast. Every send path + * routes through this single choke point. Limits apply only to agentic wallets; + * other wallets, and wallets with no on-chain limits, pass straight through. */ - async sendTon(toAddress: string, amountNano: string, comment?: string): Promise { + private async sendWithLimits( + build: () => Promise | TransactionRequest, + successMessage: string, + ): Promise { + let request: TransactionRequest; try { - const tx = await this.wallet.createTransferTonTransaction({ - recipientAddress: toAddress, - transferAmount: amountNano, - comment, - }); + request = await build(); + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : 'Unknown error' }; + } - const response = await this.wallet.sendTransaction(tx); + if (this.isAgenticWallet()) { + try { + const spend = await this.buildPendingSpend(request); + const decision = await evaluateLimits(this.createLimitsEnv(), spend); + if (!decision.allowed) { + return { success: false, message: decision.message }; + } + } catch (error) { + // Fail closed: if limits cannot be verified, refuse to broadcast. + return { + success: false, + message: `Could not verify spend limits: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } - return { - success: true, - message: `Successfully sent ${amountNano} nanoTON to ${toAddress}`, - normalizedHash: response.normalizedHash, - }; + try { + const response = await this.wallet.sendTransaction(request); + return { success: true, message: successMessage, normalizedHash: response.normalizedHash }; } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error', - }; + return { success: false, message: error instanceof Error ? error.message : 'Unknown error' }; + } + } + + private isAgenticWallet(): boolean { + return this.walletVersion === 'agentic'; + } + + /** Sum the TON and (master-resolved) jetton outflows a pending request will spend. */ + private async buildPendingSpend(request: TransactionRequest): Promise { + let ton = 0n; + const jettonProbes: Array<{ jettonWalletAddress: string; amount: bigint }> = []; + for (const message of request.messages) { + const amount = parseMessageAmount(message.amount); + if (amount > 0n) { + ton += amount; + } + const outflow = parseJettonOutflowAmount(message.payload); + if (outflow && outflow > 0n) { + jettonProbes.push({ jettonWalletAddress: message.address, amount: outflow }); + } + } + + const jettons = new Map(); + if (jettonProbes.length > 0) { + const masters = await this.resolveOwnedJettonMasters( + jettonProbes.map((probe) => probe.jettonWalletAddress), + ); + for (const probe of jettonProbes) { + const master = masters.get(probe.jettonWalletAddress); + if (master) { + jettons.set(master, (jettons.get(master) ?? 0n) + probe.amount); + } + } + } + + return { ton, jettons }; + } + + /** + * Resolve jetton-wallet addresses to their normalized master address via + * `get_wallet_data`, keeping only wallets actually owned by this wallet. Each + * distinct address is resolved once; wallets that resolve to a different owner + * are dropped. A lookup that cannot be resolved at all (RPC failure) rejects + * rather than being dropped, so the caller fails closed instead of metering an + * unverifiable jetton as zero. Shared by the pending-spend estimate and the + * historical spend scan so both decide ownership identically. + */ + private async resolveOwnedJettonMasters(jettonWalletAddresses: Iterable): Promise> { + const owner = normalizeAddressForComparison(this.wallet.getAddress()); + const masters = new Map(); + if (!owner) { + return masters; + } + const client = this.wallet.getClient(); + const resolved = await Promise.all( + [...new Set(jettonWalletAddresses)].map( + async (address) => [address, await getJettonWalletInfoFromClient(client, address)] as const, + ), + ); + for (const [address, info] of resolved) { + if (!info || normalizeAddressForComparison(info.owner) !== owner) { + continue; + } + const master = normalizeAddressForComparison(info.master); + if (master) { + masters.set(address, master); + } + } + return masters; + } + + private createLimitsEnv(): LimitsEnv { + return { + now: () => Math.floor(Date.now() / 1000), + readOnchainLimitsHash: () => this.readOnchainLimitsHash(), + readCache: () => this.limitsCache?.read() ?? {}, + writeCache: (next: CachedLimits) => this.limitsCache?.write(next) ?? Promise.resolve(), + syncLimitsFromChain: () => this.syncLimitsFromChain(), + fetchSpendEntries: (maxWindowSeconds: number, now: number) => this.fetchSpendEntries(maxWindowSeconds, now), + }; + } + + private async readOnchainLimitsHash(): Promise { + const address = this.wallet.getAddress(); + const accountState = await this.wallet.getClient().getAccountState(address); + return readAgenticLimitsHash(accountState, address); + } + + /** Find the most recent limits-change tx, parse its limitsDict, and hash it. */ + private async syncLimitsFromChain(): Promise { + const client = this.wallet.getClient(); + const address = this.wallet.getAddress(); + for (let page = 0; page < LIMITS_HISTORY_MAX_PAGES; page += 1) { + const response = await client.getAccountTransactions({ + address: [address], + limit: LIMITS_HISTORY_PAGE, + offset: page * LIMITS_HISTORY_PAGE, + }); + if (response.transactions.length === 0) { + break; + } + for (const transaction of response.transactions) { + const body = transaction.inMessage?.messageContent?.body; + if (!body) { + continue; + } + let dict: ReturnType = null; + try { + dict = parseLimitsDictFromMessageBody(Cell.fromBase64(body)); + } catch { + // Malformed base64 body; treat as not a limits-change tx. + } + if (dict && dict.size > 0) { + return { limits: limitsDictToStored(dict), hash: computeLimitsHash(dict) }; + } + } + if (response.transactions.length < LIMITS_HISTORY_PAGE) { + break; + } + } + return null; + } + + /** + * Page raw account transactions (newest first) until older than the window, then + * meter outgoing spend from the messages directly. Uses `getAccountTransactions` + * rather than `getEvents`: the events API is unreliable, carries pre-decoded data + * we don't need, and caps each request at 100 events. Fails closed (throws) if the + * page budget is exhausted while history still extends into the window: a truncated + * history would under-count spend and could pass a cap that should have been + * exceeded. + */ + private async fetchSpendEntries(maxWindowSeconds: number, now: number): Promise { + const client = this.wallet.getClient(); + const address = this.wallet.getAddress(); + const cutoff = now - maxWindowSeconds; + const tonEntries: SpendEntry[] = []; + const jettonProbes: JettonSpendProbe[] = []; + for (let page = 0; page < LIMITS_HISTORY_MAX_PAGES; page += 1) { + const response = await client.getAccountTransactions({ + address: [address], + limit: LIMITS_HISTORY_PAGE, + offset: page * LIMITS_HISTORY_PAGE, + }); + if (response.transactions.length === 0) { + return this.resolveSpendEntries(tonEntries, jettonProbes); + } + const within: typeof response.transactions = []; + let reachedCutoff = false; + for (const transaction of response.transactions) { + if (transaction.now < cutoff) { + reachedCutoff = true; + break; + } + within.push(transaction); + } + const spend = transactionsToSpend(within, address); + tonEntries.push(...spend.tonEntries); + jettonProbes.push(...spend.jettonProbes); + if (reachedCutoff || response.transactions.length < LIMITS_HISTORY_PAGE) { + return this.resolveSpendEntries(tonEntries, jettonProbes); + } + } + throw new Error( + `Spend history exceeds ${LIMITS_HISTORY_MAX_PAGES * LIMITS_HISTORY_PAGE} transactions within the limit ` + + `window; cannot verify limits without truncating history.`, + ); + } + + /** + * Resolve the jetton-wallet addresses gathered while paging history to their + * masters and emit one spend entry per outflow we own, merged with the already + * netted TON entries. Jetton resolution is deferred to here so the per-page + * transaction parse stays synchronous and the `get_wallet_data` calls run once + * over the whole window rather than per page. + */ + private async resolveSpendEntries( + tonEntries: SpendEntry[], + jettonProbes: JettonSpendProbe[], + ): Promise { + if (jettonProbes.length === 0) { + return tonEntries; } + const masters = await this.resolveOwnedJettonMasters(jettonProbes.map((probe) => probe.jettonWalletAddress)); + const entries = [...tonEntries]; + for (const probe of jettonProbes) { + const master = masters.get(probe.jettonWalletAddress); + if (master) { + entries.push({ timestamp: probe.timestamp, asset: master, amount: probe.amount }); + } + } + return entries; + } + + /** + * Send TON + */ + async sendTon(toAddress: string, amountNano: string, comment?: string): Promise { + return this.sendWithLimits( + () => + this.wallet.createTransferTonTransaction({ + recipientAddress: toAddress, + transferAmount: amountNano, + comment, + }), + `Successfully sent ${amountNano} nanoTON to ${toAddress}`, + ); } /** @@ -710,27 +958,16 @@ export class McpWalletService { amountRaw: string, comment?: string, ): Promise { - try { - const tx = await this.wallet.createTransferJettonTransaction({ - recipientAddress: toAddress, - jettonAddress, - transferAmount: amountRaw, - comment, - }); - - const response = await this.wallet.sendTransaction(tx); - - return { - success: true, - message: `Successfully sent jettons to ${toAddress}`, - normalizedHash: response.normalizedHash, - }; - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error', - }; - } + return this.sendWithLimits( + () => + this.wallet.createTransferJettonTransaction({ + recipientAddress: toAddress, + jettonAddress, + transferAmount: amountRaw, + comment, + }), + `Successfully sent jettons to ${toAddress}`, + ); } /** @@ -747,20 +984,10 @@ export class McpWalletService { validUntil?: number; fromAddress?: string; }): Promise { - try { - const tx = await this.wallet.sendTransaction(request as TransactionRequest); - - return { - success: true, - message: `Successfully sent transaction with ${request.messages.length} message(s)`, - normalizedHash: tx.normalizedHash, - }; - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error', - }; - } + return this.sendWithLimits( + () => request as TransactionRequest, + `Successfully sent transaction with ${request.messages.length} message(s)`, + ); } /** @@ -842,7 +1069,7 @@ export class McpWalletService { const stateInit = beginCell().store(storeStateInit(childInit)).endCell(); - const response = await this.wallet.sendTransaction({ + const request: TransactionRequest = { validUntil: Math.floor(Date.now() / 1000) + AGENTIC_DEFAULT_VALID_UNTIL, messages: [ { @@ -852,12 +1079,18 @@ export class McpWalletService { payload: deployBody.toBoc().toString('base64'), }, ], - } as TransactionRequest); + } as TransactionRequest; + + const result = await this.sendWithLimits( + () => request, + `Successfully sent deploy transaction for sub-wallet ${childAddressFriendly}`, + ); + if (!result.success) { + return result; + } return { - success: true, - message: `Successfully sent deploy transaction for sub-wallet ${childAddressFriendly}`, - normalizedHash: response.normalizedHash, + ...result, subwalletAddress: childAddressFriendly, subwalletNftIndex: subwalletNftIndex.toString(), ownerAddress: rootState.ownerAddress.toString(), @@ -1067,26 +1300,15 @@ export class McpWalletService { * Send NFT */ async sendNft(nftAddress: string, toAddress: string, comment?: string): Promise { - try { - const tx = await this.wallet.createTransferNftTransaction({ - nftAddress, - recipientAddress: toAddress, - comment, - }); - - const response = await this.wallet.sendTransaction(tx); - - return { - success: true, - message: `Successfully sent NFT ${nftAddress} to ${toAddress}`, - normalizedHash: response.normalizedHash, - }; - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : 'Unknown error', - }; - } + return this.sendWithLimits( + () => + this.wallet.createTransferNftTransaction({ + nftAddress, + recipientAddress: toAddress, + comment, + }), + `Successfully sent NFT ${nftAddress} to ${toAddress}`, + ); } /** diff --git a/packages/mcp/src/utils/agentic.ts b/packages/mcp/src/utils/agentic.ts index c96a87268..f71720230 100644 --- a/packages/mcp/src/utils/agentic.ts +++ b/packages/mcp/src/utils/agentic.ts @@ -15,6 +15,7 @@ import type { ApiClient, AccountState } from '@ton/walletkit'; import { AgenticWalletCodeCell } from '../contracts/agentic_wallet/AgenticWallet.source.js'; import type { TonNetwork } from '../registry/config.js'; import { parsePrivateKeyInput } from './private-key.js'; +import { readOnchainMetadataValue } from './tep64.js'; const AGENTIC_DASHBOARD_BASE_URL = 'https://agents.ton.org/'; @@ -281,6 +282,17 @@ function parseAgenticWalletState(accountState: AccountState, address: string): A }; } +/** + * Read the on-chain `limits_hash` TEP-64 attribute from an agentic wallet's + * account state, or `undefined` when no limits are set (uninitialized wallet, + * missing content, or absent attribute). Throws only if the account state cannot + * be parsed as an agentic wallet. + */ +export function readAgenticLimitsHash(accountState: AccountState, address: string): string | undefined { + const state = parseAgenticWalletState(accountState, address); + return readOnchainMetadataValue(state.nftItemContent, 'limits_hash'); +} + async function getAgenticWalletSnapshot(input: { client: ApiClient; address: string; @@ -309,33 +321,6 @@ async function getAgenticWalletSnapshot(input: { }; } -function extractMetadataText(cell: Cell | null): string | undefined { - if (!cell) { - return undefined; - } - - try { - const slice = cell.beginParse(); - const prefix = slice.loadUint(8); - if (prefix !== 0x00) { - return undefined; - } - const content = slice.loadRef(); - const contentSlice = content.beginParse(); - if (contentSlice.remainingBits >= 8) { - contentSlice.loadUint(8); - } - const bytes: number[] = []; - while (contentSlice.remainingBits >= 8) { - bytes.push(contentSlice.loadUint(8)); - } - const text = Buffer.from(bytes).toString('utf-8').trim(); - return text || undefined; - } catch { - return undefined; - } -} - export async function listAgenticWalletsByOwner(input: { client: ApiClient; ownerAddress: string; @@ -382,7 +367,7 @@ export async function listAgenticWalletsByOwner(input: { collectionAddress: state.collectionAddress.toString(), nftItemIndex: state.nftItemIndex.toString(), deployedByUser: state.deployedByUser, - name: nft.info?.name ?? extractMetadataText(state.nftItemContent), + name: nft.info?.name ?? readOnchainMetadataValue(state.nftItemContent, 'name'), }); } catch { // Skip malformed or uninitialized records. @@ -442,6 +427,6 @@ export async function validateAgenticWalletAddress(input: { collectionAddress: state.collectionAddress.toString(), nftItemIndex: state.nftItemIndex.toString(), deployedByUser: state.deployedByUser, - name: extractMetadataText(state.nftItemContent), + name: readOnchainMetadataValue(state.nftItemContent, 'name'), }; } diff --git a/packages/mcp/src/utils/tep64.ts b/packages/mcp/src/utils/tep64.ts new file mode 100644 index 000000000..7dae5c18c --- /dev/null +++ b/packages/mcp/src/utils/tep64.ts @@ -0,0 +1,79 @@ +/** + * 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 { createHash } from 'node:crypto'; + +import { Dictionary } from '@ton/core'; +import type { Cell } from '@ton/core'; + +/** TEP-64 content-layout prefix: 0x00 = onchain dictionary, 0x01 = offchain URI. */ +const ONCHAIN_CONTENT_PREFIX = 0x00; +/** TEP-64 per-value data prefix: 0x00 = snake, 0x01 = chunked. */ +const SNAKE_DATA_PREFIX = 0x00; +const CHUNKED_DATA_PREFIX = 0x01; + +/** + * Derives the TEP-64 onchain dictionary key for an attribute: sha256(name) as a 256-bit int. + * This is the single source of truth shared by the metadata writer and reader. + */ +export function onchainMetadataKey(key: string): bigint { + return BigInt(`0x${createHash('sha256').update(key, 'utf8').digest('hex')}`); +} + +/** Reads a TEP-64 ContentData value cell (snake or chunked) into a UTF-8 string. */ +function readContentValue(value: Cell): string { + const slice = value.beginParse(); + if (slice.remainingBits < 8) { + return ''; + } + const dataPrefix = slice.loadUint(8); + if (dataPrefix === SNAKE_DATA_PREFIX) { + return slice.loadStringTail(); + } + if (dataPrefix === CHUNKED_DATA_PREFIX) { + const chunks = slice.loadDict(Dictionary.Keys.Uint(32), Dictionary.Values.Cell()); + return chunks + .keys() + .sort((a, b) => a - b) + .map((index) => chunks.get(index)!.beginParse().loadStringTail()) + .join(''); + } + return ''; +} + +/** + * Reads a single attribute from a TEP-64 onchain content cell (layout prefix 0x00), + * looking the value up by sha256(key) and decoding its snake/chunked payload. + * + * Returns undefined when the cell is missing, offchain, malformed, or the key is absent. + */ +export function readOnchainMetadataValue(content: Cell | null, key: string): string | undefined { + if (!content) { + return undefined; + } + try { + const slice = content.beginParse(); + if (slice.remainingBits < 8) { + return undefined; + } + const prefix = slice.loadUint(8); + if (prefix !== ONCHAIN_CONTENT_PREFIX) { + // Offchain content (0x01) stores a URI, not per-key onchain attributes. + return undefined; + } + const dict = slice.loadDict(Dictionary.Keys.BigUint(256), Dictionary.Values.Cell()); + const value = dict.get(onchainMetadataKey(key)); + if (!value) { + return undefined; + } + const text = readContentValue(value).trim(); + return text || undefined; + } catch { + return undefined; + } +} diff --git a/packages/walletkit/src/api/models/transactions/Transaction.ts b/packages/walletkit/src/api/models/transactions/Transaction.ts index da031f551..8a8757d36 100644 --- a/packages/walletkit/src/api/models/transactions/Transaction.ts +++ b/packages/walletkit/src/api/models/transactions/Transaction.ts @@ -57,9 +57,10 @@ export interface Transaction { mcBlockSeqno: number; /** - * External hash of the trace + * External hash of the trace. Absent when the toncenter API omits + * `trace_external_hash` (e.g. transactions with no external in-message). */ - traceExternalHash: Hex; + traceExternalHash?: Hex; /** * ID of the trace diff --git a/packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts b/packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts index e1e98de51..fef2dd1b7 100644 --- a/packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts +++ b/packages/walletkit/src/clients/tonapi/ApiClientTonApi.ts @@ -258,7 +258,7 @@ export class ApiClientTonApi extends BaseApiClient implements ApiClient { return { transactions: [], addressBook: {} }; } - const limit = Math.max(1, Math.min(request.limit ?? 10, 100)); + const limit = Math.max(1, Math.min(request.limit ?? 10, 1000)); const offset = Math.max(0, request.offset ?? 0); const response = await this.getJson( diff --git a/packages/walletkit/src/clients/toncenter/ApiClientToncenter.ts b/packages/walletkit/src/clients/toncenter/ApiClientToncenter.ts index 2a80c351a..c971891e5 100644 --- a/packages/walletkit/src/clients/toncenter/ApiClientToncenter.ts +++ b/packages/walletkit/src/clients/toncenter/ApiClientToncenter.ts @@ -227,7 +227,7 @@ export class ApiClientToncenter extends BaseApiClient implements ApiClient { let offset = request.offset ?? 0; let limit = request.limit ?? 10; if (limit > 100) { - limit = 100; + limit = 1000; } else if (limit < 0) { limit = 0; } diff --git a/packages/walletkit/src/clients/toncenter/mappers/map-transactions.ts b/packages/walletkit/src/clients/toncenter/mappers/map-transactions.ts index d2405f2d7..79d528117 100644 --- a/packages/walletkit/src/clients/toncenter/mappers/map-transactions.ts +++ b/packages/walletkit/src/clients/toncenter/mappers/map-transactions.ts @@ -47,7 +47,7 @@ function toTransaction(tx: ToncenterTransaction): Transaction { logicalTime: tx.lt, now: tx.now, mcBlockSeqno: tx.mc_block_seqno, - traceExternalHash: Base64ToHex(tx.trace_external_hash), + traceExternalHash: tx.trace_external_hash ? Base64ToHex(tx.trace_external_hash) : undefined, traceId: tx.trace_id ?? undefined, previousTransactionHash: tx.prev_trans_hash ? Base64ToHex(tx.prev_trans_hash) : undefined, previousTransactionLogicalTime: tx.prev_trans_lt ?? undefined, From 1bd6f33f785d609e3c69a6a2a3b4b2aba80967e3 Mon Sep 17 00:00:00 2001 From: Arkadiy Stena Date: Thu, 4 Jun 2026 17:01:07 +0300 Subject: [PATCH 2/3] add limits getter --- .../__tests__/AgenticWalletAdapter.spec.ts | 1 - packages/mcp/src/__tests__/limits.spec.ts | 117 ++++++++++++++- packages/mcp/src/factory.ts | 9 ++ packages/mcp/src/limits/enforce.ts | 138 ++++++++++++++---- packages/mcp/src/services/McpWalletService.ts | 42 +++++- packages/mcp/src/tools/index.ts | 1 + packages/mcp/src/tools/limits-tools.ts | 52 +++++++ 7 files changed, 327 insertions(+), 33 deletions(-) create mode 100644 packages/mcp/src/tools/limits-tools.ts diff --git a/packages/mcp/src/__tests__/AgenticWalletAdapter.spec.ts b/packages/mcp/src/__tests__/AgenticWalletAdapter.spec.ts index d063fdb91..620cd649f 100644 --- a/packages/mcp/src/__tests__/AgenticWalletAdapter.spec.ts +++ b/packages/mcp/src/__tests__/AgenticWalletAdapter.spec.ts @@ -17,7 +17,6 @@ import { Network } from '@ton/walletkit'; import type { ApiClient, Hex, WalletSigner } from '@ton/walletkit'; import { AgenticWalletAdapter } from '../contracts/agentic_wallet/AgenticWalletAdapter.js'; -import { ActionSendMsg } from '../contracts/agentic_wallet/actions.js'; import { DEFAULT_AGENTIC_COLLECTION_ADDRESS, createAgenticWalletRecord, diff --git a/packages/mcp/src/__tests__/limits.spec.ts b/packages/mcp/src/__tests__/limits.spec.ts index a1b71be18..4d842ab9a 100644 --- a/packages/mcp/src/__tests__/limits.spec.ts +++ b/packages/mcp/src/__tests__/limits.spec.ts @@ -20,7 +20,15 @@ import { parseLimitsDictFromMessageBody, storedToLimitsDict, } from '../limits/limits-codec.js'; -import { evaluateLimits, findLimitViolation, formatLimitViolation, maxRelevantWindow } from '../limits/enforce.js'; +import { + computeLimitsUsage, + evaluateLimits, + findLimitViolation, + formatLimitViolation, + loadActiveLimits, + maxConfiguredWindow, + maxRelevantWindow, +} from '../limits/enforce.js'; import type { LimitsEnv } from '../limits/enforce.js'; import { getJettonWalletInfoFromClient, parseJettonOutflowAmount } from '../limits/jetton.js'; import { sumSpendWithinWindow, transactionsToSpend } from '../limits/spend-window.js'; @@ -278,6 +286,113 @@ describe('evaluateLimits', () => { }); }); +describe('loadActiveLimits', () => { + function env(overrides: Partial): LimitsEnv { + return { + now: () => NOW, + readOnchainLimitsHash: async () => 'hash-1', + readCache: () => ({}), + writeCache: async () => {}, + syncLimitsFromChain: async () => ({ limits: LIMITS, hash: 'hash-1' }), + fetchSpendEntries: async () => [], + ...overrides, + }; + } + + it('reports none and clears a stale cache when no on-chain limits are set', async () => { + const writeCache = vi.fn(async () => {}); + const loaded = await loadActiveLimits( + env({ + readOnchainLimitsHash: async () => undefined, + readCache: () => ({ limits_hash: 'old' }), + writeCache, + }), + ); + expect(loaded).toEqual({ status: 'none' }); + expect(writeCache).toHaveBeenCalledWith({}); + }); + + it('returns the cached limits without re-syncing when the hash matches', async () => { + const syncLimitsFromChain = vi.fn(async () => ({ limits: LIMITS, hash: 'hash-1' })); + const loaded = await loadActiveLimits( + env({ readCache: () => ({ limits: LIMITS, limits_hash: 'hash-1' }), syncLimitsFromChain }), + ); + expect(loaded).toEqual({ status: 'active', limits: LIMITS, hash: 'hash-1' }); + expect(syncLimitsFromChain).not.toHaveBeenCalled(); + }); + + it('re-syncs and persists when the cache is stale', async () => { + const writeCache = vi.fn(async () => {}); + const loaded = await loadActiveLimits(env({ readCache: () => ({}), writeCache })); + expect(loaded).toEqual({ status: 'active', limits: LIMITS, hash: 'hash-1' }); + expect(writeCache).toHaveBeenCalledWith({ limits: LIMITS, limits_hash: 'hash-1' }); + }); + + it('reports an error when no limits-change transaction can be found', async () => { + const loaded = await loadActiveLimits(env({ syncLimitsFromChain: async () => null })); + expect(loaded.status).toBe('error'); + expect(loaded.status === 'error' && loaded.message).toContain('no limits-change'); + }); + + it('reports an error when the synced hash does not match the on-chain hash', async () => { + const loaded = await loadActiveLimits( + env({ syncLimitsFromChain: async () => ({ limits: LIMITS, hash: 'other' }) }), + ); + expect(loaded.status).toBe('error'); + expect(loaded.status === 'error' && loaded.message).toContain('does not match'); + }); +}); + +describe('maxConfiguredWindow', () => { + it('returns the largest rolling window across all assets', () => { + expect(maxConfiguredWindow(LIMITS)).toBe(86400); + }); + + it('returns 0 when only per-transaction limits are configured', () => { + expect(maxConfiguredWindow({ assets: { [TON_ASSET_KEY]: { windows: { '0': '5' } } } })).toBe(0); + }); +}); + +describe('computeLimitsUsage', () => { + it('reports spent and remaining per asset and window, with per-tx windows un-metered', () => { + const entries: SpendEntry[] = [ + { timestamp: NOW - 100, asset: TON_ASSET_KEY, amount: 12n }, + { timestamp: NOW - 100, asset: JETTON_KEY, amount: 400n }, + ]; + const usage = computeLimitsUsage(LIMITS, entries, NOW); + + const ton = usage.find((a) => a.asset === TON_ASSET_KEY); + expect(ton?.windows).toEqual([ + { windowSeconds: 0, limit: '5', spent: '0', remaining: '5' }, + { windowSeconds: 3600, limit: '20', spent: '12', remaining: '8' }, + ]); + + const jetton = usage.find((a) => a.asset === JETTON.toString()); + expect(jetton?.windows).toEqual([{ windowSeconds: 86400, limit: '1000', spent: '400', remaining: '600' }]); + }); + + it('clamps remaining at zero when spend already exceeds the cap', () => { + const entries: SpendEntry[] = [{ timestamp: NOW - 100, asset: TON_ASSET_KEY, amount: 25n }]; + const ton = computeLimitsUsage(LIMITS, entries, NOW).find((a) => a.asset === TON_ASSET_KEY); + expect(ton?.windows.find((w) => w.windowSeconds === 3600)).toEqual({ + windowSeconds: 3600, + limit: '20', + spent: '25', + remaining: '0', + }); + }); + + it('excludes spend older than the window', () => { + const entries: SpendEntry[] = [{ timestamp: NOW - 4000, asset: TON_ASSET_KEY, amount: 12n }]; + const ton = computeLimitsUsage(LIMITS, entries, NOW).find((a) => a.asset === TON_ASSET_KEY); + expect(ton?.windows.find((w) => w.windowSeconds === 3600)?.spent).toBe('0'); + }); + + it('fails closed (throws) on a malformed asset key', () => { + expect(() => computeLimitsUsage({ assets: { xyz: { windows: { '0': '5' } } } }, [], NOW)).toThrow(); + }); +}); + // ------------------------------------------------------------------------------------------------- // limits-jetton (parseJettonOutflowAmount / getJettonWalletInfoFromClient) // ------------------------------------------------------------------------------------------------- diff --git a/packages/mcp/src/factory.ts b/packages/mcp/src/factory.ts index 283ef3239..70256e0ee 100644 --- a/packages/mcp/src/factory.ts +++ b/packages/mcp/src/factory.ts @@ -29,6 +29,7 @@ import { createMcpAgenticTools, createMcpBalanceTools, createMcpKnownJettonsTools, + createMcpLimitsTools, createMcpNftTools, createMcpSwapTools, createMcpTransactionTools, @@ -118,6 +119,7 @@ export async function createTonWalletMCP(config: TonMcpFactoryConfig): Promise createMcpAgenticTools(service).deploy_agentic_subwallet, { requiresSigning: true }, ); + registerRegistryWalletTool( + 'agentic_get_limits', + limitsToolDefs.get_limits, + (service) => createMcpLimitsTools(service).get_limits, + ); registerRegistryWalletTool( 'get_transaction_status', transactionToolDefs.get_transaction_status, diff --git a/packages/mcp/src/limits/enforce.ts b/packages/mcp/src/limits/enforce.ts index f3a1e975c..592b1c1b0 100644 --- a/packages/mcp/src/limits/enforce.ts +++ b/packages/mcp/src/limits/enforce.ts @@ -227,14 +227,18 @@ export interface LimitsEnv { export type LimitsDecision = { allowed: true } | { allowed: false; message: string }; +/** Active limits resolved against the on-chain hash: none set, verified, or unverifiable. */ +export type LoadedLimits = + | { status: 'none' } + | { status: 'active'; limits: StoredLimits; hash: string } + | { status: 'error'; message: string }; + /** - * Decide whether a pending transaction may broadcast: - * 1. read the on-chain limits_hash; absent -> allow and clear any stale cache; - * 2. hash matches cache -> use cached limits, else re-sync and persist; - * 3. a hash that cannot be verified against an on-chain tx is a hard failure; - * 4. measure spend over the largest relevant window and apply the per-asset checks. + * Resolve a wallet's active limits without metering spend (shared by enforcement and the + * read-only query): no on-chain hash -> `none` and clear stale cache; hash matches cache -> + * use it; else re-sync from chain and persist; an unverifiable hash -> `error`. */ -export async function evaluateLimits(env: LimitsEnv, spend: PendingSpend): Promise { +export async function loadActiveLimits(env: LimitsEnv): Promise { const onchainHash = await env.readOnchainLimitsHash(); if (!onchainHash) { @@ -242,34 +246,48 @@ export async function evaluateLimits(env: LimitsEnv, spend: PendingSpend): Promi if (cached.limits || cached.limits_hash) { await env.writeCache({}); } - return { allowed: true }; + return { status: 'none' }; } - let limits: StoredLimits; const cached = env.readCache(); if (cached.limits && cached.limits_hash === onchainHash) { - limits = cached.limits; - } else { - const synced = await env.syncLimitsFromChain(); - if (!synced) { - return { - allowed: false, - message: - `Wallet has on-chain limits (limits_hash=${onchainHash}) but no limits-change ` + - `transaction was found to verify them; refusing to send.`, - }; - } - if (synced.hash !== onchainHash) { - return { - allowed: false, - message: - `On-chain limits_hash (${onchainHash}) does not match the hash of the latest ` + - `limits-change transaction (${synced.hash}); refusing to send.`, - }; - } - limits = synced.limits; - await env.writeCache({ limits, limits_hash: onchainHash }); + return { status: 'active', limits: cached.limits, hash: onchainHash }; + } + + const synced = await env.syncLimitsFromChain(); + if (!synced) { + return { + status: 'error', + message: + `Wallet has on-chain limits (limits_hash=${onchainHash}) but no limits-change ` + + `transaction was found to verify them; refusing to send.`, + }; + } + if (synced.hash !== onchainHash) { + return { + status: 'error', + message: + `On-chain limits_hash (${onchainHash}) does not match the hash of the latest ` + + `limits-change transaction (${synced.hash}); refusing to send.`, + }; + } + await env.writeCache({ limits: synced.limits, limits_hash: onchainHash }); + return { status: 'active', limits: synced.limits, hash: onchainHash }; +} + +/** + * Decide whether a pending transaction may broadcast: resolve the active limits, + * then measure spend over the largest relevant window and apply the per-asset checks. + */ +export async function evaluateLimits(env: LimitsEnv, spend: PendingSpend): Promise { + const loaded = await loadActiveLimits(env); + if (loaded.status === 'none') { + return { allowed: true }; } + if (loaded.status === 'error') { + return { allowed: false, message: loaded.message }; + } + const { limits } = loaded; // Capture `now` once so the history-fetch cutoff and the window math share a boundary. const now = env.now(); @@ -278,3 +296,65 @@ export async function evaluateLimits(env: LimitsEnv, spend: PendingSpend): Promi const violation = findLimitViolation(limits, spend, spendEntries, now); return violation ? { allowed: false, message: formatLimitViolation(violation) } : { allowed: true }; } + +/** Per-window usage of one asset's limit. Amounts in base units (decimal strings); window `0` = per-transaction. */ +export interface AssetWindowUsage { + windowSeconds: number; + limit: string; + spent: string; + remaining: string; +} + +/** Configured limits and current usage for a single asset (`'TON'` or a jetton master). */ +export interface AssetUsage { + asset: string; + /** Windows sorted ascending (a per-transaction `0` window, if present, comes first). */ + windows: AssetWindowUsage[]; +} + +/** Largest rolling window (seconds > 0) configured across every asset; `0` if none. */ +export function maxConfiguredWindow(limits: StoredLimits): number { + let maxWindow = 0; + for (const assetLimit of Object.values(limits.assets)) { + for (const windowSeconds of Object.keys(assetLimit.windows)) { + const seconds = Number(windowSeconds); + if (Number.isInteger(seconds) && seconds > maxWindow) { + maxWindow = seconds; + } + } + } + return maxWindow; +} + +/** + * Per-asset/per-window used and remaining amounts, metered exactly like + * {@link findLimitViolation} so they match what enforcement would decide. Throws on a + * malformed asset key. + */ +export function computeLimitsUsage(limits: StoredLimits, spendEntries: SpendEntry[], now: number): AssetUsage[] { + const usage: AssetUsage[] = []; + for (const [displayKey, assetLimit] of Object.entries(limits.assets)) { + const normalizedKey = normalizeAssetKey(displayKey); + if (normalizedKey === null) { + throw new Error(`Invalid spend-limit config: asset key "${displayKey}" is not a valid address.`); + } + const windows: AssetWindowUsage[] = Object.entries(assetLimit.windows) + .map(([windowSeconds, amount]) => [Number(windowSeconds), BigInt(amount)] as const) + .sort((a, b) => a[0] - b[0]) + .map(([windowSeconds, limit]) => { + const spent = + windowSeconds === PER_TX_WINDOW + ? 0n + : sumSpendWithinWindow(spendEntries, normalizedKey, now, windowSeconds); + const remaining = limit > spent ? limit - spent : 0n; + return { + windowSeconds, + limit: limit.toString(), + spent: spent.toString(), + remaining: remaining.toString(), + }; + }); + usage.push({ asset: displayKey, windows }); + } + return usage; +} diff --git a/packages/mcp/src/services/McpWalletService.ts b/packages/mcp/src/services/McpWalletService.ts index 4c3aea1d6..b3e7b1938 100644 --- a/packages/mcp/src/services/McpWalletService.ts +++ b/packages/mcp/src/services/McpWalletService.ts @@ -51,8 +51,8 @@ import { UINT_256_MAX } from '../utils/math.js'; import { onchainMetadataKey } from '../utils/tep64.js'; import { readAgenticLimitsHash } from '../utils/agentic.js'; import { normalizeAddressForComparison } from '../utils/address.js'; -import { evaluateLimits } from '../limits/enforce.js'; -import type { CachedLimits, LimitsCache, LimitsEnv, SyncedLimits } from '../limits/enforce.js'; +import { computeLimitsUsage, evaluateLimits, loadActiveLimits, maxConfiguredWindow } from '../limits/enforce.js'; +import type { AssetUsage, CachedLimits, LimitsCache, LimitsEnv, SyncedLimits } from '../limits/enforce.js'; import { getJettonWalletInfoFromClient, parseJettonOutflowAmount } from '../limits/jetton.js'; import { computeLimitsHash, limitsDictToStored, parseLimitsDictFromMessageBody } from '../limits/limits-codec.js'; import { transactionsToSpend } from '../limits/spend-window.js'; @@ -107,6 +107,19 @@ export interface AddressBalanceResult { balanceTon: string; } +/** + * Active spend-limit config and usage for the wallet, in base units (matching the on-chain + * `limitsDict`). `enabled: false` means no limits in force; `reason` says why. + */ +export interface LimitsInfoResult { + enabled: boolean; + reason?: 'not-agentic' | 'no-limits' | 'unverifiable'; + message?: string; + limitsHash?: string; + checkedAt?: number; + assets?: AssetUsage[]; +} + /** * NFT information */ @@ -811,6 +824,31 @@ export class McpWalletService { return masters; } + /** + * Current spend-limit config and usage, without sending. Resolves limits via the same + * {@link loadActiveLimits} path enforcement uses, then meters spend over the largest window. + */ + async getLimitsInfo(): Promise { + if (!this.isAgenticWallet()) { + return { enabled: false, reason: 'not-agentic' }; + } + const env = this.createLimitsEnv(); + const loaded = await loadActiveLimits(env); + if (loaded.status === 'none') { + return { enabled: false, reason: 'no-limits' }; + } + if (loaded.status === 'error') { + return { enabled: false, reason: 'unverifiable', message: loaded.message }; + } + + // One `now` so the fetch cutoff and window math share a boundary. + const now = env.now(); + const maxWindow = maxConfiguredWindow(loaded.limits); + const spendEntries = maxWindow > 0 ? await env.fetchSpendEntries(maxWindow, now) : []; + const assets = computeLimitsUsage(loaded.limits, spendEntries, now); + return { enabled: true, limitsHash: loaded.hash, checkedAt: now, assets }; + } + private createLimitsEnv(): LimitsEnv { return { now: () => Math.floor(Date.now() / 1000), diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 208eddf19..5e2d02a44 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -12,6 +12,7 @@ export { createMcpSwapTools } from './swap-tools.js'; export { createMcpKnownJettonsTools, KNOWN_JETTONS } from './known-jettons-tools.js'; export { createMcpNftTools } from './nft-tools.js'; export { createMcpTransactionTools } from './transaction-tools.js'; +export { createMcpLimitsTools } from './limits-tools.js'; export { createMcpAgenticTools } from './agentic-tools.js'; export { createMcpAddressTools } from './address-tools.js'; export { createMcpWalletManagementTools } from './wallet-management-tools.js'; diff --git a/packages/mcp/src/tools/limits-tools.ts b/packages/mcp/src/tools/limits-tools.ts new file mode 100644 index 000000000..3a1ceb886 --- /dev/null +++ b/packages/mcp/src/tools/limits-tools.ts @@ -0,0 +1,52 @@ +/** + * 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 { z } from 'zod'; + +import type { McpWalletService } from '../services/McpWalletService.js'; +import type { ToolResponse } from './types.js'; + +export const getLimitsSchema = z.object({}); + +export function createMcpLimitsTools(service: McpWalletService) { + return { + get_limits: { + description: + 'Get the active agentic-wallet spend limits and usage: per asset and rolling window, the configured ' + + 'cap, amount already spent, and remaining headroom (base units; window 0 = per-transaction). Returns ' + + 'enabled:false when the wallet is not agentic or has no verifiable on-chain limits.', + inputSchema: getLimitsSchema, + handler: async (): Promise => { + try { + const info = await service.getLimitsInfo(); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ success: true, ...info }, null, 2), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: `Failed to get spend limits: ${error instanceof Error ? error.message : 'Unknown error'}`, + }), + }, + ], + isError: true, + }; + } + }, + }, + }; +} From 9b1d0a507894d9484cefd19b6fa7059aab070a31 Mon Sep 17 00:00:00 2001 From: Arkadiy Stena Date: Fri, 5 Jun 2026 05:34:08 +0300 Subject: [PATCH 3/3] add jetton wallets cache --- packages/mcp/src/__tests__/config.spec.ts | 98 ++++ packages/mcp/src/__tests__/limits.spec.ts | 450 ++++++++++++++---- packages/mcp/src/limits/enforce.ts | 55 ++- packages/mcp/src/limits/jetton.ts | 44 -- packages/mcp/src/limits/pending.ts | 95 ++++ packages/mcp/src/limits/spend-window.ts | 25 +- packages/mcp/src/limits/types.ts | 7 +- packages/mcp/src/registry/config.ts | 37 +- packages/mcp/src/runtime/wallet-runtime.ts | 3 +- packages/mcp/src/services/McpWalletService.ts | 177 +++---- 10 files changed, 718 insertions(+), 273 deletions(-) create mode 100644 packages/mcp/src/limits/pending.ts diff --git a/packages/mcp/src/__tests__/config.spec.ts b/packages/mcp/src/__tests__/config.spec.ts index f321a2b70..ece3c88bd 100644 --- a/packages/mcp/src/__tests__/config.spec.ts +++ b/packages/mcp/src/__tests__/config.spec.ts @@ -30,10 +30,12 @@ import { removeWallet, saveConfig, setActiveWallet, + updateAgenticWalletLimits, updateAgenticWalletNftIndex, upsertPendingAgenticDeployment, upsertWallet, } from '../registry/config.js'; +import type { StoredLimits } from '../registry/config.js'; describe('mcp config registry', () => { const baseAddress = 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAM9c'; @@ -319,6 +321,102 @@ describe('mcp config registry', () => { expect(result).toBe(false); }); + + const LIMITS: StoredLimits = { assets: { TON: { windows: { '0': '5' } } } }; + const FORWARD_MAP = { EQjetton: '0:abc' }; + + it('createAgenticWalletRecord stores the forward jetton map and survives a save round-trip', async () => { + const record = createAgenticWalletRecord({ + name: 'Agent wallet', + network: 'mainnet', + address: baseAddress, + ownerAddress: DEFAULT_AGENTIC_COLLECTION_ADDRESS, + limits: LIMITS, + limitsHash: 'hash-1', + jettonWallets: FORWARD_MAP, + }); + expect(record).toMatchObject({ limits: LIMITS, limits_hash: 'hash-1', jetton_wallets: FORWARD_MAP }); + + saveConfig({ ...createEmptyConfig(), active_wallet_id: record.id, wallets: [record] }); + const persisted = await loadConfigWithMigration(); + expect(persisted?.wallets[0]).toMatchObject({ jetton_wallets: FORWARD_MAP, limits_hash: 'hash-1' }); + }); + + it('updateAgenticWalletLimits sets limits, hash, and forward map together', () => { + const agent = createAgenticWalletRecord({ + name: 'Agent wallet', + network: 'mainnet', + address: baseAddress, + ownerAddress: DEFAULT_AGENTIC_COLLECTION_ADDRESS, + }); + const config = upsertWallet(createEmptyConfig(), agent); + + const updated = updateAgenticWalletLimits(config, agent.id, LIMITS, 'hash-1', FORWARD_MAP); + + expect(updated).not.toBe(config); + expect(updated.wallets.find((w) => w.id === agent.id)).toMatchObject({ + limits: LIMITS, + limits_hash: 'hash-1', + jetton_wallets: FORWARD_MAP, + }); + }); + + it('updateAgenticWalletLimits upgrades a legacy record (same hash, forward map newly added)', () => { + const agent = createAgenticWalletRecord({ + name: 'Agent wallet', + network: 'mainnet', + address: baseAddress, + ownerAddress: DEFAULT_AGENTIC_COLLECTION_ADDRESS, + limits: LIMITS, + limitsHash: 'hash-1', + // No jettonWallets: a record written before the forward map existed. + }); + const config = upsertWallet(createEmptyConfig(), agent); + expect(config.wallets[0]).not.toHaveProperty('jetton_wallets'); + + const updated = updateAgenticWalletLimits(config, agent.id, LIMITS, 'hash-1', FORWARD_MAP); + + // The hash is unchanged, but the missing forward map must still force a write. + expect(updated).not.toBe(config); + expect(updated.wallets.find((w) => w.id === agent.id)).toMatchObject({ jetton_wallets: FORWARD_MAP }); + }); + + it('updateAgenticWalletLimits is a no-op when hash and forward map are unchanged', () => { + const agent = createAgenticWalletRecord({ + name: 'Agent wallet', + network: 'mainnet', + address: baseAddress, + ownerAddress: DEFAULT_AGENTIC_COLLECTION_ADDRESS, + limits: LIMITS, + limitsHash: 'hash-1', + jettonWallets: FORWARD_MAP, + }); + const config = upsertWallet(createEmptyConfig(), agent); + + const result = updateAgenticWalletLimits(config, agent.id, LIMITS, 'hash-1', { ...FORWARD_MAP }); + + expect(result).toBe(config); + }); + + it('updateAgenticWalletLimits clears limits, hash, and forward map when all are undefined', () => { + const agent = createAgenticWalletRecord({ + name: 'Agent wallet', + network: 'mainnet', + address: baseAddress, + ownerAddress: DEFAULT_AGENTIC_COLLECTION_ADDRESS, + limits: LIMITS, + limitsHash: 'hash-1', + jettonWallets: FORWARD_MAP, + }); + const config = upsertWallet(createEmptyConfig(), agent); + + const cleared = updateAgenticWalletLimits(config, agent.id, undefined, undefined, undefined); + + const wallet = cleared.wallets.find((w) => w.id === agent.id)!; + expect(wallet).not.toHaveProperty('limits'); + expect(wallet).not.toHaveProperty('limits_hash'); + expect(wallet).not.toHaveProperty('jetton_wallets'); + }); }); describe('mcp config registry compatibility with real CLI config', () => { diff --git a/packages/mcp/src/__tests__/limits.spec.ts b/packages/mcp/src/__tests__/limits.spec.ts index 4d842ab9a..366779ece 100644 --- a/packages/mcp/src/__tests__/limits.spec.ts +++ b/packages/mcp/src/__tests__/limits.spec.ts @@ -6,8 +6,9 @@ * */ -import { Address, beginCell } from '@ton/core'; -import type { ApiClient, Base64String, Hex, Transaction } from '@ton/walletkit'; +import { Address, Dictionary, beginCell } from '@ton/core'; +import type { Cell } from '@ton/core'; +import type { Base64String, Hex, Transaction } from '@ton/walletkit'; import { describe, expect, it, vi } from 'vitest'; import { @@ -29,12 +30,15 @@ import { maxConfiguredWindow, maxRelevantWindow, } from '../limits/enforce.js'; -import type { LimitsEnv } from '../limits/enforce.js'; -import { getJettonWalletInfoFromClient, parseJettonOutflowAmount } from '../limits/jetton.js'; -import { sumSpendWithinWindow, transactionsToSpend } from '../limits/spend-window.js'; -import type { LimitsDict, PendingSpend, SpendEntry } from '../limits/types.js'; +import type { LimitsCache, LimitsEnv } from '../limits/enforce.js'; +import { parseJettonOutflowAmount } from '../limits/jetton.js'; +import { buildPendingSpend, buildReverseJettonMap } from '../limits/pending.js'; +import type { SpendMessage } from '../limits/pending.js'; +import { resolveJettonProbes, sumSpendWithinWindow, transactionsToSpend } from '../limits/spend-window.js'; +import type { JettonSpendProbe, LimitsDict, PendingSpend, SpendEntry } from '../limits/types.js'; import type { StoredLimits } from '../registry/config.js'; import { McpWalletService } from '../services/McpWalletService.js'; +import { onchainMetadataKey } from '../utils/tep64.js'; const SENTINEL = new Address(0, Buffer.alloc(32)); const JETTON = new Address(0, Buffer.alloc(32, 7)); @@ -127,6 +131,11 @@ function ton(amount: bigint): PendingSpend { return { ton: amount, jettons: new Map() }; } +/** A single TON-only message carrying `amount` nanotons, for the message-based enforcement path. */ +function tonMessages(amount: bigint): SpendMessage[] { + return [{ address: RECIPIENT, amount: amount.toString() }]; +} + describe('findLimitViolation', () => { it('blocks a transaction over the per-transaction (window 0) cap', () => { const v = findLimitViolation(LIMITS, ton(6n), [], NOW); @@ -198,70 +207,44 @@ describe('evaluateLimits', () => { readOnchainLimitsHash: async () => 'hash-1', readCache: () => ({}), writeCache: async () => {}, - syncLimitsFromChain: async () => ({ limits: LIMITS, hash: 'hash-1' }), + syncLimitsFromChain: async () => ({ limits: LIMITS, hash: 'hash-1', jettonWallets: {} }), fetchSpendEntries: async () => [], ...overrides, }; } - it('allows and clears a stale cache when the wallet has no on-chain limits', async () => { - const writeCache = vi.fn(async () => {}); + it('allows when the wallet has no on-chain limits (none -> allowed)', async () => { const decision = await evaluateLimits( env({ readOnchainLimitsHash: async () => undefined, readCache: () => ({ limits_hash: 'old' }), - writeCache, }), - ton(1n), + tonMessages(1n), ); expect(decision.allowed).toBe(true); - expect(writeCache).toHaveBeenCalledWith({}); }); it('does not write when there is no on-chain hash and no cache to clear', async () => { const writeCache = vi.fn(async () => {}); - await evaluateLimits(env({ readOnchainLimitsHash: async () => undefined, writeCache }), ton(1n)); + await evaluateLimits(env({ readOnchainLimitsHash: async () => undefined, writeCache }), tonMessages(1n)); expect(writeCache).not.toHaveBeenCalled(); }); - it('uses the cache without re-syncing when the hash matches', async () => { - const syncLimitsFromChain = vi.fn(async () => ({ limits: LIMITS, hash: 'hash-1' })); - const decision = await evaluateLimits( - env({ readCache: () => ({ limits: LIMITS, limits_hash: 'hash-1' }), syncLimitsFromChain }), - ton(1n), - ); - expect(decision.allowed).toBe(true); - expect(syncLimitsFromChain).not.toHaveBeenCalled(); - }); - - it('re-syncs and persists when the on-chain hash differs from the cache', async () => { - const writeCache = vi.fn(async () => {}); - const decision = await evaluateLimits(env({ readCache: () => ({}), writeCache }), ton(1n)); - expect(decision.allowed).toBe(true); - expect(writeCache).toHaveBeenCalledWith({ limits: LIMITS, limits_hash: 'hash-1' }); - }); - it('refuses to send when no limits-change transaction can be found', async () => { - const decision = await evaluateLimits(env({ syncLimitsFromChain: async () => null }), ton(1n)); + const decision = await evaluateLimits(env({ syncLimitsFromChain: async () => null }), tonMessages(1n)); expect(decision).toMatchObject({ allowed: false }); expect(decision.allowed === false && decision.message).toContain('no limits-change'); }); - it('refuses to send when the synced hash does not match the on-chain hash', async () => { - const decision = await evaluateLimits( - env({ syncLimitsFromChain: async () => ({ limits: LIMITS, hash: 'other' }) }), - ton(1n), - ); - expect(decision).toMatchObject({ allowed: false }); - expect(decision.allowed === false && decision.message).toContain('does not match'); - }); - it('skips the history fetch when only a per-transaction limit applies', async () => { const fetchSpendEntries = vi.fn(async () => []); const perTxOnly: StoredLimits = { assets: { [TON_ASSET_KEY]: { windows: { '0': '5' } } } }; await evaluateLimits( - env({ syncLimitsFromChain: async () => ({ limits: perTxOnly, hash: 'hash-1' }), fetchSpendEntries }), - ton(1n), + env({ + syncLimitsFromChain: async () => ({ limits: perTxOnly, hash: 'hash-1', jettonWallets: {} }), + fetchSpendEntries, + }), + tonMessages(1n), ); expect(fetchSpendEntries).not.toHaveBeenCalled(); }); @@ -274,26 +257,57 @@ describe('evaluateLimits', () => { throw new Error('rpc down'); }, }), - ton(1n), + tonMessages(1n), ), ).rejects.toThrow('rpc down'); }); + it('meters a configured jetton via the cached forward map with no extra resolution', async () => { + const fetchSpendEntries = vi.fn(async () => []); + const decision = await evaluateLimits( + env({ + readCache: () => ({ + limits: LIMITS, + limits_hash: 'hash-1', + jetton_wallets: { [JETTON.toString()]: JETTON_WALLET }, + }), + fetchSpendEntries, + }), + [{ address: JETTON_WALLET, amount: '0', payload: jettonTransferPayload(2000n) }], + ); + // JETTON cap is 1000 over 86400s; a 2000 transfer breaches it. + expect(decision).toMatchObject({ allowed: false }); + expect(decision.allowed === false && decision.message).toContain('spend limit'); + expect(fetchSpendEntries).toHaveBeenCalledWith(86400, NOW, expect.any(Map)); + }); + + it('ignores a jetton transfer whose wallet is not in the forward map (unlimited jetton)', async () => { + const decision = await evaluateLimits( + env({ + readCache: () => ({ limits: LIMITS, limits_hash: 'hash-1', jetton_wallets: {} }), + }), + [{ address: JETTON_WALLET, amount: '0', payload: jettonTransferPayload(10n ** 18n) }], + ); + expect(decision.allowed).toBe(true); + }); + it('blocks an over-limit spend with a descriptive message', async () => { - const decision = await evaluateLimits(env({}), ton(6n)); + const decision = await evaluateLimits(env({}), tonMessages(6n)); expect(decision).toMatchObject({ allowed: false }); expect(decision.allowed === false && decision.message).toContain('spend limit'); }); }); describe('loadActiveLimits', () => { + const FORWARD_MAP = { [JETTON.toString()]: JETTON_WALLET }; + function env(overrides: Partial): LimitsEnv { return { now: () => NOW, readOnchainLimitsHash: async () => 'hash-1', readCache: () => ({}), writeCache: async () => {}, - syncLimitsFromChain: async () => ({ limits: LIMITS, hash: 'hash-1' }), + syncLimitsFromChain: async () => ({ limits: LIMITS, hash: 'hash-1', jettonWallets: FORWARD_MAP }), fetchSpendEntries: async () => [], ...overrides, }; @@ -312,20 +326,55 @@ describe('loadActiveLimits', () => { expect(writeCache).toHaveBeenCalledWith({}); }); - it('returns the cached limits without re-syncing when the hash matches', async () => { - const syncLimitsFromChain = vi.fn(async () => ({ limits: LIMITS, hash: 'hash-1' })); + it('returns the cached limits and forward map without re-syncing when both are present', async () => { + const syncLimitsFromChain = vi.fn(async () => ({ limits: LIMITS, hash: 'hash-1', jettonWallets: FORWARD_MAP })); const loaded = await loadActiveLimits( - env({ readCache: () => ({ limits: LIMITS, limits_hash: 'hash-1' }), syncLimitsFromChain }), + env({ + readCache: () => ({ limits: LIMITS, limits_hash: 'hash-1', jetton_wallets: FORWARD_MAP }), + syncLimitsFromChain, + }), ); - expect(loaded).toEqual({ status: 'active', limits: LIMITS, hash: 'hash-1' }); + expect(loaded).toEqual({ status: 'active', limits: LIMITS, hash: 'hash-1', jettonWallets: FORWARD_MAP }); expect(syncLimitsFromChain).not.toHaveBeenCalled(); }); - it('re-syncs and persists when the cache is stale', async () => { + it('re-syncs and persists limits, hash, and forward map when the cache is stale', async () => { const writeCache = vi.fn(async () => {}); const loaded = await loadActiveLimits(env({ readCache: () => ({}), writeCache })); - expect(loaded).toEqual({ status: 'active', limits: LIMITS, hash: 'hash-1' }); - expect(writeCache).toHaveBeenCalledWith({ limits: LIMITS, limits_hash: 'hash-1' }); + expect(loaded).toEqual({ status: 'active', limits: LIMITS, hash: 'hash-1', jettonWallets: FORWARD_MAP }); + expect(writeCache).toHaveBeenCalledWith({ limits: LIMITS, limits_hash: 'hash-1', jetton_wallets: FORWARD_MAP }); + }); + + it('treats a legacy cache that has limits but no forward map as a miss and re-syncs', async () => { + const writeCache = vi.fn(async () => {}); + const syncLimitsFromChain = vi.fn(async () => ({ limits: LIMITS, hash: 'hash-1', jettonWallets: FORWARD_MAP })); + const loaded = await loadActiveLimits( + env({ + // Same hash as on-chain, but no jetton_wallets (pre-forward-map record). + readCache: () => ({ limits: LIMITS, limits_hash: 'hash-1' }), + writeCache, + syncLimitsFromChain, + }), + ); + expect(syncLimitsFromChain).toHaveBeenCalledOnce(); + expect(loaded).toEqual({ status: 'active', limits: LIMITS, hash: 'hash-1', jettonWallets: FORWARD_MAP }); + expect(writeCache).toHaveBeenCalledWith({ limits: LIMITS, limits_hash: 'hash-1', jetton_wallets: FORWARD_MAP }); + }); + + it('does not persist a partial cache when the forward-map sync throws', async () => { + const writeCache = vi.fn(async () => {}); + await expect( + loadActiveLimits( + env({ + readCache: () => ({}), + writeCache, + syncLimitsFromChain: async () => { + throw new Error('jetton-wallet unresolved'); + }, + }), + ), + ).rejects.toThrow('jetton-wallet unresolved'); + expect(writeCache).not.toHaveBeenCalled(); }); it('reports an error when no limits-change transaction can be found', async () => { @@ -336,7 +385,7 @@ describe('loadActiveLimits', () => { it('reports an error when the synced hash does not match the on-chain hash', async () => { const loaded = await loadActiveLimits( - env({ syncLimitsFromChain: async () => ({ limits: LIMITS, hash: 'other' }) }), + env({ syncLimitsFromChain: async () => ({ limits: LIMITS, hash: 'other', jettonWallets: FORWARD_MAP }) }), ); expect(loaded.status).toBe('error'); expect(loaded.status === 'error' && loaded.message).toContain('does not match'); @@ -394,24 +443,18 @@ describe('computeLimitsUsage', () => { }); // ------------------------------------------------------------------------------------------------- -// limits-jetton (parseJettonOutflowAmount / getJettonWalletInfoFromClient) +// limits-jetton (parseJettonOutflowAmount) // ------------------------------------------------------------------------------------------------- -const OWNER = new Address(0, Buffer.alloc(32, 1)); -const MASTER = new Address(0, Buffer.alloc(32, 9)); - function opBody(op: number, amount: bigint): string { return beginCell().storeUint(op, 32).storeUint(0n, 64).storeCoins(amount).endCell().toBoc().toString('base64'); } +/** TVM stack item carrying an address as a serialized cell, as `runGetMethod` returns it. */ function addressStackItem(address: Address) { return { type: 'cell' as const, value: beginCell().storeAddress(address).endCell().toBoc().toString('base64') }; } -function clientWithRunGetMethod(impl: ApiClient['runGetMethod']): ApiClient { - return { runGetMethod: impl } as unknown as ApiClient; -} - describe('parseJettonOutflowAmount', () => { it('reads the amount from a transfer or burn op', () => { expect(parseJettonOutflowAmount(opBody(JETTON_TRANSFER_OP, 1234n))).toBe(1234n); @@ -426,33 +469,53 @@ describe('parseJettonOutflowAmount', () => { }); }); -describe('getJettonWalletInfoFromClient', () => { - it('resolves owner and master from a successful get_wallet_data', async () => { - const client = clientWithRunGetMethod( - vi.fn(async () => ({ - gasUsed: 0, - exitCode: 0, - stack: [{ type: 'num' as const, value: '100' }, addressStackItem(OWNER), addressStackItem(MASTER)], - })), +// ------------------------------------------------------------------------------------------------- +// pending-spend forward map (buildReverseJettonMap / buildPendingSpend / resolveJettonProbes) +// ------------------------------------------------------------------------------------------------- + +describe('forward jetton-wallet map metering', () => { + const reverse = buildReverseJettonMap({ [JETTON.toString()]: JETTON_WALLET }); + + it('maps our jetton-wallet address to the normalized master key', () => { + expect(reverse.get(normalizeAssetKey(JETTON_WALLET)!)).toBe(JETTON_KEY); + }); + + it('meters a configured jetton transfer against its master with no resolution call', () => { + const spend = buildPendingSpend( + [{ address: JETTON_WALLET, amount: '0', payload: jettonTransferPayload(600n) }], + reverse, ); - await expect(getJettonWalletInfoFromClient(client, OWNER.toString())).resolves.toEqual({ - owner: OWNER.toString(), - master: MASTER.toString(), - }); + expect(spend.ton).toBe(0n); + expect(spend.jettons).toEqual(new Map([[JETTON_KEY, 600n]])); }); - it('returns null when get_wallet_data ran but exited non-zero (not a jetton wallet)', async () => { - const client = clientWithRunGetMethod(vi.fn(async () => ({ gasUsed: 0, exitCode: -13, stack: [] }))); - await expect(getJettonWalletInfoFromClient(client, OWNER.toString())).resolves.toBeNull(); + it('sums TON and jetton outflows across messages, aggregating per master', () => { + const spend = buildPendingSpend( + [ + { address: RECIPIENT, amount: '1000' }, + { address: JETTON_WALLET, amount: '50', payload: jettonTransferPayload(200n) }, + { address: JETTON_WALLET, amount: '50', payload: jettonTransferPayload(300n) }, + ], + reverse, + ); + expect(spend.ton).toBe(1100n); + expect(spend.jettons).toEqual(new Map([[JETTON_KEY, 500n]])); }); - it('propagates (fails closed) when the get_wallet_data call itself throws', async () => { - const client = clientWithRunGetMethod( - vi.fn(async () => { - throw new Error('429 Too Many Requests'); - }), + it('ignores a jetton transfer to a wallet outside the forward map (unlimited jetton)', () => { + const spend = buildPendingSpend( + [{ address: OTHER, amount: '0', payload: jettonTransferPayload(10n ** 18n) }], + reverse, ); - await expect(getJettonWalletInfoFromClient(client, OWNER.toString())).rejects.toThrow('429'); + expect(spend.jettons.size).toBe(0); + }); + + it('resolves history probes through the same map and drops unknown wallets', () => { + const probes: JettonSpendProbe[] = [ + { timestamp: 100, jettonWalletAddress: JETTON_WALLET, amount: 400n }, + { timestamp: 200, jettonWalletAddress: OTHER, amount: 999n }, + ]; + expect(resolveJettonProbes(probes, reverse)).toEqual([{ timestamp: 100, asset: JETTON_KEY, amount: 400n }]); }); }); @@ -604,14 +667,73 @@ interface FakeWallet { createTransferTonTransaction?: ReturnType; } -function makeService(wallet: FakeWallet): McpWalletService { +function makeService(wallet: FakeWallet, limitsCache: LimitsCache | null = null): McpWalletService { const service = Object.create(McpWalletService.prototype) as McpWalletService; Object.defineProperty(service, 'wallet', { value: wallet, configurable: true }); Object.defineProperty(service, 'config', { value: {}, configurable: true }); - Object.defineProperty(service, 'limitsCache', { value: null, configurable: true }); + Object.defineProperty(service, 'limitsCache', { value: limitsCache, configurable: true }); return service; } +// A jetton-only, per-transaction cap of 1000; its dict and canonical hash anchor the +// account state (on-chain limits_hash) and the limits-change transaction the sync reads. +const SERVICE_LIMITS: StoredLimits = { assets: { [JETTON.toString()]: { windows: { '0': '1000' } } } }; +const SERVICE_LIMITS_DICT = storedToLimitsDict(SERVICE_LIMITS); +const SERVICE_LIMITS_HASH = computeLimitsHash(SERVICE_LIMITS_DICT); +const SERVICE_FORWARD_MAP = { [JETTON.toString()]: JETTON_WALLET }; + +// A jetton rolling-window cap (1000 per day). Unlike the per-transaction SERVICE_LIMITS +// above, this opens a real spend window, so enforcement must page history and resolve the +// recovered jetton outflows through the cached forward map. +const ROLLING_LIMITS: StoredLimits = { assets: { [JETTON.toString()]: { windows: { '86400': '1000' } } } }; +const ROLLING_LIMITS_HASH = computeLimitsHash(storedToLimitsDict(ROLLING_LIMITS)); + +/** A TEP-64 onchain content cell carrying a single `limits_hash` attribute. */ +function limitsHashContent(limitsHashHex: string): Cell { + const dict = Dictionary.empty(Dictionary.Keys.BigUint(256), Dictionary.Values.Cell()); + dict.set( + onchainMetadataKey('limits_hash'), + beginCell().storeUint(0x00, 8).storeStringTail(limitsHashHex).endCell(), + ); + return beginCell().storeUint(0x00, 8).storeDict(dict).endCell(); +} + +/** A minimal agentic-wallet account state whose nft content advertises `limitsHashHex`. */ +function agenticAccountState(limitsHashHex: string): { data: string } { + const walletData = beginCell() + .storeAddress(Address.parse(WALLET)) + .storeMaybeRef(limitsHashContent(limitsHashHex)) + .storeUint(0n, 256) + .storeUint(0n, 256) + .storeBit(false) + .endCell(); + const state = beginCell() + .storeUint(0n, 256) + .storeAddress(new Address(0, Buffer.alloc(32, 5))) + .storeBit(true) + .storeUint(0, 32) + .storeBit(false) + .storeMaybeRef(walletData) + .endCell(); + return { data: state.toBoc().toString('base64') }; +} + +/** A getAccountTransactions response carrying one limits-change tx for `dict`. */ +function limitsChangeResponse(dict: LimitsDict) { + const body = changeContentBody(dict).toBoc().toString('base64'); + return { transactions: [{ inMessage: { messageContent: { body } } }] }; +} + +/** A runGetMethod that answers `get_wallet_address` with `walletAddress`. */ +function walletAddressGetMethod(walletAddress: Address) { + return vi.fn(async (_address: string, method: string) => { + if (method === 'get_wallet_address') { + return { gasUsed: 0, exitCode: 0, stack: [addressStackItem(walletAddress)] }; + } + throw new Error(`unexpected get-method: ${method}`); + }); +} + describe('McpWalletService send choke point', () => { it('skips limit enforcement for non-agentic wallets', async () => { const sendTransaction = vi.fn().mockResolvedValue({ normalizedHash: 'hash' }); @@ -655,22 +777,176 @@ describe('McpWalletService send choke point', () => { expect(sendTransaction).not.toHaveBeenCalled(); }); - it('fails closed when a pending jetton wallet cannot be resolved (no silent TON-only metering)', async () => { + it('fails closed all-or-nothing when a jetton master wallet cannot be resolved at sync', async () => { const sendTransaction = vi.fn(); + const write = vi.fn(async () => {}); const runGetMethod = vi.fn().mockRejectedValue(new Error('429 Too Many Requests')); + const service = makeService( + { + version: 'agentic', + getAddress: () => WALLET, + getClient: () => ({ + getAccountState: vi.fn().mockResolvedValue(agenticAccountState(SERVICE_LIMITS_HASH)), + getAccountTransactions: vi.fn().mockResolvedValue(limitsChangeResponse(SERVICE_LIMITS_DICT)), + runGetMethod, + }), + sendTransaction, + }, + { read: () => ({}), write }, + ); + + const result = await service.sendRawTransaction({ + messages: [{ address: JETTON_WALLET, amount: '50000000', payload: jettonTransferPayload(500n) }], + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('Could not verify spend limits'); + expect(sendTransaction).not.toHaveBeenCalled(); + // No partial persist: a half-computed forward map must never reach the cache. + expect(write).not.toHaveBeenCalled(); + }); + + it('meters a configured jetton via the synced forward map without any get_wallet_data call', async () => { + const sendTransaction = vi.fn(); + const runGetMethod = walletAddressGetMethod(new Address(0, Buffer.alloc(32, 3))); + const getAccountTransactions = vi.fn().mockResolvedValue(limitsChangeResponse(SERVICE_LIMITS_DICT)); const service = makeService({ version: 'agentic', getAddress: () => WALLET, - getClient: () => ({ runGetMethod, getAccountState: vi.fn() }), + getClient: () => ({ + getAccountState: vi.fn().mockResolvedValue(agenticAccountState(SERVICE_LIMITS_HASH)), + getAccountTransactions, + runGetMethod, + }), sendTransaction, }); + // 2000 > the per-transaction cap of 1000 -> blocked by the jetton limit. + const result = await service.sendRawTransaction({ + messages: [{ address: JETTON_WALLET, amount: '50000000', payload: jettonTransferPayload(2000n) }], + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('spend limit'); + expect(sendTransaction).not.toHaveBeenCalled(); + // Per-transaction cap -> no rolling window -> no history paging beyond the sync read. + // (The walletAddressGetMethod mock throws on any method but get_wallet_address, so + // reaching here already proves no get_wallet_data was issued.) + expect(getAccountTransactions).toHaveBeenCalledOnce(); + }); + + it('allows a within-cap jetton spend metered through the forward map', async () => { + const sendTransaction = vi.fn().mockResolvedValue({ normalizedHash: 'ok' }); + const service = makeService({ + version: 'agentic', + getAddress: () => WALLET, + getClient: () => ({ + getAccountState: vi.fn().mockResolvedValue(agenticAccountState(SERVICE_LIMITS_HASH)), + getAccountTransactions: vi.fn().mockResolvedValue(limitsChangeResponse(SERVICE_LIMITS_DICT)), + runGetMethod: walletAddressGetMethod(new Address(0, Buffer.alloc(32, 3))), + }), + sendTransaction, + }); + + const result = await service.sendRawTransaction({ + messages: [{ address: JETTON_WALLET, amount: '50000000', payload: jettonTransferPayload(500n) }], + }); + + expect(result.success).toBe(true); + expect(sendTransaction).toHaveBeenCalledOnce(); + }); + + it('meters from the cached forward map on a hash hit with zero metering RPC', async () => { + const sendTransaction = vi.fn().mockResolvedValue({ normalizedHash: 'ok' }); + const getAccountTransactions = vi.fn(); + const runGetMethod = vi.fn(); + const service = makeService( + { + version: 'agentic', + getAddress: () => WALLET, + getClient: () => ({ + getAccountState: vi.fn().mockResolvedValue(agenticAccountState(SERVICE_LIMITS_HASH)), + getAccountTransactions, + runGetMethod, + }), + sendTransaction, + }, + { + read: () => ({ + limits: SERVICE_LIMITS, + limits_hash: SERVICE_LIMITS_HASH, + jetton_wallets: SERVICE_FORWARD_MAP, + }), + write: vi.fn(async () => {}), + }, + ); + + const result = await service.sendRawTransaction({ + messages: [{ address: JETTON_WALLET, amount: '50000000', payload: jettonTransferPayload(500n) }], + }); + + expect(result.success).toBe(true); + expect(sendTransaction).toHaveBeenCalledOnce(); + // Cache hit -> no re-sync and no get_wallet_address/get_wallet_data at metering time. + expect(getAccountTransactions).not.toHaveBeenCalled(); + expect(runGetMethod).not.toHaveBeenCalled(); + }); + + it('blocks a jetton send when rolling-window history resolved via the cached map tips it over the cap', async () => { + const sendTransaction = vi.fn(); + const runGetMethod = vi.fn(); + // A prior outgoing jetton transfer of 700 sitting inside the 1-day window, recovered + // from account history. `now` is read from the same real clock the service uses, so + // `now - 100` is comfortably within the 86400s window with sub-second test skew. + const nowSeconds = Math.floor(Date.now() / 1000); + const history = { + transactions: [ + makeTx({ + now: nowSeconds - 100, + outs: [ + { + source: WALLET, + destination: JETTON_WALLET, + value: '50000000', + body: jettonTransferBody(700n), + }, + ], + }), + ], + }; + const getAccountTransactions = vi.fn().mockResolvedValue(history); + const service = makeService( + { + version: 'agentic', + getAddress: () => WALLET, + getClient: () => ({ + getAccountState: vi.fn().mockResolvedValue(agenticAccountState(ROLLING_LIMITS_HASH)), + getAccountTransactions, + runGetMethod, + }), + sendTransaction, + }, + { + read: () => ({ + limits: ROLLING_LIMITS, + limits_hash: ROLLING_LIMITS_HASH, + jetton_wallets: SERVICE_FORWARD_MAP, + }), + write: vi.fn(async () => {}), + }, + ); + + // 700 already spent in-window + 500 pending = 1200 > the 1000 daily cap. const result = await service.sendRawTransaction({ messages: [{ address: JETTON_WALLET, amount: '50000000', payload: jettonTransferPayload(500n) }], }); expect(result.success).toBe(false); - expect(result.message).toContain('Could not verify spend limits'); + expect(result.message).toContain('spend limit'); expect(sendTransaction).not.toHaveBeenCalled(); + // The whole history->master->window->violation seam ran through the cached forward + // map: history was paged, but no jetton-wallet RPC (get_wallet_address/get_wallet_data). + expect(getAccountTransactions).toHaveBeenCalled(); + expect(runGetMethod).not.toHaveBeenCalled(); }); }); diff --git a/packages/mcp/src/limits/enforce.ts b/packages/mcp/src/limits/enforce.ts index 592b1c1b0..9f0cee0a8 100644 --- a/packages/mcp/src/limits/enforce.ts +++ b/packages/mcp/src/limits/enforce.ts @@ -16,6 +16,8 @@ import type { StoredLimits } from '../registry/config.js'; import { TON_ASSET_KEY, normalizeAssetKey } from './limits-codec.js'; +import { buildPendingSpend, buildReverseJettonMap } from './pending.js'; +import type { SpendMessage } from './pending.js'; import { sumSpendWithinWindow } from './spend-window.js'; import type { PendingSpend, SpendEntry } from './types.js'; @@ -193,6 +195,12 @@ function humanizeWindow(seconds: number): string { export interface CachedLimits { limits?: StoredLimits; limits_hash?: string; + /** + * Forward map `masterDisplayKey -> our normalized jetton-wallet address`, + * computed once per `limits_hash`. Absent on a legacy cache (pre-forward-map), + * which {@link loadActiveLimits} treats as a miss and re-syncs. + */ + jetton_wallets?: Record; } /** Read-through/write-through port over a single wallet's cached limits. */ @@ -205,6 +213,12 @@ export interface LimitsCache { export interface SyncedLimits { limits: StoredLimits; hash: string; + /** + * Forward map keyed by the master display-key exactly as it appears in + * `limits.assets` (the TON sentinel excluded), valued by our normalized + * jetton-wallet address for that master. Computed all-or-nothing at sync. + */ + jettonWallets: Record; } /** IO surface the enforcement flow depends on; implemented by the wallet service. */ @@ -219,10 +233,15 @@ export interface LimitsEnv { syncLimitsFromChain(): Promise; /** * Outgoing spend entries within `[now - maxWindowSeconds, now]`. Receives the - * same `now` the check uses so the fetch cutoff and the window math agree. + * same `now` the check uses so the fetch cutoff and the window math agree, plus + * the reverse jetton map used to resolve jetton outflows with no extra RPC. * Must throw rather than return a truncated history (fail closed). */ - fetchSpendEntries(maxWindowSeconds: number, now: number): Promise; + fetchSpendEntries( + maxWindowSeconds: number, + now: number, + reverseJettonMap: Map, + ): Promise; } export type LimitsDecision = { allowed: true } | { allowed: false; message: string }; @@ -230,13 +249,17 @@ export type LimitsDecision = { allowed: true } | { allowed: false; message: stri /** Active limits resolved against the on-chain hash: none set, verified, or unverifiable. */ export type LoadedLimits = | { status: 'none' } - | { status: 'active'; limits: StoredLimits; hash: string } + | { status: 'active'; limits: StoredLimits; hash: string; jettonWallets: Record } | { status: 'error'; message: string }; /** * Resolve a wallet's active limits without metering spend (shared by enforcement and the - * read-only query): no on-chain hash -> `none` and clear stale cache; hash matches cache -> - * use it; else re-sync from chain and persist; an unverifiable hash -> `error`. + * read-only query): no on-chain hash -> `none` and clear stale cache; hash matches cache + * (and the cache carries the forward jetton map) -> use it; else re-sync from chain and + * persist limits + hash + jetton map together; an unverifiable hash -> `error`. + * + * A cache holding `limits` but no `jetton_wallets` is a legacy entry from before the + * forward map existed; it is treated as a miss so the map is recomputed and persisted. */ export async function loadActiveLimits(env: LimitsEnv): Promise { const onchainHash = await env.readOnchainLimitsHash(); @@ -250,8 +273,8 @@ export async function loadActiveLimits(env: LimitsEnv): Promise { } const cached = env.readCache(); - if (cached.limits && cached.limits_hash === onchainHash) { - return { status: 'active', limits: cached.limits, hash: onchainHash }; + if (cached.limits && cached.limits_hash === onchainHash && cached.jetton_wallets) { + return { status: 'active', limits: cached.limits, hash: onchainHash, jettonWallets: cached.jetton_wallets }; } const synced = await env.syncLimitsFromChain(); @@ -271,15 +294,17 @@ export async function loadActiveLimits(env: LimitsEnv): Promise { `limits-change transaction (${synced.hash}); refusing to send.`, }; } - await env.writeCache({ limits: synced.limits, limits_hash: onchainHash }); - return { status: 'active', limits: synced.limits, hash: onchainHash }; + await env.writeCache({ limits: synced.limits, limits_hash: onchainHash, jetton_wallets: synced.jettonWallets }); + return { status: 'active', limits: synced.limits, hash: onchainHash, jettonWallets: synced.jettonWallets }; } /** - * Decide whether a pending transaction may broadcast: resolve the active limits, - * then measure spend over the largest relevant window and apply the per-asset checks. + * Decide whether a pending transaction may broadcast. Resolves the active limits + * first, derives the reverse jetton map from the cached forward map, then meters + * both the pending spend and the spend history against it (a pure in-memory lookup, + * no per-send RPC), and applies the per-asset checks over the largest relevant window. */ -export async function evaluateLimits(env: LimitsEnv, spend: PendingSpend): Promise { +export async function evaluateLimits(env: LimitsEnv, messages: readonly SpendMessage[]): Promise { const loaded = await loadActiveLimits(env); if (loaded.status === 'none') { return { allowed: true }; @@ -287,12 +312,14 @@ export async function evaluateLimits(env: LimitsEnv, spend: PendingSpend): Promi if (loaded.status === 'error') { return { allowed: false, message: loaded.message }; } - const { limits } = loaded; + const { limits, jettonWallets } = loaded; + const reverseJettonMap = buildReverseJettonMap(jettonWallets); + const spend = buildPendingSpend(messages, reverseJettonMap); // Capture `now` once so the history-fetch cutoff and the window math share a boundary. const now = env.now(); const maxWindow = maxRelevantWindow(limits, spend); - const spendEntries = maxWindow > 0 ? await env.fetchSpendEntries(maxWindow, now) : []; + const spendEntries = maxWindow > 0 ? await env.fetchSpendEntries(maxWindow, now, reverseJettonMap) : []; const violation = findLimitViolation(limits, spend, spendEntries, now); return violation ? { allowed: false, message: formatLimitViolation(violation) } : { allowed: true }; } diff --git a/packages/mcp/src/limits/jetton.ts b/packages/mcp/src/limits/jetton.ts index 29f206d8a..bf9de47ee 100644 --- a/packages/mcp/src/limits/jetton.ts +++ b/packages/mcp/src/limits/jetton.ts @@ -7,8 +7,6 @@ */ 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. @@ -38,45 +36,3 @@ export function parseJettonOutflowAmount(payloadBase64: string | null | undefine return null; } } - -export interface JettonWalletInfo { - owner: string; - master: string; -} - -/** - * Resolve a jetton wallet's (owner, master) via `get_wallet_data`. - * - * Returns `null` only when `get_wallet_data` *ran* but the address is not a usable - * jetton wallet (non-zero exit, or a stack that does not decode to owner+master) — - * a definitive "not owned by us" answer the caller may safely drop. An RPC failure - * (network error, timeout, 4xx/5xx) is *not* swallowed: it propagates so spend - * metering fails closed rather than silently under-counting an unresolvable wallet. - */ -export async function getJettonWalletInfoFromClient( - client: ApiClient, - jettonWalletAddress: string, -): Promise { - const result = await client.runGetMethod(jettonWalletAddress, 'get_wallet_data'); - if (result.exitCode !== 0) { - return null; - } - try { - 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/packages/mcp/src/limits/pending.ts b/packages/mcp/src/limits/pending.ts new file mode 100644 index 000000000..17f177d76 --- /dev/null +++ b/packages/mcp/src/limits/pending.ts @@ -0,0 +1,95 @@ +/** + * 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. + * + */ + +/** + * Pure, RPC-free metering of what a not-yet-broadcast transaction will spend. + * + * Jetton resolution uses a forward map computed once at limits-sync time: our + * jetton-wallet address for each configured master. {@link buildReverseJettonMap} + * inverts it to `our-jetton-wallet -> master`, so {@link buildPendingSpend} can + * resolve a transfer's destination to its master with an in-memory lookup instead + * of a per-send `get_wallet_data` call. + */ + +import { normalizeAddressForComparison } from '../utils/address.js'; +import { normalizeAssetKey } from './limits-codec.js'; +import { parseJettonOutflowAmount } from './jetton.js'; +import type { PendingSpend } from './types.js'; + +/** + * Minimal view of an outgoing transaction message the spend meter reads: the + * destination address, the attached TON `amount`, and the optional `payload` + * (a TEP-74 transfer/burn body for jetton sends). Structurally a superset-safe + * subset of the wallet `TransactionRequestMessage`. + */ +export interface SpendMessage { + address: string; + amount?: string; + payload?: string | null; +} + +/** Parse a message `amount` (nanotons) into a non-negative bigint; 0 on absence/garbage. */ +function parseMessageAmount(amount: string | undefined): bigint { + if (!amount) { + return 0n; + } + try { + const value = BigInt(amount); + return value > 0n ? value : 0n; + } catch { + return 0n; + } +} + +/** + * Invert the cached forward map (`masterDisplayKey -> our jetton-wallet`) into a + * lookup keyed by the normalized jetton-wallet address, valued by the normalized + * master key. The master value matches `normalizeAssetKey(displayKey)`, the same + * key `indexLimits`/`affectedAssets` use, so resolved spend lines up with the + * configured caps. Entries with an unparseable address or master key are dropped. + */ +export function buildReverseJettonMap(jettonWallets: Record): Map { + const reverse = new Map(); + for (const [masterDisplayKey, walletAddress] of Object.entries(jettonWallets)) { + const wallet = normalizeAddressForComparison(walletAddress); + const master = normalizeAssetKey(masterDisplayKey); + if (wallet && master) { + reverse.set(wallet, master); + } + } + return reverse; +} + +/** + * Sum the TON and (master-resolved) jetton outflows a pending request will spend. + * Pure and synchronous: a jetton transfer is metered only when its destination is + * one of our own jetton-wallets in {@link buildReverseJettonMap}; transfers to any + * other wallet target an unconfigured/unlimited jetton and are ignored. + */ +export function buildPendingSpend( + messages: readonly SpendMessage[], + reverseJettonMap: Map, +): PendingSpend { + let ton = 0n; + const jettons = new Map(); + for (const message of messages) { + const amount = parseMessageAmount(message.amount); + if (amount > 0n) { + ton += amount; + } + const outflow = parseJettonOutflowAmount(message.payload); + if (outflow && outflow > 0n) { + const walletKey = normalizeAddressForComparison(message.address); + const master = walletKey ? reverseJettonMap.get(walletKey) : undefined; + if (master) { + jettons.set(master, (jettons.get(master) ?? 0n) + outflow); + } + } + } + return { ton, jettons }; +} diff --git a/packages/mcp/src/limits/spend-window.ts b/packages/mcp/src/limits/spend-window.ts index 20f52e15c..8989f3ace 100644 --- a/packages/mcp/src/limits/spend-window.ts +++ b/packages/mcp/src/limits/spend-window.ts @@ -29,9 +29,9 @@ import type { JettonSpendProbe, SpendEntry, TransactionSpend } from './types.js' * A send that later bounces is not refunded here: its outflow stays counted until * the rolling window rolls past it (conservative — it over-blocks, never bypasses). * - Jettons: every out-message carrying a TEP-74 transfer/burn op yields a probe - * (amount + jetton-wallet address); the service 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. + * (amount + jetton-wallet address); {@link resolveJettonProbes} maps the wallet to + * its master via the cached forward map 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. @@ -75,6 +75,25 @@ export function transactionsToSpend(transactions: Transaction[], walletAddress: return { tonEntries, jettonProbes }; } +/** + * Resolve unresolved jetton outflows to spend entries via the cached reverse map + * (`our-jetton-wallet -> master`). A probe whose destination is not one of our own + * jetton-wallets (no configured limit) yields no entry — the same in-memory lookup + * the pending-spend estimate uses, so history and pending meter identically with no + * `get_wallet_data` calls. + */ +export function resolveJettonProbes(probes: JettonSpendProbe[], reverseJettonMap: Map): SpendEntry[] { + const entries: SpendEntry[] = []; + for (const probe of probes) { + const walletKey = normalizeAddressForComparison(probe.jettonWalletAddress); + const master = walletKey ? reverseJettonMap.get(walletKey) : undefined; + if (master) { + entries.push({ timestamp: probe.timestamp, asset: master, amount: probe.amount }); + } + } + return entries; +} + /** 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; diff --git a/packages/mcp/src/limits/types.ts b/packages/mcp/src/limits/types.ts index f6e3b4afc..5625335dc 100644 --- a/packages/mcp/src/limits/types.ts +++ b/packages/mcp/src/limits/types.ts @@ -35,9 +35,10 @@ export interface SpendEntry { /** * 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. + * before its jetton-wallet address is resolved to a master. Resolution + * (`resolveJettonProbes`) is an in-memory lookup against the cached forward jetton + * map — no `get_wallet_data` call — keeping the transaction parser pure and + * synchronous and the history scan RPC-free beyond transaction paging. */ export interface JettonSpendProbe { /** Unix timestamp in seconds of the transaction that emitted the transfer. */ diff --git a/packages/mcp/src/registry/config.ts b/packages/mcp/src/registry/config.ts index 8881819ee..3327db64a 100644 --- a/packages/mcp/src/registry/config.ts +++ b/packages/mcp/src/registry/config.ts @@ -66,6 +66,12 @@ export interface StoredAgenticWallet extends StoredWalletBase { limits?: StoredLimits; /** Hex of the on-chain `limits_hash` the cached `limits` correspond to. */ limits_hash?: string; + /** + * Forward map `jettonMasterDisplayKey -> our normalized jetton-wallet address`, + * computed once per `limits_hash` alongside the cached limits. Lets spend metering + * resolve a jetton transfer to its master with no `get_wallet_data` call. + */ + jetton_wallets?: Record; } /** Decoded mirror of the on-chain limitsDict, JSON-friendly. */ @@ -206,6 +212,7 @@ function normalizeConfig(raw: TonConfig): TonConfig { // Carry limits explicitly so they survive normalization round-trips. ...(wallet.limits ? { limits: wallet.limits } : {}), ...(wallet.limits_hash ? { limits_hash: wallet.limits_hash } : {}), + ...(wallet.jetton_wallets ? { jetton_wallets: wallet.jetton_wallets } : {}), }; const normalizeSetupSession = (session: StoredAgenticSetupSession): StoredAgenticSetupSession => ({ ...session, @@ -607,31 +614,46 @@ export async function persistAgenticWalletNftIndex(walletId: string, walletNftIn return true; } +/** Shallow equality for the `jetton_wallets` forward map (presence-sensitive). */ +function sameJettonWallets(a: Record | undefined, b: Record | undefined): boolean { + if (a === undefined || b === undefined) { + return a === b; + } + const aKeys = Object.keys(a); + if (aKeys.length !== Object.keys(b).length) { + return false; + } + return aKeys.every((key) => a[key] === b[key]); +} + /** - * Set or clear the cached limits for an agentic wallet. Identity-keyed on - * `limits_hash`: passing the hash already stored is a no-op; passing `undefined` - * clears both `limits` and `limits_hash`. + * Set or clear the cached limits for an agentic wallet. A no-op only when both the + * `limits_hash` and the `jetton_wallets` forward map already match — so a legacy + * record carrying limits without a forward map is still upgraded in place. Passing + * `undefined` for all three clears `limits`, `limits_hash`, and `jetton_wallets`. */ export function updateAgenticWalletLimits( config: TonConfig, walletId: string, limits: StoredLimits | undefined, limitsHash: string | undefined, + jettonWallets: Record | undefined, ): TonConfig { let changed = false; const nextWallets = config.wallets.map((item) => { if (item.id !== walletId || item.type !== 'agentic' || isWalletRemoved(item)) { return item; } - if (item.limits_hash === limitsHash) { + if (item.limits_hash === limitsHash && sameJettonWallets(item.jetton_wallets, jettonWallets)) { return item; } changed = true; - const { limits: _limits, limits_hash: _limitsHash, ...rest } = item; + const { limits: _limits, limits_hash: _limitsHash, jetton_wallets: _jettonWallets, ...rest } = item; return { ...rest, ...(limits ? { limits } : {}), ...(limitsHash ? { limits_hash: limitsHash } : {}), + ...(jettonWallets ? { jetton_wallets: jettonWallets } : {}), updated_at: nowIso(), }; }); @@ -650,12 +672,13 @@ export async function persistAgenticWalletLimits( walletId: string, limits: StoredLimits | undefined, limitsHash: string | undefined, + jettonWallets: Record | undefined, ): Promise { const config = await loadConfigWithMigration(); if (!config) { return false; } - const nextConfig = updateAgenticWalletLimits(config, walletId, limits, limitsHash); + const nextConfig = updateAgenticWalletLimits(config, walletId, limits, limitsHash, jettonWallets); if (nextConfig === config) { return false; } @@ -994,6 +1017,7 @@ export function createAgenticWalletRecord(input: { deployedByUser?: boolean; limits?: StoredLimits; limitsHash?: string; + jettonWallets?: Record; idPrefix?: string; }): StoredAgenticWallet { const now = nowIso(); @@ -1015,6 +1039,7 @@ export function createAgenticWalletRecord(input: { ...(typeof input.deployedByUser === 'boolean' ? { deployed_by_user: input.deployedByUser } : {}), ...(input.limits ? { limits: input.limits } : {}), ...(input.limitsHash ? { limits_hash: input.limitsHash } : {}), + ...(input.jettonWallets ? { jetton_wallets: input.jettonWallets } : {}), created_at: now, updated_at: now, }; diff --git a/packages/mcp/src/runtime/wallet-runtime.ts b/packages/mcp/src/runtime/wallet-runtime.ts index b95fb717f..1f62fdef3 100644 --- a/packages/mcp/src/runtime/wallet-runtime.ts +++ b/packages/mcp/src/runtime/wallet-runtime.ts @@ -165,13 +165,14 @@ function createConfigBackedLimitsCache(wallet: StoredAgenticWallet): LimitsCache let snapshot: CachedLimits = { ...(wallet.limits ? { limits: wallet.limits } : {}), ...(wallet.limits_hash ? { limits_hash: wallet.limits_hash } : {}), + ...(wallet.jetton_wallets ? { jetton_wallets: wallet.jetton_wallets } : {}), }; return { read: () => snapshot, write: async (next) => { // Persist first, then update the in-memory snapshot, so a failed (or // out-of-order concurrent) write never leaves memory ahead of disk. - await persistAgenticWalletLimits(wallet.id, next.limits, next.limits_hash); + await persistAgenticWalletLimits(wallet.id, next.limits, next.limits_hash, next.jetton_wallets); snapshot = next; }, }; diff --git a/packages/mcp/src/services/McpWalletService.ts b/packages/mcp/src/services/McpWalletService.ts index b3e7b1938..1b1afda90 100644 --- a/packages/mcp/src/services/McpWalletService.ts +++ b/packages/mcp/src/services/McpWalletService.ts @@ -53,10 +53,16 @@ import { readAgenticLimitsHash } from '../utils/agentic.js'; import { normalizeAddressForComparison } from '../utils/address.js'; import { computeLimitsUsage, evaluateLimits, loadActiveLimits, maxConfiguredWindow } from '../limits/enforce.js'; import type { AssetUsage, CachedLimits, LimitsCache, LimitsEnv, SyncedLimits } from '../limits/enforce.js'; -import { getJettonWalletInfoFromClient, parseJettonOutflowAmount } from '../limits/jetton.js'; -import { computeLimitsHash, limitsDictToStored, parseLimitsDictFromMessageBody } from '../limits/limits-codec.js'; -import { transactionsToSpend } from '../limits/spend-window.js'; -import type { JettonSpendProbe, PendingSpend, SpendEntry } from '../limits/types.js'; +import { buildReverseJettonMap } from '../limits/pending.js'; +import { + TON_ASSET_KEY, + computeLimitsHash, + limitsDictToStored, + parseLimitsDictFromMessageBody, +} from '../limits/limits-codec.js'; +import { resolveJettonProbes, transactionsToSpend } from '../limits/spend-window.js'; +import type { JettonSpendProbe, SpendEntry } from '../limits/types.js'; +import type { StoredLimits } from '../registry/config.js'; /** History fetch bounds for limit enforcement. */ const LIMITS_HISTORY_PAGE = 1000; @@ -67,19 +73,6 @@ const AGENTIC_DEFAULT_VALID_UNTIL = 600; const TEP64_ONCHAIN_CONTENT_PREFIX = 0x00; const TEP64_SNAKE_CONTENT_PREFIX = 0x00; -/** Parse a message `amount` (nanotons) into a non-negative bigint; 0 on absence/garbage. */ -function parseMessageAmount(amount: string | undefined): bigint { - if (!amount) { - return 0n; - } - try { - const value = BigInt(amount); - return value > 0n ? value : 0n; - } catch { - return 0n; - } -} - /** * Jetton information */ @@ -734,8 +727,7 @@ export class McpWalletService { if (this.isAgenticWallet()) { try { - const spend = await this.buildPendingSpend(request); - const decision = await evaluateLimits(this.createLimitsEnv(), spend); + const decision = await evaluateLimits(this.createLimitsEnv(), request.messages); if (!decision.allowed) { return { success: false, message: decision.message }; } @@ -760,70 +752,6 @@ export class McpWalletService { return this.walletVersion === 'agentic'; } - /** Sum the TON and (master-resolved) jetton outflows a pending request will spend. */ - private async buildPendingSpend(request: TransactionRequest): Promise { - let ton = 0n; - const jettonProbes: Array<{ jettonWalletAddress: string; amount: bigint }> = []; - for (const message of request.messages) { - const amount = parseMessageAmount(message.amount); - if (amount > 0n) { - ton += amount; - } - const outflow = parseJettonOutflowAmount(message.payload); - if (outflow && outflow > 0n) { - jettonProbes.push({ jettonWalletAddress: message.address, amount: outflow }); - } - } - - const jettons = new Map(); - if (jettonProbes.length > 0) { - const masters = await this.resolveOwnedJettonMasters( - jettonProbes.map((probe) => probe.jettonWalletAddress), - ); - for (const probe of jettonProbes) { - const master = masters.get(probe.jettonWalletAddress); - if (master) { - jettons.set(master, (jettons.get(master) ?? 0n) + probe.amount); - } - } - } - - return { ton, jettons }; - } - - /** - * Resolve jetton-wallet addresses to their normalized master address via - * `get_wallet_data`, keeping only wallets actually owned by this wallet. Each - * distinct address is resolved once; wallets that resolve to a different owner - * are dropped. A lookup that cannot be resolved at all (RPC failure) rejects - * rather than being dropped, so the caller fails closed instead of metering an - * unverifiable jetton as zero. Shared by the pending-spend estimate and the - * historical spend scan so both decide ownership identically. - */ - private async resolveOwnedJettonMasters(jettonWalletAddresses: Iterable): Promise> { - const owner = normalizeAddressForComparison(this.wallet.getAddress()); - const masters = new Map(); - if (!owner) { - return masters; - } - const client = this.wallet.getClient(); - const resolved = await Promise.all( - [...new Set(jettonWalletAddresses)].map( - async (address) => [address, await getJettonWalletInfoFromClient(client, address)] as const, - ), - ); - for (const [address, info] of resolved) { - if (!info || normalizeAddressForComparison(info.owner) !== owner) { - continue; - } - const master = normalizeAddressForComparison(info.master); - if (master) { - masters.set(address, master); - } - } - return masters; - } - /** * Current spend-limit config and usage, without sending. Resolves limits via the same * {@link loadActiveLimits} path enforcement uses, then meters spend over the largest window. @@ -843,8 +771,9 @@ export class McpWalletService { // One `now` so the fetch cutoff and window math share a boundary. const now = env.now(); + const reverseJettonMap = buildReverseJettonMap(loaded.jettonWallets); const maxWindow = maxConfiguredWindow(loaded.limits); - const spendEntries = maxWindow > 0 ? await env.fetchSpendEntries(maxWindow, now) : []; + const spendEntries = maxWindow > 0 ? await env.fetchSpendEntries(maxWindow, now, reverseJettonMap) : []; const assets = computeLimitsUsage(loaded.limits, spendEntries, now); return { enabled: true, limitsHash: loaded.hash, checkedAt: now, assets }; } @@ -856,7 +785,8 @@ export class McpWalletService { readCache: () => this.limitsCache?.read() ?? {}, writeCache: (next: CachedLimits) => this.limitsCache?.write(next) ?? Promise.resolve(), syncLimitsFromChain: () => this.syncLimitsFromChain(), - fetchSpendEntries: (maxWindowSeconds: number, now: number) => this.fetchSpendEntries(maxWindowSeconds, now), + fetchSpendEntries: (maxWindowSeconds: number, now: number, reverseJettonMap: Map) => + this.fetchSpendEntries(maxWindowSeconds, now, reverseJettonMap), }; } @@ -866,7 +796,11 @@ export class McpWalletService { return readAgenticLimitsHash(accountState, address); } - /** Find the most recent limits-change tx, parse its limitsDict, and hash it. */ + /** + * Find the most recent limits-change tx, parse its limitsDict, hash it, and + * compute the forward jetton-wallet map for the configured masters. The map is + * derived once here (same lifecycle as `limits_hash`) so metering needs no RPC. + */ private async syncLimitsFromChain(): Promise { const client = this.wallet.getClient(); const address = this.wallet.getAddress(); @@ -891,7 +825,9 @@ export class McpWalletService { // Malformed base64 body; treat as not a limits-change tx. } if (dict && dict.size > 0) { - return { limits: limitsDictToStored(dict), hash: computeLimitsHash(dict) }; + const limits = limitsDictToStored(dict); + const jettonWallets = await this.computeJettonWalletMap(limits); + return { limits, hash: computeLimitsHash(dict), jettonWallets }; } } if (response.transactions.length < LIMITS_HISTORY_PAGE) { @@ -901,6 +837,34 @@ export class McpWalletService { return null; } + /** + * Compute our jetton-wallet address for every configured jetton master (the TON + * sentinel is skipped). The `(owner, master) -> wallet` mapping is a deterministic, + * immutable function of the jetton-wallet code, so it is computed once per limits + * sync and cached. Keyed by the master display-key exactly as it appears in + * `limits.assets`, valued by the normalized wallet address. + * + * Fails closed, all-or-nothing: if any master's wallet cannot be resolved (RPC + * error or unparseable result), the whole map is rejected — a missing master at + * metering time would otherwise be treated as unlimited and bypass its cap. + */ + private async computeJettonWalletMap(limits: StoredLimits): Promise> { + const owner = this.wallet.getAddress(); + const client = this.wallet.getClient(); + const masters = Object.keys(limits.assets).filter((assetKey) => assetKey !== TON_ASSET_KEY); + const resolved = await Promise.all( + masters.map(async (master) => { + const walletAddress = await getJettonWalletAddressFromClient(client, master, owner); + const normalized = normalizeAddressForComparison(walletAddress); + if (!normalized) { + throw new Error(`Could not resolve a jetton-wallet address for master ${master}.`); + } + return [master, normalized] as const; + }), + ); + return Object.fromEntries(resolved); + } + /** * Page raw account transactions (newest first) until older than the window, then * meter outgoing spend from the messages directly. Uses `getAccountTransactions` @@ -909,8 +873,16 @@ export class McpWalletService { * page budget is exhausted while history still extends into the window: a truncated * history would under-count spend and could pass a cap that should have been * exceeded. + * + * Jetton outflows are resolved to their masters through the in-memory reverse map + * ({@link resolveJettonProbes}) rather than a per-wallet `get_wallet_data` call, so + * a full history scan adds no extra RPC beyond the transaction paging itself. */ - private async fetchSpendEntries(maxWindowSeconds: number, now: number): Promise { + private async fetchSpendEntries( + maxWindowSeconds: number, + now: number, + reverseJettonMap: Map, + ): Promise { const client = this.wallet.getClient(); const address = this.wallet.getAddress(); const cutoff = now - maxWindowSeconds; @@ -923,7 +895,7 @@ export class McpWalletService { offset: page * LIMITS_HISTORY_PAGE, }); if (response.transactions.length === 0) { - return this.resolveSpendEntries(tonEntries, jettonProbes); + return [...tonEntries, ...resolveJettonProbes(jettonProbes, reverseJettonMap)]; } const within: typeof response.transactions = []; let reachedCutoff = false; @@ -938,7 +910,7 @@ export class McpWalletService { tonEntries.push(...spend.tonEntries); jettonProbes.push(...spend.jettonProbes); if (reachedCutoff || response.transactions.length < LIMITS_HISTORY_PAGE) { - return this.resolveSpendEntries(tonEntries, jettonProbes); + return [...tonEntries, ...resolveJettonProbes(jettonProbes, reverseJettonMap)]; } } throw new Error( @@ -947,31 +919,6 @@ export class McpWalletService { ); } - /** - * Resolve the jetton-wallet addresses gathered while paging history to their - * masters and emit one spend entry per outflow we own, merged with the already - * netted TON entries. Jetton resolution is deferred to here so the per-page - * transaction parse stays synchronous and the `get_wallet_data` calls run once - * over the whole window rather than per page. - */ - private async resolveSpendEntries( - tonEntries: SpendEntry[], - jettonProbes: JettonSpendProbe[], - ): Promise { - if (jettonProbes.length === 0) { - return tonEntries; - } - const masters = await this.resolveOwnedJettonMasters(jettonProbes.map((probe) => probe.jettonWalletAddress)); - const entries = [...tonEntries]; - for (const probe of jettonProbes) { - const master = masters.get(probe.jettonWalletAddress); - if (master) { - entries.push({ timestamp: probe.timestamp, asset: master, amount: probe.amount }); - } - } - return entries; - } - /** * Send TON */