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__/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 new file mode 100644 index 000000000..366779ece --- /dev/null +++ b/packages/mcp/src/__tests__/limits.spec.ts @@ -0,0 +1,952 @@ +/** + * 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, 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 { + CHANGE_NFT_CONTENT_OP, + TON_ASSET_KEY, + assetKeyForAddress, + computeLimitsHash, + limitsDictToStored, + normalizeAssetKey, + parseLimitsDictFromMessageBody, + storedToLimitsDict, +} from '../limits/limits-codec.js'; +import { + computeLimitsUsage, + evaluateLimits, + findLimitViolation, + formatLimitViolation, + loadActiveLimits, + maxConfiguredWindow, + maxRelevantWindow, +} from '../limits/enforce.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)); +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() }; +} + +/** 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); + 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', jettonWallets: {} }), + fetchSpendEntries: async () => [], + ...overrides, + }; + } + + 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' }), + }), + tonMessages(1n), + ); + expect(decision.allowed).toBe(true); + }); + + 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 }), tonMessages(1n)); + expect(writeCache).not.toHaveBeenCalled(); + }); + + it('refuses to send when no limits-change transaction can be found', async () => { + 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('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', jettonWallets: {} }), + fetchSpendEntries, + }), + tonMessages(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'); + }, + }), + 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({}), 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', jettonWallets: FORWARD_MAP }), + 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 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', jetton_wallets: FORWARD_MAP }), + syncLimitsFromChain, + }), + ); + expect(loaded).toEqual({ status: 'active', limits: LIMITS, hash: 'hash-1', jettonWallets: FORWARD_MAP }); + expect(syncLimitsFromChain).not.toHaveBeenCalled(); + }); + + 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', 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 () => { + 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', jettonWallets: FORWARD_MAP }) }), + ); + 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) +// ------------------------------------------------------------------------------------------------- + +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') }; +} + +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(); + }); +}); + +// ------------------------------------------------------------------------------------------------- +// 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, + ); + expect(spend.ton).toBe(0n); + expect(spend.jettons).toEqual(new Map([[JETTON_KEY, 600n]])); + }); + + 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('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, + ); + 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 }]); + }); +}); + +// ------------------------------------------------------------------------------------------------- +// 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, 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: 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' }); + 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 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: () => ({ + 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('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/__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/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 new file mode 100644 index 000000000..9f0cee0a8 --- /dev/null +++ b/packages/mcp/src/limits/enforce.ts @@ -0,0 +1,387 @@ +/** + * 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 { buildPendingSpend, buildReverseJettonMap } from './pending.js'; +import type { SpendMessage } from './pending.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; + /** + * 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. */ +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; + /** + * 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. */ +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, 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, + reverseJettonMap: Map, + ): Promise; +} + +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; 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 + * (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(); + + if (!onchainHash) { + const cached = env.readCache(); + if (cached.limits || cached.limits_hash) { + await env.writeCache({}); + } + return { status: 'none' }; + } + + const cached = env.readCache(); + 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(); + 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, jetton_wallets: synced.jettonWallets }); + return { status: 'active', limits: synced.limits, hash: onchainHash, jettonWallets: synced.jettonWallets }; +} + +/** + * 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, messages: readonly SpendMessage[]): 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, 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, reverseJettonMap) : []; + 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/limits/jetton.ts b/packages/mcp/src/limits/jetton.ts new file mode 100644 index 000000000..bf9de47ee --- /dev/null +++ b/packages/mcp/src/limits/jetton.ts @@ -0,0 +1,38 @@ +/** + * 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'; + +// 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; + } +} 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/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 new file mode 100644 index 000000000..8989f3ace --- /dev/null +++ b/packages/mcp/src/limits/spend-window.ts @@ -0,0 +1,131 @@ +/** + * 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); {@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. + */ +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 }; +} + +/** + * 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; + 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..5625335dc --- /dev/null +++ b/packages/mcp/src/limits/types.ts @@ -0,0 +1,72 @@ +/** + * 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 + * (`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. */ + 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..3327db64a 100644 --- a/packages/mcp/src/registry/config.ts +++ b/packages/mcp/src/registry/config.ts @@ -62,6 +62,27 @@ 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; + /** + * 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. */ +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 +209,10 @@ 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 } : {}), + ...(wallet.jetton_wallets ? { jetton_wallets: wallet.jetton_wallets } : {}), }; const normalizeSetupSession = (session: StoredAgenticSetupSession): StoredAgenticSetupSession => ({ ...session, @@ -589,6 +614,78 @@ 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. 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 && sameJettonWallets(item.jetton_wallets, jettonWallets)) { + return item; + } + changed = true; + 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(), + }; + }); + + if (!changed) { + return config; + } + + return { + ...config, + wallets: nextWallets, + }; +} + +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, jettonWallets); + if (nextConfig === config) { + return false; + } + saveConfig(nextConfig); + return true; +} + export function setActiveWallet( config: TonConfig, selector: string, @@ -918,6 +1015,9 @@ export function createAgenticWalletRecord(input: { walletNftIndex?: string; originOperatorPublicKey?: string; deployedByUser?: boolean; + limits?: StoredLimits; + limitsHash?: string; + jettonWallets?: Record; idPrefix?: string; }): StoredAgenticWallet { const now = nowIso(); @@ -937,6 +1037,9 @@ 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 } : {}), + ...(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 a492f0f6d..1f62fdef3 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,28 @@ 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 } : {}), + ...(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, next.jetton_wallets); + snapshot = next; + }, + }; +} + async function createServiceFromStoredAgentic( wallet: StoredAgenticWallet, contacts: IContactResolver | undefined, @@ -193,6 +216,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..1b1afda90 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,6 +48,25 @@ 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 { computeLimitsUsage, evaluateLimits, loadActiveLimits, maxConfiguredWindow } from '../limits/enforce.js'; +import type { AssetUsage, CachedLimits, LimitsCache, LimitsEnv, SyncedLimits } from '../limits/enforce.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; +const LIMITS_HISTORY_MAX_PAGES = 10; const OP_DEPLOY_WALLET = 0x0609e47b; const AGENTIC_DEFAULT_VALID_UNTIL = 600; @@ -83,6 +100,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 */ @@ -217,6 +247,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 +259,7 @@ interface McpWalletServiceInternalConfig { mainnet?: NetworkConfig; testnet?: NetworkConfig; }; + limitsCache?: LimitsCache; } interface DeployAgenticSubwalletParams { @@ -248,11 +281,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 +316,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 +324,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 +384,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 +710,228 @@ 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 decision = await evaluateLimits(this.createLimitsEnv(), request.messages); + 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'; + } + + /** + * 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 reverseJettonMap = buildReverseJettonMap(loaded.jettonWallets); + const maxWindow = maxConfiguredWindow(loaded.limits); + 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 }; + } + + 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, reverseJettonMap: Map) => + this.fetchSpendEntries(maxWindowSeconds, now, reverseJettonMap), + }; + } + + 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, 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(); + 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) { + const limits = limitsDictToStored(dict); + const jettonWallets = await this.computeJettonWalletMap(limits); + return { limits, hash: computeLimitsHash(dict), jettonWallets }; + } + } + if (response.transactions.length < LIMITS_HISTORY_PAGE) { + break; + } + } + 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` + * 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. + * + * 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, + reverseJettonMap: Map, + ): 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 [...tonEntries, ...resolveJettonProbes(jettonProbes, reverseJettonMap)]; + } + 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 [...tonEntries, ...resolveJettonProbes(jettonProbes, reverseJettonMap)]; + } } + throw new Error( + `Spend history exceeds ${LIMITS_HISTORY_MAX_PAGES * LIMITS_HISTORY_PAGE} transactions within the limit ` + + `window; cannot verify limits without truncating history.`, + ); + } + + /** + * 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 +943,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 +969,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 +1054,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 +1064,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 +1285,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/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, + }; + } + }, + }, + }; +} 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,