@0xmonaco/react 1.0.55 → 1.0.57

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.
@@ -78,6 +78,7 @@ export declare const INTENTIONALLY_EXCLUDED: {
78
78
  get_portfolio_stats: string;
79
79
  get_portfolio_summary: string;
80
80
  get_screener: string;
81
+ get_sweeper_chains: string;
81
82
  get_sub_account_limits: string;
82
83
  get_trade_by_id: string;
83
84
  get_trading_pair_by_id: string;
package/dist/coverage.js CHANGED
@@ -78,6 +78,7 @@ export const INTENTIONALLY_EXCLUDED = {
78
78
  get_portfolio_stats: "Portfolio analytics endpoint; no React hook yet.",
79
79
  get_portfolio_summary: "Authoritative portfolio analytics is available through @0xmonaco/core; no dedicated React hook in this release.",
80
80
  get_screener: "Market lookup available via the core client / list+symbol hooks; no dedicated React hook.",
81
+ get_sweeper_chains: "Sweeper chain/asset listing; deposit-onboarding data available via the core client, not a UI action. No React hook.",
81
82
  get_sub_account_limits: "Sub-account administration; no React hook.",
82
83
  get_trade_by_id: "Trade-history lookup with a core client method but no dedicated React hook yet.",
83
84
  get_trading_pair_by_id: "Market lookup available via the core client / list+symbol hooks; no dedicated React hook.",
@@ -13,13 +13,9 @@ export declare function updateBalanceFromEvent(balance: AccountBalance, event: U
13
13
  * - A held asset the snapshot omits has no balance left, so it is zeroed rather
14
14
  * than left showing a figure that is no longer true. The row itself stays —
15
15
  * a portfolio needs to render "0 WSEI", not to lose the line.
16
- * - The snapshot's totals are taken VERBATIM. A snapshot row is built from the
17
- * same accounts-service read that produced the REST row, so its `total`
18
- * already is the REST `totalBalance` with margin collateral included —
19
- * unlike a live event, whose `total` can describe the spot balance alone and
20
- * therefore needs {@link updateBalanceFromEvent}'s margin reconciliation.
21
- * Running a snapshot row through that would add the derived margin component
22
- * a second time.
16
+ * - The snapshot's totals and durable spot-row version are taken VERBATIM.
17
+ * A snapshot row is built from the same `user_balances` state as REST and a
18
+ * versioned live event. Margin collateral is not part of this row.
23
19
  * - A row for an asset the list does not hold cannot be completed here: the
24
20
  * wire payload carries no `assetId`, `decimals` or wrapped-native flag. That
25
21
  * case is reported by {@link snapshotHasUnknownAsset} rather than from here,
@@ -60,6 +56,16 @@ export declare function snapshotHasUnknownAsset(current: AccountBalance[], items
60
56
  * second is a deliberate "give me the server's answer" action.
61
57
  */
62
58
  export declare function reconcileBalances(current: AccountBalance[], fetched: AccountBalance[]): AccountBalance[];
59
+ /**
60
+ * Merge a deliberate REST refresh without letting its response roll back a
61
+ * versioned live row that arrived while the request was in flight.
62
+ *
63
+ * Version zero is the backward-compatible representation of an unversioned
64
+ * REST row. It may refresh other legacy state, but cannot replace a known
65
+ * producer-versioned row. A known versioned row omitted by the response is
66
+ * retained because its authoritative zero would be an explicit tombstone.
67
+ */
68
+ export declare function mergeBalanceRefresh(current: AccountBalance[], fetched: AccountBalance[]): AccountBalance[];
63
69
  /**
64
70
  * Overlay buffered wire state onto rows a REST read has just supplied metadata
65
71
  * for.
@@ -72,15 +78,67 @@ export declare function reconcileBalances(current: AccountBalance[], fetched: Ac
72
78
  * takes the update path. Buffering the newest wire state for an unheld token
73
79
  * and laying it over the row once metadata arrives is what closes that.
74
80
  *
75
- * Only entries newer than the read are applied. One buffered BEFORE the read
76
- * started is already reflected in its result, and re-applying it would roll the
77
- * row back to that older value.
81
+ * Eligibility is NOT one rule, and the split is the point:
82
+ *
83
+ * - a RANKED frame always replays. It carries a version, so
84
+ * `updateBalanceFromData` can compare and reject it if the row is genuinely
85
+ * newer. Gating it on arrival instead would drop it whenever persistence
86
+ * lags — a read STARTED after the frame was buffered can still return an
87
+ * older version — and dropping it deletes the buffer entry, leaving the
88
+ * stale row installed with nothing left to correct it.
89
+ * - an UNRANKED frame has nothing to compare against and applies fail-open, so
90
+ * arrival position is the only thing that can tell whether the read already
91
+ * reflects it. Re-applying one it does reflect rolls the row back.
92
+ *
93
+ * For that second rule the mark is the point the read was ASKED for, not the
94
+ * point it starts: a frame that asks for a read has already advanced the
95
+ * sequence past where the read begins, so measuring from the start would
96
+ * exclude the very frame the read exists to place.
78
97
  */
79
- export declare function applyBufferedBalances(rows: AccountBalance[], buffered: Map<string, {
98
+ export type BufferedFrame = {
80
99
  data: UserBalanceEventData;
100
+ /**
101
+ * Arrival position of THIS payload. Orders it against the other slot on
102
+ * replay, so a retained frame keeps the place it actually arrived in.
103
+ */
81
104
  seq: number;
105
+ /**
106
+ * Most recent arrival for this slot, retained payload or not. Used ONLY for
107
+ * read eligibility: it answers "did anything land for this token after the
108
+ * in-flight read was asked for", which is a different question from "where
109
+ * does this payload sit in the sequence". Collapsing the two let a retained
110
+ * frame inherit a later arrival's position and replay out of order.
111
+ */
112
+ latestSeq: number;
82
113
  source: "event" | "snapshot";
83
- }>, readSeq: number): AccountBalance[];
114
+ };
115
+ /**
116
+ * Wire state held for a token the list cannot apply yet.
117
+ *
118
+ * THREE kinds of frame arrive here and none supersedes another, so each keeps
119
+ * its own slot and they replay together in arrival order:
120
+ *
121
+ * - a RANKED spot frame carries a real version and describes whole-row state,
122
+ * so ranking orders it against other ranked frames;
123
+ * - an UNRANKED SNAPSHOT is still the whole authoritative row — its totals are
124
+ * taken verbatim — it simply came from a producer that does not stamp
125
+ * versions yet;
126
+ * - an UNRANKED LIVE notification (margin, funding, a legacy producer) carries
127
+ * no version, applies fail-open, and DERIVES its total from the row it lands
128
+ * on so a margin component survives.
129
+ *
130
+ * One slot for everything let the ranking rule discard whichever kind arrived
131
+ * second. One slot for both unranked kinds was the same bug a level down: a
132
+ * funding notification overwrote the snapshot baseline, and its derivation
133
+ * then ran against whatever the REST row happened to carry instead of against
134
+ * the row the snapshot established.
135
+ */
136
+ export type BufferedUnheld = {
137
+ ranked?: BufferedFrame;
138
+ unrankedSnapshot?: BufferedFrame;
139
+ unrankedEvent?: BufferedFrame;
140
+ };
141
+ export declare function applyBufferedBalances(rows: AccountBalance[], buffered: Map<string, BufferedUnheld>, readSeq: number): AccountBalance[];
84
142
  /**
85
143
  * Hook for subscribing to real-time user balance updates via WebSocket (authenticated)
86
144
  *
@@ -53,11 +53,54 @@ async function readAllBalances(profile) {
53
53
  * Update an AccountBalance with data from a WebSocket balance event
54
54
  */
55
55
  export function updateBalanceFromEvent(balance, event) {
56
- return updateBalanceFromData(balance, event.data);
56
+ return updateBalanceFromData(balance, event.data, "event");
57
57
  }
58
- function updateBalanceFromData(balance, data) {
59
- const totalBalance = totalBalanceFromData(balance, data);
60
- const totalBalanceRaw = totalBalanceRawFromData(balance, data);
58
+ /**
59
+ * Collapse every shape of "unranked" to 0 so callers can compare numerically.
60
+ *
61
+ * `AccountBalance.version` is optional, and a REST response from a server that
62
+ * predates the field carries no version at all — so the type now says what the
63
+ * runtime does. That is exactly why every read goes through here rather than
64
+ * comparing the field: `undefined <= 710` is false in BOTH directions, so an
65
+ * un-normalized guard does not fail loudly, it silently passes and lets a
66
+ * stale row win. Absent, null, non-finite and an explicit 0 all mean the same
67
+ * thing.
68
+ */
69
+ function rankedVersion(value) {
70
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
71
+ }
72
+ function updateBalanceFromData(balance, data, source) {
73
+ // Legacy live margin notifications remain fail-open. A legacy snapshot,
74
+ // however, cannot order against an already-versioned spot row and therefore
75
+ // must not replace it.
76
+ // An explicit `version: 0` is the documented legacy sentinel and means the
77
+ // same thing as an omitted one: unranked. Checking only for `undefined` let a
78
+ // version-0 snapshot overwrite a held real version and reset it to 0, after
79
+ // which later stale spot frames would win.
80
+ if (source === "snapshot" && rankedVersion(data.version) === 0 && rankedVersion(balance.version) > 0) {
81
+ return balance;
82
+ }
83
+ // Version 0 is the unranked sentinel on both sides, so it orders nothing:
84
+ // rank only when the incoming frame and the held row each carry a real
85
+ // version. Otherwise a version-0 REST row rejects a version-0 subscribe
86
+ // snapshot that carries newer amounts, which is exactly the rolling-deploy
87
+ // case where an older producer has not started stamping yet.
88
+ const incomingRank = rankedVersion(data.version);
89
+ const heldRank = rankedVersion(balance.version);
90
+ if (incomingRank > 0 && heldRank > 0 && incomingRank <= heldRank) {
91
+ return balance;
92
+ }
93
+ // The margin-preserving derivation belongs to UNRANKED live notifications
94
+ // only. A snapshot is the whole row by definition, so it takes `total`
95
+ // verbatim whether or not it carries a version — deriving from it would add a
96
+ // previously held margin component back on top of a figure that already
97
+ // accounts for everything, turning a snapshot total of 1000 into 1250.
98
+ //
99
+ // Unranked means absent OR the explicit 0 sentinel: both describe a frame
100
+ // that reports the spot leg alone, so both need the reconciliation.
101
+ const derivesTotal = source === "event" && rankedVersion(data.version) === 0;
102
+ const totalBalance = derivesTotal ? totalBalanceFromData(balance, data) : data.total;
103
+ const totalBalanceRaw = derivesTotal ? totalBalanceRawFromData(balance, data) : data.totalRaw;
61
104
  return {
62
105
  ...balance,
63
106
  availableBalance: data.available,
@@ -66,6 +109,17 @@ function updateBalanceFromData(balance, data) {
66
109
  lockedBalanceRaw: data.lockedRaw,
67
110
  totalBalance: totalBalance,
68
111
  totalBalanceRaw: totalBalanceRaw,
112
+ // An unranked frame is a margin collateral notification or a legacy
113
+ // producer, and by scope it must not touch the spot ranking dimension:
114
+ // margin frames stay unranked and fail open. Writing the sentinel onto the
115
+ // row would drop it out of the ranked dimension, and the NEXT stale spot
116
+ // frame would then pass the ordering guard — trading a spot-ordering
117
+ // guarantee for a margin problem that shared token-keyed rows cause and
118
+ // that a dedicated margin identity resolves.
119
+ //
120
+ // `??` is not the test: an explicit `0` is the documented sentinel and is
121
+ // not nullish, so it would be written through as if it were a real rank.
122
+ version: rankedVersion(data.version) > 0 ? data.version : balance.version,
69
123
  };
70
124
  }
71
125
  /**
@@ -77,13 +131,9 @@ function updateBalanceFromData(balance, data) {
77
131
  * - A held asset the snapshot omits has no balance left, so it is zeroed rather
78
132
  * than left showing a figure that is no longer true. The row itself stays —
79
133
  * a portfolio needs to render "0 WSEI", not to lose the line.
80
- * - The snapshot's totals are taken VERBATIM. A snapshot row is built from the
81
- * same accounts-service read that produced the REST row, so its `total`
82
- * already is the REST `totalBalance` with margin collateral included —
83
- * unlike a live event, whose `total` can describe the spot balance alone and
84
- * therefore needs {@link updateBalanceFromEvent}'s margin reconciliation.
85
- * Running a snapshot row through that would add the derived margin component
86
- * a second time.
134
+ * - The snapshot's totals and durable spot-row version are taken VERBATIM.
135
+ * A snapshot row is built from the same `user_balances` state as REST and a
136
+ * versioned live event. Margin collateral is not part of this row.
87
137
  * - A row for an asset the list does not hold cannot be completed here: the
88
138
  * wire payload carries no `assetId`, `decimals` or wrapped-native flag. That
89
139
  * case is reported by {@link snapshotHasUnknownAsset} rather than from here,
@@ -98,6 +148,10 @@ export function applyBalanceSnapshot(current, items) {
98
148
  const balances = current.map((held) => {
99
149
  const item = byToken.get(held.token.toLowerCase());
100
150
  if (!item) {
151
+ // Since versioned zero rows are explicit tombstones, omission cannot
152
+ // safely zero a known versioned row. Retain it until a newer row arrives.
153
+ if (rankedVersion(held.version) > 0)
154
+ return held;
101
155
  return {
102
156
  ...held,
103
157
  availableBalance: "0",
@@ -108,15 +162,7 @@ export function applyBalanceSnapshot(current, items) {
108
162
  totalBalanceRaw: "0",
109
163
  };
110
164
  }
111
- return {
112
- ...held,
113
- availableBalance: item.available,
114
- lockedBalance: item.locked,
115
- totalBalance: item.total,
116
- availableBalanceRaw: item.availableRaw,
117
- lockedBalanceRaw: item.lockedRaw,
118
- totalBalanceRaw: item.totalRaw,
119
- };
165
+ return updateBalanceFromData(held, item, "snapshot");
120
166
  });
121
167
  return { balances, hasUnknownAsset: snapshotHasUnknownAsset(current, items) };
122
168
  }
@@ -155,43 +201,70 @@ export function reconcileBalances(current, fetched) {
155
201
  return added.length === 0 ? current : [...current, ...added];
156
202
  }
157
203
  /**
158
- * Overlay buffered wire state onto rows a REST read has just supplied metadata
159
- * for.
160
- *
161
- * A frame naming a token the list does not hold yet is dropped by the event
162
- * handler — there is no row to apply it to — and the repair read that follows
163
- * may have queried the server BEFORE that frame. Appending its result would
164
- * then leave the token at a value the stream has already moved past, with no
165
- * later frame to correct it because every subsequent frame finds a row and
166
- * takes the update path. Buffering the newest wire state for an unheld token
167
- * and laying it over the row once metadata arrives is what closes that.
204
+ * Merge a deliberate REST refresh without letting its response roll back a
205
+ * versioned live row that arrived while the request was in flight.
168
206
  *
169
- * Only entries newer than the read are applied. One buffered BEFORE the read
170
- * started is already reflected in its result, and re-applying it would roll the
171
- * row back to that older value.
207
+ * Version zero is the backward-compatible representation of an unversioned
208
+ * REST row. It may refresh other legacy state, but cannot replace a known
209
+ * producer-versioned row. A known versioned row omitted by the response is
210
+ * retained because its authoritative zero would be an explicit tombstone.
172
211
  */
212
+ export function mergeBalanceRefresh(current, fetched) {
213
+ const currentByToken = new Map(current.map((row) => [row.token.toLowerCase(), row]));
214
+ const fetchedTokens = new Set();
215
+ const merged = fetched.map((incoming) => {
216
+ const token = incoming.token.toLowerCase();
217
+ fetchedTokens.add(token);
218
+ const held = currentByToken.get(token);
219
+ if (!held)
220
+ return incoming;
221
+ const heldVersion = rankedVersion(held.version);
222
+ if (heldVersion > 0 && rankedVersion(incoming.version) <= heldVersion)
223
+ return held;
224
+ return incoming;
225
+ });
226
+ for (const held of current) {
227
+ // `rankedVersion` is defensive consistency here rather than a behavior
228
+ // change: `undefined > 0` is already false, so an unranked held row is
229
+ // dropped either way. Kept so every version read in this file goes through
230
+ // one rule.
231
+ if (rankedVersion(held.version) > 0 && !fetchedTokens.has(held.token.toLowerCase()))
232
+ merged.push(held);
233
+ }
234
+ return merged;
235
+ }
173
236
  export function applyBufferedBalances(rows, buffered, readSeq) {
174
237
  if (buffered.size === 0)
175
238
  return rows;
176
239
  return rows.map((row) => {
177
240
  const entry = buffered.get(row.token.toLowerCase());
178
- if (!entry || entry.seq <= readSeq)
241
+ if (!entry)
179
242
  return row;
180
- if (entry.source === "event") {
181
- // A live non-margin frame can carry spot total only. Replaying it as a
182
- // snapshot would replace the REST row's margin component instead of
183
- // preserving it through the ordinary event reconciliation.
184
- return updateBalanceFromData(row, entry.data);
185
- }
186
- return {
187
- ...row,
188
- availableBalance: entry.data.available,
189
- lockedBalance: entry.data.locked,
190
- totalBalance: entry.data.total,
191
- availableBalanceRaw: entry.data.availableRaw,
192
- lockedBalanceRaw: entry.data.lockedRaw,
193
- totalBalanceRaw: entry.data.totalRaw,
194
- };
243
+ // Replay in ARRIVAL order and apply each with its own source semantics:
244
+ // whichever kind landed last is what the client would have seen had the row
245
+ // been known.
246
+ const frames = [entry.ranked, entry.unrankedSnapshot, entry.unrankedEvent]
247
+ .filter((frame) => {
248
+ if (frame === undefined)
249
+ return false;
250
+ // A RANKED frame always replays. The read-start gate assumes anything
251
+ // buffered before the read is already reflected in its result, and that
252
+ // is false here: persistence is asynchronous, so a read started later
253
+ // can still return an OLDER version. Skipping the frame would then
254
+ // delete it and leave the stale row installed. It carries a version, so
255
+ // `updateBalanceFromData` can compare and reject it if the row is
256
+ // genuinely newer — which is strictly safer than guessing from arrival.
257
+ if (rankedVersion(frame.data.version) > 0)
258
+ return true;
259
+ // An UNRANKED frame has nothing to compare against, so the request
260
+ // mark is the only thing stopping a re-apply of state the read already
261
+ // includes. It applies fail-open by design — a margin or funding
262
+ // notification is not ordered against the spot row — so replaying one
263
+ // the read already reflects would roll the row back.
264
+ return frame.latestSeq > readSeq;
265
+ })
266
+ .sort((a, b) => a.seq - b.seq);
267
+ return frames.reduce((acc, frame) => updateBalanceFromData(acc, frame.data, frame.source), row);
195
268
  });
196
269
  }
197
270
  function parseDecimal(value) {
@@ -313,9 +386,24 @@ export function useUserBalances() {
313
386
  * newer than every frame that set it.
314
387
  */
315
388
  const repairDirty = useRef(false);
389
+ /**
390
+ * Buffer position the next repair read must be measured from.
391
+ *
392
+ * A read is measured from the point it was ASKED for, not the point it
393
+ * starts. Those differ every time: a frame is buffered and only then does it
394
+ * request the read, and a read coalesced behind an in-flight one does not
395
+ * start until frames later than its request have landed. Measuring from the
396
+ * start excluded every frame that asked for the read — including the single
397
+ * frame that is the whole reason the buffer exists — because its own entry
398
+ * had already advanced the sequence past the mark.
399
+ *
400
+ * Coalesced requests keep the EARLIEST mark, so the one trailing read
401
+ * replays every frame that asked for it.
402
+ */
403
+ const repairSince = useRef(null);
316
404
  /**
317
405
  * Newest wire state for tokens the list does not hold yet, with a monotonic
318
- * sequence so a read can tell which entries postdate it. Cleared per token
406
+ * sequence so a read can tell which entries postdate the request for it. Cleared per token
319
407
  * once a read has supplied that token's metadata; entries for tokens a read
320
408
  * did not return stay buffered for the next one.
321
409
  */
@@ -325,16 +413,55 @@ export function useUserBalances() {
325
413
  const [error, setError] = useState(null);
326
414
  const [subscribed, setSubscribed] = useState(false);
327
415
  const clearError = useCallback(() => setError(null), []);
328
- /** Remember a frame for a token the list cannot represent yet. */
416
+ /**
417
+ * Remember a frame for a token the list cannot represent yet.
418
+ *
419
+ * Returns the buffer position from BEFORE anything was recorded, which is
420
+ * the mark the read these frames are about to ask for must be measured
421
+ * from.
422
+ */
329
423
  const bufferUnheld = useCallback((items, source) => {
424
+ const before = bufferSeq.current;
330
425
  const held = new Set(balancesRef.current.map((row) => row.token.toLowerCase()));
331
426
  for (const item of items) {
332
427
  const token = item.tokenAddress.toLowerCase();
333
428
  if (held.has(token))
334
429
  continue;
335
430
  bufferSeq.current += 1;
336
- bufferedUnheld.current.set(token, { data: item, seq: bufferSeq.current, source });
431
+ // Route by KIND, not by rank alone. A ranked spot frame orders against
432
+ // other ranked frames; an unranked live notification cannot be ordered
433
+ // against anything, so it occupies its own slot rather than losing to —
434
+ // or evicting — a buffered spot frame.
435
+ const existing = bufferedUnheld.current.get(token) ?? {};
436
+ const seq = bufferSeq.current;
437
+ const incomingVersion = rankedVersion(item.version);
438
+ const next = incomingVersion > 0
439
+ ? {
440
+ ...existing,
441
+ // Equal ranked versions are the same state, so the first stands.
442
+ // Only the watermark advances — rewriting its `seq` would move the
443
+ // retained payload behind an unranked frame that arrived earlier.
444
+ ranked: rankedVersion(existing.ranked?.data.version) >= incomingVersion && existing.ranked
445
+ ? { ...existing.ranked, latestSeq: seq }
446
+ : { data: item, seq, latestSeq: seq, source },
447
+ }
448
+ : source === "snapshot"
449
+ ? {
450
+ ...existing,
451
+ // Nothing orders two unranked snapshots, so the latest stands —
452
+ // but it must not evict a live notification, whose derivation
453
+ // needs the baseline this establishes.
454
+ unrankedSnapshot: { data: item, seq, latestSeq: seq, source },
455
+ }
456
+ : {
457
+ ...existing,
458
+ // Nothing can order two unranked notifications, so the latest
459
+ // arrival stands.
460
+ unrankedEvent: { data: item, seq, latestSeq: seq, source },
461
+ };
462
+ bufferedUnheld.current.set(token, next);
337
463
  }
464
+ return before;
338
465
  }, []);
339
466
  /**
340
467
  * Apply a new list, reducing against the ref rather than through a functional
@@ -362,13 +489,15 @@ export function useUserBalances() {
362
489
  * event arriving while the request is in flight is overwritten by the older
363
490
  * response.
364
491
  */
365
- const loadBalances = useCallback(async (mode) => {
492
+ const loadBalances = useCallback(async (mode, sinceSeq) => {
366
493
  if (!sdk?.profile)
367
494
  return;
368
495
  const generation = readGeneration.current;
369
- // Captured where the read starts: only frames buffered AFTER this point
370
- // are newer than what the response will carry.
371
- const readSeq = bufferSeq.current;
496
+ // The point this read was ASKED for. Only frames buffered after it can
497
+ // be newer than what the response will carry; a frame that asked for
498
+ // this read is by definition one of them. Reads nobody asked for — the
499
+ // initial load, a manual refresh — are measured from where they start.
500
+ const readSeq = sinceSeq ?? bufferSeq.current;
372
501
  // A read belonging to a previous client must not write ANY of this state
373
502
  // — not the rows, not the error, not the loading flag. Clearing `loading`
374
503
  // from a stale request would mark the hook loaded while the new client's
@@ -389,7 +518,7 @@ export function useUserBalances() {
389
518
  const repaired = applyBufferedBalances(fetched, bufferedUnheld.current, readSeq);
390
519
  for (const row of fetched)
391
520
  bufferedUnheld.current.delete(row.token.toLowerCase());
392
- applyBalances(mode === "replace" ? repaired : (prev) => reconcileBalances(prev, repaired));
521
+ applyBalances(mode === "replace" ? (prev) => mergeBalanceRefresh(prev, repaired) : (prev) => reconcileBalances(prev, repaired));
393
522
  }
394
523
  }
395
524
  catch (err) {
@@ -428,7 +557,10 @@ export function useUserBalances() {
428
557
  * live, so the response is already potentially stale by the time it lands
429
558
  * — and coalesces, so a burst of frames for an unheld token costs one read.
430
559
  */
431
- const refetch = () => {
560
+ const refetch = (sinceSeq) => {
561
+ // Keep the EARLIEST mark asked for: the read that eventually runs has to
562
+ // replay every frame that requested it, not just the last one's.
563
+ repairSince.current = repairSince.current === null ? sinceSeq : Math.min(repairSince.current, sinceSeq);
432
564
  // Already reading: remember that something asked again, so the trailing
433
565
  // read below picks up whatever those frames carried.
434
566
  if (repairInFlight.current) {
@@ -445,7 +577,11 @@ export function useUserBalances() {
445
577
  const generation = readGeneration.current;
446
578
  repairInFlight.current = true;
447
579
  repairDirty.current = false;
448
- loadBalances("reconcile")
580
+ // Consumed here rather than on completion: frames that land while this
581
+ // read runs belong to the trailing read, and must set a fresh mark.
582
+ const readSeq = repairSince.current ?? bufferSeq.current;
583
+ repairSince.current = null;
584
+ loadBalances("reconcile", readSeq)
449
585
  .catch((err) => {
450
586
  if (!cancelled) {
451
587
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -460,7 +596,7 @@ export function useUserBalances() {
460
596
  // which every request made so far is subsumed. Clearing it in both
461
597
  // places would read as two rules for one thing.
462
598
  if (repairDirty.current && !cancelled)
463
- refetch();
599
+ refetch(repairSince.current ?? bufferSeq.current);
464
600
  });
465
601
  };
466
602
  fetchBalances()
@@ -479,8 +615,7 @@ export function useUserBalances() {
479
615
  if (!held && !cancelled) {
480
616
  // The updater below cannot apply this frame — there is no row
481
617
  // for it — so hold it until the read supplies one.
482
- bufferUnheld([event.data], "event");
483
- refetch();
618
+ refetch(bufferUnheld([event.data], "event"));
484
619
  }
485
620
  applyBalances((prev) => {
486
621
  // Find by token address and update (case-insensitive for addresses)
@@ -509,8 +644,7 @@ export function useUserBalances() {
509
644
  if (snapshotHasUnknownAsset(balancesRef.current, items) && !cancelled) {
510
645
  // Held before the read is issued, so a frame the read predates
511
646
  // is not lost when its row finally appears.
512
- bufferUnheld(items, "snapshot");
513
- refetch();
647
+ refetch(bufferUnheld(items, "snapshot"));
514
648
  }
515
649
  applyBalances((prev) => applyBalanceSnapshot(prev, items).balances);
516
650
  });
@@ -534,6 +668,7 @@ export function useUserBalances() {
534
668
  readGeneration.current += 1;
535
669
  repairInFlight.current = false;
536
670
  repairDirty.current = false;
671
+ repairSince.current = null;
537
672
  bufferedUnheld.current = new Map();
538
673
  setSubscribed(false);
539
674
  };
@@ -1,3 +1,3 @@
1
1
  import { type MonacoContextValue, type MonacoProviderProps } from "./types";
2
- export declare const MonacoProvider: ({ children, clientId, network, seiRpcUrl, walletClient, tokenLifecycle: tokenLifecycleConfig }: MonacoProviderProps) => import("react/jsx-runtime").JSX.Element;
2
+ export declare const MonacoProvider: ({ children, clientId, network, seiRpcUrl, walletClient, tokenLifecycle: tokenLifecycleConfig }: MonacoProviderProps) => import("react").JSX.Element;
3
3
  export declare const useMonacoContext: () => MonacoContextValue;
@@ -17,6 +17,6 @@ interface TradeFeedContextValue {
17
17
  interface TradeFeedProviderProps {
18
18
  children: ReactNode;
19
19
  }
20
- export declare const TradeFeedProvider: ({ children }: TradeFeedProviderProps) => import("react/jsx-runtime").JSX.Element;
20
+ export declare const TradeFeedProvider: ({ children }: TradeFeedProviderProps) => import("react").JSX.Element;
21
21
  export declare const useTradeFeedContext: () => TradeFeedContextValue;
22
22
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmonaco/react",
3
- "version": "1.0.55",
3
+ "version": "1.0.57",
4
4
  "type": "module",
5
5
  "homepage": "https://docs.0xmonaco.com/sdk/typescript",
6
6
  "main": "./dist/index.js",
@@ -16,12 +16,12 @@
16
16
  "lint": "biome lint ."
17
17
  },
18
18
  "dependencies": {
19
- "@0xmonaco/core": "1.0.55",
20
- "@0xmonaco/types": "1.0.55"
19
+ "@0xmonaco/core": "1.0.57",
20
+ "@0xmonaco/types": "1.0.57"
21
21
  },
22
22
  "devDependencies": {
23
- "@types/react": "^19.1.12",
24
- "react": "^18.3.1"
23
+ "@types/react": "^19.2.18",
24
+ "react": "^19.2.8"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "react": "^17.0.0 || ^18.0.0 || ^19.0.0",