@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.js CHANGED
@@ -1694,18 +1694,31 @@ const fetchRpcQueryPack = async (connector, networkId, queries, at) => {
1694
1694
  return decodeRpcQueryPackChunked(queries, new Map(result.changes), { label: `rpcQueryPack decode ${networkId}` });
1695
1695
  };
1696
1696
  /**
1697
+ * The connector reports a chain that has been unreachable for a while with a StaleRpcError,
1698
+ * but keeps the subscription registered and re-issues it when a socket reconnects (the
1699
+ * first notification after that is a full snapshot again). Matched by its `type` rather
1700
+ * than `instanceof`, so a second copy of @talismn/chain-connectors still matches.
1701
+ */
1702
+ const isStaleRpcError = (error) => typeof error === "object" && error !== null && error.type === "STALE_RPC_ERROR";
1703
+ /**
1697
1704
  * Wraps the raw state_subscribeStorage subscription and emits, for every storage change
1698
1705
  * callback, a SNAPSHOT of the accumulated changes keyed by state key.
1699
1706
  *
1700
1707
  * A snapshot (shallow copy of string refs, cheap) is required because decoding is chunked
1701
1708
  * and asynchronous: a later websocket callback must not mutate the map that an in-flight
1702
1709
  * decode is reading.
1710
+ *
1711
+ * A StaleRpcError does not end the stream. Erroring would unsubscribe, and with no
1712
+ * subscription holding the chain's socket, every later request (eg the dtao poll) would
1713
+ * open and close a socket of its own, starting from the network's first RPC each time.
1703
1714
  */
1704
1715
  const getRawStorageUpdates$ = (connector, networkId, allStateKeys, timeout) => new rxjs.Observable((subscriber) => {
1705
1716
  const changesCache = /* @__PURE__ */ new Map();
1706
1717
  const promUnsub = connector.subscribe(networkId, "state_subscribeStorage", "state_storage", [allStateKeys], (error, result) => {
1707
- if (error) subscriber.error(error);
1708
- else if (result) {
1718
+ if (error) {
1719
+ if (isStaleRpcError(error)) log_default.warn(`Storage subscription stale on ${networkId}`);
1720
+ else subscriber.error(error);
1721
+ } else if (result) {
1709
1722
  for (const [stateKey, encodedResult] of result.changes) changesCache.set(stateKey, encodedResult);
1710
1723
  subscriber.next(new Map(changesCache));
1711
1724
  }
@@ -2510,6 +2523,12 @@ const createBalanceStabilizer = (isEffectivelyEqual, options) => {
2510
2523
  };
2511
2524
  };
2512
2525
  /**
2526
+ * Cross-module drift tolerance (see classifySmallAmountDrift): amounts moving by no more
2527
+ * than this classify as drift and re-emit at most once per stabilizer refresh interval.
2528
+ * Kept tight — a real incoming transfer above 0.01% of the position emits immediately.
2529
+ */
2530
+ const GENERIC_DRIFT_TOLERANCE_BPS = 1n;
2531
+ /**
2513
2532
  * Default cross-module drift classifier: balances whose SHAPE is identical (same
2514
2533
  * address/token/status, same value set with same types/labels/metas) but whose amounts
2515
2534
  * moved by no more than `toleranceBps` classify as "drift" — continuously-moving
@@ -2734,7 +2753,7 @@ const fetchOnChainTokenData$1 = async (connector, tokenId) => {
2734
2753
  //#region src/modules/sol-spl/fetchBalances.ts
2735
2754
  const SPL_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
2736
2755
  const dynamicTokenMetadataCache$1 = new _talismn_util.LruMap(1024);
2737
- const associatedTokenAccountCache = new _talismn_util.LruMap(1024);
2756
+ const associatedTokenAccountCache$1 = new _talismn_util.LruMap(1024);
2738
2757
  const fetchBalances$8 = async ({ networkId, tokensWithAddresses, connector }) => {
2739
2758
  if (!tokensWithAddresses.length) return {
2740
2759
  success: [],
@@ -2754,7 +2773,7 @@ const fetchBalances$8 = async ({ networkId, tokensWithAddresses, connector }) =>
2754
2773
  return {
2755
2774
  mintAddress,
2756
2775
  value,
2757
- isSpendable: value !== 0n && state !== "frozen" && d.pubkey === await getAssociatedTokenAccount(address, mintAddress)
2776
+ isSpendable: value !== 0n && state !== "frozen" && d.pubkey === await getAssociatedTokenAccount$1(address, mintAddress)
2758
2777
  };
2759
2778
  } catch {
2760
2779
  log_default.warn("Failed to parse token amount", {
@@ -2847,16 +2866,16 @@ const fetchBalances$8 = async ({ networkId, tokensWithAddresses, connector }) =>
2847
2866
  };
2848
2867
  };
2849
2868
  const getBalanceKey$1 = (tokenId, address) => `${tokenId}:${address}`;
2850
- const getAssociatedTokenAccount = async (owner, mint) => {
2869
+ const getAssociatedTokenAccount$1 = async (owner, mint) => {
2851
2870
  const key = `${owner}:${mint}`;
2852
- const cached = associatedTokenAccountCache.get(key);
2871
+ const cached = associatedTokenAccountCache$1.get(key);
2853
2872
  if (cached) return cached;
2854
2873
  const [associatedTokenAccount] = await (0, _solana_program_token.findAssociatedTokenPda)({
2855
2874
  mint: (0, _solana_kit.address)(mint),
2856
2875
  owner: (0, _solana_kit.address)(owner),
2857
2876
  tokenProgram: _solana_program_token.TOKEN_PROGRAM_ADDRESS
2858
2877
  });
2859
- associatedTokenAccountCache.set(key, associatedTokenAccount);
2878
+ associatedTokenAccountCache$1.set(key, associatedTokenAccount);
2860
2879
  return associatedTokenAccount;
2861
2880
  };
2862
2881
  //#endregion
@@ -3306,6 +3325,7 @@ const fetchOnChainTokenData = async (connector, tokenId) => {
3306
3325
  //#region src/modules/sol-token2022/fetchBalances.ts
3307
3326
  const TOKEN_2022_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
3308
3327
  const dynamicTokenMetadataCache = /* @__PURE__ */ new Map();
3328
+ const associatedTokenAccountCache = new _talismn_util.LruMap(1024);
3309
3329
  const fetchBalances$7 = async ({ networkId, tokensWithAddresses, connector }) => {
3310
3330
  if (!tokensWithAddresses.length) return {
3311
3331
  success: [],
@@ -3317,19 +3337,15 @@ const fetchBalances$7 = async ({ networkId, tokensWithAddresses, connector }) =>
3317
3337
  const knownTokenIds = new Set(tokensWithAddresses.map(([token]) => token.id));
3318
3338
  const unknownMints = /* @__PURE__ */ new Set();
3319
3339
  const balancesPerAddress = await Promise.all(accountAddresses.map(async (address) => {
3320
- const balances = (await rpc.getTokenAccountsByOwner((0, _solana_kit.address)(address), { programId: (0, _solana_kit.address)(TOKEN_2022_PROGRAM_ID) }, { encoding: "jsonParsed" }).send()).value.map((d) => {
3340
+ const tokenAccounts = await rpc.getTokenAccountsByOwner((0, _solana_kit.address)(address), { programId: (0, _solana_kit.address)(TOKEN_2022_PROGRAM_ID) }, { encoding: "jsonParsed" }).send();
3341
+ const tokenAccountHoldings = await Promise.all(tokenAccounts.value.map(async (d) => {
3321
3342
  try {
3322
- const mintAddress = d.account.data.parsed.info.mint;
3323
- const value = d.account.data.parsed.info.tokenAmount.amount ?? "0";
3324
- const tokenId = (0, _talismn_chaindata_provider.solToken2022TokenId)(networkId, mintAddress);
3325
- if (!knownTokenIds.has(tokenId) && value !== "0") unknownMints.add(mintAddress);
3343
+ const { mint: mintAddress, state, tokenAmount } = d.account.data.parsed.info;
3344
+ const value = BigInt(tokenAmount.amount ?? "0");
3326
3345
  return {
3327
- tokenId,
3328
- networkId,
3329
- address,
3330
- source: MODULE_TYPE$7,
3331
- status: "live",
3332
- value
3346
+ mintAddress,
3347
+ value,
3348
+ isSpendable: value !== 0n && state !== "frozen" && d.pubkey === await getAssociatedTokenAccount(address, mintAddress)
3333
3349
  };
3334
3350
  } catch {
3335
3351
  log_default.warn("Failed to parse token amount", {
@@ -3338,7 +3354,36 @@ const fetchBalances$7 = async ({ networkId, tokensWithAddresses, connector }) =>
3338
3354
  });
3339
3355
  return null;
3340
3356
  }
3341
- }).filter(_talismn_util.isNotNil);
3357
+ }));
3358
+ const holdingsPerTokenId = /* @__PURE__ */ new Map();
3359
+ for (const { mintAddress, value, isSpendable } of tokenAccountHoldings.filter(_talismn_util.isNotNil)) {
3360
+ const tokenId = (0, _talismn_chaindata_provider.solToken2022TokenId)(networkId, mintAddress);
3361
+ if (!knownTokenIds.has(tokenId) && value !== 0n) unknownMints.add(mintAddress);
3362
+ const { total, spendable } = holdingsPerTokenId.get(tokenId) ?? {
3363
+ total: 0n,
3364
+ spendable: 0n
3365
+ };
3366
+ holdingsPerTokenId.set(tokenId, {
3367
+ total: total + value,
3368
+ spendable: spendable + (isSpendable ? value : 0n)
3369
+ });
3370
+ }
3371
+ const balances = [...holdingsPerTokenId].map(([tokenId, { total, spendable }]) => ({
3372
+ tokenId,
3373
+ networkId,
3374
+ address,
3375
+ source: MODULE_TYPE$7,
3376
+ status: "live",
3377
+ ...spendable === total ? { value: total.toString() } : { values: [{
3378
+ type: "free",
3379
+ label: "free",
3380
+ amount: total.toString()
3381
+ }, {
3382
+ type: "locked",
3383
+ label: "locked",
3384
+ amount: (total - spendable).toString()
3385
+ }] }
3386
+ }));
3342
3387
  setDetectedTokenIds(address, MODULE_TYPE$7, balances.map((b) => b.tokenId));
3343
3388
  return [address, balances];
3344
3389
  }));
@@ -3393,6 +3438,18 @@ const fetchBalances$7 = async ({ networkId, tokensWithAddresses, connector }) =>
3393
3438
  };
3394
3439
  };
3395
3440
  const getBalanceKey = (tokenId, address) => `${tokenId}:${address}`;
3441
+ const getAssociatedTokenAccount = async (owner, mint) => {
3442
+ const key = `${owner}:${mint}`;
3443
+ const cached = associatedTokenAccountCache.get(key);
3444
+ if (cached) return cached;
3445
+ const [associatedTokenAccount] = await (0, _solana_program_token.findAssociatedTokenPda)({
3446
+ mint: (0, _solana_kit.address)(mint),
3447
+ owner: (0, _solana_kit.address)(owner),
3448
+ tokenProgram: _solana_program_token_2022.TOKEN_2022_PROGRAM_ADDRESS
3449
+ });
3450
+ associatedTokenAccountCache.set(key, associatedTokenAccount);
3451
+ return associatedTokenAccount;
3452
+ };
3396
3453
  //#endregion
3397
3454
  //#region src/modules/sol-token2022/fetchTokens.ts
3398
3455
  const fetchTokens$7 = async ({ networkId, tokens, connector, cache }) => {
@@ -4458,16 +4515,10 @@ const getTransferCallData$5 = ({ from, to, value, token, metadataRpc }) => {
4458
4515
  * dividend accrual on (nearly) every block; the display is informational */
4459
4516
  const CLAIM_DRIFT_TOLERANCE_BPS = 100n;
4460
4517
  /**
4461
- * subnet staking positions auto-compound: dividend injections land once per subnet tempo
4462
- * (staggered across subnets, so with many positions SOME position bumps on nearly every
4463
- * poll). Moves up to this size classify as drift (surfaced within the refresh interval);
4464
- * anything larger (a real stake/unstake) emits immediately.
4465
- */
4466
- const STAKE_DRIFT_TOLERANCE_BPS = 100n;
4467
- /**
4468
4518
  * dtao balances embed values that drift on (nearly) every block even when the user's
4469
- * position is untouched: staking positions auto-compound, basket claimable quotes move,
4470
- * and conviction locks decay.
4519
+ * position is untouched: basket claimable quotes move and conviction locks decay.
4520
+ * Stake itself only moves on dividend injections, once per subnet tempo, so every stake
4521
+ * move emits immediately.
4471
4522
  *
4472
4523
  * Without special handling, every 6s poll re-emits the full result set, which defeats
4473
4524
  * every distinctUntilChanged stage downstream and forces the whole pipeline
@@ -4518,7 +4569,6 @@ const classifyValue = (previous, next) => {
4518
4569
  if (previous.label === "Claimable rewards") {
4519
4570
  if (!isWithinDriftTolerance(previous.amount, next.amount, CLAIM_DRIFT_TOLERANCE_BPS)) drift = true;
4520
4571
  } else if (previousLock) drift = true;
4521
- else if (isWithinDriftTolerance(previous.amount, next.amount, STAKE_DRIFT_TOLERANCE_BPS)) drift = true;
4522
4572
  else return changed(`amount ${previous.amount}→${next.amount}`);
4523
4573
  }
4524
4574
  return { equivalence: drift ? "drift" : "equal" };
@@ -6801,12 +6851,8 @@ const fetchMiniMetadatas = async (chainConnector, chaindataProvider, chainId, sp
6801
6851
  };
6802
6852
  //#endregion
6803
6853
  //#region src/BalancesProvider.ts
6804
- /**
6805
- * Cross-module drift tolerance (see classifySmallAmountDrift): amounts moving by no more
6806
- * than this classify as drift and re-emit at most once per stabilizer refresh interval.
6807
- * Kept tight — a real incoming transfer above 0.1% of the position emits immediately.
6808
- */
6809
- const GENERIC_DRIFT_TOLERANCE_BPS = 10n;
6854
+ /** Delay before resubscribing a substrate module stream that errored: 2s, doubling, capped at 1min */
6855
+ const substrateModuleRetryDelay = (retryCount) => (0, rxjs.timer)(Math.min(6e4, 2e3 * 2 ** (retryCount - 1)));
6810
6856
  const DEFAULT_STORAGE = {
6811
6857
  balances: [],
6812
6858
  miniMetadatas: []
@@ -6944,7 +6990,10 @@ var BalancesProvider = class {
6944
6990
  connector: this.#chainConnectors.substrate,
6945
6991
  miniMetadata,
6946
6992
  hasFrontierMirrorWithdraw
6947
- })), stabilizeModuleResults(mod.type === "substrate-dtao" ? void 0 : classifySmallAmountDrift(GENERIC_DRIFT_TOLERANCE_BPS)), (0, rxjs.catchError)(() => rxjs.EMPTY), (0, rxjs.tap)((results) => {
6993
+ })), stabilizeModuleResults(mod.type === "substrate-dtao" ? void 0 : classifySmallAmountDrift(GENERIC_DRIFT_TOLERANCE_BPS)), (0, rxjs.retry)({
6994
+ delay: (_error, retryCount) => substrateModuleRetryDelay(retryCount),
6995
+ resetOnSuccess: true
6996
+ }), (0, rxjs.tap)((results) => {
6948
6997
  (0, _talismn_util.reportJsActivity)(`module ${mod.type} ${networkId} (${results.success.length})`);
6949
6998
  if (results.dynamicTokens?.length) this.#chaindataProvider.registerDynamicTokens(results.dynamicTokens);
6950
6999
  }), (0, rxjs.map)((results) => ({