@t2000/engine 0.54.1 → 0.55.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
@@ -532,12 +532,219 @@ function resetDefiCacheStore() {
532
532
  activeStore = new InMemoryDefiCacheStore();
533
533
  }
534
534
 
535
+ // src/wallet-cache.ts
536
+ var InMemoryWalletCacheStore = class {
537
+ store = /* @__PURE__ */ new Map();
538
+ async get(address) {
539
+ const slot = this.store.get(address.toLowerCase());
540
+ if (!slot) return null;
541
+ if (Date.now() >= slot.expiresAt) {
542
+ this.store.delete(address.toLowerCase());
543
+ return null;
544
+ }
545
+ return slot.entry;
546
+ }
547
+ async set(address, entry, ttlSec) {
548
+ this.store.set(address.toLowerCase(), {
549
+ entry,
550
+ expiresAt: Date.now() + ttlSec * 1e3
551
+ });
552
+ }
553
+ async delete(address) {
554
+ this.store.delete(address.toLowerCase());
555
+ }
556
+ async clear() {
557
+ this.store.clear();
558
+ }
559
+ };
560
+ var activeStore2 = new InMemoryWalletCacheStore();
561
+ function setWalletCacheStore(store) {
562
+ activeStore2 = store;
563
+ }
564
+ function getWalletCacheStore() {
565
+ return activeStore2;
566
+ }
567
+ function resetWalletCacheStore() {
568
+ activeStore2 = new InMemoryWalletCacheStore();
569
+ }
570
+
571
+ // src/cross-instance-lock.ts
572
+ var InMemoryFetchLock = class {
573
+ held = /* @__PURE__ */ new Map();
574
+ async acquire(key, leaseSec) {
575
+ const now = Date.now();
576
+ const expiry = this.held.get(key);
577
+ if (expiry !== void 0 && expiry > now) return false;
578
+ this.held.set(key, now + leaseSec * 1e3);
579
+ return true;
580
+ }
581
+ async release(key) {
582
+ this.held.delete(key);
583
+ }
584
+ };
585
+ var activeLock = new InMemoryFetchLock();
586
+ function setFetchLock(lock) {
587
+ activeLock = lock;
588
+ }
589
+ function getFetchLock() {
590
+ return activeLock;
591
+ }
592
+ function resetFetchLock() {
593
+ activeLock = new InMemoryFetchLock();
594
+ }
595
+ var DEFAULT_LEASE_SEC = 15;
596
+ var DEFAULT_POLL_BUDGET_MS = 4500;
597
+ var DEFAULT_POLL_INTERVAL_MS = 100;
598
+ async function awaitOrFetch(key, fetcher, opts = {}) {
599
+ const lock = opts.lock ?? getFetchLock();
600
+ const leaseSec = opts.leaseSec ?? DEFAULT_LEASE_SEC;
601
+ const pollBudgetMs = opts.pollBudgetMs ?? DEFAULT_POLL_BUDGET_MS;
602
+ const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
603
+ const rng = opts.rng ?? Math.random;
604
+ const now = opts.now ?? Date.now;
605
+ const sleep2 = opts.sleep ?? ((ms) => new Promise((resolve, reject) => {
606
+ const timer = setTimeout(resolve, ms);
607
+ if (opts.signal) {
608
+ const onAbort = () => {
609
+ clearTimeout(timer);
610
+ reject(new DOMException("Aborted", "AbortError"));
611
+ };
612
+ if (opts.signal.aborted) onAbort();
613
+ else opts.signal.addEventListener("abort", onAbort, { once: true });
614
+ }
615
+ }));
616
+ let acquired = false;
617
+ try {
618
+ acquired = await lock.acquire(key, leaseSec);
619
+ } catch (err) {
620
+ console.warn(`[fetch-lock] acquire(${key}) threw; falling through to direct fetch:`, err);
621
+ return fetcher();
622
+ }
623
+ if (acquired) {
624
+ try {
625
+ return await fetcher();
626
+ } finally {
627
+ try {
628
+ await lock.release(key);
629
+ } catch (err) {
630
+ console.warn(`[fetch-lock] release(${key}) failed (non-fatal):`, err);
631
+ }
632
+ }
633
+ }
634
+ if (!opts.pollCache) {
635
+ return fetcher();
636
+ }
637
+ const deadline = now() + pollBudgetMs;
638
+ while (now() < deadline) {
639
+ const jitterPx = (rng() * 0.4 - 0.2) * pollIntervalMs;
640
+ const wait = Math.max(0, pollIntervalMs + jitterPx);
641
+ try {
642
+ await sleep2(wait);
643
+ } catch (err) {
644
+ if (err?.name === "AbortError") throw err;
645
+ return fetcher();
646
+ }
647
+ let cached = null;
648
+ try {
649
+ cached = await opts.pollCache();
650
+ } catch (err) {
651
+ console.warn(`[fetch-lock] pollCache(${key}) threw; continuing to poll:`, err);
652
+ }
653
+ if (cached !== null) return cached;
654
+ }
655
+ return fetcher();
656
+ }
657
+
535
658
  // src/blockvision-prices.ts
536
659
  var BLOCKVISION_BASE = "https://api.blockvision.org/v2/sui";
537
660
  var PORTFOLIO_TIMEOUT_MS = 4e3;
538
661
  var PRICES_TIMEOUT_MS = 3e3;
539
662
  var CACHE_TTL_MS = 6e4;
540
- var DEGRADED_CACHE_TTL_MS = 15e3;
663
+ var WALLET_FRESH_TTL_MS_BLOCKVISION = 6e4;
664
+ var WALLET_FRESH_TTL_MS_DEGRADED = 15e3;
665
+ var WALLET_STICKY_TTL_SEC = 30 * 60;
666
+ var WALLET_LOCK_KEY = (address) => `bv-lock:wallet:${address.toLowerCase()}`;
667
+ var DEFI_LOCK_KEY = (address) => `bv-lock:defi:${address.toLowerCase()}`;
668
+ var BV_RETRY_MAX_ATTEMPTS = 3;
669
+ var BV_RETRY_BASE_DELAY_MS = 250;
670
+ var BV_RETRY_BACKOFF_FACTOR = 3;
671
+ var BV_RETRY_JITTER = 0.25;
672
+ var BV_RETRY_AFTER_CAP_MS = 5e3;
673
+ var CB_WINDOW_MS = 5e3;
674
+ var CB_THRESHOLD = 10;
675
+ var CB_COOLDOWN_MS = 3e4;
676
+ var cb429Timestamps = [];
677
+ var cbOpenUntil = 0;
678
+ function cbIsOpen(now) {
679
+ return now < cbOpenUntil;
680
+ }
681
+ function cbRecord429(now) {
682
+ cb429Timestamps.push(now);
683
+ cb429Timestamps = cb429Timestamps.filter((t) => now - t < CB_WINDOW_MS);
684
+ if (cb429Timestamps.length >= CB_THRESHOLD && !cbIsOpen(now)) {
685
+ cbOpenUntil = now + CB_COOLDOWN_MS;
686
+ console.warn(
687
+ `[blockvision] circuit breaker OPEN \u2014 ${CB_THRESHOLD} 429s in ${CB_WINDOW_MS}ms, retries disabled for ${CB_COOLDOWN_MS / 1e3}s`
688
+ );
689
+ cb429Timestamps = [];
690
+ }
691
+ }
692
+ async function fetchBlockVisionWithRetry(url, init, opts = {}) {
693
+ const rng = opts.rng ?? Math.random;
694
+ const sleep2 = opts.sleep ?? ((ms) => new Promise((resolve, reject) => {
695
+ const timer = setTimeout(resolve, ms);
696
+ if (opts.signal) {
697
+ const onAbort = () => {
698
+ clearTimeout(timer);
699
+ reject(new DOMException("Aborted", "AbortError"));
700
+ };
701
+ if (opts.signal.aborted) onAbort();
702
+ else opts.signal.addEventListener("abort", onAbort, { once: true });
703
+ }
704
+ }));
705
+ let lastError = null;
706
+ let lastResponse = null;
707
+ for (let attempt = 0; attempt < BV_RETRY_MAX_ATTEMPTS; attempt++) {
708
+ if (attempt > 0) {
709
+ let waitMs = BV_RETRY_BASE_DELAY_MS * Math.pow(BV_RETRY_BACKOFF_FACTOR, attempt - 1);
710
+ const retryAfter = lastResponse?.headers.get("retry-after");
711
+ if (retryAfter) {
712
+ const secs = Number(retryAfter);
713
+ if (Number.isFinite(secs) && secs > 0) {
714
+ waitMs = Math.min(secs * 1e3, BV_RETRY_AFTER_CAP_MS);
715
+ }
716
+ }
717
+ const jitterPx = (rng() * 2 - 1) * BV_RETRY_JITTER * waitMs;
718
+ const delay = Math.max(0, waitMs + jitterPx);
719
+ try {
720
+ await sleep2(delay);
721
+ } catch (err) {
722
+ if (lastResponse) return lastResponse;
723
+ throw err;
724
+ }
725
+ }
726
+ try {
727
+ lastResponse = await fetch(url, init);
728
+ } catch (err) {
729
+ lastError = err;
730
+ if (err?.name === "AbortError") throw err;
731
+ continue;
732
+ }
733
+ if (lastResponse.ok) return lastResponse;
734
+ if (lastResponse.status !== 429 && lastResponse.status < 500) {
735
+ return lastResponse;
736
+ }
737
+ if (lastResponse.status === 429) {
738
+ const now = (opts.now ?? Date.now)();
739
+ cbRecord429(now);
740
+ if (cbIsOpen(now)) {
741
+ return lastResponse;
742
+ }
743
+ }
744
+ }
745
+ if (lastResponse) return lastResponse;
746
+ throw lastError ?? new Error("fetch failed after retries");
747
+ }
541
748
  var PRICE_LIST_CHUNK = 10;
542
749
  var STABLE_USD_PRICES = {
543
750
  "0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC": 1,
@@ -547,47 +754,117 @@ var STABLE_USD_PRICES = {
547
754
  "0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN": 1,
548
755
  "0xc060006111016b8a020ad5b33834984a437aaa7d3c74c18e09a95d48aceab08c::coin::COIN": 1
549
756
  };
550
- var portfolioCache = /* @__PURE__ */ new Map();
551
757
  var portfolioInflight = /* @__PURE__ */ new Map();
552
758
  var priceMapCache = null;
759
+ function walletFreshTtlMs(source) {
760
+ switch (source) {
761
+ case "blockvision":
762
+ return WALLET_FRESH_TTL_MS_BLOCKVISION;
763
+ case "sui-rpc-degraded":
764
+ return WALLET_FRESH_TTL_MS_DEGRADED;
765
+ }
766
+ }
767
+ async function safeWalletStoreSet(store, address, entry, ttlSec) {
768
+ try {
769
+ await store.set(address, entry, ttlSec);
770
+ } catch (err) {
771
+ console.warn("[wallet] cache set failed (non-fatal):", err);
772
+ }
773
+ }
774
+ async function safeWalletStoreGet(store, address) {
775
+ try {
776
+ return await store.get(address);
777
+ } catch (err) {
778
+ console.warn("[wallet] cache get failed (continuing as cache miss):", err);
779
+ return null;
780
+ }
781
+ }
553
782
  async function fetchAddressPortfolio(address, apiKey, fallbackRpcUrl) {
554
- const now = Date.now();
555
- const cached = portfolioCache.get(address);
556
- if (cached && now - cached.ts < CACHE_TTL_MS) {
557
- return cached.data;
783
+ const store = getWalletCacheStore();
784
+ const cachedEntry = await safeWalletStoreGet(store, address);
785
+ if (cachedEntry) {
786
+ const ageMs = Date.now() - cachedEntry.pricedAt;
787
+ if (ageMs < walletFreshTtlMs(cachedEntry.data.source)) {
788
+ return cachedEntry.data;
789
+ }
558
790
  }
559
- let inflight = portfolioInflight.get(address);
560
- if (inflight) return inflight;
561
- inflight = (async () => {
791
+ const existing = portfolioInflight.get(address);
792
+ if (existing) return existing;
793
+ const promise = (async () => {
562
794
  try {
563
- if (apiKey && apiKey.trim().length > 0) {
564
- const blockvision = await fetchPortfolioFromBlockVision(address, apiKey);
565
- if (blockvision) {
566
- portfolioCache.set(address, { data: blockvision, ts: Date.now() });
567
- return blockvision;
795
+ return await awaitOrFetch(
796
+ WALLET_LOCK_KEY(address),
797
+ // ----------------------------------------------------------
798
+ // Leader path runs after we've won the cross-instance lock.
799
+ // Re-checks the cache (small window where another leader on a
800
+ // different process just wrote) before paying for the BV call.
801
+ // ----------------------------------------------------------
802
+ async () => {
803
+ const recheck = await safeWalletStoreGet(store, address);
804
+ if (recheck) {
805
+ const ageMs = Date.now() - recheck.pricedAt;
806
+ if (ageMs < walletFreshTtlMs(recheck.data.source)) {
807
+ return recheck.data;
808
+ }
809
+ }
810
+ if (apiKey && apiKey.trim().length > 0) {
811
+ const blockvision = await fetchPortfolioFromBlockVision(address, apiKey);
812
+ if (blockvision) {
813
+ await safeWalletStoreSet(
814
+ store,
815
+ address,
816
+ { data: blockvision, pricedAt: Date.now() },
817
+ WALLET_STICKY_TTL_SEC
818
+ );
819
+ return blockvision;
820
+ }
821
+ }
822
+ const degraded = await fetchPortfolioFromSuiRpc(address, apiKey, fallbackRpcUrl);
823
+ const stickyCandidate = recheck && recheck.data.source === "blockvision" && recheck.data.totalUsd > 0 ? recheck : cachedEntry && cachedEntry.data.source === "blockvision" && cachedEntry.data.totalUsd > 0 ? cachedEntry : null;
824
+ const stickyFresh = stickyCandidate && Date.now() - stickyCandidate.pricedAt < WALLET_STICKY_TTL_SEC * 1e3;
825
+ if (stickyFresh) {
826
+ return stickyCandidate.data;
827
+ }
828
+ await safeWalletStoreSet(
829
+ store,
830
+ address,
831
+ { data: degraded, pricedAt: Date.now() },
832
+ Math.ceil(WALLET_FRESH_TTL_MS_DEGRADED / 1e3)
833
+ );
834
+ return degraded;
835
+ },
836
+ {
837
+ // Followers poll the wallet cache while the leader fetches.
838
+ // Returns non-null only when the leader has written a
839
+ // fresh-for-source entry — stale entries keep the poll going.
840
+ pollCache: async () => {
841
+ const e = await safeWalletStoreGet(store, address);
842
+ if (!e) return null;
843
+ const ageMs = Date.now() - e.pricedAt;
844
+ return ageMs < walletFreshTtlMs(e.data.source) ? e.data : null;
845
+ }
568
846
  }
569
- }
570
- const degraded = await fetchPortfolioFromSuiRpc(address, apiKey, fallbackRpcUrl);
571
- portfolioCache.set(address, {
572
- data: degraded,
573
- ts: Date.now() - (CACHE_TTL_MS - DEGRADED_CACHE_TTL_MS)
574
- });
575
- return degraded;
847
+ );
576
848
  } finally {
577
849
  portfolioInflight.delete(address);
578
850
  }
579
851
  })();
580
- portfolioInflight.set(address, inflight);
581
- return inflight;
852
+ portfolioInflight.set(address, promise);
853
+ return promise;
582
854
  }
583
855
  async function fetchPortfolioFromBlockVision(address, apiKey) {
584
856
  const url = `${BLOCKVISION_BASE}/account/coins?account=${encodeURIComponent(address)}`;
857
+ const signal = AbortSignal.timeout(PORTFOLIO_TIMEOUT_MS);
585
858
  let res;
586
859
  try {
587
- res = await fetch(url, {
588
- headers: { "x-api-key": apiKey, accept: "application/json" },
589
- signal: AbortSignal.timeout(PORTFOLIO_TIMEOUT_MS)
590
- });
860
+ res = await fetchBlockVisionWithRetry(
861
+ url,
862
+ {
863
+ headers: { "x-api-key": apiKey, accept: "application/json" },
864
+ signal
865
+ },
866
+ { signal }
867
+ );
591
868
  } catch (err) {
592
869
  console.warn("[blockvision-prices] portfolio fetch threw, degrading:", err);
593
870
  return null;
@@ -718,12 +995,17 @@ async function fetchPricesFromBlockVision(coinTypes, apiKey) {
718
995
  const chunk = longForms.slice(i, i + PRICE_LIST_CHUNK);
719
996
  const tokenIds = encodeURIComponent(chunk.join(","));
720
997
  const url = `${BLOCKVISION_BASE}/coin/price/list?tokenIds=${tokenIds}&show24hChange=true`;
998
+ const signal = AbortSignal.timeout(PRICES_TIMEOUT_MS);
721
999
  let res;
722
1000
  try {
723
- res = await fetch(url, {
724
- headers: { "x-api-key": apiKey, accept: "application/json" },
725
- signal: AbortSignal.timeout(PRICES_TIMEOUT_MS)
726
- });
1001
+ res = await fetchBlockVisionWithRetry(
1002
+ url,
1003
+ {
1004
+ headers: { "x-api-key": apiKey, accept: "application/json" },
1005
+ signal
1006
+ },
1007
+ { signal }
1008
+ );
727
1009
  } catch (err) {
728
1010
  console.warn("[blockvision-prices] price chunk threw, skipping:", err);
729
1011
  continue;
@@ -799,6 +1081,14 @@ async function safeStoreSet(store, address, entry) {
799
1081
  console.warn("[defi] cache set failed (non-fatal):", err);
800
1082
  }
801
1083
  }
1084
+ async function safeDefiStoreGet(store, address) {
1085
+ try {
1086
+ return await store.get(address);
1087
+ } catch (err) {
1088
+ console.warn("[defi] cache get failed (continuing as cache miss):", err);
1089
+ return null;
1090
+ }
1091
+ }
802
1092
  var warnedMissingApiKey = false;
803
1093
  async function fetchAddressDefiPortfolio(address, apiKey, priceHints = {}) {
804
1094
  if (!apiKey || apiKey.trim().length === 0) {
@@ -811,15 +1101,9 @@ async function fetchAddressDefiPortfolio(address, apiKey, priceHints = {}) {
811
1101
  return { totalUsd: 0, perProtocol: {}, pricedAt: Date.now(), source: "degraded" };
812
1102
  }
813
1103
  const store = getDefiCacheStore();
814
- const now = Date.now();
815
- let cachedEntry = null;
816
- try {
817
- cachedEntry = await store.get(address);
818
- } catch (err) {
819
- console.warn("[defi] cache get failed (continuing as cache miss):", err);
820
- }
1104
+ const cachedEntry = await safeDefiStoreGet(store, address);
821
1105
  if (cachedEntry) {
822
- const ageMs = now - cachedEntry.pricedAt;
1106
+ const ageMs = Date.now() - cachedEntry.pricedAt;
823
1107
  const freshTtlMs = freshTtlForSource(cachedEntry.data.source);
824
1108
  if (ageMs < freshTtlMs) {
825
1109
  return cachedEntry.data;
@@ -829,82 +1113,108 @@ async function fetchAddressDefiPortfolio(address, apiKey, priceHints = {}) {
829
1113
  if (inflight) return inflight;
830
1114
  inflight = (async () => {
831
1115
  try {
832
- const settled = await Promise.allSettled(
833
- DEFI_PROTOCOLS.map((p) => fetchOneDefiProtocol(address, p, apiKey))
834
- );
835
- const seen = /* @__PURE__ */ new Set();
836
- for (const s of settled) {
837
- if (s.status === "fulfilled" && s.value) collectCoinTypes(s.value, seen);
838
- }
839
- const normalizedHints = {};
840
- for (const [k, v] of Object.entries(priceHints)) {
841
- normalizedHints[normalizeCoinType(k)] = v;
842
- }
843
- const missing = Array.from(seen).filter((ct) => {
844
- const norm = normalizeCoinType(ct);
845
- return !normalizedHints[norm] && !STABLE_USD_PRICES[norm];
846
- });
847
- let fetchedPrices = {};
848
- if (missing.length > 0) {
849
- try {
850
- fetchedPrices = await fetchTokenPrices(missing, apiKey);
851
- } catch (err) {
852
- console.warn("[defi] fill-missing-prices failed:", err);
853
- }
854
- }
855
- const prices = { ...normalizedHints };
856
- for (const [ct, v] of Object.entries(fetchedPrices)) {
857
- prices[normalizeCoinType(ct)] ??= v.price;
858
- }
859
- for (const [ct, p] of Object.entries(STABLE_USD_PRICES)) {
860
- prices[normalizeCoinType(ct)] ??= p;
861
- }
862
- let totalUsd = 0;
863
- let failures = 0;
864
- const perProtocol = {};
865
- for (let i = 0; i < DEFI_PROTOCOLS.length; i++) {
866
- const proto = DEFI_PROTOCOLS[i];
867
- const s = settled[i];
868
- if (s.status !== "fulfilled" || !s.value) {
869
- failures++;
870
- continue;
871
- }
872
- try {
873
- const usd = normalizeProtocol(proto, s.value, prices);
874
- if (Number.isFinite(usd) && usd !== 0) {
875
- perProtocol[proto] = usd;
876
- totalUsd += usd;
1116
+ return await awaitOrFetch(
1117
+ DEFI_LOCK_KEY(address),
1118
+ // Leader path — runs after acquiring the lock.
1119
+ async () => {
1120
+ const recheck = await safeDefiStoreGet(store, address);
1121
+ if (recheck) {
1122
+ const ageMs = Date.now() - recheck.pricedAt;
1123
+ if (ageMs < freshTtlForSource(recheck.data.source)) {
1124
+ return recheck.data;
1125
+ }
1126
+ }
1127
+ const stickyBasis = recheck ?? cachedEntry;
1128
+ const fanoutAt = Date.now();
1129
+ const settled = await Promise.allSettled(
1130
+ DEFI_PROTOCOLS.map((p) => fetchOneDefiProtocol(address, p, apiKey))
1131
+ );
1132
+ const seen = /* @__PURE__ */ new Set();
1133
+ for (const s of settled) {
1134
+ if (s.status === "fulfilled" && s.value) collectCoinTypes(s.value, seen);
1135
+ }
1136
+ const normalizedHints = {};
1137
+ for (const [k, v] of Object.entries(priceHints)) {
1138
+ normalizedHints[normalizeCoinType(k)] = v;
1139
+ }
1140
+ const missing = Array.from(seen).filter((ct) => {
1141
+ const norm = normalizeCoinType(ct);
1142
+ return !normalizedHints[norm] && !STABLE_USD_PRICES[norm];
1143
+ });
1144
+ let fetchedPrices = {};
1145
+ if (missing.length > 0) {
1146
+ try {
1147
+ fetchedPrices = await fetchTokenPrices(missing, apiKey);
1148
+ } catch (err) {
1149
+ console.warn("[defi] fill-missing-prices failed:", err);
1150
+ }
1151
+ }
1152
+ const prices = { ...normalizedHints };
1153
+ for (const [ct, v] of Object.entries(fetchedPrices)) {
1154
+ prices[normalizeCoinType(ct)] ??= v.price;
1155
+ }
1156
+ for (const [ct, p] of Object.entries(STABLE_USD_PRICES)) {
1157
+ prices[normalizeCoinType(ct)] ??= p;
1158
+ }
1159
+ let totalUsd = 0;
1160
+ let failures = 0;
1161
+ const perProtocol = {};
1162
+ for (let i = 0; i < DEFI_PROTOCOLS.length; i++) {
1163
+ const proto = DEFI_PROTOCOLS[i];
1164
+ const s = settled[i];
1165
+ if (s.status !== "fulfilled" || !s.value) {
1166
+ failures++;
1167
+ continue;
1168
+ }
1169
+ try {
1170
+ const usd = normalizeProtocol(proto, s.value, prices);
1171
+ if (Number.isFinite(usd) && usd !== 0) {
1172
+ perProtocol[proto] = usd;
1173
+ totalUsd += usd;
1174
+ }
1175
+ } catch (err) {
1176
+ console.warn(`[defi] ${proto} normaliser threw:`, err);
1177
+ failures++;
1178
+ }
1179
+ }
1180
+ if (totalUsd < 0) totalUsd = 0;
1181
+ const fetchedAt = Date.now();
1182
+ const summary = {
1183
+ totalUsd,
1184
+ perProtocol,
1185
+ pricedAt: fetchedAt,
1186
+ source: failures === DEFI_PROTOCOLS.length ? "degraded" : failures > 0 ? "partial" : "blockvision"
1187
+ };
1188
+ const cachedPositive = stickyBasis && stickyBasis.data.totalUsd > 0 && fanoutAt - stickyBasis.pricedAt < DEFI_STICKY_TTL_SEC * 1e3;
1189
+ if (summary.source === "blockvision") {
1190
+ await safeStoreSet(store, address, { data: summary, pricedAt: fetchedAt });
1191
+ return summary;
1192
+ }
1193
+ if (summary.source === "partial" && summary.totalUsd > 0) {
1194
+ await safeStoreSet(store, address, { data: summary, pricedAt: fetchedAt });
1195
+ return summary;
1196
+ }
1197
+ if (cachedPositive) {
1198
+ const stale = {
1199
+ ...stickyBasis.data,
1200
+ source: "partial-stale"
1201
+ };
1202
+ return stale;
1203
+ }
1204
+ return summary;
1205
+ },
1206
+ {
1207
+ // Followers poll the DeFi cache while the leader fans out.
1208
+ // Returns non-null only when the leader has written a
1209
+ // fresh-for-source entry — stale entries keep the poll going.
1210
+ pollCache: async () => {
1211
+ const e = await safeDefiStoreGet(store, address);
1212
+ if (!e) return null;
1213
+ const ageMs = Date.now() - e.pricedAt;
1214
+ return ageMs < freshTtlForSource(e.data.source) ? e.data : null;
877
1215
  }
878
- } catch (err) {
879
- console.warn(`[defi] ${proto} normaliser threw:`, err);
880
- failures++;
881
1216
  }
882
- }
883
- if (totalUsd < 0) totalUsd = 0;
884
- const fetchedAt = Date.now();
885
- const summary = {
886
- totalUsd,
887
- perProtocol,
888
- pricedAt: fetchedAt,
889
- source: failures === DEFI_PROTOCOLS.length ? "degraded" : failures > 0 ? "partial" : "blockvision"
890
- };
891
- const cachedPositive = cachedEntry && cachedEntry.data.totalUsd > 0 && now - cachedEntry.pricedAt < DEFI_STICKY_TTL_SEC * 1e3;
892
- if (summary.source === "blockvision") {
893
- await safeStoreSet(store, address, { data: summary, pricedAt: fetchedAt });
894
- return summary;
895
- }
896
- if (summary.source === "partial" && summary.totalUsd > 0) {
897
- await safeStoreSet(store, address, { data: summary, pricedAt: fetchedAt });
898
- return summary;
899
- }
900
- if (cachedPositive) {
901
- const stale = {
902
- ...cachedEntry.data,
903
- source: "partial-stale"
904
- };
905
- return stale;
906
- }
907
- return summary;
1217
+ );
908
1218
  } finally {
909
1219
  defiInflight.delete(address);
910
1220
  }
@@ -914,12 +1224,17 @@ async function fetchAddressDefiPortfolio(address, apiKey, priceHints = {}) {
914
1224
  }
915
1225
  async function fetchOneDefiProtocol(address, protocol, apiKey) {
916
1226
  const url = `${BLOCKVISION_BASE}/account/defiPortfolio?address=${encodeURIComponent(address)}&protocol=${protocol}`;
1227
+ const signal = AbortSignal.timeout(DEFI_PORTFOLIO_TIMEOUT_MS);
917
1228
  let res;
918
1229
  try {
919
- res = await fetch(url, {
920
- headers: { "x-api-key": apiKey, accept: "application/json" },
921
- signal: AbortSignal.timeout(DEFI_PORTFOLIO_TIMEOUT_MS)
922
- });
1230
+ res = await fetchBlockVisionWithRetry(
1231
+ url,
1232
+ {
1233
+ headers: { "x-api-key": apiKey, accept: "application/json" },
1234
+ signal
1235
+ },
1236
+ { signal }
1237
+ );
923
1238
  } catch (err) {
924
1239
  console.warn(`[defi] ${protocol} fetch threw:`, err);
925
1240
  return null;
@@ -1185,12 +1500,12 @@ function normalizeProtocol(protocol, result, prices) {
1185
1500
  if (bespoke) return bespoke(result, prices);
1186
1501
  return walkProtocolResponse(result, prices);
1187
1502
  }
1188
- function clearPortfolioCache() {
1189
- portfolioCache.clear();
1503
+ async function clearPortfolioCache() {
1504
+ await getWalletCacheStore().clear();
1190
1505
  portfolioInflight.clear();
1191
1506
  }
1192
- function clearPortfolioCacheFor(address) {
1193
- portfolioCache.delete(address);
1507
+ async function clearPortfolioCacheFor(address) {
1508
+ await getWalletCacheStore().delete(address);
1194
1509
  portfolioInflight.delete(address);
1195
1510
  }
1196
1511
  function clearPriceMapCache() {
@@ -3329,12 +3644,31 @@ var portfolioAnalysisTool = buildTool({
3329
3644
  `${apiUrl}/api/analytics/portfolio-history?days=7`,
3330
3645
  { headers: { "x-sui-address": address }, signal: context.signal }
3331
3646
  ).then((res) => res.ok ? res.json() : null).catch(() => null) : Promise.resolve(null),
3332
- // DeFi fetch — prefer the audric snapshot's already-computed value
3333
- // (when present and not 'degraded'), otherwise call the engine's
3334
- // direct aggregator. The 'degraded' check prevents the audric
3335
- // path from masking a useful direct read when the audric route's
3336
- // own DeFi field came back empty.
3337
- audricSnapshot && audricSnapshot.defiSource !== "degraded" ? Promise.resolve({
3647
+ // DeFi fetch — prefer the audric snapshot's already-computed
3648
+ // value, but only when we can trust it. Two trust signals:
3649
+ // 1. `source === 'blockvision'` — fully successful fresh read
3650
+ // (even if value is 0, that's a confirmed empty position).
3651
+ // 2. `defiValueUsd > 0` — any positive value, regardless
3652
+ // of source. `partial-stale` with a positive total is fine,
3653
+ // `partial` with a positive total is the live equivalent.
3654
+ //
3655
+ // [Bug — 2026-04-28 round 2] Pre-fix the trust gate was
3656
+ // `defiSource !== 'degraded'`, which let `partial + 0` through
3657
+ // as authoritative. During a BlockVision 429 burst the audric
3658
+ // host's `/api/portfolio` returns `partial + 0` (some protocols
3659
+ // failed, the rest reported $0, no sticky-positive available
3660
+ // *in that process*) — but the engine's direct fetcher in the
3661
+ // chat route may have a sticky-positive in *this* Vercel
3662
+ // instance's cache. Trusting audric's $0 silently dropped the
3663
+ // DeFi line that `balance_check` (which always calls direct)
3664
+ // showed correctly on the same turn — same SSOT-divergence bug
3665
+ // class, manifested in a different layer.
3666
+ //
3667
+ // The new condition routes around audric's $0 in exactly that
3668
+ // case. When the direct fetch ALSO returns $0 the answer is
3669
+ // consistent across tools (both report degraded), which is the
3670
+ // honest UX during a real outage.
3671
+ audricSnapshot && (audricSnapshot.defiSource === "blockvision" || audricSnapshot.defiValueUsd > 0) ? Promise.resolve({
3338
3672
  totalUsd: audricSnapshot.defiValueUsd,
3339
3673
  perProtocol: {},
3340
3674
  pricedAt: Date.now(),
@@ -4418,7 +4752,7 @@ function getModifiableFields(toolName) {
4418
4752
  }
4419
4753
 
4420
4754
  // src/prompt.ts
4421
- var DEFAULT_SYSTEM_PROMPT = `You are Audric \u2014 a financial agent on Sui. Audric is exactly five products: Audric Passport (the trust layer \u2014 Google sign-in, non-custodial wallet, tap-to-confirm consent, sponsored gas \u2014 wraps every other product), Audric Intelligence (you \u2014 the 5-system brain: Agent Harness with 34 tools, Reasoning Engine with 9 guards and 7 skill recipes, Silent Profile, Chain Memory, AdviceLog), Audric Finance (manage money on Sui \u2014 Save via NAVI lending at 3-8% APY USDC, Credit via NAVI borrowing with health factor, Swap via Cetus aggregator across 20+ DEXs at 0.1% fee, Charts for yield/health/portfolio viz), Audric Pay (move money \u2014 send USDC, receive via payment links / invoices / QR; free, global, instant on Sui), and Audric Store (creator marketplace, ships Phase 5 \u2014 say "coming soon" if asked). Save, swap, borrow, repay, withdraw, charts \u2192 Audric Finance. Send, receive, payment-link, invoice, QR \u2192 Audric Pay. Your silent context (profile, memory, chain facts, advice log) shapes your replies but never surfaces as a notification \u2014 you act only when the user asks, and every write waits on their tap-to-confirm via Passport. You can also call 41 paid APIs (music, image, research, translation, weather, fulfilment) via MPP micropayments using the pay_api tool \u2014 this is an internal capability, not a promoted product, so only mention it when the user asks for something that needs it.
4755
+ var DEFAULT_SYSTEM_PROMPT = `You are Audric \u2014 a financial agent on Sui. Audric is exactly five products: Audric Passport (the trust layer \u2014 Google sign-in, non-custodial wallet, tap-to-confirm consent, sponsored gas \u2014 wraps every other product), Audric Intelligence (you \u2014 the 5-system brain: Agent Harness with 34 tools, Reasoning Engine with 14 guards and 6 skill recipes, Silent Profile, Chain Memory, AdviceLog), Audric Finance (manage money on Sui \u2014 Save via NAVI lending at 3-8% APY USDC, Credit via NAVI borrowing with health factor, Swap via Cetus aggregator across 20+ DEXs at 0.1% fee, Charts for yield/health/portfolio viz), Audric Pay (move money \u2014 send USDC, receive via payment links / invoices / QR; free, global, instant on Sui), and Audric Store (creator marketplace, ships Phase 5 \u2014 say "coming soon" if asked). Save, swap, borrow, repay, withdraw, charts \u2192 Audric Finance. Send, receive, payment-link, invoice, QR \u2192 Audric Pay. Your silent context (profile, memory, chain facts, advice log) shapes your replies but never surfaces as a notification \u2014 you act only when the user asks, and every write waits on their tap-to-confirm via Passport. You can also call 41 paid APIs (music, image, research, translation, weather, fulfilment) via MPP micropayments using the pay_api tool \u2014 this is an internal capability, not a promoted product, so only mention it when the user asks for something that needs it.
4422
4756
 
4423
4757
  ## Response rules
4424
4758
  - 1-2 sentences max. No bullet lists unless asked. No preambles.
@@ -5852,7 +6186,7 @@ var QueryEngine = class {
5852
6186
  };
5853
6187
  if (this.walletAddress) {
5854
6188
  this.portfolioCache?.delete(this.walletAddress);
5855
- clearPortfolioCacheFor(this.walletAddress);
6189
+ await clearPortfolioCacheFor(this.walletAddress);
5856
6190
  }
5857
6191
  if (!signal.aborted) {
5858
6192
  await new Promise((resolve) => {
@@ -7752,6 +8086,6 @@ function sanitizeAnthropicMessages(messages) {
7752
8086
  return merged;
7753
8087
  }
7754
8088
 
7755
- export { AnthropicProvider, BalanceTracker, CANVAS_TEMPLATES, ContextBudget, CostTracker, DEFAULT_GUARD_CONFIG, DEFAULT_PERMISSION_CONFIG, DEFAULT_SYSTEM_PROMPT, EarlyToolDispatcher, InMemoryDefiCacheStore, McpClientManager, McpResponseCache, MemorySessionStore, NAVI_MCP_CONFIG, NAVI_MCP_URL, NAVI_SERVER_NAME, NaviTools, PERMISSION_PRESETS, QueryEngine, READ_TOOLS, RecipeRegistry, RetryTracker, TOOL_FLAGS, TOOL_MODIFIABLE_FIELDS, TxMutex, WRITE_TOOLS, activitySummaryTool, adaptAllMcpTools, adaptAllServerTools, adaptMcpTool, applyToolFlags, balanceCheckTool, borrowTool, budgetToolResult, buildCachedSystemPrompt, buildMcpTools, buildProactivenessInstructions, buildProfileContext, buildSelfEvaluationInstruction, buildStateContext, buildTool, claimRewardsTool, classifyEffort, clearPortfolioCache, clearPortfolioCacheFor, clearPriceMapCache, compactMessages, createGuardRunnerState, engineToSSE, estimateTokens, explainTxTool, extractConversationText, extractMcpText, fetchAddressDefiPortfolio, fetchAddressPortfolio, fetchAudricHistory, fetchAudricPortfolio, fetchAvailableRewards, fetchBalance, fetchHealthFactor, fetchPositions, fetchProtocolStats, fetchRates, fetchSavings, fetchTokenPrices, fetchWalletCoins, findTool, getAudricApiBase, getDefaultTools, getDefiCacheStore, getMcpManager, getModifiableFields, getToolFlags, getWalletAddress, guardArtifactPreview, guardStaleData, hasNaviMcp, healthCheckTool, loadRecipes, microcompact, mppServicesTool, parseMcpJson, parseRecipe, parseSSE, payApiTool, portfolioAnalysisTool, protocolDeepDiveTool, ratesInfoTool, registerEngineTools, renderCanvasTool, repayDebtTool, requireAgent, resetDefiCacheStore, resolvePermissionTier, resolveUsdValue, runGuards, runTools, saveContactTool, saveDepositTool, savingsInfoTool, sendTransferTool, serializeSSE, setDefiCacheStore, spendingAnalyticsTool, swapExecuteTool, swapQuoteTool, tokenPricesTool, toolNameToOperation, toolsToDefinitions, transactionHistoryTool, transformBalance, transformHealthFactor, transformPositions, transformRates, transformRewards, transformSavings, updateGuardStateAfterToolResult, validateHistory, voloStakeTool, voloStatsTool, voloUnstakeTool, webSearchTool, withdrawTool, yieldSummaryTool };
8089
+ export { AnthropicProvider, BalanceTracker, CANVAS_TEMPLATES, ContextBudget, CostTracker, DEFAULT_GUARD_CONFIG, DEFAULT_LEASE_SEC, DEFAULT_PERMISSION_CONFIG, DEFAULT_POLL_BUDGET_MS, DEFAULT_POLL_INTERVAL_MS, DEFAULT_SYSTEM_PROMPT, EarlyToolDispatcher, InMemoryDefiCacheStore, InMemoryFetchLock, InMemoryWalletCacheStore, McpClientManager, McpResponseCache, MemorySessionStore, NAVI_MCP_CONFIG, NAVI_MCP_URL, NAVI_SERVER_NAME, NaviTools, PERMISSION_PRESETS, QueryEngine, READ_TOOLS, RecipeRegistry, RetryTracker, TOOL_FLAGS, TOOL_MODIFIABLE_FIELDS, TxMutex, WRITE_TOOLS, activitySummaryTool, adaptAllMcpTools, adaptAllServerTools, adaptMcpTool, applyToolFlags, awaitOrFetch, balanceCheckTool, borrowTool, budgetToolResult, buildCachedSystemPrompt, buildMcpTools, buildProactivenessInstructions, buildProfileContext, buildSelfEvaluationInstruction, buildStateContext, buildTool, claimRewardsTool, classifyEffort, clearPortfolioCache, clearPortfolioCacheFor, clearPriceMapCache, compactMessages, createGuardRunnerState, engineToSSE, estimateTokens, explainTxTool, extractConversationText, extractMcpText, fetchAddressDefiPortfolio, fetchAddressPortfolio, fetchAudricHistory, fetchAudricPortfolio, fetchAvailableRewards, fetchBalance, fetchHealthFactor, fetchPositions, fetchProtocolStats, fetchRates, fetchSavings, fetchTokenPrices, fetchWalletCoins, findTool, getAudricApiBase, getDefaultTools, getDefiCacheStore, getFetchLock, getMcpManager, getModifiableFields, getToolFlags, getWalletAddress, getWalletCacheStore, guardArtifactPreview, guardStaleData, hasNaviMcp, healthCheckTool, loadRecipes, microcompact, mppServicesTool, parseMcpJson, parseRecipe, parseSSE, payApiTool, portfolioAnalysisTool, protocolDeepDiveTool, ratesInfoTool, registerEngineTools, renderCanvasTool, repayDebtTool, requireAgent, resetDefiCacheStore, resetFetchLock, resetWalletCacheStore, resolvePermissionTier, resolveUsdValue, runGuards, runTools, saveContactTool, saveDepositTool, savingsInfoTool, sendTransferTool, serializeSSE, setDefiCacheStore, setFetchLock, setWalletCacheStore, spendingAnalyticsTool, swapExecuteTool, swapQuoteTool, tokenPricesTool, toolNameToOperation, toolsToDefinitions, transactionHistoryTool, transformBalance, transformHealthFactor, transformPositions, transformRates, transformRewards, transformSavings, updateGuardStateAfterToolResult, validateHistory, voloStakeTool, voloStatsTool, voloUnstakeTool, webSearchTool, withdrawTool, yieldSummaryTool };
7756
8090
  //# sourceMappingURL=index.js.map
7757
8091
  //# sourceMappingURL=index.js.map