@t2000/engine 0.46.11 → 0.46.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -228,6 +228,25 @@ interface GuardConfig {
228
228
  * upstream guard (e.g. an off-process verifier).
229
229
  */
230
230
  addressSource?: boolean;
231
+ /**
232
+ * Companion to `addressSource`: blocks send_transfer that defaults to
233
+ * USDC when the user's recent messages clearly named a non-USDC token
234
+ * (SUI, USDT, WAL, etc.). Without this, the LLM would call
235
+ * `send_transfer({ amount, to })` for a "send my SUI" request and the
236
+ * tool would silently ship USDC. Default on.
237
+ */
238
+ assetIntent?: boolean;
239
+ /**
240
+ * Root-cause fix for "LLM hallucinates a stale training-data price
241
+ * (e.g. '$3.50/SUI') and shows the user a wildly wrong estimate before
242
+ * the swap card renders". When enabled (default), `swap_execute` is
243
+ * blocked unless a matching `swap_quote(from, to, amount)` ran in the
244
+ * recent past (60s window, ±1% amount tolerance). The block forces the
245
+ * LLM to fetch a real on-chain quote and cite its actual numbers, not
246
+ * a guess. Set to `false` only if the host has its own pre-execution
247
+ * quote requirement.
248
+ */
249
+ swapPreview?: boolean;
231
250
  }
232
251
  declare const DEFAULT_GUARD_CONFIG: GuardConfig;
233
252
  declare class BalanceTracker {
@@ -247,11 +266,39 @@ declare class RetryTracker {
247
266
  previousResult?: unknown;
248
267
  };
249
268
  }
269
+ declare class SwapQuoteTracker {
270
+ /** Quotes recorded in the recent window. Trimmed lazily on every check. */
271
+ private quotes;
272
+ /** Match window: 60s is generous enough for slow LLM turns but tight enough
273
+ * to invalidate stale quotes from earlier in the session. */
274
+ private readonly windowMs;
275
+ /** Amount tolerance: ±1% (covers gas-padding, integer-rounding, and the
276
+ * rare case where the LLM rounds the input differently between quote and
277
+ * execute). Prices barely move in 60s so 1% is forgiving but meaningful. */
278
+ private readonly amountTolerance;
279
+ /**
280
+ * Normalize a token identifier so symbol vs. coinType vs. case don't
281
+ * cause spurious mismatches. Lowercase + trim is sufficient because the
282
+ * SDK's resolver itself is case-insensitive on symbols.
283
+ */
284
+ private normalize;
285
+ record(input: {
286
+ from: string;
287
+ to: string;
288
+ amount: number;
289
+ }): void;
290
+ hasMatchingQuote(input: {
291
+ from: string;
292
+ to: string;
293
+ amount: number;
294
+ }): boolean;
295
+ }
250
296
  declare function guardArtifactPreview(result: unknown): GuardInjection | null;
251
297
  declare function guardStaleData(toolFlags: ToolFlags): GuardInjection | null;
252
298
  interface GuardRunnerState {
253
299
  balanceTracker: BalanceTracker;
254
300
  retryTracker: RetryTracker;
301
+ swapQuoteTracker: SwapQuoteTracker;
255
302
  lastHealthFactor: number | null;
256
303
  }
257
304
  declare function createGuardRunnerState(): GuardRunnerState;
@@ -1862,11 +1909,13 @@ declare const withdrawTool: Tool<{
1862
1909
  declare const sendTransferTool: Tool<{
1863
1910
  to: string;
1864
1911
  amount: number;
1912
+ asset?: string | undefined;
1865
1913
  memo?: string | undefined;
1866
1914
  }, {
1867
1915
  success: boolean;
1868
1916
  tx: string;
1869
1917
  amount: number;
1918
+ asset: "USDC" | "USDT" | "SUI" | "USDe" | "USDsui" | "WAL" | "ETH" | "NAVX" | "GOLD";
1870
1919
  to: string;
1871
1920
  contactName: string | undefined;
1872
1921
  gasCost: number;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { resolveSymbol, getDecimalsForCoinType, assertAllowedAsset, getSwapQuote, extractTransferDetails, classifyTransaction } from '@t2000/sdk';
2
+ import { ALL_NAVI_ASSETS, resolveSymbol, getDecimalsForCoinType, assertAllowedAsset, SUPPORTED_ASSETS, getSwapQuote, extractTransferDetails, classifyTransaction } from '@t2000/sdk';
3
3
  import { readdirSync, readFileSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import yaml from 'js-yaml';
@@ -1447,12 +1447,14 @@ var withdrawTool = buildTool({
1447
1447
  };
1448
1448
  }
1449
1449
  });
1450
+ var ASSET_LIST = ALL_NAVI_ASSETS.map((a) => String(a)).join(", ");
1450
1451
  var sendTransferTool = buildTool({
1451
1452
  name: "send_transfer",
1452
- description: "Send USDC to another Sui address or contact name. Validates the address, checks balance, and executes the on-chain transfer. Returns tx hash, gas cost, and updated balance.",
1453
+ description: `Send ANY supported token (${ASSET_LIST}) to another Sui address or contact name. Validates the address, checks balance, and executes the on-chain transfer. MUST set the \`asset\` field to the token symbol you want to send (case-insensitive). If \`asset\` is omitted, USDC is assumed \u2014 only do this when the user explicitly asks for USDC. When the user asks to send a token by name (SUI, USDT, etc.) or to send the proceeds of a just-completed swap, you MUST pass \`asset\` matching that token. Returns tx hash, gas cost, and updated balance.`,
1453
1454
  inputSchema: z.object({
1454
1455
  to: z.string().min(1),
1455
1456
  amount: z.number().positive(),
1457
+ asset: z.string().optional(),
1456
1458
  memo: z.string().optional()
1457
1459
  }),
1458
1460
  jsonSchema: {
@@ -1464,7 +1466,11 @@ var sendTransferTool = buildTool({
1464
1466
  },
1465
1467
  amount: {
1466
1468
  type: "number",
1467
- description: "Amount in USD to send"
1469
+ description: "Amount of the asset to send (denominated in the asset\u2019s own units, NOT USD). For USDC this is the USDC count; for SUI this is the SUI count."
1470
+ },
1471
+ asset: {
1472
+ type: "string",
1473
+ description: `Token symbol to send. One of: ${ASSET_LIST}. Defaults to USDC if omitted. REQUIRED whenever the user names a non-USDC token or you are forwarding the proceeds of a swap.`
1468
1474
  },
1469
1475
  memo: {
1470
1476
  type: "string",
@@ -1487,16 +1493,27 @@ var sendTransferTool = buildTool({
1487
1493
  if (input.amount <= 0) {
1488
1494
  return { valid: false, error: "Amount must be positive." };
1489
1495
  }
1496
+ if (input.asset !== void 0) {
1497
+ const normalized = String(input.asset).toUpperCase();
1498
+ if (!(normalized in SUPPORTED_ASSETS)) {
1499
+ return {
1500
+ valid: false,
1501
+ error: `Unsupported asset "${input.asset}". send_transfer accepts: ${ASSET_LIST}.`
1502
+ };
1503
+ }
1504
+ }
1490
1505
  return { valid: true };
1491
1506
  },
1492
1507
  async call(input, context) {
1493
1508
  const agent = requireAgent(context);
1494
- const result = await agent.send({ to: input.to, amount: input.amount });
1509
+ const asset = input.asset ? String(input.asset).toUpperCase() : "USDC";
1510
+ const result = await agent.send({ to: input.to, amount: input.amount, asset });
1495
1511
  return {
1496
1512
  data: {
1497
1513
  success: result.success,
1498
1514
  tx: result.tx,
1499
1515
  amount: result.amount,
1516
+ asset,
1500
1517
  to: result.to,
1501
1518
  contactName: result.contactName,
1502
1519
  gasCost: result.gasCost,
@@ -1504,7 +1521,7 @@ var sendTransferTool = buildTool({
1504
1521
  balance: result.balance,
1505
1522
  memo: input.memo ?? null
1506
1523
  },
1507
- displayText: `Sent $${result.amount.toFixed(2)} to ${result.contactName ?? result.to.slice(0, 10)}\u2026 (tx: ${result.tx.slice(0, 8)}\u2026)`
1524
+ displayText: `Sent ${result.amount} ${asset} to ${result.contactName ?? `${result.to.slice(0, 10)}\u2026`} (tx: ${result.tx.slice(0, 8)}\u2026)`
1508
1525
  };
1509
1526
  }
1510
1527
  });
@@ -3610,7 +3627,9 @@ var DEFAULT_GUARD_CONFIG = {
3610
3627
  costWarning: true,
3611
3628
  retryProtection: true,
3612
3629
  inputValidation: true,
3613
- addressSource: true
3630
+ addressSource: true,
3631
+ assetIntent: true,
3632
+ swapPreview: true
3614
3633
  };
3615
3634
  var BalanceTracker = class {
3616
3635
  lastBalanceAt = 0;
@@ -3651,6 +3670,45 @@ var RetryTracker = class {
3651
3670
  return { blocked: true, previousResult: prev.result };
3652
3671
  }
3653
3672
  };
3673
+ var SwapQuoteTracker = class {
3674
+ /** Quotes recorded in the recent window. Trimmed lazily on every check. */
3675
+ quotes = [];
3676
+ /** Match window: 60s is generous enough for slow LLM turns but tight enough
3677
+ * to invalidate stale quotes from earlier in the session. */
3678
+ windowMs = 6e4;
3679
+ /** Amount tolerance: ±1% (covers gas-padding, integer-rounding, and the
3680
+ * rare case where the LLM rounds the input differently between quote and
3681
+ * execute). Prices barely move in 60s so 1% is forgiving but meaningful. */
3682
+ amountTolerance = 0.01;
3683
+ /**
3684
+ * Normalize a token identifier so symbol vs. coinType vs. case don't
3685
+ * cause spurious mismatches. Lowercase + trim is sufficient because the
3686
+ * SDK's resolver itself is case-insensitive on symbols.
3687
+ */
3688
+ normalize(token) {
3689
+ return token.trim().toLowerCase();
3690
+ }
3691
+ record(input) {
3692
+ const now = Date.now();
3693
+ this.quotes.push({
3694
+ from: this.normalize(input.from),
3695
+ to: this.normalize(input.to),
3696
+ amount: input.amount,
3697
+ ts: now
3698
+ });
3699
+ const cutoff = now - this.windowMs;
3700
+ this.quotes = this.quotes.filter((q) => q.ts > cutoff);
3701
+ }
3702
+ hasMatchingQuote(input) {
3703
+ const cutoff = Date.now() - this.windowMs;
3704
+ const fromN = this.normalize(input.from);
3705
+ const toN = this.normalize(input.to);
3706
+ const target = input.amount;
3707
+ return this.quotes.some(
3708
+ (q) => q.ts > cutoff && q.from === fromN && q.to === toN && target > 0 && Math.abs(q.amount - target) / target <= this.amountTolerance
3709
+ );
3710
+ }
3711
+ };
3654
3712
  function guardRetryProtection(tool, call, retryTracker) {
3655
3713
  const check = retryTracker.isBlocked(tool.name, call.input);
3656
3714
  if (check.blocked) {
@@ -3793,6 +3851,58 @@ var SUI_ADDRESS_REGEX = /^0x[a-fA-F0-9]{64}$/;
3793
3851
  function normalizeAddress(addr) {
3794
3852
  return addr.trim().toLowerCase();
3795
3853
  }
3854
+ var NON_USDC_TOKEN_WORDS = [
3855
+ // Patterns are anchored with \b on both sides. Case-insensitive.
3856
+ { symbol: "SUI", pattern: /\bSUI\b/i },
3857
+ { symbol: "USDT", pattern: /\bUSDT\b/i },
3858
+ { symbol: "USDe", pattern: /\bUSDe\b/i },
3859
+ { symbol: "USDsui", pattern: /\bUSDsui\b/i },
3860
+ { symbol: "WAL", pattern: /\bWAL\b/i },
3861
+ { symbol: "ETH", pattern: /\bETH\b/i },
3862
+ { symbol: "NAVX", pattern: /\bNAVX\b/i },
3863
+ { symbol: "GOLD", pattern: /\bGOLD\b/i }
3864
+ ];
3865
+ function guardAssetIntent(tool, call, userText) {
3866
+ if (tool.name !== "send_transfer") {
3867
+ return { verdict: "pass", gate: "asset_intent", tier: "safety" };
3868
+ }
3869
+ const input = call.input;
3870
+ const assetWasSet = !(input.asset === void 0 || input.asset === null || input.asset === "");
3871
+ if (assetWasSet) {
3872
+ return { verdict: "pass", gate: "asset_intent", tier: "safety" };
3873
+ }
3874
+ const mentioned = NON_USDC_TOKEN_WORDS.find((t) => t.pattern.test(userText));
3875
+ if (!mentioned) {
3876
+ return { verdict: "pass", gate: "asset_intent", tier: "safety" };
3877
+ }
3878
+ return {
3879
+ verdict: "block",
3880
+ gate: "asset_intent",
3881
+ tier: "safety",
3882
+ message: `Asset mismatch: the user's recent messages mention "${mentioned.symbol}" but send_transfer was called without an \`asset\` field (defaults to USDC). If the user asked you to send ${mentioned.symbol}, re-issue send_transfer with \`asset: "${mentioned.symbol}"\`. If the user really meant USDC, set \`asset: "USDC"\` explicitly to confirm intent. Never default to USDC when the user named a different token.`
3883
+ };
3884
+ }
3885
+ function guardSwapPreview(tool, call, swapQuoteTracker) {
3886
+ if (tool.name !== "swap_execute") {
3887
+ return { verdict: "pass", gate: "swap_preview", tier: "safety" };
3888
+ }
3889
+ const input = call.input;
3890
+ const from = typeof input.from === "string" ? input.from : "";
3891
+ const to = typeof input.to === "string" ? input.to : "";
3892
+ const amount = Number(input.amount ?? 0);
3893
+ if (!from || !to || !(amount > 0)) {
3894
+ return { verdict: "pass", gate: "swap_preview", tier: "safety" };
3895
+ }
3896
+ if (swapQuoteTracker.hasMatchingQuote({ from, to, amount })) {
3897
+ return { verdict: "pass", gate: "swap_preview", tier: "safety" };
3898
+ }
3899
+ return {
3900
+ verdict: "block",
3901
+ gate: "swap_preview",
3902
+ tier: "safety",
3903
+ message: `swap_execute requires a recent matching swap_quote so the user sees an accurate preview. Call swap_quote({ from: "${from}", to: "${to}", amount: ${amount} }) first, then re-issue swap_execute with the same params. swap_quote is read-only and returns the real on-chain output, route, and price impact \u2014 never estimate from memory.`
3904
+ };
3905
+ }
3796
3906
  function guardAddressSource(tool, call, userText, contacts, walletAddress) {
3797
3907
  if (tool.name !== "send_transfer") {
3798
3908
  return { verdict: "pass", gate: "address_source", tier: "safety" };
@@ -3848,6 +3958,7 @@ function createGuardRunnerState() {
3848
3958
  return {
3849
3959
  balanceTracker: new BalanceTracker(),
3850
3960
  retryTracker: new RetryTracker(),
3961
+ swapQuoteTracker: new SwapQuoteTracker(),
3851
3962
  lastHealthFactor: null
3852
3963
  };
3853
3964
  }
@@ -3903,6 +4014,12 @@ function runGuards(tool, call, state, config, conversationContext, onGuardFired,
3903
4014
  )
3904
4015
  );
3905
4016
  }
4017
+ if (config.assetIntent !== false) {
4018
+ results.push(guardAssetIntent(tool, call, conversationContext.recentUserText));
4019
+ }
4020
+ if (config.swapPreview !== false) {
4021
+ results.push(guardSwapPreview(tool, call, state.swapQuoteTracker));
4022
+ }
3906
4023
  if (config.irreversibility !== false) {
3907
4024
  results.push(guardIrreversibility(tool, call, conversationContext.fullText));
3908
4025
  }
@@ -3969,6 +4086,15 @@ function updateGuardStateAfterToolResult(toolName, tool, input, result, isError,
3969
4086
  state.lastHealthFactor = hf;
3970
4087
  }
3971
4088
  }
4089
+ if (toolName === "swap_quote" && input && typeof input === "object") {
4090
+ const i = input;
4091
+ const from = typeof i.from === "string" ? i.from : "";
4092
+ const to = typeof i.to === "string" ? i.to : "";
4093
+ const amount = Number(i.amount ?? 0);
4094
+ if (from && to && amount > 0) {
4095
+ state.swapQuoteTracker.record({ from, to, amount });
4096
+ }
4097
+ }
3972
4098
  state.retryTracker.record(toolName, input, result);
3973
4099
  }
3974
4100
  function extractConversationText(messages) {