shll-skills 5.2.0 → 5.3.0

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.
Files changed (4) hide show
  1. package/dist/mcp.js +138 -24
  2. package/dist/mcp.mjs +138 -24
  3. package/package.json +1 -1
  4. package/src/mcp.ts +150 -33
package/dist/mcp.js CHANGED
@@ -613,7 +613,12 @@ var WBNB_ABI = [
613
613
  ];
614
614
  var SPENDING_LIMIT_ABI = [
615
615
  { type: "function", name: "setLimits", inputs: [{ name: "instanceId", type: "uint256" }, { name: "maxPerTx", type: "uint256" }, { name: "maxPerDay", type: "uint256" }, { name: "maxSlippageBps", type: "uint256" }], outputs: [], stateMutability: "nonpayable" },
616
- { type: "function", name: "instanceLimits", inputs: [{ name: "instanceId", type: "uint256" }], outputs: [{ name: "maxPerTx", type: "uint256" }, { name: "maxPerDay", type: "uint256" }, { name: "maxSlippageBps", type: "uint256" }], stateMutability: "view" }
616
+ { type: "function", name: "instanceLimits", inputs: [{ name: "instanceId", type: "uint256" }], outputs: [{ name: "maxPerTx", type: "uint256" }, { name: "maxPerDay", type: "uint256" }, { name: "maxSlippageBps", type: "uint256" }], stateMutability: "view" },
617
+ { type: "function", name: "tokenRestrictionEnabled", inputs: [{ name: "instanceId", type: "uint256" }], outputs: [{ name: "", type: "bool" }], stateMutability: "view" },
618
+ { type: "function", name: "getTokenList", inputs: [{ name: "instanceId", type: "uint256" }], outputs: [{ name: "", type: "address[]" }], stateMutability: "view" },
619
+ { type: "function", name: "addToken", inputs: [{ name: "instanceId", type: "uint256" }, { name: "token", type: "address" }], outputs: [], stateMutability: "nonpayable" },
620
+ { type: "function", name: "removeToken", inputs: [{ name: "instanceId", type: "uint256" }, { name: "token", type: "address" }], outputs: [], stateMutability: "nonpayable" },
621
+ { type: "function", name: "setTokenRestriction", inputs: [{ name: "instanceId", type: "uint256" }, { name: "enabled", type: "bool" }], outputs: [], stateMutability: "nonpayable" }
617
622
  ];
618
623
  var COOLDOWN_ABI = [
619
624
  { type: "function", name: "setCooldown", inputs: [{ name: "instanceId", type: "uint256" }, { name: "seconds_", type: "uint256" }], outputs: [], stateMutability: "nonpayable" },
@@ -683,6 +688,25 @@ async function checkAgentExpiry(tokenId) {
683
688
  }
684
689
  return { expired: false };
685
690
  }
691
+ function policyRejectionHelp(reason, tokenId) {
692
+ const consoleUrl = `https://shll.run/agent/0xE98DCdbf370D7b52c9A2b88F79bEF514A5375a2b/${tokenId}/console/safety`;
693
+ const r = reason ?? "";
694
+ if (r.includes("Approve spender not allowed"))
695
+ return { explanation: "The DEX router address is not in the approved spender whitelist. This is a platform-level security setting.", action: "Contact the Agent owner to approve this router, or use a different DEX.", consoleUrl };
696
+ if (r.includes("Target not allowed"))
697
+ return { explanation: "The contract address is not in the DeFi target whitelist.", action: "Enable the corresponding DeFi Pack in Console > Safety, or contact the Agent owner.", consoleUrl };
698
+ if (r.includes("Token not in whitelist"))
699
+ return { explanation: "Token restriction is ON and this token is not whitelisted.", action: `Add the token to the whitelist or disable token restriction at: ${consoleUrl}`, consoleUrl };
700
+ if (r.includes("Exceeds per-tx limit"))
701
+ return { explanation: "Transaction value exceeds the per-transaction spending limit.", action: `Reduce the amount, or increase the limit at: ${consoleUrl}`, consoleUrl };
702
+ if (r.includes("Daily limit"))
703
+ return { explanation: "Daily spending limit would be exceeded.", action: `Wait until tomorrow, or increase the daily limit at: ${consoleUrl}`, consoleUrl };
704
+ if (r.includes("Approve exceeds limit"))
705
+ return { explanation: "The approve amount exceeds the configured approve limit.", action: `Reduce the amount, or increase the approve limit at: ${consoleUrl}`, consoleUrl };
706
+ if (r.includes("Cooldown"))
707
+ return { explanation: "Cooldown period has not elapsed since the last transaction.", action: "Wait for the cooldown to expire before retrying.", consoleUrl };
708
+ return { explanation: "Transaction was rejected by an on-chain security policy.", action: `Review your security settings at: ${consoleUrl}`, consoleUrl };
709
+ }
686
710
  var server = new import_mcp.McpServer({
687
711
  name: "shll-defi",
688
712
  version: "5.2.0"
@@ -850,7 +874,44 @@ server.tool(
850
874
  }
851
875
  for (const action of actions) {
852
876
  const sim = await policyClient.validate(tokenId, action);
853
- if (!sim.ok) return { content: [{ type: "text", text: JSON.stringify({ status: "rejected", reason: sim.reason }) }] };
877
+ if (!sim.ok) {
878
+ if (useV3 && sim.reason?.includes("Approve spender not allowed") && v2Available) {
879
+ const v2Actions = [];
880
+ const v2Router = PANCAKE_V2_ROUTER;
881
+ const v2MinOut = v2Quote * BigInt(100 - slippage) / 100n;
882
+ if (!isNativeIn) {
883
+ const allow = await publicClient.readContract({ address: fromToken.address, abi: ERC20_ABI, functionName: "allowance", args: [vault, v2Router] }).catch(() => 0n);
884
+ if (allow < amountIn) {
885
+ v2Actions.push({ target: fromToken.address, value: 0n, data: (0, import_viem2.encodeFunctionData)({ abi: ERC20_ABI, functionName: "approve", args: [v2Router, amountIn] }) });
886
+ }
887
+ }
888
+ const v2Path = tokenInAddr.toLowerCase() !== WBNB.toLowerCase() && tokenOutAddr.toLowerCase() !== WBNB.toLowerCase() ? [tokenInAddr, WBNB, tokenOutAddr] : [tokenInAddr, tokenOutAddr];
889
+ if (isNativeIn) {
890
+ v2Actions.push({ target: v2Router, value: amountIn, data: (0, import_viem2.encodeFunctionData)({ abi: SWAP_EXACT_ETH_ABI, functionName: "swapExactETHForTokens", args: [v2MinOut, v2Path, vault, deadline] }) });
891
+ } else {
892
+ v2Actions.push({ target: v2Router, value: 0n, data: (0, import_viem2.encodeFunctionData)({ abi: SWAP_EXACT_TOKENS_ABI, functionName: "swapExactTokensForTokens", args: [amountIn, v2MinOut, v2Path, vault, deadline] }) });
893
+ }
894
+ for (const v2a of v2Actions) {
895
+ const v2Sim = await policyClient.validate(tokenId, v2a);
896
+ if (!v2Sim.ok) return { content: [{ type: "text", text: JSON.stringify({ status: "rejected", reason: v2Sim.reason, ...policyRejectionHelp(v2Sim.reason, token_id) }) }] };
897
+ }
898
+ const v2Result = v2Actions.length === 1 ? await policyClient.execute(tokenId, v2Actions[0], true) : await policyClient.executeBatch(tokenId, v2Actions, true);
899
+ return {
900
+ content: [{
901
+ type: "text",
902
+ text: JSON.stringify({
903
+ status: "success",
904
+ hash: v2Result.hash,
905
+ dex: "v2",
906
+ quote: v2Quote.toString(),
907
+ minOut: v2MinOut.toString(),
908
+ note: "V3 was rejected by policy (Approve spender not allowed). Auto-switched to V2."
909
+ })
910
+ }]
911
+ };
912
+ }
913
+ return { content: [{ type: "text", text: JSON.stringify({ status: "rejected", reason: sim.reason, ...policyRejectionHelp(sim.reason, token_id) }) }] };
914
+ }
854
915
  }
855
916
  const result = actions.length === 1 ? await policyClient.execute(tokenId, actions[0], true) : await policyClient.executeBatch(tokenId, actions, true);
856
917
  return {
@@ -899,7 +960,7 @@ server.tool(
899
960
  }
900
961
  for (const action of actions) {
901
962
  const sim = await policyClient.validate(tokenId, action);
902
- if (!sim.ok) return { content: [{ type: "text", text: JSON.stringify({ status: "rejected", reason: sim.reason }) }] };
963
+ if (!sim.ok) return { content: [{ type: "text", text: JSON.stringify({ status: "rejected", reason: sim.reason, ...policyRejectionHelp(sim.reason, token_id) }) }] };
903
964
  }
904
965
  const result = actions.length === 1 ? await policyClient.execute(tokenId, actions[0], true) : await policyClient.executeBatch(tokenId, actions, true);
905
966
  return { content: [{ type: "text", text: JSON.stringify({ status: "success", hash: result.hash, protocol: "venus", action: "supply", token: symbol, amount }) }] };
@@ -996,17 +1057,14 @@ server.tool(
996
1057
  return { content: [{ type: "text", text: JSON.stringify({ status: "success", hash: result.hash, token, amount, to: recipient }) }] };
997
1058
  }
998
1059
  );
999
- var OPERATOR_OF_ABI = [{
1000
- type: "function",
1001
- name: "operatorOf",
1002
- inputs: [{ name: "tokenId", type: "uint256" }],
1003
- outputs: [{ name: "", type: "address" }],
1004
- stateMutability: "view"
1005
- }];
1060
+ var MY_AGENTS_ABI = [
1061
+ { type: "function", name: "operatorOf", inputs: [{ name: "tokenId", type: "uint256" }], outputs: [{ name: "", type: "address" }], stateMutability: "view" },
1062
+ { type: "function", name: "operatorExpiresOf", inputs: [{ name: "tokenId", type: "uint256" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }
1063
+ ];
1006
1064
  var DEFAULT_INDEXER = "https://indexer-mainnet.shll.run";
1007
1065
  server.tool(
1008
1066
  "my_agents",
1009
- "List all agents where the current operator key is authorized. Returns token IDs, vault addresses, and agent types. Call this first if the user does not specify a token ID.",
1067
+ "List all agents where the current operator key is or was authorized. Returns active agents and expired agents that need renewal.",
1010
1068
  {},
1011
1069
  async () => {
1012
1070
  const { account, publicClient, config } = createClients();
@@ -1023,18 +1081,34 @@ server.tool(
1023
1081
  agents.map(async (a) => {
1024
1082
  const tokenId = BigInt(a.tokenId);
1025
1083
  try {
1026
- const op = await publicClient.readContract({
1027
- address: nfaAddr,
1028
- abi: OPERATOR_OF_ABI,
1029
- functionName: "operatorOf",
1030
- args: [tokenId]
1031
- });
1032
- return op.toLowerCase() === operator ? {
1033
- tokenId: tokenId.toString(),
1034
- vault: a.account || "",
1035
- owner: a.owner || "",
1036
- agentType: a.agentType || "unknown"
1037
- } : null;
1084
+ const [op, opExpires] = await Promise.all([
1085
+ publicClient.readContract({ address: nfaAddr, abi: MY_AGENTS_ABI, functionName: "operatorOf", args: [tokenId] }),
1086
+ publicClient.readContract({ address: nfaAddr, abi: MY_AGENTS_ABI, functionName: "operatorExpiresOf", args: [tokenId] })
1087
+ ]);
1088
+ const isActive = op.toLowerCase() === operator;
1089
+ const now = BigInt(Math.floor(Date.now() / 1e3));
1090
+ const isExpired = !isActive && Number(opExpires) > 0 && now > opExpires;
1091
+ if (isActive) {
1092
+ return {
1093
+ tokenId: tokenId.toString(),
1094
+ vault: a.account || "",
1095
+ owner: a.owner || "",
1096
+ agentType: a.agentType || "unknown",
1097
+ status: "active",
1098
+ operatorExpires: new Date(Number(opExpires) * 1e3).toISOString()
1099
+ };
1100
+ } else if (isExpired) {
1101
+ return {
1102
+ tokenId: tokenId.toString(),
1103
+ vault: a.account || "",
1104
+ owner: a.owner || "",
1105
+ agentType: a.agentType || "unknown",
1106
+ status: "expired",
1107
+ operatorExpires: new Date(Number(opExpires) * 1e3).toISOString(),
1108
+ note: "Operator authorization expired. Renew at https://shll.run/me"
1109
+ };
1110
+ }
1111
+ return null;
1038
1112
  } catch {
1039
1113
  return null;
1040
1114
  }
@@ -1142,7 +1216,15 @@ server.tool(
1142
1216
  const [maxPerTx, maxPerDay, maxSlippageBps] = limits;
1143
1217
  const txBnb = (Number(maxPerTx) / 1e18).toFixed(4);
1144
1218
  const dayBnb = (Number(maxPerDay) / 1e18).toFixed(4);
1145
- entry.currentConfig = { maxPerTx: maxPerTx.toString(), maxPerTxBnb: txBnb, maxPerDay: maxPerDay.toString(), maxPerDayBnb: dayBnb, maxSlippageBps: maxSlippageBps.toString() };
1219
+ let tokenRestriction = {};
1220
+ try {
1221
+ const enabled = await publicClient.readContract({ address: p.address, abi: SPENDING_LIMIT_ABI, functionName: "tokenRestrictionEnabled", args: [tokenId] });
1222
+ const tokenList = await publicClient.readContract({ address: p.address, abi: SPENDING_LIMIT_ABI, functionName: "getTokenList", args: [tokenId] });
1223
+ tokenRestriction = { tokenRestrictionEnabled: enabled, whitelistedTokens: tokenList, whitelistedTokenCount: tokenList.length };
1224
+ summaryParts.push(enabled ? `Token whitelist ON (${tokenList.length} tokens)` : "Token whitelist OFF (any token allowed)");
1225
+ } catch {
1226
+ }
1227
+ entry.currentConfig = { maxPerTx: maxPerTx.toString(), maxPerTxBnb: txBnb, maxPerDay: maxPerDay.toString(), maxPerDayBnb: dayBnb, maxSlippageBps: maxSlippageBps.toString(), ...tokenRestriction };
1146
1228
  summaryParts.push(`Max ${txBnb} BNB/tx, ${dayBnb} BNB/day, slippage ${maxSlippageBps}bps`);
1147
1229
  } catch {
1148
1230
  }
@@ -1166,6 +1248,38 @@ server.tool(
1166
1248
  return { content: [{ type: "text", text: JSON.stringify({ tokenId: token_id, humanSummary, securityNote: "Operator wallet CANNOT withdraw vault funds or transfer Agent NFT.", policies: enriched }) }] };
1167
1249
  }
1168
1250
  );
1251
+ server.tool(
1252
+ "token_restriction",
1253
+ "Check token whitelist restriction status. Shows whether token trading is restricted and which tokens are whitelisted.",
1254
+ { token_id: import_zod.z.string().describe("Agent NFA Token ID") },
1255
+ async ({ token_id }) => {
1256
+ const { publicClient, policyClient } = createClients();
1257
+ const tokenId = BigInt(token_id);
1258
+ const policies = await policyClient.getPolicies(tokenId);
1259
+ const spendingPolicy = policies.find((p) => p.policyTypeName === "spending_limit");
1260
+ if (!spendingPolicy) {
1261
+ return { content: [{ type: "text", text: JSON.stringify({ tokenId: token_id, error: "No spending_limit policy found \u2014 token restriction is not available for this agent." }) }] };
1262
+ }
1263
+ try {
1264
+ const [enabled, tokenList] = await Promise.all([
1265
+ publicClient.readContract({ address: spendingPolicy.address, abi: SPENDING_LIMIT_ABI, functionName: "tokenRestrictionEnabled", args: [tokenId] }),
1266
+ publicClient.readContract({ address: spendingPolicy.address, abi: SPENDING_LIMIT_ABI, functionName: "getTokenList", args: [tokenId] })
1267
+ ]);
1268
+ const result = {
1269
+ tokenId: token_id,
1270
+ tokenRestrictionEnabled: enabled,
1271
+ status: enabled ? "ON \u2014 only whitelisted tokens can be traded" : "OFF \u2014 any token can be traded",
1272
+ whitelistedTokens: tokenList,
1273
+ whitelistedTokenCount: tokenList.length,
1274
+ manageUrl: `https://shll.run/agent/0xE98DCdbf370D7b52c9A2b88F79bEF514A5375a2b/${token_id}/console/safety`,
1275
+ note: enabled ? "To add/remove tokens or disable restriction, visit the management URL above (requires connected wallet as renter/owner)." : "Token restriction is disabled. The agent can trade any token. To enable, visit the management URL."
1276
+ };
1277
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1278
+ } catch {
1279
+ return { content: [{ type: "text", text: JSON.stringify({ tokenId: token_id, error: "Failed to read token restriction \u2014 the SpendingLimitPolicy may not support this feature." }) }] };
1280
+ }
1281
+ }
1282
+ );
1169
1283
  server.tool(
1170
1284
  "status",
1171
1285
  "One-shot security overview: vault balance, operator status, policies, and recent activity",
package/dist/mcp.mjs CHANGED
@@ -124,7 +124,12 @@ var WBNB_ABI = [
124
124
  ];
125
125
  var SPENDING_LIMIT_ABI = [
126
126
  { type: "function", name: "setLimits", inputs: [{ name: "instanceId", type: "uint256" }, { name: "maxPerTx", type: "uint256" }, { name: "maxPerDay", type: "uint256" }, { name: "maxSlippageBps", type: "uint256" }], outputs: [], stateMutability: "nonpayable" },
127
- { type: "function", name: "instanceLimits", inputs: [{ name: "instanceId", type: "uint256" }], outputs: [{ name: "maxPerTx", type: "uint256" }, { name: "maxPerDay", type: "uint256" }, { name: "maxSlippageBps", type: "uint256" }], stateMutability: "view" }
127
+ { type: "function", name: "instanceLimits", inputs: [{ name: "instanceId", type: "uint256" }], outputs: [{ name: "maxPerTx", type: "uint256" }, { name: "maxPerDay", type: "uint256" }, { name: "maxSlippageBps", type: "uint256" }], stateMutability: "view" },
128
+ { type: "function", name: "tokenRestrictionEnabled", inputs: [{ name: "instanceId", type: "uint256" }], outputs: [{ name: "", type: "bool" }], stateMutability: "view" },
129
+ { type: "function", name: "getTokenList", inputs: [{ name: "instanceId", type: "uint256" }], outputs: [{ name: "", type: "address[]" }], stateMutability: "view" },
130
+ { type: "function", name: "addToken", inputs: [{ name: "instanceId", type: "uint256" }, { name: "token", type: "address" }], outputs: [], stateMutability: "nonpayable" },
131
+ { type: "function", name: "removeToken", inputs: [{ name: "instanceId", type: "uint256" }, { name: "token", type: "address" }], outputs: [], stateMutability: "nonpayable" },
132
+ { type: "function", name: "setTokenRestriction", inputs: [{ name: "instanceId", type: "uint256" }, { name: "enabled", type: "bool" }], outputs: [], stateMutability: "nonpayable" }
128
133
  ];
129
134
  var COOLDOWN_ABI = [
130
135
  { type: "function", name: "setCooldown", inputs: [{ name: "instanceId", type: "uint256" }, { name: "seconds_", type: "uint256" }], outputs: [], stateMutability: "nonpayable" },
@@ -194,6 +199,25 @@ async function checkAgentExpiry(tokenId) {
194
199
  }
195
200
  return { expired: false };
196
201
  }
202
+ function policyRejectionHelp(reason, tokenId) {
203
+ const consoleUrl = `https://shll.run/agent/0xE98DCdbf370D7b52c9A2b88F79bEF514A5375a2b/${tokenId}/console/safety`;
204
+ const r = reason ?? "";
205
+ if (r.includes("Approve spender not allowed"))
206
+ return { explanation: "The DEX router address is not in the approved spender whitelist. This is a platform-level security setting.", action: "Contact the Agent owner to approve this router, or use a different DEX.", consoleUrl };
207
+ if (r.includes("Target not allowed"))
208
+ return { explanation: "The contract address is not in the DeFi target whitelist.", action: "Enable the corresponding DeFi Pack in Console > Safety, or contact the Agent owner.", consoleUrl };
209
+ if (r.includes("Token not in whitelist"))
210
+ return { explanation: "Token restriction is ON and this token is not whitelisted.", action: `Add the token to the whitelist or disable token restriction at: ${consoleUrl}`, consoleUrl };
211
+ if (r.includes("Exceeds per-tx limit"))
212
+ return { explanation: "Transaction value exceeds the per-transaction spending limit.", action: `Reduce the amount, or increase the limit at: ${consoleUrl}`, consoleUrl };
213
+ if (r.includes("Daily limit"))
214
+ return { explanation: "Daily spending limit would be exceeded.", action: `Wait until tomorrow, or increase the daily limit at: ${consoleUrl}`, consoleUrl };
215
+ if (r.includes("Approve exceeds limit"))
216
+ return { explanation: "The approve amount exceeds the configured approve limit.", action: `Reduce the amount, or increase the approve limit at: ${consoleUrl}`, consoleUrl };
217
+ if (r.includes("Cooldown"))
218
+ return { explanation: "Cooldown period has not elapsed since the last transaction.", action: "Wait for the cooldown to expire before retrying.", consoleUrl };
219
+ return { explanation: "Transaction was rejected by an on-chain security policy.", action: `Review your security settings at: ${consoleUrl}`, consoleUrl };
220
+ }
197
221
  var server = new McpServer({
198
222
  name: "shll-defi",
199
223
  version: "5.2.0"
@@ -361,7 +385,44 @@ server.tool(
361
385
  }
362
386
  for (const action of actions) {
363
387
  const sim = await policyClient.validate(tokenId, action);
364
- if (!sim.ok) return { content: [{ type: "text", text: JSON.stringify({ status: "rejected", reason: sim.reason }) }] };
388
+ if (!sim.ok) {
389
+ if (useV3 && sim.reason?.includes("Approve spender not allowed") && v2Available) {
390
+ const v2Actions = [];
391
+ const v2Router = PANCAKE_V2_ROUTER;
392
+ const v2MinOut = v2Quote * BigInt(100 - slippage) / 100n;
393
+ if (!isNativeIn) {
394
+ const allow = await publicClient.readContract({ address: fromToken.address, abi: ERC20_ABI, functionName: "allowance", args: [vault, v2Router] }).catch(() => 0n);
395
+ if (allow < amountIn) {
396
+ v2Actions.push({ target: fromToken.address, value: 0n, data: encodeFunctionData({ abi: ERC20_ABI, functionName: "approve", args: [v2Router, amountIn] }) });
397
+ }
398
+ }
399
+ const v2Path = tokenInAddr.toLowerCase() !== WBNB.toLowerCase() && tokenOutAddr.toLowerCase() !== WBNB.toLowerCase() ? [tokenInAddr, WBNB, tokenOutAddr] : [tokenInAddr, tokenOutAddr];
400
+ if (isNativeIn) {
401
+ v2Actions.push({ target: v2Router, value: amountIn, data: encodeFunctionData({ abi: SWAP_EXACT_ETH_ABI, functionName: "swapExactETHForTokens", args: [v2MinOut, v2Path, vault, deadline] }) });
402
+ } else {
403
+ v2Actions.push({ target: v2Router, value: 0n, data: encodeFunctionData({ abi: SWAP_EXACT_TOKENS_ABI, functionName: "swapExactTokensForTokens", args: [amountIn, v2MinOut, v2Path, vault, deadline] }) });
404
+ }
405
+ for (const v2a of v2Actions) {
406
+ const v2Sim = await policyClient.validate(tokenId, v2a);
407
+ if (!v2Sim.ok) return { content: [{ type: "text", text: JSON.stringify({ status: "rejected", reason: v2Sim.reason, ...policyRejectionHelp(v2Sim.reason, token_id) }) }] };
408
+ }
409
+ const v2Result = v2Actions.length === 1 ? await policyClient.execute(tokenId, v2Actions[0], true) : await policyClient.executeBatch(tokenId, v2Actions, true);
410
+ return {
411
+ content: [{
412
+ type: "text",
413
+ text: JSON.stringify({
414
+ status: "success",
415
+ hash: v2Result.hash,
416
+ dex: "v2",
417
+ quote: v2Quote.toString(),
418
+ minOut: v2MinOut.toString(),
419
+ note: "V3 was rejected by policy (Approve spender not allowed). Auto-switched to V2."
420
+ })
421
+ }]
422
+ };
423
+ }
424
+ return { content: [{ type: "text", text: JSON.stringify({ status: "rejected", reason: sim.reason, ...policyRejectionHelp(sim.reason, token_id) }) }] };
425
+ }
365
426
  }
366
427
  const result = actions.length === 1 ? await policyClient.execute(tokenId, actions[0], true) : await policyClient.executeBatch(tokenId, actions, true);
367
428
  return {
@@ -410,7 +471,7 @@ server.tool(
410
471
  }
411
472
  for (const action of actions) {
412
473
  const sim = await policyClient.validate(tokenId, action);
413
- if (!sim.ok) return { content: [{ type: "text", text: JSON.stringify({ status: "rejected", reason: sim.reason }) }] };
474
+ if (!sim.ok) return { content: [{ type: "text", text: JSON.stringify({ status: "rejected", reason: sim.reason, ...policyRejectionHelp(sim.reason, token_id) }) }] };
414
475
  }
415
476
  const result = actions.length === 1 ? await policyClient.execute(tokenId, actions[0], true) : await policyClient.executeBatch(tokenId, actions, true);
416
477
  return { content: [{ type: "text", text: JSON.stringify({ status: "success", hash: result.hash, protocol: "venus", action: "supply", token: symbol, amount }) }] };
@@ -507,17 +568,14 @@ server.tool(
507
568
  return { content: [{ type: "text", text: JSON.stringify({ status: "success", hash: result.hash, token, amount, to: recipient }) }] };
508
569
  }
509
570
  );
510
- var OPERATOR_OF_ABI = [{
511
- type: "function",
512
- name: "operatorOf",
513
- inputs: [{ name: "tokenId", type: "uint256" }],
514
- outputs: [{ name: "", type: "address" }],
515
- stateMutability: "view"
516
- }];
571
+ var MY_AGENTS_ABI = [
572
+ { type: "function", name: "operatorOf", inputs: [{ name: "tokenId", type: "uint256" }], outputs: [{ name: "", type: "address" }], stateMutability: "view" },
573
+ { type: "function", name: "operatorExpiresOf", inputs: [{ name: "tokenId", type: "uint256" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" }
574
+ ];
517
575
  var DEFAULT_INDEXER = "https://indexer-mainnet.shll.run";
518
576
  server.tool(
519
577
  "my_agents",
520
- "List all agents where the current operator key is authorized. Returns token IDs, vault addresses, and agent types. Call this first if the user does not specify a token ID.",
578
+ "List all agents where the current operator key is or was authorized. Returns active agents and expired agents that need renewal.",
521
579
  {},
522
580
  async () => {
523
581
  const { account, publicClient, config } = createClients();
@@ -534,18 +592,34 @@ server.tool(
534
592
  agents.map(async (a) => {
535
593
  const tokenId = BigInt(a.tokenId);
536
594
  try {
537
- const op = await publicClient.readContract({
538
- address: nfaAddr,
539
- abi: OPERATOR_OF_ABI,
540
- functionName: "operatorOf",
541
- args: [tokenId]
542
- });
543
- return op.toLowerCase() === operator ? {
544
- tokenId: tokenId.toString(),
545
- vault: a.account || "",
546
- owner: a.owner || "",
547
- agentType: a.agentType || "unknown"
548
- } : null;
595
+ const [op, opExpires] = await Promise.all([
596
+ publicClient.readContract({ address: nfaAddr, abi: MY_AGENTS_ABI, functionName: "operatorOf", args: [tokenId] }),
597
+ publicClient.readContract({ address: nfaAddr, abi: MY_AGENTS_ABI, functionName: "operatorExpiresOf", args: [tokenId] })
598
+ ]);
599
+ const isActive = op.toLowerCase() === operator;
600
+ const now = BigInt(Math.floor(Date.now() / 1e3));
601
+ const isExpired = !isActive && Number(opExpires) > 0 && now > opExpires;
602
+ if (isActive) {
603
+ return {
604
+ tokenId: tokenId.toString(),
605
+ vault: a.account || "",
606
+ owner: a.owner || "",
607
+ agentType: a.agentType || "unknown",
608
+ status: "active",
609
+ operatorExpires: new Date(Number(opExpires) * 1e3).toISOString()
610
+ };
611
+ } else if (isExpired) {
612
+ return {
613
+ tokenId: tokenId.toString(),
614
+ vault: a.account || "",
615
+ owner: a.owner || "",
616
+ agentType: a.agentType || "unknown",
617
+ status: "expired",
618
+ operatorExpires: new Date(Number(opExpires) * 1e3).toISOString(),
619
+ note: "Operator authorization expired. Renew at https://shll.run/me"
620
+ };
621
+ }
622
+ return null;
549
623
  } catch {
550
624
  return null;
551
625
  }
@@ -653,7 +727,15 @@ server.tool(
653
727
  const [maxPerTx, maxPerDay, maxSlippageBps] = limits;
654
728
  const txBnb = (Number(maxPerTx) / 1e18).toFixed(4);
655
729
  const dayBnb = (Number(maxPerDay) / 1e18).toFixed(4);
656
- entry.currentConfig = { maxPerTx: maxPerTx.toString(), maxPerTxBnb: txBnb, maxPerDay: maxPerDay.toString(), maxPerDayBnb: dayBnb, maxSlippageBps: maxSlippageBps.toString() };
730
+ let tokenRestriction = {};
731
+ try {
732
+ const enabled = await publicClient.readContract({ address: p.address, abi: SPENDING_LIMIT_ABI, functionName: "tokenRestrictionEnabled", args: [tokenId] });
733
+ const tokenList = await publicClient.readContract({ address: p.address, abi: SPENDING_LIMIT_ABI, functionName: "getTokenList", args: [tokenId] });
734
+ tokenRestriction = { tokenRestrictionEnabled: enabled, whitelistedTokens: tokenList, whitelistedTokenCount: tokenList.length };
735
+ summaryParts.push(enabled ? `Token whitelist ON (${tokenList.length} tokens)` : "Token whitelist OFF (any token allowed)");
736
+ } catch {
737
+ }
738
+ entry.currentConfig = { maxPerTx: maxPerTx.toString(), maxPerTxBnb: txBnb, maxPerDay: maxPerDay.toString(), maxPerDayBnb: dayBnb, maxSlippageBps: maxSlippageBps.toString(), ...tokenRestriction };
657
739
  summaryParts.push(`Max ${txBnb} BNB/tx, ${dayBnb} BNB/day, slippage ${maxSlippageBps}bps`);
658
740
  } catch {
659
741
  }
@@ -677,6 +759,38 @@ server.tool(
677
759
  return { content: [{ type: "text", text: JSON.stringify({ tokenId: token_id, humanSummary, securityNote: "Operator wallet CANNOT withdraw vault funds or transfer Agent NFT.", policies: enriched }) }] };
678
760
  }
679
761
  );
762
+ server.tool(
763
+ "token_restriction",
764
+ "Check token whitelist restriction status. Shows whether token trading is restricted and which tokens are whitelisted.",
765
+ { token_id: z.string().describe("Agent NFA Token ID") },
766
+ async ({ token_id }) => {
767
+ const { publicClient, policyClient } = createClients();
768
+ const tokenId = BigInt(token_id);
769
+ const policies = await policyClient.getPolicies(tokenId);
770
+ const spendingPolicy = policies.find((p) => p.policyTypeName === "spending_limit");
771
+ if (!spendingPolicy) {
772
+ return { content: [{ type: "text", text: JSON.stringify({ tokenId: token_id, error: "No spending_limit policy found \u2014 token restriction is not available for this agent." }) }] };
773
+ }
774
+ try {
775
+ const [enabled, tokenList] = await Promise.all([
776
+ publicClient.readContract({ address: spendingPolicy.address, abi: SPENDING_LIMIT_ABI, functionName: "tokenRestrictionEnabled", args: [tokenId] }),
777
+ publicClient.readContract({ address: spendingPolicy.address, abi: SPENDING_LIMIT_ABI, functionName: "getTokenList", args: [tokenId] })
778
+ ]);
779
+ const result = {
780
+ tokenId: token_id,
781
+ tokenRestrictionEnabled: enabled,
782
+ status: enabled ? "ON \u2014 only whitelisted tokens can be traded" : "OFF \u2014 any token can be traded",
783
+ whitelistedTokens: tokenList,
784
+ whitelistedTokenCount: tokenList.length,
785
+ manageUrl: `https://shll.run/agent/0xE98DCdbf370D7b52c9A2b88F79bEF514A5375a2b/${token_id}/console/safety`,
786
+ note: enabled ? "To add/remove tokens or disable restriction, visit the management URL above (requires connected wallet as renter/owner)." : "Token restriction is disabled. The agent can trade any token. To enable, visit the management URL."
787
+ };
788
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
789
+ } catch {
790
+ return { content: [{ type: "text", text: JSON.stringify({ tokenId: token_id, error: "Failed to read token restriction \u2014 the SpendingLimitPolicy may not support this feature." }) }] };
791
+ }
792
+ }
793
+ );
680
794
  server.tool(
681
795
  "status",
682
796
  "One-shot security overview: vault balance, operator status, policies, and recent activity",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shll-skills",
3
- "version": "5.2.0",
3
+ "version": "5.3.0",
4
4
  "description": "SHLL DeFi Agent — CLI + MCP Server for BSC",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
package/src/mcp.ts CHANGED
@@ -153,6 +153,11 @@ const WBNB_ABI = [
153
153
  const SPENDING_LIMIT_ABI = [
154
154
  { type: "function" as const, name: "setLimits", inputs: [{ name: "instanceId", type: "uint256" }, { name: "maxPerTx", type: "uint256" }, { name: "maxPerDay", type: "uint256" }, { name: "maxSlippageBps", type: "uint256" }], outputs: [], stateMutability: "nonpayable" as const },
155
155
  { type: "function" as const, name: "instanceLimits", inputs: [{ name: "instanceId", type: "uint256" }], outputs: [{ name: "maxPerTx", type: "uint256" }, { name: "maxPerDay", type: "uint256" }, { name: "maxSlippageBps", type: "uint256" }], stateMutability: "view" as const },
156
+ { type: "function" as const, name: "tokenRestrictionEnabled", inputs: [{ name: "instanceId", type: "uint256" }], outputs: [{ name: "", type: "bool" }], stateMutability: "view" as const },
157
+ { type: "function" as const, name: "getTokenList", inputs: [{ name: "instanceId", type: "uint256" }], outputs: [{ name: "", type: "address[]" }], stateMutability: "view" as const },
158
+ { type: "function" as const, name: "addToken", inputs: [{ name: "instanceId", type: "uint256" }, { name: "token", type: "address" }], outputs: [], stateMutability: "nonpayable" as const },
159
+ { type: "function" as const, name: "removeToken", inputs: [{ name: "instanceId", type: "uint256" }, { name: "token", type: "address" }], outputs: [], stateMutability: "nonpayable" as const },
160
+ { type: "function" as const, name: "setTokenRestriction", inputs: [{ name: "instanceId", type: "uint256" }, { name: "enabled", type: "bool" }], outputs: [], stateMutability: "nonpayable" as const },
156
161
  ] as const;
157
162
 
158
163
  const COOLDOWN_ABI = [
@@ -234,6 +239,27 @@ async function checkAgentExpiry(tokenId: bigint) {
234
239
  return { expired: false };
235
240
  }
236
241
 
242
+ // Policy rejection → actionable user guidance
243
+ function policyRejectionHelp(reason: string | undefined, tokenId: string): Record<string, string> {
244
+ const consoleUrl = `https://shll.run/agent/0xE98DCdbf370D7b52c9A2b88F79bEF514A5375a2b/${tokenId}/console/safety`;
245
+ const r = reason ?? "";
246
+ if (r.includes("Approve spender not allowed"))
247
+ return { explanation: "The DEX router address is not in the approved spender whitelist. This is a platform-level security setting.", action: "Contact the Agent owner to approve this router, or use a different DEX.", consoleUrl };
248
+ if (r.includes("Target not allowed"))
249
+ return { explanation: "The contract address is not in the DeFi target whitelist.", action: "Enable the corresponding DeFi Pack in Console > Safety, or contact the Agent owner.", consoleUrl };
250
+ if (r.includes("Token not in whitelist"))
251
+ return { explanation: "Token restriction is ON and this token is not whitelisted.", action: `Add the token to the whitelist or disable token restriction at: ${consoleUrl}`, consoleUrl };
252
+ if (r.includes("Exceeds per-tx limit"))
253
+ return { explanation: "Transaction value exceeds the per-transaction spending limit.", action: `Reduce the amount, or increase the limit at: ${consoleUrl}`, consoleUrl };
254
+ if (r.includes("Daily limit"))
255
+ return { explanation: "Daily spending limit would be exceeded.", action: `Wait until tomorrow, or increase the daily limit at: ${consoleUrl}`, consoleUrl };
256
+ if (r.includes("Approve exceeds limit"))
257
+ return { explanation: "The approve amount exceeds the configured approve limit.", action: `Reduce the amount, or increase the approve limit at: ${consoleUrl}`, consoleUrl };
258
+ if (r.includes("Cooldown"))
259
+ return { explanation: "Cooldown period has not elapsed since the last transaction.", action: "Wait for the cooldown to expire before retrying.", consoleUrl };
260
+ return { explanation: "Transaction was rejected by an on-chain security policy.", action: `Review your security settings at: ${consoleUrl}`, consoleUrl };
261
+ }
262
+
237
263
  // ═══════════════════════════════════════════════════════
238
264
  // MCP Server
239
265
  // ═══════════════════════════════════════════════════════
@@ -349,7 +375,7 @@ server.tool(
349
375
  async ({ token_id, from, to, amount, dex, slippage }) => {
350
376
  const { publicClient, policyClient } = createClients();
351
377
  const tokenId = BigInt(token_id);
352
- const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
378
+ const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
353
379
  const vault = await policyClient.getVault(tokenId);
354
380
 
355
381
  const fromToken = resolveToken(from);
@@ -421,10 +447,48 @@ server.tool(
421
447
  }
422
448
  }
423
449
 
424
- // Validate + execute
450
+ // Validate + execute (with V3→V2 fallback on approve rejection)
425
451
  for (const action of actions) {
426
452
  const sim = await policyClient.validate(tokenId, action);
427
- if (!sim.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ status: "rejected", reason: sim.reason }) }] };
453
+ if (!sim.ok) {
454
+ // If V3 approve was rejected, try V2 fallback
455
+ if (useV3 && sim.reason?.includes("Approve spender not allowed") && v2Available) {
456
+ const v2Actions: Action[] = [];
457
+ const v2Router = PANCAKE_V2_ROUTER as Address;
458
+ const v2MinOut = (v2Quote * BigInt(100 - slippage)) / 100n;
459
+ if (!isNativeIn) {
460
+ const allow = await publicClient.readContract({ address: fromToken.address, abi: ERC20_ABI, functionName: "allowance", args: [vault, v2Router] }).catch(() => 0n);
461
+ if (allow < amountIn) {
462
+ v2Actions.push({ target: fromToken.address, value: 0n, data: encodeFunctionData({ abi: ERC20_ABI, functionName: "approve", args: [v2Router, amountIn] }) });
463
+ }
464
+ }
465
+ const v2Path: Address[] = tokenInAddr.toLowerCase() !== WBNB.toLowerCase() && tokenOutAddr.toLowerCase() !== WBNB.toLowerCase()
466
+ ? [tokenInAddr, WBNB as Address, tokenOutAddr] : [tokenInAddr, tokenOutAddr];
467
+ if (isNativeIn) {
468
+ v2Actions.push({ target: v2Router, value: amountIn, data: encodeFunctionData({ abi: SWAP_EXACT_ETH_ABI, functionName: "swapExactETHForTokens", args: [v2MinOut, v2Path, vault, deadline] }) });
469
+ } else {
470
+ v2Actions.push({ target: v2Router, value: 0n, data: encodeFunctionData({ abi: SWAP_EXACT_TOKENS_ABI, functionName: "swapExactTokensForTokens", args: [amountIn, v2MinOut, v2Path, vault, deadline] }) });
471
+ }
472
+ // Validate V2 fallback
473
+ for (const v2a of v2Actions) {
474
+ const v2Sim = await policyClient.validate(tokenId, v2a);
475
+ if (!v2Sim.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ status: "rejected", reason: v2Sim.reason, ...policyRejectionHelp(v2Sim.reason, token_id) }) }] };
476
+ }
477
+ const v2Result = v2Actions.length === 1
478
+ ? await policyClient.execute(tokenId, v2Actions[0], true)
479
+ : await policyClient.executeBatch(tokenId, v2Actions, true);
480
+ return {
481
+ content: [{
482
+ type: "text" as const, text: JSON.stringify({
483
+ status: "success", hash: v2Result.hash, dex: "v2",
484
+ quote: v2Quote.toString(), minOut: v2MinOut.toString(),
485
+ note: "V3 was rejected by policy (Approve spender not allowed). Auto-switched to V2.",
486
+ })
487
+ }]
488
+ };
489
+ }
490
+ return { content: [{ type: "text" as const, text: JSON.stringify({ status: "rejected", reason: sim.reason, ...policyRejectionHelp(sim.reason, token_id) }) }] };
491
+ }
428
492
  }
429
493
 
430
494
  const result = actions.length === 1
@@ -454,7 +518,7 @@ server.tool(
454
518
  async ({ token_id, token, amount }) => {
455
519
  const { publicClient, policyClient } = createClients();
456
520
  const tokenId = BigInt(token_id);
457
- const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
521
+ const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
458
522
  const symbol = token.toUpperCase();
459
523
  const vTokenAddr = VENUS_VTOKENS[symbol];
460
524
  if (!vTokenAddr) return { content: [{ type: "text" as const, text: JSON.stringify({ error: `Unsupported: ${symbol}. Use: ${Object.keys(VENUS_VTOKENS).join(", ")}` }) }] };
@@ -477,7 +541,7 @@ server.tool(
477
541
 
478
542
  for (const action of actions) {
479
543
  const sim = await policyClient.validate(tokenId, action);
480
- if (!sim.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ status: "rejected", reason: sim.reason }) }] };
544
+ if (!sim.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ status: "rejected", reason: sim.reason, ...policyRejectionHelp(sim.reason, token_id) }) }] };
481
545
  }
482
546
 
483
547
  const result = actions.length === 1
@@ -500,7 +564,7 @@ server.tool(
500
564
  async ({ token_id, token, amount }) => {
501
565
  const { policyClient } = createClients();
502
566
  const tokenId = BigInt(token_id);
503
- const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
567
+ const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
504
568
  const symbol = token.toUpperCase();
505
569
  const vTokenAddr = VENUS_VTOKENS[symbol];
506
570
  if (!vTokenAddr) return { content: [{ type: "text" as const, text: JSON.stringify({ error: `Unsupported: ${symbol}` }) }] };
@@ -565,7 +629,7 @@ server.tool(
565
629
  async ({ token_id, token, amount, to }) => {
566
630
  const { policyClient } = createClients();
567
631
  const tokenId = BigInt(token_id);
568
- const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
632
+ const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
569
633
  const tokenInfo = resolveToken(token);
570
634
  const amt = parseAmount(amount, tokenInfo.decimals);
571
635
  const recipient = to as Address;
@@ -593,18 +657,16 @@ server.tool(
593
657
  );
594
658
 
595
659
  // ── Tool: my_agents ─────────────────────────────────────
596
- const OPERATOR_OF_ABI = [{
597
- type: "function" as const, name: "operatorOf",
598
- inputs: [{ name: "tokenId", type: "uint256" }],
599
- outputs: [{ name: "", type: "address" }],
600
- stateMutability: "view" as const,
601
- }] as const;
660
+ const MY_AGENTS_ABI = [
661
+ { type: "function" as const, name: "operatorOf", inputs: [{ name: "tokenId", type: "uint256" }], outputs: [{ name: "", type: "address" }], stateMutability: "view" as const },
662
+ { type: "function" as const, name: "operatorExpiresOf", inputs: [{ name: "tokenId", type: "uint256" }], outputs: [{ name: "", type: "uint256" }], stateMutability: "view" as const },
663
+ ] as const;
602
664
 
603
665
  const DEFAULT_INDEXER = "https://indexer-mainnet.shll.run";
604
666
 
605
667
  server.tool(
606
668
  "my_agents",
607
- "List all agents where the current operator key is authorized. Returns token IDs, vault addresses, and agent types. Call this first if the user does not specify a token ID.",
669
+ "List all agents where the current operator key is or was authorized. Returns active agents and expired agents that need renewal.",
608
670
  {},
609
671
  async () => {
610
672
  const { account, publicClient, config } = createClients();
@@ -621,23 +683,34 @@ server.tool(
621
683
  return { content: [{ type: "text" as const, text: JSON.stringify({ operator, agents: [], count: 0 }) }] };
622
684
  }
623
685
 
624
- // 2. Batch check operatorOf for all agents
686
+ // 2. Check operatorOf AND operatorExpiresOf for all agents
625
687
  const checks = await Promise.all(
626
688
  agents.map(async (a) => {
627
689
  const tokenId = BigInt(a.tokenId!);
628
690
  try {
629
- const op = await publicClient.readContract({
630
- address: nfaAddr,
631
- abi: OPERATOR_OF_ABI,
632
- functionName: "operatorOf",
633
- args: [tokenId],
634
- });
635
- return (op as string).toLowerCase() === operator ? {
636
- tokenId: tokenId.toString(),
637
- vault: a.account || "",
638
- owner: a.owner || "",
639
- agentType: a.agentType || "unknown",
640
- } : null;
691
+ const [op, opExpires] = await Promise.all([
692
+ publicClient.readContract({ address: nfaAddr, abi: MY_AGENTS_ABI, functionName: "operatorOf", args: [tokenId] }) as Promise<string>,
693
+ publicClient.readContract({ address: nfaAddr, abi: MY_AGENTS_ABI, functionName: "operatorExpiresOf", args: [tokenId] }) as Promise<bigint>,
694
+ ]);
695
+ const isActive = op.toLowerCase() === operator;
696
+ const now = BigInt(Math.floor(Date.now() / 1000));
697
+ const isExpired = !isActive && Number(opExpires) > 0 && now > opExpires;
698
+
699
+ if (isActive) {
700
+ return {
701
+ tokenId: tokenId.toString(), vault: a.account || "", owner: a.owner || "",
702
+ agentType: a.agentType || "unknown", status: "active" as const,
703
+ operatorExpires: new Date(Number(opExpires) * 1000).toISOString(),
704
+ };
705
+ } else if (isExpired) {
706
+ return {
707
+ tokenId: tokenId.toString(), vault: a.account || "", owner: a.owner || "",
708
+ agentType: a.agentType || "unknown", status: "expired" as const,
709
+ operatorExpires: new Date(Number(opExpires) * 1000).toISOString(),
710
+ note: "Operator authorization expired. Renew at https://shll.run/me",
711
+ };
712
+ }
713
+ return null;
641
714
  } catch { return null; }
642
715
  })
643
716
  );
@@ -663,7 +736,7 @@ server.tool(
663
736
  async ({ token_id, amount }) => {
664
737
  const { policyClient } = createClients();
665
738
  const tokenId = BigInt(token_id);
666
- const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
739
+ const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
667
740
  const amt = parseEther(amount);
668
741
  const data = encodeFunctionData({ abi: WBNB_ABI, functionName: "deposit" });
669
742
  const action: Action = { target: WBNB as Address, value: amt, data };
@@ -687,7 +760,7 @@ server.tool(
687
760
  async ({ token_id, amount }) => {
688
761
  const { policyClient } = createClients();
689
762
  const tokenId = BigInt(token_id);
690
- const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
763
+ const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
691
764
  const amt = parseEther(amount);
692
765
  const data = encodeFunctionData({ abi: WBNB_ABI, functionName: "withdraw", args: [amt] });
693
766
  const action: Action = { target: WBNB as Address, value: 0n, data };
@@ -759,7 +832,15 @@ server.tool(
759
832
  const [maxPerTx, maxPerDay, maxSlippageBps] = limits;
760
833
  const txBnb = (Number(maxPerTx) / 1e18).toFixed(4);
761
834
  const dayBnb = (Number(maxPerDay) / 1e18).toFixed(4);
762
- entry.currentConfig = { maxPerTx: maxPerTx.toString(), maxPerTxBnb: txBnb, maxPerDay: maxPerDay.toString(), maxPerDayBnb: dayBnb, maxSlippageBps: maxSlippageBps.toString() };
835
+ // Also read token restriction status
836
+ let tokenRestriction: Record<string, unknown> = {};
837
+ try {
838
+ const enabled = await publicClient.readContract({ address: p.address, abi: SPENDING_LIMIT_ABI, functionName: "tokenRestrictionEnabled", args: [tokenId] }) as boolean;
839
+ const tokenList = await publicClient.readContract({ address: p.address, abi: SPENDING_LIMIT_ABI, functionName: "getTokenList", args: [tokenId] }) as string[];
840
+ tokenRestriction = { tokenRestrictionEnabled: enabled, whitelistedTokens: tokenList, whitelistedTokenCount: tokenList.length };
841
+ summaryParts.push(enabled ? `Token whitelist ON (${tokenList.length} tokens)` : "Token whitelist OFF (any token allowed)");
842
+ } catch { /* token restriction not available on this version */ }
843
+ entry.currentConfig = { maxPerTx: maxPerTx.toString(), maxPerTxBnb: txBnb, maxPerDay: maxPerDay.toString(), maxPerDayBnb: dayBnb, maxSlippageBps: maxSlippageBps.toString(), ...tokenRestriction };
763
844
  summaryParts.push(`Max ${txBnb} BNB/tx, ${dayBnb} BNB/day, slippage ${maxSlippageBps}bps`);
764
845
  } catch { /* policy read failed */ }
765
846
  }
@@ -784,6 +865,42 @@ server.tool(
784
865
  }
785
866
  );
786
867
 
868
+ // ── Tool: token_restriction ─────────────────────────────
869
+ server.tool(
870
+ "token_restriction",
871
+ "Check token whitelist restriction status. Shows whether token trading is restricted and which tokens are whitelisted.",
872
+ { token_id: z.string().describe("Agent NFA Token ID") },
873
+ async ({ token_id }) => {
874
+ const { publicClient, policyClient } = createClients();
875
+ const tokenId = BigInt(token_id);
876
+ const policies = await policyClient.getPolicies(tokenId);
877
+ const spendingPolicy = policies.find(p => p.policyTypeName === "spending_limit");
878
+ if (!spendingPolicy) {
879
+ return { content: [{ type: "text" as const, text: JSON.stringify({ tokenId: token_id, error: "No spending_limit policy found — token restriction is not available for this agent." }) }] };
880
+ }
881
+ try {
882
+ const [enabled, tokenList] = await Promise.all([
883
+ publicClient.readContract({ address: spendingPolicy.address, abi: SPENDING_LIMIT_ABI, functionName: "tokenRestrictionEnabled", args: [tokenId] }) as Promise<boolean>,
884
+ publicClient.readContract({ address: spendingPolicy.address, abi: SPENDING_LIMIT_ABI, functionName: "getTokenList", args: [tokenId] }) as Promise<string[]>,
885
+ ]);
886
+ const result = {
887
+ tokenId: token_id,
888
+ tokenRestrictionEnabled: enabled,
889
+ status: enabled ? "ON — only whitelisted tokens can be traded" : "OFF — any token can be traded",
890
+ whitelistedTokens: tokenList,
891
+ whitelistedTokenCount: tokenList.length,
892
+ manageUrl: `https://shll.run/agent/0xE98DCdbf370D7b52c9A2b88F79bEF514A5375a2b/${token_id}/console/safety`,
893
+ note: enabled
894
+ ? "To add/remove tokens or disable restriction, visit the management URL above (requires connected wallet as renter/owner)."
895
+ : "Token restriction is disabled. The agent can trade any token. To enable, visit the management URL.",
896
+ };
897
+ return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
898
+ } catch {
899
+ return { content: [{ type: "text" as const, text: JSON.stringify({ tokenId: token_id, error: "Failed to read token restriction — the SpendingLimitPolicy may not support this feature." }) }] };
900
+ }
901
+ }
902
+ );
903
+
787
904
  // ── Tool: status ────────────────────────────────────────
788
905
  server.tool(
789
906
  "status",
@@ -911,7 +1028,7 @@ server.tool(
911
1028
 
912
1029
  const { account, publicClient, policyClient, config } = createClients();
913
1030
  const tokenId = BigInt(token_id);
914
- const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
1031
+ const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
915
1032
  const walletClient = createWalletClient({ account, chain: bsc, transport: http(config.rpc) });
916
1033
  const policies = await policyClient.getPolicies(tokenId);
917
1034
  const results: string[] = [];
@@ -1033,7 +1150,7 @@ server.tool(
1033
1150
  async ({ token_id, target, data, value }) => {
1034
1151
  const { policyClient } = createClients();
1035
1152
  const tokenId = BigInt(token_id);
1036
- const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
1153
+ const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
1037
1154
  const action: Action = {
1038
1155
  target: target as Address,
1039
1156
  value: BigInt(value),
@@ -1082,7 +1199,7 @@ server.tool(
1082
1199
  async ({ token_id, actions: rawActions }) => {
1083
1200
  const { policyClient } = createClients();
1084
1201
  const tokenId = BigInt(token_id);
1085
- const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
1202
+ const expiryCheck = await checkAgentExpiry(tokenId); if (expiryCheck.expired) return { content: expiryCheck.content! };
1086
1203
  const actions: Action[] = rawActions.map(a => ({
1087
1204
  target: a.target as Address,
1088
1205
  value: BigInt(a.value || "0"),