@t2000/engine 0.48.0 → 0.50.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.
package/dist/index.js CHANGED
@@ -711,6 +711,262 @@ function parseNumberOrNull(input) {
711
711
  const n = Number(input);
712
712
  return Number.isFinite(n) ? n : null;
713
713
  }
714
+ var DEFI_PORTFOLIO_TIMEOUT_MS = 4e3;
715
+ var DEFI_CACHE_TTL_MS = 6e4;
716
+ var DEFI_PROTOCOLS = [
717
+ "cetus",
718
+ "suilend",
719
+ "scallop",
720
+ "bluefin",
721
+ "aftermath",
722
+ "haedal"
723
+ ];
724
+ var defiCache = /* @__PURE__ */ new Map();
725
+ var defiInflight = /* @__PURE__ */ new Map();
726
+ async function fetchAddressDefiPortfolio(address, apiKey, priceHints = {}) {
727
+ if (!apiKey || apiKey.trim().length === 0) {
728
+ return { totalUsd: 0, perProtocol: {}, pricedAt: Date.now(), source: "degraded" };
729
+ }
730
+ const now = Date.now();
731
+ const cached = defiCache.get(address);
732
+ if (cached && now - cached.ts < DEFI_CACHE_TTL_MS) return cached.data;
733
+ let inflight = defiInflight.get(address);
734
+ if (inflight) return inflight;
735
+ inflight = (async () => {
736
+ try {
737
+ const settled = await Promise.allSettled(
738
+ DEFI_PROTOCOLS.map((p) => fetchOneDefiProtocol(address, p, apiKey))
739
+ );
740
+ const seen = /* @__PURE__ */ new Set();
741
+ for (const s of settled) {
742
+ if (s.status === "fulfilled" && s.value) collectCoinTypes(s.value, seen);
743
+ }
744
+ const normalizedHints = {};
745
+ for (const [k, v] of Object.entries(priceHints)) {
746
+ normalizedHints[normalizeCoinType(k)] = v;
747
+ }
748
+ const missing = Array.from(seen).filter((ct) => {
749
+ const norm = normalizeCoinType(ct);
750
+ return !normalizedHints[norm] && !STABLE_USD_PRICES[norm];
751
+ });
752
+ let fetchedPrices = {};
753
+ if (missing.length > 0) {
754
+ try {
755
+ fetchedPrices = await fetchTokenPrices(missing, apiKey);
756
+ } catch (err) {
757
+ console.warn("[defi] fill-missing-prices failed:", err);
758
+ }
759
+ }
760
+ const prices = { ...normalizedHints };
761
+ for (const [ct, v] of Object.entries(fetchedPrices)) {
762
+ prices[normalizeCoinType(ct)] ??= v.price;
763
+ }
764
+ for (const [ct, p] of Object.entries(STABLE_USD_PRICES)) {
765
+ prices[normalizeCoinType(ct)] ??= p;
766
+ }
767
+ let totalUsd = 0;
768
+ let failures = 0;
769
+ const perProtocol = {};
770
+ for (let i = 0; i < DEFI_PROTOCOLS.length; i++) {
771
+ const proto = DEFI_PROTOCOLS[i];
772
+ const s = settled[i];
773
+ if (s.status !== "fulfilled" || !s.value) {
774
+ failures++;
775
+ continue;
776
+ }
777
+ try {
778
+ const usd = NORMALIZERS[proto](s.value, prices);
779
+ if (Number.isFinite(usd) && usd !== 0) {
780
+ perProtocol[proto] = usd;
781
+ totalUsd += usd;
782
+ }
783
+ } catch (err) {
784
+ console.warn(`[defi] ${proto} normaliser threw:`, err);
785
+ failures++;
786
+ }
787
+ }
788
+ if (totalUsd < 0) totalUsd = 0;
789
+ const summary = {
790
+ totalUsd,
791
+ perProtocol,
792
+ pricedAt: Date.now(),
793
+ source: failures === DEFI_PROTOCOLS.length ? "degraded" : failures > 0 ? "partial" : "blockvision"
794
+ };
795
+ defiCache.set(address, { data: summary, ts: Date.now() });
796
+ return summary;
797
+ } finally {
798
+ defiInflight.delete(address);
799
+ }
800
+ })();
801
+ defiInflight.set(address, inflight);
802
+ return inflight;
803
+ }
804
+ async function fetchOneDefiProtocol(address, protocol, apiKey) {
805
+ const url = `${BLOCKVISION_BASE}/account/defiPortfolio?address=${encodeURIComponent(address)}&protocol=${protocol}`;
806
+ let res;
807
+ try {
808
+ res = await fetch(url, {
809
+ headers: { "x-api-key": apiKey, accept: "application/json" },
810
+ signal: AbortSignal.timeout(DEFI_PORTFOLIO_TIMEOUT_MS)
811
+ });
812
+ } catch (err) {
813
+ console.warn(`[defi] ${protocol} fetch threw:`, err);
814
+ return null;
815
+ }
816
+ if (!res.ok) {
817
+ console.warn(`[defi] ${protocol} HTTP ${res.status}`);
818
+ return null;
819
+ }
820
+ let json;
821
+ try {
822
+ json = await res.json();
823
+ } catch (err) {
824
+ console.warn(`[defi] ${protocol} JSON parse failed:`, err);
825
+ return null;
826
+ }
827
+ if (json.code !== 200 || !json.result) return null;
828
+ return json.result;
829
+ }
830
+ function collectCoinTypes(obj, out) {
831
+ if (!obj || typeof obj !== "object") return;
832
+ if (Array.isArray(obj)) {
833
+ for (const x of obj) collectCoinTypes(x, out);
834
+ return;
835
+ }
836
+ for (const [k, v] of Object.entries(obj)) {
837
+ if (typeof v === "string" && v.startsWith("0x") && v.includes("::")) {
838
+ const lk = k.toLowerCase();
839
+ if (lk.includes("cointype") || lk === "cointypea" || lk === "cointypeb" || lk === "tokenxtype" || lk === "tokenytype" || lk === "coinaddress" || lk === "phantomtype" || lk === "typename") {
840
+ out.add(v);
841
+ }
842
+ } else if (typeof v === "object" && v !== null) {
843
+ collectCoinTypes(v, out);
844
+ }
845
+ }
846
+ }
847
+ function priceFor(coinType, prices) {
848
+ const norm = normalizeCoinType(coinType);
849
+ return prices[norm] ?? prices[coinType] ?? STABLE_USD_PRICES[norm] ?? 0;
850
+ }
851
+ function rawToUsd(coinType, raw, decimalsHint, prices) {
852
+ if (raw == null) return 0;
853
+ const decimals = typeof decimalsHint === "number" ? decimalsHint : getDecimalsForCoinType(coinType);
854
+ const amount = Number(raw) / 10 ** decimals;
855
+ if (!Number.isFinite(amount)) return 0;
856
+ return amount * priceFor(coinType, prices);
857
+ }
858
+ var NORMALIZERS = {
859
+ cetus: normalizeCetus,
860
+ suilend: normalizeSuilend,
861
+ scallop: normalizeScallop,
862
+ bluefin: normalizeBluefin,
863
+ aftermath: normalizeAftermath,
864
+ haedal: normalizeHaedal
865
+ };
866
+ function normalizeCetus(result, prices) {
867
+ const data = result.cetus ?? {};
868
+ let total = 0;
869
+ const sumPair = (item, aField, bField) => {
870
+ if (item.coinTypeA && item[aField] != null) {
871
+ const dec = item.coinTypeADecimals ?? item.coinA?.decimals;
872
+ total += rawToUsd(item.coinTypeA, item[aField], dec, prices);
873
+ }
874
+ if (item.coinTypeB && item[bField] != null) {
875
+ const dec = item.coinTypeBDecimals ?? item.coinB?.decimals;
876
+ total += rawToUsd(item.coinTypeB, item[bField], dec, prices);
877
+ }
878
+ };
879
+ for (const lp of data.lps ?? []) sumPair(lp, "balanceA", "balanceB");
880
+ for (const farm of data.farms ?? []) sumPair(farm, "balanceA", "balanceB");
881
+ for (const vault of data.vaults ?? []) sumPair(vault, "coinAAmount", "coinBAmount");
882
+ return total;
883
+ }
884
+ function normalizeSuilend(result, prices) {
885
+ const data = result.suilend ?? {};
886
+ let total = 0;
887
+ for (const d of data.deposits ?? []) {
888
+ if (d.coinType && d.amount != null) total += rawToUsd(d.coinType, d.amount, d.decimals, prices);
889
+ }
890
+ for (const b of data.borrows ?? []) {
891
+ if (b.coinType && b.amount != null) total -= rawToUsd(b.coinType, b.amount, b.decimals, prices);
892
+ }
893
+ for (const s of data.strategies ?? []) {
894
+ if (s.coinType && s.amount != null) total += rawToUsd(s.coinType, s.amount, s.decimals, prices);
895
+ }
896
+ return total;
897
+ }
898
+ function normalizeScallop(result, _prices) {
899
+ const s = result.scallop;
900
+ if (!s) return 0;
901
+ const supply = Number(s.totalSupplyValue ?? 0);
902
+ const collateral = Number(s.totalCollateralValue ?? 0);
903
+ const locked = Number(s.totalLockedScaValue ?? 0);
904
+ const debt = Number(s.totalDebtValue ?? 0);
905
+ const net = (Number.isFinite(supply) ? supply : 0) + (Number.isFinite(collateral) ? collateral : 0) + (Number.isFinite(locked) ? locked : 0) - (Number.isFinite(debt) ? debt : 0);
906
+ return net;
907
+ }
908
+ function normalizeBluefin(result, prices) {
909
+ const data = result.bluefin ?? {};
910
+ let total = 0;
911
+ for (const lp of data.lps ?? []) {
912
+ if (lp.coinTypeA && lp.coinAmountA != null) {
913
+ total += rawToUsd(lp.coinTypeA, lp.coinAmountA, void 0, prices);
914
+ }
915
+ if (lp.coinTypeB && lp.coinAmountB != null) {
916
+ total += rawToUsd(lp.coinTypeB, lp.coinAmountB, void 0, prices);
917
+ }
918
+ }
919
+ if (data.usdcVault?.amount != null) {
920
+ total += rawToUsd(
921
+ "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC",
922
+ data.usdcVault.amount,
923
+ 6,
924
+ prices
925
+ );
926
+ }
927
+ if (data.blueVault?.amount != null) {
928
+ total += rawToUsd(
929
+ "0xe1b45a0e641b9955a20aa0ad1c1f4ad86aad8afb07296d4085e349a50e90bdca::blue::BLUE",
930
+ data.blueVault.amount,
931
+ 9,
932
+ prices
933
+ );
934
+ }
935
+ return total;
936
+ }
937
+ function normalizeAftermath(result, prices) {
938
+ const data = result.aftermath ?? {};
939
+ let total = 0;
940
+ const positions = [...data.lpPositions ?? [], ...data.farmPositions ?? []];
941
+ for (const pos of positions) {
942
+ for (const c of pos.coins ?? []) {
943
+ if (c.coinType && c.amount != null) {
944
+ total += rawToUsd(c.coinType, c.amount, void 0, prices);
945
+ }
946
+ }
947
+ }
948
+ return total;
949
+ }
950
+ function normalizeHaedal(result, prices) {
951
+ const SUI_TYPE_FULL = "0x0000000000000000000000000000000000000000000000000000000000000002::sui::SUI";
952
+ const data = result.haedal ?? {};
953
+ let total = 0;
954
+ for (const lp of data.lps ?? []) {
955
+ const item = lp;
956
+ if (item.coinTypeA && item.balanceA != null) {
957
+ total += rawToUsd(item.coinTypeA, item.balanceA, void 0, prices);
958
+ }
959
+ if (item.coinTypeB && item.balanceB != null) {
960
+ total += rawToUsd(item.coinTypeB, item.balanceB, void 0, prices);
961
+ }
962
+ }
963
+ for (const stake of data.stakings ?? []) {
964
+ if (stake.sui_amount != null) {
965
+ total += rawToUsd(SUI_TYPE_FULL, stake.sui_amount, 9, prices);
966
+ }
967
+ }
968
+ return total;
969
+ }
714
970
  function clearPortfolioCache() {
715
971
  portfolioCache.clear();
716
972
  portfolioInflight.clear();
@@ -725,6 +981,7 @@ function clearPriceMapCache() {
725
981
 
726
982
  // src/tools/balance.ts
727
983
  var GAS_RESERVE_SUI2 = 0.05;
984
+ var SUI_ADDRESS_REGEX = /^0x[a-fA-F0-9]{1,64}$/;
728
985
  var VSUI_COIN_TYPE = "0x549e8b69270defbfafd4f94e17ec44cdbdd99820b33bda2278dea3b9a32d3f55::cert::CERT";
729
986
  var SUI_COIN_TYPE = "0x2::sui::SUI";
730
987
  var VSUI_FALLBACK_RATE = 1.05;
@@ -776,21 +1033,38 @@ async function loadPortfolio(address, blockvisionApiKey, fallbackRpcUrl, cache)
776
1033
  }
777
1034
  var balanceCheckTool = buildTool({
778
1035
  name: "balance_check",
779
- description: "Get the user's full balance breakdown. Returns wallet holdings (tokens the user owns \u2014 NOT savings), NAVI savings deposits (USDC deposited into NAVI Protocol earning yield), outstanding debt, pending rewards, gas reserve, total net worth, and saveableUsdc (only USDC can be deposited into savings). IMPORTANT: wallet holdings like GOLD, SUI, USDT are NOT savings positions \u2014 they are just tokens sitting in the wallet.",
780
- inputSchema: z.object({}),
781
- jsonSchema: { type: "object", properties: {}, required: [] },
1036
+ description: "Get the full balance breakdown for the signed-in user OR any public Sui address. Returns wallet holdings (tokens the address owns \u2014 NOT savings), NAVI savings deposits (USDC deposited into NAVI Protocol earning yield), outstanding debt, pending rewards, gas reserve, total net worth, and saveableUsdc (only USDC can be deposited into savings). IMPORTANT: wallet holdings like GOLD, SUI, USDT are NOT savings positions \u2014 they are just tokens sitting in the wallet. Pass `address` to inspect a contact / watched / public wallet; defaults to the signed-in user when omitted.",
1037
+ inputSchema: z.object({
1038
+ address: z.string().regex(SUI_ADDRESS_REGEX).optional().describe("Sui address to inspect (defaults to the signed-in wallet)")
1039
+ }),
1040
+ jsonSchema: {
1041
+ type: "object",
1042
+ properties: {
1043
+ address: {
1044
+ type: "string",
1045
+ pattern: "^0x[a-fA-F0-9]{1,64}$",
1046
+ description: "Sui address to inspect (defaults to the signed-in wallet)"
1047
+ }
1048
+ },
1049
+ required: []
1050
+ },
782
1051
  isReadOnly: true,
783
1052
  // [v1.4 BlockVision] Wallet contents change after every send / swap /
784
1053
  // save / etc. and the price half of this result is sourced from
785
1054
  // BlockVision's Indexer REST API. Microcompact must NEVER dedupe these
786
1055
  // calls — each one reflects a different on-chain + market snapshot.
787
1056
  cacheable: false,
788
- async call(_input, context) {
789
- if (hasNaviMcp(context)) {
790
- const address = getWalletAddress(context);
1057
+ async call(input, context) {
1058
+ const targetAddress = input.address ?? context.walletAddress;
1059
+ const isSelfQuery = !!context.walletAddress && !!targetAddress && targetAddress.toLowerCase() === context.walletAddress.toLowerCase();
1060
+ if (hasNaviMcpGlobal(context)) {
1061
+ if (!targetAddress) {
1062
+ throw new Error("No wallet address provided. Sign in or pass `address` to inspect a public wallet.");
1063
+ }
1064
+ const address = targetAddress;
791
1065
  const mgr = getMcpManager(context);
792
- const hasPositionFetcher = !!(context.positionFetcher && context.walletAddress);
793
- const [portfolio, positions, rewards, serverPositions] = await Promise.all([
1066
+ const hasPositionFetcher = !!context.positionFetcher;
1067
+ const [portfolio, positions, rewards, serverPositions, defiPortfolio] = await Promise.all([
794
1068
  loadPortfolio(
795
1069
  address,
796
1070
  context.blockvisionApiKey,
@@ -818,10 +1092,25 @@ var balanceCheckTool = buildTool({
818
1092
  console.warn("[balance_check] NAVI GET_AVAILABLE_REWARDS failed:", err);
819
1093
  return null;
820
1094
  }),
821
- hasPositionFetcher ? context.positionFetcher(context.walletAddress).catch((err) => {
1095
+ hasPositionFetcher ? context.positionFetcher(address).catch((err) => {
822
1096
  console.warn("[balance_check] positionFetcher failed:", err);
823
1097
  return null;
824
- }) : Promise.resolve(null)
1098
+ }) : Promise.resolve(null),
1099
+ // [v0.50] DeFi leg — independent of NAVI (excluded) and the wallet
1100
+ // portfolio (which only has coin holdings). Failure here surfaces
1101
+ // as defi.totalUsd === 0 and `source: 'degraded'`, leaving the
1102
+ // rest of balance_check unaffected. The fetcher fills its own
1103
+ // prices via fetchTokenPrices for any coin types it discovers.
1104
+ fetchAddressDefiPortfolio(address, context.blockvisionApiKey).catch((err) => {
1105
+ console.warn("[balance_check] defi fetch failed:", err);
1106
+ const fallback = {
1107
+ totalUsd: 0,
1108
+ perProtocol: {},
1109
+ pricedAt: Date.now(),
1110
+ source: "degraded"
1111
+ };
1112
+ return fallback;
1113
+ })
825
1114
  ]);
826
1115
  await applyVsuiPriceFallback(portfolio);
827
1116
  let availableUsd = 0;
@@ -868,32 +1157,60 @@ var balanceCheckTool = buildTool({
868
1157
  const visibleHoldings = holdings.filter((h) => h.usdValue >= 0.01).sort((a, b) => b.usdValue - a.usdValue);
869
1158
  const usdcHolding2 = holdings.find((h) => h.symbol === "USDC");
870
1159
  const saveableUsdc = usdcHolding2 ? usdcHolding2.balance : 0;
1160
+ const defi2 = defiPortfolio;
871
1161
  const bal = {
872
1162
  available: availableUsd,
873
1163
  savings,
874
1164
  debt,
875
1165
  pendingRewards: pendingRewardsUsd,
876
1166
  gasReserve: gasReserveUsd2,
877
- total: availableUsd + savings + gasReserveUsd2 + pendingRewardsUsd - debt,
1167
+ defi: defi2.totalUsd,
1168
+ defiByProtocol: defi2.perProtocol,
1169
+ defiSource: defi2.source,
1170
+ total: availableUsd + savings + gasReserveUsd2 + pendingRewardsUsd + defi2.totalUsd - debt,
878
1171
  stables: stablesUsd,
879
1172
  holdings: visibleHoldings,
880
1173
  saveableUsdc,
881
- priceSource: portfolio.source
1174
+ priceSource: portfolio.source,
1175
+ address,
1176
+ isSelfQuery
882
1177
  };
883
1178
  const holdingsList = visibleHoldings.map((h) => `${h.symbol}: ${h.balance < 1 ? h.balance.toFixed(6) : h.balance.toFixed(2)} ($${h.usdValue.toFixed(2)})`).join(", ");
1179
+ const subjectPrefix = isSelfQuery ? "Balance" : `Balance for ${address.slice(0, 6)}\u2026${address.slice(-4)}`;
1180
+ const defiSummaryText = defi2.totalUsd > 0 ? ` Other DeFi positions (LPs/staking/lending across ${Object.keys(defi2.perProtocol).join("/")}): $${defi2.totalUsd.toFixed(2)}.` : "";
884
1181
  return {
885
1182
  data: bal,
886
- displayText: `Balance: $${bal.total.toFixed(2)} total. Wallet holdings (NOT savings): ${holdingsList || "none"}. NAVI savings deposits: $${bal.savings.toFixed(2)}. Saveable USDC (only USDC can be saved): ${saveableUsdc.toFixed(2)} USDC.`
1183
+ displayText: `${subjectPrefix}: $${bal.total.toFixed(2)} total. Wallet holdings (NOT savings): ${holdingsList || "none"}. NAVI savings deposits: $${bal.savings.toFixed(2)}.${defiSummaryText} Saveable USDC (only USDC can be saved): ${saveableUsdc.toFixed(2)} USDC.`
887
1184
  };
888
1185
  }
1186
+ if (input.address && context.walletAddress && input.address.toLowerCase() !== context.walletAddress.toLowerCase()) {
1187
+ throw new Error(
1188
+ `Cannot inspect ${input.address.slice(0, 8)}\u2026 without NAVI MCP enabled. Configure NAVI MCP to enable third-party address reads.`
1189
+ );
1190
+ }
889
1191
  const agent = requireAgent(context);
890
- const balance = await agent.balance();
1192
+ const fetchAddress = targetAddress ?? context.walletAddress;
1193
+ const [balance, defi] = await Promise.all([
1194
+ agent.balance(),
1195
+ fetchAddressDefiPortfolio(fetchAddress, context.blockvisionApiKey).catch((err) => {
1196
+ console.warn("[balance_check] sdk-path defi fetch failed:", err);
1197
+ const fallback = {
1198
+ totalUsd: 0,
1199
+ perProtocol: {},
1200
+ pricedAt: Date.now(),
1201
+ source: "degraded"
1202
+ };
1203
+ return fallback;
1204
+ })
1205
+ ]);
891
1206
  const gasReserveUsd = typeof balance.gasReserve === "number" ? balance.gasReserve : balance.gasReserve.usdEquiv ?? 0;
892
1207
  const stablesTotal = typeof balance.stables === "number" ? balance.stables : Object.values(balance.stables).reduce((a, b) => a + b, 0);
893
1208
  const sdkHoldings = balance.holdings;
894
1209
  const holdingsArr = Array.isArray(sdkHoldings) ? sdkHoldings : [];
895
1210
  const usdcHolding = holdingsArr.find((h) => h.symbol === "USDC");
896
1211
  const sdkSaveableUsdc = usdcHolding ? usdcHolding.balance ?? 0 : 0;
1212
+ const sdkDefiSummaryText = defi.totalUsd > 0 ? ` Other DeFi positions (LPs/staking/lending across ${Object.keys(defi.perProtocol).join("/")}): $${defi.totalUsd.toFixed(2)}.` : "";
1213
+ const sdkTotal = balance.total + defi.totalUsd;
897
1214
  return {
898
1215
  data: {
899
1216
  available: balance.available,
@@ -901,12 +1218,17 @@ var balanceCheckTool = buildTool({
901
1218
  debt: balance.debt,
902
1219
  pendingRewards: balance.pendingRewards,
903
1220
  gasReserve: gasReserveUsd,
904
- total: balance.total,
1221
+ defi: defi.totalUsd,
1222
+ defiByProtocol: defi.perProtocol,
1223
+ defiSource: defi.source,
1224
+ total: sdkTotal,
905
1225
  stables: stablesTotal,
906
1226
  holdings: holdingsArr,
907
- saveableUsdc: sdkSaveableUsdc
1227
+ saveableUsdc: sdkSaveableUsdc,
1228
+ address: targetAddress ?? "",
1229
+ isSelfQuery: true
908
1230
  },
909
- displayText: `Balance: $${balance.total.toFixed(2)} total. Wallet: $${balance.available.toFixed(2)} available. NAVI savings deposits: $${balance.savings.toFixed(2)}. Saveable USDC (only USDC can be saved): ${sdkSaveableUsdc.toFixed(2)} USDC.`
1231
+ displayText: `Balance: $${sdkTotal.toFixed(2)} total. Wallet: $${balance.available.toFixed(2)} available. NAVI savings deposits: $${balance.savings.toFixed(2)}.${sdkDefiSummaryText} Saveable USDC (only USDC can be saved): ${sdkSaveableUsdc.toFixed(2)} USDC.`
910
1232
  };
911
1233
  }
912
1234
  });
@@ -998,6 +1320,7 @@ async function fetchProtocolStats(manager, opts) {
998
1320
 
999
1321
  // src/tools/savings.ts
1000
1322
  var DUST_THRESHOLD_USD = 0.01;
1323
+ var SUI_ADDRESS_REGEX2 = /^0x[a-fA-F0-9]{1,64}$/;
1001
1324
  function buildSavingsFromPositions(sp) {
1002
1325
  const positions = [
1003
1326
  ...sp.supplies.filter((s) => s.amountUsd >= DUST_THRESHOLD_USD).map((s) => ({
@@ -1039,18 +1362,19 @@ function buildSavingsFromPositions(sp) {
1039
1362
  }
1040
1363
  };
1041
1364
  }
1042
- function formatSavingsDisplay(result) {
1365
+ function formatSavingsDisplay(result, isSelfQuery = true, address) {
1043
1366
  const { positions, earnings, fundStatus } = result;
1044
1367
  const supplies = positions.filter((p) => p.type === "supply");
1045
1368
  const borrows = positions.filter((p) => p.type === "borrow");
1369
+ const subjectPrefix = isSelfQuery || !address ? "" : `${address.slice(0, 6)}\u2026${address.slice(-4)} \u2014 `;
1046
1370
  const lines = [];
1047
1371
  if (supplies.length > 0) {
1048
- lines.push(`Savings: $${fundStatus.supplied.toFixed(2)} at ${(earnings.currentApy * 100).toFixed(2)}% blended APY`);
1372
+ lines.push(`${subjectPrefix}Savings: $${fundStatus.supplied.toFixed(2)} at ${(earnings.currentApy * 100).toFixed(2)}% blended APY`);
1049
1373
  for (const s of supplies) {
1050
1374
  lines.push(` ${s.symbol}: ${s.amount.toFixed(s.amount < 1 ? 6 : 2)} ($${s.valueUsd.toFixed(2)}) at ${(s.apy * 100).toFixed(2)}% APY`);
1051
1375
  }
1052
1376
  } else {
1053
- lines.push("No savings positions.");
1377
+ lines.push(`${subjectPrefix}No savings positions.`);
1054
1378
  }
1055
1379
  if (borrows.length > 0) {
1056
1380
  const totalDebt = borrows.reduce((s, b) => s + b.valueUsd, 0);
@@ -1062,26 +1386,44 @@ function formatSavingsDisplay(result) {
1062
1386
  }
1063
1387
  var savingsInfoTool = buildTool({
1064
1388
  name: "savings_info",
1065
- description: "Get detailed savings positions and earnings: current deposits by protocol, APY, total yield earned, daily earning rate, and projected monthly returns.",
1066
- inputSchema: z.object({}),
1067
- jsonSchema: { type: "object", properties: {}, required: [] },
1389
+ description: "Get detailed savings positions and earnings for the signed-in user OR any public Sui address: current deposits by protocol, APY, total yield earned, daily earning rate, and projected monthly returns. Pass `address` to inspect a contact / watched / public wallet; defaults to the signed-in user when omitted.",
1390
+ inputSchema: z.object({
1391
+ address: z.string().regex(SUI_ADDRESS_REGEX2).optional().describe("Sui address to inspect (defaults to the signed-in wallet)")
1392
+ }),
1393
+ jsonSchema: {
1394
+ type: "object",
1395
+ properties: {
1396
+ address: {
1397
+ type: "string",
1398
+ pattern: "^0x[a-fA-F0-9]{1,64}$",
1399
+ description: "Sui address to inspect (defaults to the signed-in wallet)"
1400
+ }
1401
+ },
1402
+ required: []
1403
+ },
1068
1404
  isReadOnly: true,
1069
1405
  // [v1.5.1] NAVI deposits change on save_deposit / withdraw / claim.
1070
1406
  // Each call reflects a fresh on-chain snapshot — never dedupe.
1071
1407
  cacheable: false,
1072
- async call(_input, context) {
1073
- if (context.positionFetcher && context.walletAddress) {
1074
- const sp = await context.positionFetcher(context.walletAddress);
1408
+ async call(input, context) {
1409
+ const targetAddress = input.address ?? context.walletAddress;
1410
+ const isSelfQuery = !!context.walletAddress && !!targetAddress && targetAddress.toLowerCase() === context.walletAddress.toLowerCase();
1411
+ if (context.positionFetcher && targetAddress) {
1412
+ const sp = await context.positionFetcher(targetAddress);
1075
1413
  const result2 = buildSavingsFromPositions(sp);
1076
- return { data: result2, displayText: formatSavingsDisplay(result2) };
1414
+ const stamped2 = { ...result2, address: targetAddress, isSelfQuery };
1415
+ return { data: stamped2, displayText: formatSavingsDisplay(result2, isSelfQuery, targetAddress) };
1077
1416
  }
1078
- if (hasNaviMcp(context)) {
1079
- const savings = await fetchSavings(
1080
- getMcpManager(context),
1081
- getWalletAddress(context)
1082
- );
1417
+ if (hasNaviMcpGlobal(context) && targetAddress) {
1418
+ const savings = await fetchSavings(getMcpManager(context), targetAddress);
1083
1419
  savings.positions = savings.positions.filter((p) => p.valueUsd >= DUST_THRESHOLD_USD);
1084
- return { data: savings, displayText: formatSavingsDisplay(savings) };
1420
+ const stamped2 = { ...savings, address: targetAddress, isSelfQuery };
1421
+ return { data: stamped2, displayText: formatSavingsDisplay(savings, isSelfQuery, targetAddress) };
1422
+ }
1423
+ if (input.address && context.walletAddress && input.address.toLowerCase() !== context.walletAddress.toLowerCase()) {
1424
+ throw new Error(
1425
+ `Cannot inspect ${input.address.slice(0, 8)}\u2026 without NAVI MCP or a positionFetcher. Configure NAVI MCP to enable third-party address reads.`
1426
+ );
1085
1427
  }
1086
1428
  const agent = requireAgent(context);
1087
1429
  const [posResult, earnings, fundStatus] = await Promise.all([
@@ -1114,9 +1456,11 @@ var savingsInfoTool = buildTool({
1114
1456
  projectedMonthly: fundStatus.projectedMonthly
1115
1457
  }
1116
1458
  };
1117
- return { data: result, displayText: formatSavingsDisplay(result) };
1459
+ const stamped = { ...result, address: targetAddress ?? "", isSelfQuery: true };
1460
+ return { data: stamped, displayText: formatSavingsDisplay(result, true, void 0) };
1118
1461
  }
1119
1462
  });
1463
+ var SUI_ADDRESS_REGEX3 = /^0x[a-fA-F0-9]{1,64}$/;
1120
1464
  var DEBT_DUST_USD = 0.01;
1121
1465
  function hfStatus(hf, borrowed) {
1122
1466
  if (borrowed <= DEBT_DUST_USD) return "healthy";
@@ -1130,24 +1474,39 @@ function serializeHf(hf, borrowed) {
1130
1474
  if (hf == null || !Number.isFinite(hf)) return null;
1131
1475
  return hf;
1132
1476
  }
1133
- function displayHfText(hf, borrowed, status) {
1477
+ function displayHfText(hf, borrowed, status, isSelfQuery = true, address) {
1478
+ const subject = isSelfQuery || !address ? "Health Factor" : `Health Factor for ${address.slice(0, 6)}\u2026${address.slice(-4)}`;
1134
1479
  if (hf == null) {
1135
- return `Health Factor: \u221E (${status} \u2014 no debt)`;
1480
+ return `${subject}: \u221E (${status} \u2014 no debt)`;
1136
1481
  }
1137
- return `Health Factor: ${hf.toFixed(2)} (${status})`;
1482
+ return `${subject}: ${hf.toFixed(2)} (${status})`;
1138
1483
  }
1139
1484
  var healthCheckTool = buildTool({
1140
1485
  name: "health_check",
1141
- description: 'Check the lending health factor: current HF ratio, total supplied collateral, total borrowed, max additional borrow capacity, and liquidation threshold. HF < 1.5 is risky, < 1.2 is critical. When the user has no debt the tool returns healthFactor=null (semantically infinity) \u2014 render that as "Healthy" / \u221E, never as 0 or "Critical".',
1142
- inputSchema: z.object({}),
1143
- jsonSchema: { type: "object", properties: {}, required: [] },
1486
+ description: 'Check the lending health factor for the signed-in user OR any public Sui address: current HF ratio, total supplied collateral, total borrowed, max additional borrow capacity, and liquidation threshold. HF < 1.5 is risky, < 1.2 is critical. When the address has no debt the tool returns healthFactor=null (semantically infinity) \u2014 render that as "Healthy" / \u221E, never as 0 or "Critical". Pass `address` to inspect a contact / watched / public wallet; defaults to the signed-in user when omitted.',
1487
+ inputSchema: z.object({
1488
+ address: z.string().regex(SUI_ADDRESS_REGEX3).optional().describe("Sui address to inspect (defaults to the signed-in wallet)")
1489
+ }),
1490
+ jsonSchema: {
1491
+ type: "object",
1492
+ properties: {
1493
+ address: {
1494
+ type: "string",
1495
+ pattern: "^0x[a-fA-F0-9]{1,64}$",
1496
+ description: "Sui address to inspect (defaults to the signed-in wallet)"
1497
+ }
1498
+ },
1499
+ required: []
1500
+ },
1144
1501
  isReadOnly: true,
1145
1502
  // [v1.5.1] Health factor changes on every borrow / repay / collateral
1146
1503
  // movement and even passively as oracle prices update. Never dedupe.
1147
1504
  cacheable: false,
1148
- async call(_input, context) {
1149
- if (context.positionFetcher && context.walletAddress) {
1150
- const sp = await context.positionFetcher(context.walletAddress);
1505
+ async call(input, context) {
1506
+ const targetAddress = input.address ?? context.walletAddress;
1507
+ const isSelfQuery = !!context.walletAddress && !!targetAddress && targetAddress.toLowerCase() === context.walletAddress.toLowerCase();
1508
+ if (context.positionFetcher && targetAddress) {
1509
+ const sp = await context.positionFetcher(targetAddress);
1151
1510
  const borrowed2 = sp.borrows;
1152
1511
  const rawHf = sp.healthFactor ?? (borrowed2 > 0 ? 0 : Infinity);
1153
1512
  const status2 = hfStatus(rawHf, borrowed2);
@@ -1159,24 +1518,28 @@ var healthCheckTool = buildTool({
1159
1518
  borrowed: borrowed2,
1160
1519
  maxBorrow: sp.maxBorrow,
1161
1520
  liquidationThreshold: 0,
1162
- status: status2
1521
+ status: status2,
1522
+ address: targetAddress,
1523
+ isSelfQuery
1163
1524
  },
1164
- displayText: displayHfText(transportHf2, borrowed2, status2)
1525
+ displayText: displayHfText(transportHf2, borrowed2, status2, isSelfQuery, targetAddress)
1165
1526
  };
1166
1527
  }
1167
- if (hasNaviMcp(context)) {
1168
- const hf2 = await fetchHealthFactor(
1169
- getMcpManager(context),
1170
- getWalletAddress(context)
1171
- );
1528
+ if (hasNaviMcpGlobal(context) && targetAddress) {
1529
+ const hf2 = await fetchHealthFactor(getMcpManager(context), targetAddress);
1172
1530
  const borrowed2 = hf2.borrowed;
1173
1531
  const status2 = hfStatus(hf2.healthFactor, borrowed2);
1174
1532
  const transportHf2 = serializeHf(hf2.healthFactor, borrowed2);
1175
1533
  return {
1176
- data: { ...hf2, healthFactor: transportHf2, status: status2 },
1177
- displayText: displayHfText(transportHf2, borrowed2, status2)
1534
+ data: { ...hf2, healthFactor: transportHf2, status: status2, address: targetAddress, isSelfQuery },
1535
+ displayText: displayHfText(transportHf2, borrowed2, status2, isSelfQuery, targetAddress)
1178
1536
  };
1179
1537
  }
1538
+ if (input.address && context.walletAddress && input.address.toLowerCase() !== context.walletAddress.toLowerCase()) {
1539
+ throw new Error(
1540
+ `Cannot inspect ${input.address.slice(0, 8)}\u2026 without NAVI MCP or a positionFetcher. Configure NAVI MCP to enable third-party address reads.`
1541
+ );
1542
+ }
1180
1543
  const agent = requireAgent(context);
1181
1544
  const hf = await agent.healthFactor();
1182
1545
  const borrowed = hf.borrowed;
@@ -1189,9 +1552,11 @@ var healthCheckTool = buildTool({
1189
1552
  borrowed,
1190
1553
  maxBorrow: hf.maxBorrow,
1191
1554
  liquidationThreshold: hf.liquidationThreshold,
1192
- status
1555
+ status,
1556
+ address: targetAddress ?? "",
1557
+ isSelfQuery: true
1193
1558
  },
1194
- displayText: displayHfText(transportHf, borrowed, status)
1559
+ displayText: displayHfText(transportHf, borrowed, status, true, void 0)
1195
1560
  };
1196
1561
  }
1197
1562
  });
@@ -1400,14 +1765,14 @@ async function queryHistoryByDate(rpcUrl, address, targetDate, limit) {
1400
1765
  }
1401
1766
  var HISTORY_ACTIONS = ["send", "lending", "swap", "transaction"];
1402
1767
  var DEFAULT_LOOKBACK_DAYS = 30;
1403
- var SUI_ADDRESS_REGEX = /^0x[0-9a-fA-F]{64}$/;
1768
+ var SUI_ADDRESS_REGEX4 = /^0x[0-9a-fA-F]{64}$/;
1404
1769
  var transactionHistoryTool = buildTool({
1405
1770
  name: "transaction_history",
1406
1771
  description: 'Retrieve recent transaction history (last 30 days by default): sends, saves, withdrawals, borrows, repayments, swaps, and rewards claims. Renders a rich transaction card.\n\nBy default, queries the SIGNED-IN USER\'S history. To inspect another wallet (a saved contact, a watched address, any public Sui address), pass `address` \u2014 e.g. user asks "show funkii\'s recent transactions" with funkii at 0x40cd\u20263e62, call with `address: "0x40cd\u20263e62"`. To filter the user\'s own history to a specific counterparty (user asks "show transactions WITH funkii"), pass `counterparty` \u2014 keeps the query rooted in the user\'s wallet but shows only rows where funkii is the recipient or sender.\n\nFilter args: `date` (YYYY-MM-DD), `action` (send/lending/swap), `minUsd` (minimum amount in USD \u2014 use this for "transactions over $X" instead of post-filtering), `assetSymbol` (e.g. "USDC", "SUI"), `direction` ("in" or "out"). The card itself respects all filters \u2014 never re-list the rows in narration.\n\nInternally queries both `FromAddress` and `ToAddress` filters in parallel and dedupes by digest, so pure-receive transactions (someone sends to the queried address with no balance-affecting outbound) are no longer dropped.',
1407
1772
  inputSchema: z.object({
1408
1773
  limit: z.number().int().min(1).max(50).optional(),
1409
- address: z.string().regex(SUI_ADDRESS_REGEX, "Must be a 0x-prefixed 64-hex Sui address").optional().describe("Sui address to query history FOR. When omitted, defaults to the signed-in user's wallet. Pass this when the user asks about a contact's, watched address's, or any other public wallet's history."),
1410
- counterparty: z.string().regex(SUI_ADDRESS_REGEX, "Must be a 0x-prefixed 64-hex Sui address").optional().describe('Sui address to filter rows by \u2014 only transactions where the queried address sent to or received from this counterparty are returned. Use for "show transactions with <contact>" queries. Compares against `tx.recipient` (case-insensitive).'),
1774
+ address: z.string().regex(SUI_ADDRESS_REGEX4, "Must be a 0x-prefixed 64-hex Sui address").optional().describe("Sui address to query history FOR. When omitted, defaults to the signed-in user's wallet. Pass this when the user asks about a contact's, watched address's, or any other public wallet's history."),
1775
+ counterparty: z.string().regex(SUI_ADDRESS_REGEX4, "Must be a 0x-prefixed 64-hex Sui address").optional().describe('Sui address to filter rows by \u2014 only transactions where the queried address sent to or received from this counterparty are returned. Use for "show transactions with <contact>" queries. Compares against `tx.recipient` (case-insensitive).'),
1411
1776
  date: z.string().optional().describe("Specific date to search for transactions (YYYY-MM-DD format). Paginates back to find that day."),
1412
1777
  action: z.enum(HISTORY_ACTIONS).optional().describe("Filter by action: send, lending, swap, or transaction."),
1413
1778
  minUsd: z.number().min(0).optional().describe('Minimum transaction amount in USD. Use this for "transactions over $X" \u2014 the amount is converted to USD using the asset price snapshot.'),
@@ -1505,7 +1870,7 @@ var transactionHistoryTool = buildTool({
1505
1870
  const targetAddress = input.address ?? context.walletAddress;
1506
1871
  const isSelfQuery = !!targetAddress && !!context.walletAddress && targetAddress.toLowerCase() === context.walletAddress.toLowerCase();
1507
1872
  const prices = context.tokenPrices;
1508
- const priceFor = (sym) => {
1873
+ const priceFor2 = (sym) => {
1509
1874
  if (!sym || !prices) return void 0;
1510
1875
  return prices[sym.toUpperCase()] ?? prices[sym.toLowerCase()] ?? prices[sym];
1511
1876
  };
@@ -1526,8 +1891,8 @@ var transactionHistoryTool = buildTool({
1526
1891
  if (r.amount == null) return false;
1527
1892
  const sym = r.asset?.toUpperCase() ?? "";
1528
1893
  const isStableLike = sym === "USDC" || sym === "USDT" || sym === "WUSDC" || sym === "WUSDT" || sym === "SUIUSDT" || sym === "USDY" || sym === "USDSUI" || sym === "USDE" || sym === "AUSD" || sym === "FDUSD" || sym === "BUCK";
1529
- const usd = isStableLike ? r.amount : (priceFor(sym) ?? 0) * r.amount;
1530
- if (!isStableLike && priceFor(sym) == null) return true;
1894
+ const usd = isStableLike ? r.amount : (priceFor2(sym) ?? 0) * r.amount;
1895
+ if (!isStableLike && priceFor2(sym) == null) return true;
1531
1896
  return usd >= minUsd;
1532
1897
  });
1533
1898
  }
@@ -3028,14 +3393,14 @@ Use when the user asks for a visual chart, simulator, or financial overview. Pic
3028
3393
 
3029
3394
  - activity_heatmap \u2014 on-chain transaction history as a GitHub-style heatmap (WORKS NOW \u2014 accepts \`params.address\` to inspect any public Sui wallet; defaults to the signed-in user)
3030
3395
  - portfolio_timeline \u2014 net worth over time, wallet/savings/debt breakdown (WORKS NOW \u2014 accepts \`params.address\` for any public wallet; defaults to the signed-in user)
3031
- - yield_projector \u2014 compound yield simulator with amount/APY/period sliders (WORKS NOW \u2014 client-side)
3032
- - health_simulator \u2014 borrow health factor simulator with collateral/debt sliders (WORKS NOW \u2014 uses current position)
3033
- - dca_planner \u2014 savings plan curve for regular monthly deposits (WORKS NOW \u2014 client-side)
3396
+ - yield_projector \u2014 compound yield simulator with amount/APY/period sliders (WORKS NOW \u2014 client-side, no address needed)
3397
+ - health_simulator \u2014 borrow health factor simulator with collateral/debt sliders (WORKS NOW \u2014 accepts \`params.address\` for any public wallet; defaults to the signed-in user's current position)
3398
+ - dca_planner \u2014 savings plan curve for regular monthly deposits (WORKS NOW \u2014 client-side, no address needed)
3034
3399
  - spending_breakdown \u2014 spending by service category (WORKS NOW \u2014 accepts \`params.address\` for any public wallet; defaults to the signed-in user)
3035
3400
  - watch_address \u2014 portfolio overview for any public Sui address (WORKS NOW \u2014 pass \`params.address\`)
3036
- - full_portfolio \u2014 4-panel overview: savings, health, activity, spending (WORKS NOW \u2014 aggregates all data)
3401
+ - full_portfolio \u2014 4-panel overview: savings, health, activity, spending (WORKS NOW \u2014 accepts \`params.address\` for any public wallet; defaults to the signed-in user)
3037
3402
 
3038
- When the user asks to inspect a saved contact or watched address \u2014 e.g. "show funkii's activity heatmap", "what's funkii's portfolio look like", "spending breakdown for 0x40cd\u2026" \u2014 pass that wallet's address as \`params.address\`. The four address-aware templates (activity_heatmap, portfolio_timeline, spending_breakdown, watch_address) will scope their data fetch to that address; the rest target the signed-in user regardless of params.
3403
+ When the user asks to inspect a saved contact or watched address \u2014 e.g. "show funkii's activity heatmap", "what's funkii's portfolio look like", "spending breakdown for 0x40cd\u2026", "give me a full portfolio overview of 0x40cd\u2026" \u2014 pass that wallet's address as \`params.address\`. Six of the eight templates (activity_heatmap, portfolio_timeline, spending_breakdown, watch_address, health_simulator, full_portfolio) will scope their data fetch to that address; only the pure client-side simulators (yield_projector, dca_planner) ignore params.address.
3039
3404
 
3040
3405
  Always prefer the canvas for visualisation requests. After rendering, offer to explain what the user sees.`,
3041
3406
  inputSchema: z.object({
@@ -3043,7 +3408,7 @@ Always prefer the canvas for visualisation requests. After rendering, offer to e
3043
3408
  params: z.object({
3044
3409
  period: z.enum(["1m", "3m", "6m", "1y"]).optional().describe("Time period for time-based templates"),
3045
3410
  address: z.string().optional().describe(
3046
- "Sui address for the four address-aware templates (activity_heatmap, portfolio_timeline, spending_breakdown, watch_address). Defaults to the signed-in user; pass an explicit address to inspect a contact, watched wallet, or any other public address."
3411
+ "Sui address for the six address-aware templates (activity_heatmap, portfolio_timeline, spending_breakdown, watch_address, health_simulator, full_portfolio). Defaults to the signed-in user; pass an explicit address to inspect a contact, watched wallet, or any other public address."
3047
3412
  )
3048
3413
  }).optional()
3049
3414
  }),
@@ -3077,7 +3442,20 @@ Always prefer the canvas for visualisation requests. After rendering, offer to e
3077
3442
  return { address: target, isSelfRender };
3078
3443
  };
3079
3444
  if (template === "full_portfolio") {
3080
- const pos = context.serverPositions;
3445
+ const { address, isSelfRender } = resolveAddressTarget();
3446
+ if (!address) {
3447
+ return {
3448
+ data: {
3449
+ __canvas: true,
3450
+ template,
3451
+ title,
3452
+ templateData: { available: false, message: "Full Portfolio needs an address." }
3453
+ },
3454
+ displayText: "Full Portfolio requires an address."
3455
+ };
3456
+ }
3457
+ const titleSuffix = isSelfRender ? "" : ` \u2014 ${address.slice(0, 6)}\u2026${address.slice(-4)}`;
3458
+ const pos = isSelfRender ? context.serverPositions : null;
3081
3459
  const rate = normalizeSavingsRate(pos?.savingsRate);
3082
3460
  const savings = pos?.savings ?? 0;
3083
3461
  const borrows = pos?.borrows ?? 0;
@@ -3085,17 +3463,18 @@ Always prefer the canvas for visualisation requests. After rendering, offer to e
3085
3463
  data: {
3086
3464
  __canvas: true,
3087
3465
  template,
3088
- title,
3466
+ title: `${title}${titleSuffix}`,
3089
3467
  templateData: {
3090
3468
  available: true,
3091
- address: context.walletAddress ?? "",
3469
+ address,
3470
+ isSelfRender,
3092
3471
  currentSavings: savings,
3093
3472
  currentDebt: borrows,
3094
3473
  healthFactor: pos?.healthFactor ?? null,
3095
3474
  savingsRate: rate
3096
3475
  }
3097
3476
  },
3098
- displayText: `Opened Full Portfolio Overview.`
3477
+ displayText: isSelfRender ? `Opened Full Portfolio Overview.` : `Opened Full Portfolio Overview for ${address.slice(0, 6)}\u2026${address.slice(-4)}.`
3099
3478
  };
3100
3479
  }
3101
3480
  if (template === "watch_address") {
@@ -3226,20 +3605,28 @@ Always prefer the canvas for visualisation requests. After rendering, offer to e
3226
3605
  };
3227
3606
  }
3228
3607
  if (template === "health_simulator") {
3229
- const roundedDebt = totalBorrows >= 1 ? Math.round(totalBorrows) : totalBorrows > 0 ? parseFloat(totalBorrows.toFixed(4)) : 0;
3608
+ const { address: targetAddress, isSelfRender } = resolveAddressTarget();
3609
+ const seedFromPos = isSelfRender;
3610
+ const seedSavings = seedFromPos ? totalSavings : 0;
3611
+ const seedBorrows = seedFromPos ? totalBorrows : 0;
3612
+ const seedHf = seedFromPos ? healthFactor : null;
3613
+ const roundedDebt = seedBorrows >= 1 ? Math.round(seedBorrows) : seedBorrows > 0 ? parseFloat(seedBorrows.toFixed(4)) : 0;
3614
+ const titleSuffix = !targetAddress || isSelfRender ? "" : ` \u2014 ${targetAddress.slice(0, 6)}\u2026${targetAddress.slice(-4)}`;
3230
3615
  return {
3231
3616
  data: {
3232
3617
  __canvas: true,
3233
3618
  template,
3234
- title,
3619
+ title: `${title}${titleSuffix}`,
3235
3620
  templateData: {
3236
3621
  available: true,
3237
- initialCollateral: totalSavings > 0 ? Math.round(totalSavings) : 1500,
3238
- initialDebt: roundedDebt > 0 ? roundedDebt : totalSavings > 0 ? 0 : 500,
3239
- currentHf: healthFactor
3622
+ address: targetAddress ?? "",
3623
+ isSelfRender,
3624
+ initialCollateral: seedSavings > 0 ? Math.round(seedSavings) : 1500,
3625
+ initialDebt: roundedDebt > 0 ? roundedDebt : seedSavings > 0 ? 0 : 500,
3626
+ currentHf: seedHf
3240
3627
  }
3241
3628
  },
3242
- displayText: `Opened Health Factor Simulator. Current HF: ${healthFactor !== null ? healthFactor.toFixed(2) : "no active position"}.`
3629
+ displayText: isSelfRender ? `Opened Health Factor Simulator. Current HF: ${healthFactor !== null ? healthFactor.toFixed(2) : "no active position"}.` : `Opened Health Factor Simulator${titleSuffix}. The simulator will fetch the current health factor for that wallet.`
3243
3630
  };
3244
3631
  }
3245
3632
  if (template === "dca_planner") {
@@ -3354,49 +3741,62 @@ var yieldSummaryTool = buildTool({
3354
3741
  }
3355
3742
  }
3356
3743
  });
3744
+ var SUI_ADDRESS_REGEX5 = /^0x[a-fA-F0-9]{1,64}$/;
3357
3745
  var activitySummaryTool = buildTool({
3358
3746
  name: "activity_summary",
3359
- description: "Returns a categorised DeFi activity summary for a period: transaction count, breakdown by action type (saves, sends, borrows, repayments, swaps, payments), total moved, net savings change, and yield earned. Use when the user asks about their activity, transaction history summary, or what they have done recently.",
3747
+ description: "Returns a categorised DeFi activity summary for the signed-in user OR any public Sui address: transaction count, breakdown by action type (saves, sends, borrows, repayments, swaps, payments), total moved, net savings change, and yield earned. Use when the user asks about activity, transaction history summary, or what someone has done recently. Pass `address` to inspect a contact / watched / public wallet; defaults to the signed-in user when omitted.",
3360
3748
  inputSchema: z.object({
3361
- period: z.enum(["week", "month", "year", "all"]).optional().describe("Time period. Defaults to current month.")
3749
+ period: z.enum(["week", "month", "year", "all"]).optional().describe("Time period. Defaults to current month."),
3750
+ address: z.string().regex(SUI_ADDRESS_REGEX5).optional().describe("Sui address to inspect (defaults to the signed-in wallet)")
3362
3751
  }),
3363
3752
  jsonSchema: {
3364
3753
  type: "object",
3365
3754
  properties: {
3366
- period: { type: "string", enum: ["week", "month", "year", "all"] }
3755
+ period: { type: "string", enum: ["week", "month", "year", "all"] },
3756
+ address: {
3757
+ type: "string",
3758
+ pattern: "^0x[a-fA-F0-9]{1,64}$",
3759
+ description: "Sui address to inspect (defaults to the signed-in wallet)"
3760
+ }
3367
3761
  }
3368
3762
  },
3369
3763
  isReadOnly: true,
3370
3764
  async call(input, context) {
3371
3765
  const period = input.period ?? "month";
3372
3766
  const apiUrl = context.env?.AUDRIC_INTERNAL_API_URL;
3373
- const address = context.walletAddress;
3767
+ const targetAddress = input.address ?? context.walletAddress;
3768
+ const isSelfQuery = !!context.walletAddress && !!targetAddress && targetAddress.toLowerCase() === context.walletAddress.toLowerCase();
3374
3769
  const empty = {
3375
3770
  period,
3376
3771
  totalTransactions: 0,
3377
3772
  byAction: [],
3378
3773
  totalMovedUsd: 0,
3379
3774
  netSavingsUsd: 0,
3380
- yieldEarnedUsd: 0
3775
+ yieldEarnedUsd: 0,
3776
+ address: targetAddress,
3777
+ isSelfQuery
3381
3778
  };
3382
- if (!apiUrl || !address) {
3779
+ if (!apiUrl || !targetAddress) {
3383
3780
  return { data: empty, displayText: "Activity summary not available." };
3384
3781
  }
3385
3782
  try {
3783
+ const callerHeader = context.walletAddress ?? targetAddress;
3386
3784
  const res = await fetch(
3387
- `${apiUrl}/api/analytics/activity-summary?address=${address}&period=${period}`,
3388
- { headers: { "x-sui-address": address }, signal: context.signal }
3785
+ `${apiUrl}/api/analytics/activity-summary?address=${targetAddress}&period=${period}`,
3786
+ { headers: { "x-sui-address": callerHeader }, signal: context.signal }
3389
3787
  );
3390
3788
  if (!res.ok) {
3391
3789
  return { data: empty, displayText: `Could not fetch activity data (HTTP ${res.status}).` };
3392
3790
  }
3393
- const data = await res.json();
3791
+ const raw = await res.json();
3792
+ const data = { ...raw, address: targetAddress, isSelfQuery };
3394
3793
  const sorted = [...data.byAction ?? []].sort((a, b) => b.count - a.count);
3395
3794
  const top = sorted.slice(0, 3).map((a) => `${a.action} (${a.count})`).join(", ");
3396
3795
  const periodLabel = data.period === "all" ? "all time" : `this ${data.period}`;
3796
+ const subjectPrefix = isSelfQuery ? "" : `${targetAddress.slice(0, 6)}\u2026${targetAddress.slice(-4)} \u2014 `;
3397
3797
  return {
3398
3798
  data,
3399
- displayText: data.totalTransactions > 0 ? `${data.totalTransactions} transactions ${periodLabel}. Top: ${top}. Total moved: $${data.totalMovedUsd.toFixed(2)}. Net savings: $${data.netSavingsUsd.toFixed(2)}.` : `No activity recorded for ${periodLabel}.`
3799
+ displayText: data.totalTransactions > 0 ? `${subjectPrefix}${data.totalTransactions} transactions ${periodLabel}. Top: ${top}. Total moved: $${data.totalMovedUsd.toFixed(2)}. Net savings: $${data.netSavingsUsd.toFixed(2)}.` : `${subjectPrefix}No activity recorded for ${periodLabel}.`
3400
3800
  };
3401
3801
  } catch {
3402
3802
  return { data: empty, displayText: "Error fetching activity summary." };
@@ -3873,7 +4273,7 @@ function guardCostWarning(tool, _call, conversationText) {
3873
4273
  message: "This action has a monetary cost. Confirm the user is aware before proceeding."
3874
4274
  };
3875
4275
  }
3876
- var SUI_ADDRESS_REGEX2 = /^0x[a-fA-F0-9]{64}$/;
4276
+ var SUI_ADDRESS_REGEX6 = /^0x[a-fA-F0-9]{64}$/;
3877
4277
  function normalizeAddress(addr) {
3878
4278
  return addr.trim().toLowerCase();
3879
4279
  }
@@ -3938,7 +4338,7 @@ function guardAddressSource(tool, call, userText, contacts, walletAddress) {
3938
4338
  if (!rawTo) {
3939
4339
  return { verdict: "pass", gate: "address_source", tier: "safety" };
3940
4340
  }
3941
- if (!SUI_ADDRESS_REGEX2.test(rawTo)) {
4341
+ if (!SUI_ADDRESS_REGEX6.test(rawTo)) {
3942
4342
  return { verdict: "pass", gate: "address_source", tier: "safety" };
3943
4343
  }
3944
4344
  const normalizedTo = normalizeAddress(rawTo);