@dynamic-labs/sdk-react-core 4.84.0 → 4.84.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/package.cjs +1 -1
  3. package/package.js +1 -1
  4. package/package.json +12 -12
  5. package/src/index.cjs +2 -0
  6. package/src/index.d.ts +2 -2
  7. package/src/index.js +1 -0
  8. package/src/lib/components/SendBalanceForm/SendBalanceForm.cjs +26 -1
  9. package/src/lib/components/SendBalanceForm/SendBalanceForm.js +26 -1
  10. package/src/lib/components/SendBalancePageLayout/SendBalancePageLayout.cjs +6 -1
  11. package/src/lib/components/SendBalancePageLayout/SendBalancePageLayout.js +6 -1
  12. package/src/lib/utils/hooks/index.d.ts +2 -0
  13. package/src/lib/utils/hooks/useAleoAutoShieldSponsoredTokens/pollOnShielded.cjs +24 -4
  14. package/src/lib/utils/hooks/useAleoAutoShieldSponsoredTokens/pollOnShielded.d.ts +10 -2
  15. package/src/lib/utils/hooks/useAleoAutoShieldSponsoredTokens/pollOnShielded.js +24 -4
  16. package/src/lib/utils/hooks/useAleoAutoShieldSponsoredTokens/useAleoAutoShieldSponsoredTokens.cjs +14 -3
  17. package/src/lib/utils/hooks/useAleoAutoShieldSponsoredTokens/useAleoAutoShieldSponsoredTokens.d.ts +5 -1
  18. package/src/lib/utils/hooks/useAleoAutoShieldSponsoredTokens/useAleoAutoShieldSponsoredTokens.js +14 -3
  19. package/src/lib/utils/hooks/useAleoShieldedBalances/useAleoShieldedBalances.cjs +14 -0
  20. package/src/lib/utils/hooks/useAleoShieldedBalances/useAleoShieldedBalances.js +14 -0
  21. package/src/lib/utils/hooks/usePrivateTokenBalances/index.d.ts +2 -0
  22. package/src/lib/utils/hooks/usePrivateTokenBalances/usePrivateTokenBalances.cjs +19 -0
  23. package/src/lib/utils/hooks/usePrivateTokenBalances/usePrivateTokenBalances.d.ts +9 -0
  24. package/src/lib/utils/hooks/usePrivateTokenBalances/usePrivateTokenBalances.js +15 -0
  25. package/src/lib/utils/hooks/useWalletDelegation/useWalletDelegation.cjs +19 -16
  26. package/src/lib/utils/hooks/useWalletDelegation/useWalletDelegation.d.ts +8 -0
  27. package/src/lib/utils/hooks/useWalletDelegation/useWalletDelegation.js +19 -17
  28. package/src/lib/widgets/DynamicWidget/components/ActiveWalletBalance/ActiveWalletBalance.cjs +138 -21
  29. package/src/lib/widgets/DynamicWidget/components/ActiveWalletBalance/ActiveWalletBalance.js +139 -22
  30. package/src/lib/widgets/DynamicWidget/components/ActiveWalletBalance/optimisticShield.cjs +134 -0
  31. package/src/lib/widgets/DynamicWidget/components/ActiveWalletBalance/optimisticShield.d.ts +69 -0
  32. package/src/lib/widgets/DynamicWidget/components/ActiveWalletBalance/optimisticShield.js +127 -0
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
  import { __awaiter } from '../../../../../../_virtual/_tslib.js';
3
3
  import { jsx, jsxs } from 'react/jsx-runtime';
4
- import { useState, useRef, useMemo, useCallback, useEffect } from 'react';
4
+ import { useState, useRef, useMemo, useEffect, useCallback } from 'react';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import '../../../../context/DynamicContext/useDynamicContext/useDynamicContext.js';
7
7
  import { useInternalDynamicContext } from '../../../../context/DynamicContext/useDynamicContext/useInternalDynamicContext/useInternalDynamicContext.js';
@@ -123,6 +123,7 @@ import '../../views/ReceiveWalletFunds/ReceiveWalletFunds.js';
123
123
  import '../../../../store/state/multichainBalances.js';
124
124
  import '@dynamic-labs/store';
125
125
  import '../../../../shared/utils/functions/getInitialUrl/getInitialUrl.js';
126
+ import { applyOptimisticUnshieldedDeductions, applyOptimisticShieldedAdditions, reconcileOptimisticShields } from './optimisticShield.js';
126
127
  import { TokenBalanceList } from './TokenBalanceList/TokenBalanceList.js';
127
128
 
128
129
  /** Component to display token balances for the primary wallet */
@@ -149,6 +150,57 @@ const ActiveWalletBalance = ({ isLoading = false, }) => {
149
150
  includeNativeBalance: true,
150
151
  });
151
152
  const { tokenBalances: shieldedTokenBalances, isLoading: isLoadingShielded, refetch: refetchShielded, supportsShielded, } = useAleoShieldedBalances();
153
+ // Optimistic shield ledger. A shield broadcast resolves before the
154
+ // public-balance mapping flips and well before the RecordScanner
155
+ // index reflects the new private record, so naively trusting the
156
+ // server feed shows a stale unshielded balance and an unchanged
157
+ // shielded balance for ~10–90s after the user clicks. Pushing an
158
+ // entry here deducts the burned amount from unshielded and prepends
159
+ // / bumps the matching shielded row, then `reconcileOptimisticShields`
160
+ // drops the entry as soon as either server feed catches up.
161
+ const [optimisticShields, setOptimisticShields] = useState([]);
162
+ const effectiveUnshieldedTokenBalances = useMemo(() => applyOptimisticUnshieldedDeductions(unshieldedTokenBalances !== null && unshieldedTokenBalances !== void 0 ? unshieldedTokenBalances : [], optimisticShields), [unshieldedTokenBalances, optimisticShields]);
163
+ const effectiveShieldedTokenBalances = useMemo(() => applyOptimisticShieldedAdditions(shieldedTokenBalances, optimisticShields), [shieldedTokenBalances, optimisticShields]);
164
+ // Reconcile + GC optimistic entries whenever either server feed
165
+ // updates. The reconciler is pure; we short-circuit the state write
166
+ // when nothing changed so downstream memos don't churn on every
167
+ // refresh tick.
168
+ useEffect(() => {
169
+ setOptimisticShields((current) => {
170
+ if (current.length === 0)
171
+ return current;
172
+ const next = reconcileOptimisticShields(current, unshieldedTokenBalances !== null && unshieldedTokenBalances !== void 0 ? unshieldedTokenBalances : [], shieldedTokenBalances, Date.now());
173
+ return next.length === current.length ? current : next;
174
+ });
175
+ }, [unshieldedTokenBalances, shieldedTokenBalances]);
176
+ // Push helper used by both the manual Shield Manually CTA and the
177
+ // auto-shield hook so the optimistic UI semantics are identical
178
+ // whichever flow dispatches the broadcast.
179
+ const recordOptimisticShield = useCallback((info) => {
180
+ const { token, amount } = info;
181
+ const matchingShielded = shieldedTokenBalances.find((t) => token.isNative
182
+ ? Boolean(t.isNative)
183
+ : !t.isNative && t.address === token.address);
184
+ setOptimisticShields((current) => {
185
+ var _a, _b, _c, _d;
186
+ return [
187
+ ...current,
188
+ {
189
+ amount,
190
+ createdAtMs: Date.now(),
191
+ decimals: (_a = token.decimals) !== null && _a !== void 0 ? _a : 0,
192
+ isNative: Boolean(token.isNative),
193
+ logoURI: (_b = token.logoURI) !== null && _b !== void 0 ? _b : '',
194
+ name: token.name,
195
+ preShieldShieldedRaw: BigInt(Math.round((_c = matchingShielded === null || matchingShielded === void 0 ? void 0 : matchingShielded.rawBalance) !== null && _c !== void 0 ? _c : 0)),
196
+ preShieldUnshieldedRaw: BigInt(Math.round((_d = token.rawBalance) !== null && _d !== void 0 ? _d : 0)),
197
+ price: token.price,
198
+ symbol: token.symbol,
199
+ tokenAddress: token.address,
200
+ },
201
+ ];
202
+ });
203
+ }, [shieldedTokenBalances]);
152
204
  /**
153
205
  * Tab state for chains that have a shielded/unshielded split. Defaults to
154
206
  * shielded so Aleo wallets land on their primary balance type. Other
@@ -193,11 +245,22 @@ const ActiveWalletBalance = ({ isLoading = false, }) => {
193
245
  // Manually CTA, which prompts the user-paid confirmation modal.
194
246
  const { isShielding: isAutoShielding, currentlyShieldingTokenKeys: autoShieldingTokenKeys, } = useAleoAutoShieldSponsoredTokens({
195
247
  accountAddress: primaryWallet === null || primaryWallet === void 0 ? void 0 : primaryWallet.address,
196
- onShielded: useCallback(() => __awaiter(void 0, void 0, void 0, function* () {
197
- yield fetchAccountBalances(true);
198
- yield refetchShielded();
248
+ onShieldDispatched: recordOptimisticShield,
249
+ onShielded: useCallback(
250
+ // Parallel refresh — same reasoning as the manual shield path:
251
+ // shield ops move value across both tabs, and serializing the
252
+ // two refreshes doubled the visible latency for the Shielded
253
+ // tab in the common case.
254
+ () => __awaiter(void 0, void 0, void 0, function* () {
255
+ return Promise.all([fetchAccountBalances(true), refetchShielded()]).then(() => undefined);
199
256
  }), [fetchAccountBalances, refetchShielded]),
200
257
  shieldHandle: aleoShieldHandle,
258
+ // Auto-shield reads the raw server feed (not the optimistic one):
259
+ // once we deduct optimistically the to-be-shielded token drops to
260
+ // zero in the effective list, which would suppress the next
261
+ // auto-shield run before this one finishes. The auto-shield hook
262
+ // has its own per-session idempotency (`seenKeys`) so it won't
263
+ // re-fire for the same token anyway.
201
264
  unshieldedTokenBalances: unshieldedTokenBalances,
202
265
  });
203
266
  // Liveness handle for the manual post-shield poll — set to true when
@@ -210,12 +273,25 @@ const ActiveWalletBalance = ({ isLoading = false, }) => {
210
273
  isUnmountedRef.current = true;
211
274
  };
212
275
  }, []);
276
+ // Latest server-side unshielded list mirrored into a ref so the
277
+ // post-shield convergence check can read the freshest value after
278
+ // each polled refresh. `unshieldedTokenBalances` is closure-captured
279
+ // by `handleShieldToken` at callback construction time, so without
280
+ // this ref the predicate would read the pre-shield list forever and
281
+ // the poll would never exit early. Reads from the raw server feed,
282
+ // NOT the optimistic one — convergence means "on-chain effect
283
+ // observed", which is only meaningful against the actual server
284
+ // state.
285
+ const unshieldedTokenBalancesRef = useRef(unshieldedTokenBalances);
286
+ useEffect(() => {
287
+ unshieldedTokenBalancesRef.current = unshieldedTokenBalances;
288
+ }, [unshieldedTokenBalances]);
213
289
  // Token currently awaiting user-paid fee confirmation. Set when
214
290
  // Shield Manually is clicked on a token Feemaster doesn't sponsor;
215
291
  // cleared on Cancel or after the modal's Shield button dispatches.
216
292
  const [pendingShieldToken, setPendingShieldToken] = useState(null);
217
293
  const handleShieldToken = useCallback((token) => __awaiter(void 0, void 0, void 0, function* () {
218
- var _f;
294
+ var _f, _g;
219
295
  if (!aleoShieldHandle || shieldingAddress)
220
296
  return;
221
297
  // `rawBalance` is the atomic-units count redcoast surfaces (number).
@@ -225,6 +301,14 @@ const ActiveWalletBalance = ({ isLoading = false, }) => {
225
301
  const atomic = BigInt(Math.round((_f = token.rawBalance) !== null && _f !== void 0 ? _f : 0));
226
302
  if (atomic <= BigInt(0))
227
303
  return;
304
+ // Snapshot the pre-shield unshielded balance for the convergence
305
+ // predicate below. Aleo's `transfer_public_to_private` burns the
306
+ // public mapping value, so a successful shield must show up as a
307
+ // drop in this token's `rawBalance`. Polling exits early once
308
+ // that drop materializes — no need to keep refreshing on the
309
+ // long-tail schedule when we've already observed the on-chain
310
+ // effect.
311
+ const preShieldRawBalance = (_g = token.rawBalance) !== null && _g !== void 0 ? _g : 0;
228
312
  setShieldingAddress(token.address);
229
313
  let didBroadcast = false;
230
314
  try {
@@ -234,11 +318,22 @@ const ActiveWalletBalance = ({ isLoading = false, }) => {
234
318
  tokenAddress: token.address,
235
319
  });
236
320
  didBroadcast = true;
237
- // Refresh the unshielded list so the just-shielded balance drops to
238
- // 0 in the redcoast feed. The shielded side will pick the new
239
- // record up on its own polling once the RecordScanner indexes.
240
- yield fetchAccountBalances(true);
241
- yield refetchShielded();
321
+ // Optimistic balance update: deduct the burned amount from
322
+ // unshielded and bump (or synthesise) the matching shielded
323
+ // row before the server-side feeds catch up. Without this the
324
+ // Shield Manually CTA flickers back into view during the
325
+ // indexing tail and a fast user can issue a second shield
326
+ // that fails server-side (validator rejects the now-zero
327
+ // public balance) and looks to them like the first one
328
+ // failed.
329
+ recordOptimisticShield({ amount: atomic, token });
330
+ // Refresh both tabs in parallel. Shield moves value across the
331
+ // shielded/unshielded boundary, so both lists need to
332
+ // converge. Running them in parallel (vs. sequential) halves
333
+ // the visible refresh latency and shortens the window where
334
+ // `useTokenBalances`' `isLoading` guard could drop a
335
+ // concurrent fetch.
336
+ yield Promise.all([fetchAccountBalances(true), refetchShielded()]);
242
337
  }
243
338
  catch (err) {
244
339
  logger.debug('[ActiveWalletBalance] shieldToken failed', err);
@@ -249,7 +344,7 @@ const ActiveWalletBalance = ({ isLoading = false, }) => {
249
344
  // The relay-accepted broadcast resolves *before* the
250
345
  // public→private transition finalizes on-chain, so the immediate
251
346
  // refresh above sees the pre-confirmation balance. Re-poll on the
252
- // shared backoff so the Unshielded row visibly drops to 0 and the
347
+ // shared backoff so the Unshielded row visibly drops and the
253
348
  // Shielded row picks the new record up without a manual reload.
254
349
  // Polling is best-effort — errors are swallowed inside
255
350
  // `pollOnShielded`. Skip when the broadcast itself failed; there's
@@ -257,10 +352,27 @@ const ActiveWalletBalance = ({ isLoading = false, }) => {
257
352
  if (!didBroadcast)
258
353
  return;
259
354
  yield pollOnShielded(() => __awaiter(void 0, void 0, void 0, function* () {
260
- yield fetchAccountBalances(true);
261
- yield refetchShielded();
262
- }), () => isUnmountedRef.current);
263
- }), [aleoShieldHandle, fetchAccountBalances, refetchShielded, shieldingAddress]);
355
+ yield Promise.all([fetchAccountBalances(true), refetchShielded()]);
356
+ }), () => isUnmountedRef.current, () => {
357
+ var _a, _b;
358
+ // Convergence: the just-shielded token's unshielded raw
359
+ // balance has dropped below the pre-shield value. We read
360
+ // via the ref so we see whatever the store published after
361
+ // the most recent poll, not the closure-captured list from
362
+ // callback construction time. We don't require an exact 0
363
+ // — additional unshielded credits could land between scans;
364
+ // any decrease is sufficient evidence the burn took effect.
365
+ const current = (_a = unshieldedTokenBalancesRef.current) === null || _a === void 0 ? void 0 : _a.find((t) => t.address === token.address && t.isNative === token.isNative);
366
+ const currentRaw = (_b = current === null || current === void 0 ? void 0 : current.rawBalance) !== null && _b !== void 0 ? _b : 0;
367
+ return currentRaw < preShieldRawBalance;
368
+ });
369
+ }), [
370
+ aleoShieldHandle,
371
+ fetchAccountBalances,
372
+ recordOptimisticShield,
373
+ refetchShielded,
374
+ shieldingAddress,
375
+ ]);
264
376
  const getSecondaryAction = useCallback((token) => {
265
377
  // Only on the Unshielded tab, only when the connector exposes shield
266
378
  // helpers, only for tokens registered as shieldable, only for tokens
@@ -323,12 +435,12 @@ const ActiveWalletBalance = ({ isLoading = false, }) => {
323
435
  shieldingAddress,
324
436
  ]);
325
437
  const tokenBalances = useMemo(() => supportsShielded && activeShieldTab === 'shielded'
326
- ? shieldedTokenBalances
327
- : unshieldedTokenBalances, [
438
+ ? effectiveShieldedTokenBalances
439
+ : effectiveUnshieldedTokenBalances, [
328
440
  activeShieldTab,
329
- shieldedTokenBalances,
441
+ effectiveShieldedTokenBalances,
442
+ effectiveUnshieldedTokenBalances,
330
443
  supportsShielded,
331
- unshieldedTokenBalances,
332
444
  ]);
333
445
  const filteredTokenBalances = useMemo(() => (tokenBalances === null || tokenBalances === void 0 ? void 0 : tokenBalances.filter((token) => token.name)) || [], [tokenBalances]);
334
446
  const totalValue = useMemo(() => filteredTokenBalances.reduce((acc, token) => acc + ((token === null || token === void 0 ? void 0 : token.marketValue) || 0), 0), [filteredTokenBalances]);
@@ -371,8 +483,14 @@ const ActiveWalletBalance = ({ isLoading = false, }) => {
371
483
  setIsRefreshing(true);
372
484
  setIsSuccess(false);
373
485
  try {
374
- if (supportsShielded && activeShieldTab === 'shielded') {
375
- yield refetchShielded();
486
+ // Chains with a shielded/unshielded split (Aleo) refresh BOTH
487
+ // tabs on a single click. A shield op moves value across the
488
+ // boundary, so refreshing only the active tab leaves the other
489
+ // visibly stale — the original complaint that drove this fix.
490
+ // Other chains never had `refetchShielded` (no-op fallback) so
491
+ // they still just hit `fetchAccountBalances` here.
492
+ if (supportsShielded) {
493
+ yield Promise.all([refetchShielded(), fetchAccountBalances(true)]);
376
494
  }
377
495
  else {
378
496
  yield fetchAccountBalances(true);
@@ -396,7 +514,6 @@ const ActiveWalletBalance = ({ isLoading = false, }) => {
396
514
  fetchAccountBalances,
397
515
  refetchShielded,
398
516
  supportsShielded,
399
- activeShieldTab,
400
517
  ]);
401
518
  const primaryWalletNativeBalance = () => {
402
519
  if (!primaryWallet ||
@@ -0,0 +1,134 @@
1
+ 'use client'
2
+ 'use strict';
3
+
4
+ Object.defineProperty(exports, '__esModule', { value: true });
5
+
6
+ // Default safety-net stale window for optimistic entries. Tuned to
7
+ // comfortably outlast the post-shield polling schedule (~106s) so
8
+ // reconciliation has a chance to fire on the real server-side state
9
+ // before the safety net trips.
10
+ const OPTIMISTIC_SHIELD_STALE_MS = 180000;
11
+ const matches = (entry, token) => {
12
+ if (entry.isNative)
13
+ return Boolean(token.isNative);
14
+ return !token.isNative && entry.tokenAddress === token.address;
15
+ };
16
+ /**
17
+ * Reduce each unshielded token's `rawBalance` by the sum of pending
18
+ * optimistic shields for the same `(address, isNative)`, clamped to 0.
19
+ * `balance` and `marketValue` scale by the same ratio so the fiat
20
+ * column doesn't disagree with the optimistic raw balance.
21
+ *
22
+ * Tokens with no pending optimistic shield pass through by reference
23
+ * (no copy) so downstream memos / identity comparisons don't churn on
24
+ * every render.
25
+ */
26
+ const applyOptimisticUnshieldedDeductions = (balances, optimisticShields) => {
27
+ if (optimisticShields.length === 0)
28
+ return balances;
29
+ return balances.map((token) => {
30
+ var _a, _b;
31
+ const adjustments = optimisticShields.filter((o) => matches(o, token));
32
+ if (adjustments.length === 0)
33
+ return token;
34
+ const currentRaw = BigInt(Math.round((_a = token.rawBalance) !== null && _a !== void 0 ? _a : 0));
35
+ const totalAdjustment = adjustments.reduce((sum, o) => sum + o.amount, BigInt(0));
36
+ const newRaw = currentRaw > totalAdjustment ? currentRaw - totalAdjustment : BigInt(0);
37
+ // Ratio-scale display-units balance + fiat so they stay consistent
38
+ // with the optimistic raw value. `currentRaw === 0n` happens when
39
+ // the server feed has already caught up to zero but reconciliation
40
+ // hasn't fired yet; treat ratio as 0 to keep display at 0.
41
+ const ratio = currentRaw > BigInt(0) ? Number(newRaw) / Number(currentRaw) : 0;
42
+ return Object.assign(Object.assign({}, token), { balance: ((_b = token.balance) !== null && _b !== void 0 ? _b : 0) * ratio, marketValue: token.marketValue !== undefined ? token.marketValue * ratio : undefined, rawBalance: Number(newRaw), rawBalanceString: newRaw.toString() });
43
+ });
44
+ };
45
+ /**
46
+ * Apply optimistic shield additions to the shielded balance list. For
47
+ * each optimistic shield with a matching `(address, isNative)` entry
48
+ * already in `shielded`, bump that entry; otherwise prepend a fresh
49
+ * synthesized row built from the entry's captured metadata.
50
+ *
51
+ * Returns a fresh array only when at least one optimistic shield
52
+ * applies. When `optimisticShields` is empty we pass through the
53
+ * input array by reference so consumers that compare by identity
54
+ * don't churn.
55
+ */
56
+ const applyOptimisticShieldedAdditions = (shielded, optimisticShields) => {
57
+ if (optimisticShields.length === 0)
58
+ return shielded;
59
+ const consumedIndices = new Set();
60
+ const merged = shielded.map((token) => {
61
+ var _a, _b;
62
+ const adjustments = optimisticShields.filter((o, idx) => {
63
+ if (!matches(o, token))
64
+ return false;
65
+ consumedIndices.add(idx);
66
+ return true;
67
+ });
68
+ if (adjustments.length === 0)
69
+ return token;
70
+ const totalAtomic = adjustments.reduce((sum, o) => sum + o.amount, BigInt(0));
71
+ const newRaw = BigInt(Math.round((_a = token.rawBalance) !== null && _a !== void 0 ? _a : 0)) + totalAtomic;
72
+ const decimals = (_b = token.decimals) !== null && _b !== void 0 ? _b : 0;
73
+ const denominator = Math.pow(10, decimals);
74
+ const newDisplay = Number(newRaw) / denominator;
75
+ return Object.assign(Object.assign({}, token), { balance: newDisplay, marketValue: token.price !== undefined
76
+ ? newDisplay * token.price
77
+ : token.marketValue, rawBalance: Number(newRaw), rawBalanceString: newRaw.toString() });
78
+ });
79
+ // Prepend synthesized rows for any optimistic shield that didn't
80
+ // fold into an existing shielded row above. New rows go to the top
81
+ // of the list so the just-shielded token is immediately visible.
82
+ const synthesized = [];
83
+ optimisticShields.forEach((entry, idx) => {
84
+ if (consumedIndices.has(idx))
85
+ return;
86
+ const denominator = Math.pow(10, entry.decimals);
87
+ const display = Number(entry.amount) / denominator;
88
+ const marketValue = entry.price !== undefined ? display * entry.price : undefined;
89
+ synthesized.push(Object.assign(Object.assign({ address: entry.tokenAddress, balance: display, decimals: entry.decimals, isNative: entry.isNative, logoURI: entry.logoURI, name: entry.name, rawBalance: Number(entry.amount), rawBalanceString: entry.amount.toString(), symbol: entry.symbol }, (entry.price !== undefined ? { price: entry.price } : {})), (marketValue !== undefined ? { marketValue } : {})));
90
+ });
91
+ return [...synthesized, ...merged];
92
+ };
93
+ /**
94
+ * Filter the optimistic shield list down to entries that are still
95
+ * relevant. An entry is dropped when any of:
96
+ *
97
+ * - the unshielded server feed reports a `rawBalance` strictly less
98
+ * than `preShieldUnshieldedRaw` for that token (the burn was
99
+ * observed on-chain),
100
+ * - the shielded server feed reports a `rawBalance` strictly greater
101
+ * than `preShieldShieldedRaw` for that token (the new record was
102
+ * indexed),
103
+ * - the unshielded server no longer lists the token at all (rare,
104
+ * but means the public balance is effectively zero — our
105
+ * optimistic deduct is moot),
106
+ * - the entry is older than `staleMs` (safety net for shields whose
107
+ * on-chain effect is masked by parallel inbound credits).
108
+ *
109
+ * Pure / module-scope so it's trivially unit-testable; the widget
110
+ * just calls it on every server-side balance update.
111
+ */
112
+ const reconcileOptimisticShields = (optimisticShields, unshieldedTokenBalances, shieldedTokenBalances, nowMs, staleMs = OPTIMISTIC_SHIELD_STALE_MS) => optimisticShields.filter((entry) => {
113
+ var _a, _b;
114
+ if (nowMs - entry.createdAtMs > staleMs)
115
+ return false;
116
+ const unshielded = unshieldedTokenBalances.find((t) => matches(entry, t));
117
+ if (!unshielded)
118
+ return false;
119
+ const serverUnshielded = BigInt(Math.round((_a = unshielded.rawBalance) !== null && _a !== void 0 ? _a : 0));
120
+ if (serverUnshielded < entry.preShieldUnshieldedRaw)
121
+ return false;
122
+ const shielded = shieldedTokenBalances.find((t) => matches(entry, t));
123
+ const serverShielded = shielded
124
+ ? BigInt(Math.round((_b = shielded.rawBalance) !== null && _b !== void 0 ? _b : 0))
125
+ : BigInt(0);
126
+ if (serverShielded > entry.preShieldShieldedRaw)
127
+ return false;
128
+ return true;
129
+ });
130
+
131
+ exports.OPTIMISTIC_SHIELD_STALE_MS = OPTIMISTIC_SHIELD_STALE_MS;
132
+ exports.applyOptimisticShieldedAdditions = applyOptimisticShieldedAdditions;
133
+ exports.applyOptimisticUnshieldedDeductions = applyOptimisticUnshieldedDeductions;
134
+ exports.reconcileOptimisticShields = reconcileOptimisticShields;
@@ -0,0 +1,69 @@
1
+ import { TokenBalance } from '@dynamic-labs/sdk-api-core';
2
+ /**
3
+ * In-memory record of a shield operation that has been accepted by the
4
+ * prover/relay but not yet observed on-chain by either the public
5
+ * balance mapping (Provable's `/mapping/balances/...` endpoint) or
6
+ * the RecordScanner index. Until reconciliation drops it, the entry
7
+ * forces the widget to display the post-shield state — unshielded
8
+ * rawBalance reduced by `amount`, shielded rawBalance increased by
9
+ * the same — so the Shield Manually CTA disappears the moment the
10
+ * shield is broadcast instead of flickering back into view during
11
+ * the indexing tail and tempting the user into a second shield.
12
+ */
13
+ export type OptimisticShieldEntry = {
14
+ tokenAddress: string;
15
+ isNative: boolean;
16
+ amount: bigint;
17
+ preShieldUnshieldedRaw: bigint;
18
+ preShieldShieldedRaw: bigint;
19
+ symbol: string;
20
+ name: string;
21
+ decimals: number;
22
+ logoURI: string;
23
+ price?: number;
24
+ createdAtMs: number;
25
+ };
26
+ export declare const OPTIMISTIC_SHIELD_STALE_MS = 180000;
27
+ /**
28
+ * Reduce each unshielded token's `rawBalance` by the sum of pending
29
+ * optimistic shields for the same `(address, isNative)`, clamped to 0.
30
+ * `balance` and `marketValue` scale by the same ratio so the fiat
31
+ * column doesn't disagree with the optimistic raw balance.
32
+ *
33
+ * Tokens with no pending optimistic shield pass through by reference
34
+ * (no copy) so downstream memos / identity comparisons don't churn on
35
+ * every render.
36
+ */
37
+ export declare const applyOptimisticUnshieldedDeductions: (balances: TokenBalance[], optimisticShields: ReadonlyArray<OptimisticShieldEntry>) => TokenBalance[];
38
+ /**
39
+ * Apply optimistic shield additions to the shielded balance list. For
40
+ * each optimistic shield with a matching `(address, isNative)` entry
41
+ * already in `shielded`, bump that entry; otherwise prepend a fresh
42
+ * synthesized row built from the entry's captured metadata.
43
+ *
44
+ * Returns a fresh array only when at least one optimistic shield
45
+ * applies. When `optimisticShields` is empty we pass through the
46
+ * input array by reference so consumers that compare by identity
47
+ * don't churn.
48
+ */
49
+ export declare const applyOptimisticShieldedAdditions: (shielded: TokenBalance[], optimisticShields: ReadonlyArray<OptimisticShieldEntry>) => TokenBalance[];
50
+ /**
51
+ * Filter the optimistic shield list down to entries that are still
52
+ * relevant. An entry is dropped when any of:
53
+ *
54
+ * - the unshielded server feed reports a `rawBalance` strictly less
55
+ * than `preShieldUnshieldedRaw` for that token (the burn was
56
+ * observed on-chain),
57
+ * - the shielded server feed reports a `rawBalance` strictly greater
58
+ * than `preShieldShieldedRaw` for that token (the new record was
59
+ * indexed),
60
+ * - the unshielded server no longer lists the token at all (rare,
61
+ * but means the public balance is effectively zero — our
62
+ * optimistic deduct is moot),
63
+ * - the entry is older than `staleMs` (safety net for shields whose
64
+ * on-chain effect is masked by parallel inbound credits).
65
+ *
66
+ * Pure / module-scope so it's trivially unit-testable; the widget
67
+ * just calls it on every server-side balance update.
68
+ */
69
+ export declare const reconcileOptimisticShields: (optimisticShields: ReadonlyArray<OptimisticShieldEntry>, unshieldedTokenBalances: TokenBalance[], shieldedTokenBalances: TokenBalance[], nowMs: number, staleMs?: number) => OptimisticShieldEntry[];
@@ -0,0 +1,127 @@
1
+ 'use client'
2
+ // Default safety-net stale window for optimistic entries. Tuned to
3
+ // comfortably outlast the post-shield polling schedule (~106s) so
4
+ // reconciliation has a chance to fire on the real server-side state
5
+ // before the safety net trips.
6
+ const OPTIMISTIC_SHIELD_STALE_MS = 180000;
7
+ const matches = (entry, token) => {
8
+ if (entry.isNative)
9
+ return Boolean(token.isNative);
10
+ return !token.isNative && entry.tokenAddress === token.address;
11
+ };
12
+ /**
13
+ * Reduce each unshielded token's `rawBalance` by the sum of pending
14
+ * optimistic shields for the same `(address, isNative)`, clamped to 0.
15
+ * `balance` and `marketValue` scale by the same ratio so the fiat
16
+ * column doesn't disagree with the optimistic raw balance.
17
+ *
18
+ * Tokens with no pending optimistic shield pass through by reference
19
+ * (no copy) so downstream memos / identity comparisons don't churn on
20
+ * every render.
21
+ */
22
+ const applyOptimisticUnshieldedDeductions = (balances, optimisticShields) => {
23
+ if (optimisticShields.length === 0)
24
+ return balances;
25
+ return balances.map((token) => {
26
+ var _a, _b;
27
+ const adjustments = optimisticShields.filter((o) => matches(o, token));
28
+ if (adjustments.length === 0)
29
+ return token;
30
+ const currentRaw = BigInt(Math.round((_a = token.rawBalance) !== null && _a !== void 0 ? _a : 0));
31
+ const totalAdjustment = adjustments.reduce((sum, o) => sum + o.amount, BigInt(0));
32
+ const newRaw = currentRaw > totalAdjustment ? currentRaw - totalAdjustment : BigInt(0);
33
+ // Ratio-scale display-units balance + fiat so they stay consistent
34
+ // with the optimistic raw value. `currentRaw === 0n` happens when
35
+ // the server feed has already caught up to zero but reconciliation
36
+ // hasn't fired yet; treat ratio as 0 to keep display at 0.
37
+ const ratio = currentRaw > BigInt(0) ? Number(newRaw) / Number(currentRaw) : 0;
38
+ return Object.assign(Object.assign({}, token), { balance: ((_b = token.balance) !== null && _b !== void 0 ? _b : 0) * ratio, marketValue: token.marketValue !== undefined ? token.marketValue * ratio : undefined, rawBalance: Number(newRaw), rawBalanceString: newRaw.toString() });
39
+ });
40
+ };
41
+ /**
42
+ * Apply optimistic shield additions to the shielded balance list. For
43
+ * each optimistic shield with a matching `(address, isNative)` entry
44
+ * already in `shielded`, bump that entry; otherwise prepend a fresh
45
+ * synthesized row built from the entry's captured metadata.
46
+ *
47
+ * Returns a fresh array only when at least one optimistic shield
48
+ * applies. When `optimisticShields` is empty we pass through the
49
+ * input array by reference so consumers that compare by identity
50
+ * don't churn.
51
+ */
52
+ const applyOptimisticShieldedAdditions = (shielded, optimisticShields) => {
53
+ if (optimisticShields.length === 0)
54
+ return shielded;
55
+ const consumedIndices = new Set();
56
+ const merged = shielded.map((token) => {
57
+ var _a, _b;
58
+ const adjustments = optimisticShields.filter((o, idx) => {
59
+ if (!matches(o, token))
60
+ return false;
61
+ consumedIndices.add(idx);
62
+ return true;
63
+ });
64
+ if (adjustments.length === 0)
65
+ return token;
66
+ const totalAtomic = adjustments.reduce((sum, o) => sum + o.amount, BigInt(0));
67
+ const newRaw = BigInt(Math.round((_a = token.rawBalance) !== null && _a !== void 0 ? _a : 0)) + totalAtomic;
68
+ const decimals = (_b = token.decimals) !== null && _b !== void 0 ? _b : 0;
69
+ const denominator = Math.pow(10, decimals);
70
+ const newDisplay = Number(newRaw) / denominator;
71
+ return Object.assign(Object.assign({}, token), { balance: newDisplay, marketValue: token.price !== undefined
72
+ ? newDisplay * token.price
73
+ : token.marketValue, rawBalance: Number(newRaw), rawBalanceString: newRaw.toString() });
74
+ });
75
+ // Prepend synthesized rows for any optimistic shield that didn't
76
+ // fold into an existing shielded row above. New rows go to the top
77
+ // of the list so the just-shielded token is immediately visible.
78
+ const synthesized = [];
79
+ optimisticShields.forEach((entry, idx) => {
80
+ if (consumedIndices.has(idx))
81
+ return;
82
+ const denominator = Math.pow(10, entry.decimals);
83
+ const display = Number(entry.amount) / denominator;
84
+ const marketValue = entry.price !== undefined ? display * entry.price : undefined;
85
+ synthesized.push(Object.assign(Object.assign({ address: entry.tokenAddress, balance: display, decimals: entry.decimals, isNative: entry.isNative, logoURI: entry.logoURI, name: entry.name, rawBalance: Number(entry.amount), rawBalanceString: entry.amount.toString(), symbol: entry.symbol }, (entry.price !== undefined ? { price: entry.price } : {})), (marketValue !== undefined ? { marketValue } : {})));
86
+ });
87
+ return [...synthesized, ...merged];
88
+ };
89
+ /**
90
+ * Filter the optimistic shield list down to entries that are still
91
+ * relevant. An entry is dropped when any of:
92
+ *
93
+ * - the unshielded server feed reports a `rawBalance` strictly less
94
+ * than `preShieldUnshieldedRaw` for that token (the burn was
95
+ * observed on-chain),
96
+ * - the shielded server feed reports a `rawBalance` strictly greater
97
+ * than `preShieldShieldedRaw` for that token (the new record was
98
+ * indexed),
99
+ * - the unshielded server no longer lists the token at all (rare,
100
+ * but means the public balance is effectively zero — our
101
+ * optimistic deduct is moot),
102
+ * - the entry is older than `staleMs` (safety net for shields whose
103
+ * on-chain effect is masked by parallel inbound credits).
104
+ *
105
+ * Pure / module-scope so it's trivially unit-testable; the widget
106
+ * just calls it on every server-side balance update.
107
+ */
108
+ const reconcileOptimisticShields = (optimisticShields, unshieldedTokenBalances, shieldedTokenBalances, nowMs, staleMs = OPTIMISTIC_SHIELD_STALE_MS) => optimisticShields.filter((entry) => {
109
+ var _a, _b;
110
+ if (nowMs - entry.createdAtMs > staleMs)
111
+ return false;
112
+ const unshielded = unshieldedTokenBalances.find((t) => matches(entry, t));
113
+ if (!unshielded)
114
+ return false;
115
+ const serverUnshielded = BigInt(Math.round((_a = unshielded.rawBalance) !== null && _a !== void 0 ? _a : 0));
116
+ if (serverUnshielded < entry.preShieldUnshieldedRaw)
117
+ return false;
118
+ const shielded = shieldedTokenBalances.find((t) => matches(entry, t));
119
+ const serverShielded = shielded
120
+ ? BigInt(Math.round((_b = shielded.rawBalance) !== null && _b !== void 0 ? _b : 0))
121
+ : BigInt(0);
122
+ if (serverShielded > entry.preShieldShieldedRaw)
123
+ return false;
124
+ return true;
125
+ });
126
+
127
+ export { OPTIMISTIC_SHIELD_STALE_MS, applyOptimisticShieldedAdditions, applyOptimisticUnshieldedDeductions, reconcileOptimisticShields };