@0xmonaco/react 1.0.55 → 1.0.56
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/coverage.d.ts
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
-
*
|
|
159
|
-
*
|
|
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
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* row
|
|
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
|
|
241
|
+
if (!entry)
|
|
179
242
|
return row;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
//
|
|
370
|
-
//
|
|
371
|
-
|
|
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
|
-
|
|
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
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@0xmonaco/react",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.56",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"homepage": "https://docs.0xmonaco.com/sdk/typescript",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"lint": "biome lint ."
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@0xmonaco/core": "1.0.
|
|
20
|
-
"@0xmonaco/types": "1.0.
|
|
19
|
+
"@0xmonaco/core": "1.0.56",
|
|
20
|
+
"@0xmonaco/types": "1.0.56"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/react": "^19.1.12",
|