@talismn/balances 3.1.0 → 3.1.2

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.mjs CHANGED
@@ -3,7 +3,7 @@ import { EvmErc20TokenSchema, EvmNativeTokenSchema, EvmUniswapV2TokenSchema, MIN
3
3
  import { frontierH160ToSs58Mirror, frontierSs58ToTruncatedH160, getAccountPlatformFromAddress, isAddressEqual, isEthereumAddress, isOnCurveSolanaAddress, isSolanaAddress, normalizeAddress } from "@talismn/crypto";
4
4
  import { BigMath, LruMap, createTimeSlicer, forEachWithYield, getErrorMessage, getSharedObservable, isAbortError, isArrayOf, isBigInt, isErrorOfName, isNotNil, isTruthy, keepAlive, mapWithYield, planckToTokens, reportJsActivity, switchMapChunked, yieldToEventLoop } from "@talismn/util";
5
5
  import { assign, fromPairs, isEqual, keyBy, keys, mergeWith, omit, toPairs, uniq, values } from "lodash-es";
6
- import { BehaviorSubject, EMPTY, Observable, ReplaySubject, auditTime, catchError, combineLatest, defer, distinctUntilChanged, filter, firstValueFrom, from, map, of, shareReplay, startWith, switchMap, tap, timer } from "rxjs";
6
+ import { BehaviorSubject, EMPTY, Observable, ReplaySubject, auditTime, catchError, combineLatest, defer, distinctUntilChanged, filter, firstValueFrom, from, map, of, retry, shareReplay, startWith, switchMap, tap, timer } from "rxjs";
7
7
  import { encodeFunctionData, erc20Abi, erc20Abi_bytes32, getContract, hexToString, parseAbi, withRetry } from "viem";
8
8
  import { newTokenRates } from "@talismn/token-rates";
9
9
  import anylogger from "anylogger";
@@ -1666,18 +1666,31 @@ const fetchRpcQueryPack = async (connector, networkId, queries, at) => {
1666
1666
  return decodeRpcQueryPackChunked(queries, new Map(result.changes), { label: `rpcQueryPack decode ${networkId}` });
1667
1667
  };
1668
1668
  /**
1669
+ * The connector reports a chain that has been unreachable for a while with a StaleRpcError,
1670
+ * but keeps the subscription registered and re-issues it when a socket reconnects (the
1671
+ * first notification after that is a full snapshot again). Matched by its `type` rather
1672
+ * than `instanceof`, so a second copy of @talismn/chain-connectors still matches.
1673
+ */
1674
+ const isStaleRpcError = (error) => typeof error === "object" && error !== null && error.type === "STALE_RPC_ERROR";
1675
+ /**
1669
1676
  * Wraps the raw state_subscribeStorage subscription and emits, for every storage change
1670
1677
  * callback, a SNAPSHOT of the accumulated changes keyed by state key.
1671
1678
  *
1672
1679
  * A snapshot (shallow copy of string refs, cheap) is required because decoding is chunked
1673
1680
  * and asynchronous: a later websocket callback must not mutate the map that an in-flight
1674
1681
  * decode is reading.
1682
+ *
1683
+ * A StaleRpcError does not end the stream. Erroring would unsubscribe, and with no
1684
+ * subscription holding the chain's socket, every later request (eg the dtao poll) would
1685
+ * open and close a socket of its own, starting from the network's first RPC each time.
1675
1686
  */
1676
1687
  const getRawStorageUpdates$ = (connector, networkId, allStateKeys, timeout) => new Observable((subscriber) => {
1677
1688
  const changesCache = /* @__PURE__ */ new Map();
1678
1689
  const promUnsub = connector.subscribe(networkId, "state_subscribeStorage", "state_storage", [allStateKeys], (error, result) => {
1679
- if (error) subscriber.error(error);
1680
- else if (result) {
1690
+ if (error) {
1691
+ if (isStaleRpcError(error)) log_default.warn(`Storage subscription stale on ${networkId}`);
1692
+ else subscriber.error(error);
1693
+ } else if (result) {
1681
1694
  for (const [stateKey, encodedResult] of result.changes) changesCache.set(stateKey, encodedResult);
1682
1695
  subscriber.next(new Map(changesCache));
1683
1696
  }
@@ -2482,6 +2495,12 @@ const createBalanceStabilizer = (isEffectivelyEqual, options) => {
2482
2495
  };
2483
2496
  };
2484
2497
  /**
2498
+ * Cross-module drift tolerance (see classifySmallAmountDrift): amounts moving by no more
2499
+ * than this classify as drift and re-emit at most once per stabilizer refresh interval.
2500
+ * Kept tight — a real incoming transfer above 0.01% of the position emits immediately.
2501
+ */
2502
+ const GENERIC_DRIFT_TOLERANCE_BPS = 1n;
2503
+ /**
2485
2504
  * Default cross-module drift classifier: balances whose SHAPE is identical (same
2486
2505
  * address/token/status, same value set with same types/labels/metas) but whose amounts
2487
2506
  * moved by no more than `toleranceBps` classify as "drift" — continuously-moving
@@ -2706,7 +2725,7 @@ const fetchOnChainTokenData$1 = async (connector, tokenId) => {
2706
2725
  //#region src/modules/sol-spl/fetchBalances.ts
2707
2726
  const SPL_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
2708
2727
  const dynamicTokenMetadataCache$1 = new LruMap(1024);
2709
- const associatedTokenAccountCache = new LruMap(1024);
2728
+ const associatedTokenAccountCache$1 = new LruMap(1024);
2710
2729
  const fetchBalances$8 = async ({ networkId, tokensWithAddresses, connector }) => {
2711
2730
  if (!tokensWithAddresses.length) return {
2712
2731
  success: [],
@@ -2726,7 +2745,7 @@ const fetchBalances$8 = async ({ networkId, tokensWithAddresses, connector }) =>
2726
2745
  return {
2727
2746
  mintAddress,
2728
2747
  value,
2729
- isSpendable: value !== 0n && state !== "frozen" && d.pubkey === await getAssociatedTokenAccount(address$2, mintAddress)
2748
+ isSpendable: value !== 0n && state !== "frozen" && d.pubkey === await getAssociatedTokenAccount$1(address$2, mintAddress)
2730
2749
  };
2731
2750
  } catch {
2732
2751
  log_default.warn("Failed to parse token amount", {
@@ -2819,16 +2838,16 @@ const fetchBalances$8 = async ({ networkId, tokensWithAddresses, connector }) =>
2819
2838
  };
2820
2839
  };
2821
2840
  const getBalanceKey$1 = (tokenId, address) => `${tokenId}:${address}`;
2822
- const getAssociatedTokenAccount = async (owner, mint) => {
2841
+ const getAssociatedTokenAccount$1 = async (owner, mint) => {
2823
2842
  const key = `${owner}:${mint}`;
2824
- const cached = associatedTokenAccountCache.get(key);
2843
+ const cached = associatedTokenAccountCache$1.get(key);
2825
2844
  if (cached) return cached;
2826
2845
  const [associatedTokenAccount] = await findAssociatedTokenPda({
2827
2846
  mint: address(mint),
2828
2847
  owner: address(owner),
2829
2848
  tokenProgram: TOKEN_PROGRAM_ADDRESS
2830
2849
  });
2831
- associatedTokenAccountCache.set(key, associatedTokenAccount);
2850
+ associatedTokenAccountCache$1.set(key, associatedTokenAccount);
2832
2851
  return associatedTokenAccount;
2833
2852
  };
2834
2853
  //#endregion
@@ -3278,6 +3297,7 @@ const fetchOnChainTokenData = async (connector, tokenId) => {
3278
3297
  //#region src/modules/sol-token2022/fetchBalances.ts
3279
3298
  const TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
3280
3299
  const dynamicTokenMetadataCache = /* @__PURE__ */ new Map();
3300
+ const associatedTokenAccountCache = new LruMap(1024);
3281
3301
  const fetchBalances$7 = async ({ networkId, tokensWithAddresses, connector }) => {
3282
3302
  if (!tokensWithAddresses.length) return {
3283
3303
  success: [],
@@ -3289,19 +3309,15 @@ const fetchBalances$7 = async ({ networkId, tokensWithAddresses, connector }) =>
3289
3309
  const knownTokenIds = new Set(tokensWithAddresses.map(([token]) => token.id));
3290
3310
  const unknownMints = /* @__PURE__ */ new Set();
3291
3311
  const balancesPerAddress = await Promise.all(accountAddresses.map(async (address$1) => {
3292
- const balances = (await rpc.getTokenAccountsByOwner(address(address$1), { programId: address(TOKEN_2022_PROGRAM_ID) }, { encoding: "jsonParsed" }).send()).value.map((d) => {
3312
+ const tokenAccounts = await rpc.getTokenAccountsByOwner(address(address$1), { programId: address(TOKEN_2022_PROGRAM_ID) }, { encoding: "jsonParsed" }).send();
3313
+ const tokenAccountHoldings = await Promise.all(tokenAccounts.value.map(async (d) => {
3293
3314
  try {
3294
- const mintAddress = d.account.data.parsed.info.mint;
3295
- const value = d.account.data.parsed.info.tokenAmount.amount ?? "0";
3296
- const tokenId = solToken2022TokenId(networkId, mintAddress);
3297
- if (!knownTokenIds.has(tokenId) && value !== "0") unknownMints.add(mintAddress);
3315
+ const { mint: mintAddress, state, tokenAmount } = d.account.data.parsed.info;
3316
+ const value = BigInt(tokenAmount.amount ?? "0");
3298
3317
  return {
3299
- tokenId,
3300
- networkId,
3301
- address: address$1,
3302
- source: MODULE_TYPE$7,
3303
- status: "live",
3304
- value
3318
+ mintAddress,
3319
+ value,
3320
+ isSpendable: value !== 0n && state !== "frozen" && d.pubkey === await getAssociatedTokenAccount(address$1, mintAddress)
3305
3321
  };
3306
3322
  } catch {
3307
3323
  log_default.warn("Failed to parse token amount", {
@@ -3310,7 +3326,36 @@ const fetchBalances$7 = async ({ networkId, tokensWithAddresses, connector }) =>
3310
3326
  });
3311
3327
  return null;
3312
3328
  }
3313
- }).filter(isNotNil);
3329
+ }));
3330
+ const holdingsPerTokenId = /* @__PURE__ */ new Map();
3331
+ for (const { mintAddress, value, isSpendable } of tokenAccountHoldings.filter(isNotNil)) {
3332
+ const tokenId = solToken2022TokenId(networkId, mintAddress);
3333
+ if (!knownTokenIds.has(tokenId) && value !== 0n) unknownMints.add(mintAddress);
3334
+ const { total, spendable } = holdingsPerTokenId.get(tokenId) ?? {
3335
+ total: 0n,
3336
+ spendable: 0n
3337
+ };
3338
+ holdingsPerTokenId.set(tokenId, {
3339
+ total: total + value,
3340
+ spendable: spendable + (isSpendable ? value : 0n)
3341
+ });
3342
+ }
3343
+ const balances = [...holdingsPerTokenId].map(([tokenId, { total, spendable }]) => ({
3344
+ tokenId,
3345
+ networkId,
3346
+ address: address$1,
3347
+ source: MODULE_TYPE$7,
3348
+ status: "live",
3349
+ ...spendable === total ? { value: total.toString() } : { values: [{
3350
+ type: "free",
3351
+ label: "free",
3352
+ amount: total.toString()
3353
+ }, {
3354
+ type: "locked",
3355
+ label: "locked",
3356
+ amount: (total - spendable).toString()
3357
+ }] }
3358
+ }));
3314
3359
  setDetectedTokenIds(address$1, MODULE_TYPE$7, balances.map((b) => b.tokenId));
3315
3360
  return [address$1, balances];
3316
3361
  }));
@@ -3365,6 +3410,18 @@ const fetchBalances$7 = async ({ networkId, tokensWithAddresses, connector }) =>
3365
3410
  };
3366
3411
  };
3367
3412
  const getBalanceKey = (tokenId, address) => `${tokenId}:${address}`;
3413
+ const getAssociatedTokenAccount = async (owner, mint) => {
3414
+ const key = `${owner}:${mint}`;
3415
+ const cached = associatedTokenAccountCache.get(key);
3416
+ if (cached) return cached;
3417
+ const [associatedTokenAccount] = await findAssociatedTokenPda({
3418
+ mint: address(mint),
3419
+ owner: address(owner),
3420
+ tokenProgram: TOKEN_2022_PROGRAM_ADDRESS
3421
+ });
3422
+ associatedTokenAccountCache.set(key, associatedTokenAccount);
3423
+ return associatedTokenAccount;
3424
+ };
3368
3425
  //#endregion
3369
3426
  //#region src/modules/sol-token2022/fetchTokens.ts
3370
3427
  const fetchTokens$7 = async ({ networkId, tokens, connector, cache }) => {
@@ -4430,16 +4487,10 @@ const getTransferCallData$5 = ({ from, to, value, token, metadataRpc }) => {
4430
4487
  * dividend accrual on (nearly) every block; the display is informational */
4431
4488
  const CLAIM_DRIFT_TOLERANCE_BPS = 100n;
4432
4489
  /**
4433
- * subnet staking positions auto-compound: dividend injections land once per subnet tempo
4434
- * (staggered across subnets, so with many positions SOME position bumps on nearly every
4435
- * poll). Moves up to this size classify as drift (surfaced within the refresh interval);
4436
- * anything larger (a real stake/unstake) emits immediately.
4437
- */
4438
- const STAKE_DRIFT_TOLERANCE_BPS = 100n;
4439
- /**
4440
4490
  * dtao balances embed values that drift on (nearly) every block even when the user's
4441
- * position is untouched: staking positions auto-compound, basket claimable quotes move,
4442
- * and conviction locks decay.
4491
+ * position is untouched: basket claimable quotes move and conviction locks decay.
4492
+ * Stake itself only moves on dividend injections, once per subnet tempo, so every stake
4493
+ * move emits immediately.
4443
4494
  *
4444
4495
  * Without special handling, every 6s poll re-emits the full result set, which defeats
4445
4496
  * every distinctUntilChanged stage downstream and forces the whole pipeline
@@ -4490,7 +4541,6 @@ const classifyValue = (previous, next) => {
4490
4541
  if (previous.label === "Claimable rewards") {
4491
4542
  if (!isWithinDriftTolerance(previous.amount, next.amount, CLAIM_DRIFT_TOLERANCE_BPS)) drift = true;
4492
4543
  } else if (previousLock) drift = true;
4493
- else if (isWithinDriftTolerance(previous.amount, next.amount, STAKE_DRIFT_TOLERANCE_BPS)) drift = true;
4494
4544
  else return changed(`amount ${previous.amount}→${next.amount}`);
4495
4545
  }
4496
4546
  return { equivalence: drift ? "drift" : "equal" };
@@ -6773,12 +6823,8 @@ const fetchMiniMetadatas = async (chainConnector, chaindataProvider, chainId, sp
6773
6823
  };
6774
6824
  //#endregion
6775
6825
  //#region src/BalancesProvider.ts
6776
- /**
6777
- * Cross-module drift tolerance (see classifySmallAmountDrift): amounts moving by no more
6778
- * than this classify as drift and re-emit at most once per stabilizer refresh interval.
6779
- * Kept tight — a real incoming transfer above 0.1% of the position emits immediately.
6780
- */
6781
- const GENERIC_DRIFT_TOLERANCE_BPS = 10n;
6826
+ /** Delay before resubscribing a substrate module stream that errored: 2s, doubling, capped at 1min */
6827
+ const substrateModuleRetryDelay = (retryCount) => timer(Math.min(6e4, 2e3 * 2 ** (retryCount - 1)));
6782
6828
  const DEFAULT_STORAGE = {
6783
6829
  balances: [],
6784
6830
  miniMetadatas: []
@@ -6916,7 +6962,10 @@ var BalancesProvider = class {
6916
6962
  connector: this.#chainConnectors.substrate,
6917
6963
  miniMetadata,
6918
6964
  hasFrontierMirrorWithdraw
6919
- })), stabilizeModuleResults(mod.type === "substrate-dtao" ? void 0 : classifySmallAmountDrift(GENERIC_DRIFT_TOLERANCE_BPS)), catchError(() => EMPTY), tap((results) => {
6965
+ })), stabilizeModuleResults(mod.type === "substrate-dtao" ? void 0 : classifySmallAmountDrift(GENERIC_DRIFT_TOLERANCE_BPS)), retry({
6966
+ delay: (_error, retryCount) => substrateModuleRetryDelay(retryCount),
6967
+ resetOnSuccess: true
6968
+ }), tap((results) => {
6920
6969
  reportJsActivity(`module ${mod.type} ${networkId} (${results.success.length})`);
6921
6970
  if (results.dynamicTokens?.length) this.#chaindataProvider.registerDynamicTokens(results.dynamicTokens);
6922
6971
  }), map((results) => ({