@gearbox-protocol/sdk 15.1.0-next.10 → 15.1.0-next.12

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 (42) hide show
  1. package/dist/cjs/dev/mode-parity/compareOpportunities.js +105 -0
  2. package/dist/cjs/dev/mode-parity/comparePositions.js +160 -0
  3. package/dist/cjs/dev/mode-parity/compareRules.js +93 -0
  4. package/dist/cjs/dev/mode-parity/fieldDiff.js +288 -0
  5. package/dist/cjs/dev/mode-parity/scriptUtils.js +131 -0
  6. package/dist/cjs/model/compare.schema.js +33 -0
  7. package/dist/cjs/model/curators.schema.js +2 -1
  8. package/dist/cjs/model/index.js +5 -0
  9. package/dist/cjs/model/opportunities.schema.js +13 -12
  10. package/dist/cjs/model/positions.schema.js +15 -14
  11. package/dist/cjs/model/primitives.schema.js +2 -1
  12. package/dist/cjs/sdk/market/math.js +8 -1
  13. package/dist/cjs/sdk/pools/PoolService.js +1 -1
  14. package/dist/cjs/sdk/positions/PositionsService.js +6 -1
  15. package/dist/esm/common-utils/index.js +3 -3
  16. package/dist/esm/common-utils/utils/index.js +1 -1
  17. package/dist/esm/dev/mode-parity/compareOpportunities.js +103 -0
  18. package/dist/esm/dev/mode-parity/comparePositions.js +158 -0
  19. package/dist/esm/dev/mode-parity/compareRules.js +91 -0
  20. package/dist/esm/dev/mode-parity/fieldDiff.js +270 -0
  21. package/dist/esm/dev/mode-parity/scriptUtils.js +121 -0
  22. package/dist/esm/model/compare.schema.js +29 -0
  23. package/dist/esm/model/curators.schema.js +2 -1
  24. package/dist/esm/model/index.js +2 -1
  25. package/dist/esm/model/opportunities.schema.js +13 -12
  26. package/dist/esm/model/positions.schema.js +15 -14
  27. package/dist/esm/model/primitives.schema.js +2 -1
  28. package/dist/esm/plugins/apy/ApyPlugin.js +2 -2
  29. package/dist/esm/sdk/market/math.js +8 -1
  30. package/dist/esm/sdk/pools/PoolService.js +1 -1
  31. package/dist/esm/sdk/positions/PositionsService.js +6 -1
  32. package/dist/types/dev/{compareOpportunities.d.ts → mode-parity/compareOpportunities.d.ts} +18 -66
  33. package/dist/types/dev/mode-parity/comparePositions.d.ts +149 -0
  34. package/dist/types/dev/mode-parity/compareRules.d.ts +33 -0
  35. package/dist/types/dev/mode-parity/fieldDiff.d.ts +209 -0
  36. package/dist/types/dev/mode-parity/scriptUtils.d.ts +47 -0
  37. package/dist/types/model/compare.schema.d.ts +47 -0
  38. package/dist/types/model/index.d.ts +2 -1
  39. package/dist/types/sdk/market/math.d.ts +2 -0
  40. package/package.json +1 -1
  41. package/dist/cjs/dev/compareOpportunities.js +0 -218
  42. package/dist/esm/dev/compareOpportunities.js +0 -216
@@ -0,0 +1,158 @@
1
+ import { liquidationPositionSchema } from "../../model/liquidations.schema.js";
2
+ import { positionId } from "../../model/positions.js";
3
+ import { poolPositionSchema, strategyPositionSchema } from "../../model/positions.schema.js";
4
+ import "../../model/index.js";
5
+ import { countPaths, diffObjects, isRecord, toCompareCounts, union } from "./fieldDiff.js";
6
+ import { compileCompareRules, makeTagDiff } from "./compareRules.js";
7
+ //#region src/dev/mode-parity/comparePositions.ts
8
+ const tagDiff = makeTagDiff({
9
+ pool: compileCompareRules(poolPositionSchema),
10
+ strategy: compileCompareRules(strategyPositionSchema),
11
+ liquidation: compileCompareRules(liquidationPositionSchema)
12
+ });
13
+ /**
14
+ * Matches two position listings per wallet by {@link positionId} and reports
15
+ * every field the two sources disagree on.
16
+ *
17
+ * Nothing is filtered out. A field only one mode can fill, or a USD value that
18
+ * drifted within snapshot-lag noise, is still reported — tagged
19
+ * {@link FieldDiff.expected} so that {@link CompareCounts.clean} can ignore it
20
+ * while {@link CompareCounts.identical} stays strict.
21
+ **/
22
+ function comparePositions(input) {
23
+ const failures = input.failures ?? [];
24
+ const wallets = [...input.wallets.map(compareWallet), ...failures.map((failure) => ({
25
+ wallet: failure.wallet,
26
+ error: failure.error,
27
+ onlyOnchain: [],
28
+ onlyOffchain: [],
29
+ matched: []
30
+ }))];
31
+ wallets.sort((a, b) => a.wallet.localeCompare(b.wallet));
32
+ return {
33
+ generatedAt: input.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
34
+ backendUrl: input.backendUrl,
35
+ networks: [...input.networks],
36
+ onchainChains: collectChains(input.wallets, "onchain"),
37
+ offchainChains: collectChains(input.wallets, "offchain"),
38
+ summary: summarize(input.wallets, wallets, failures.length),
39
+ wallets
40
+ };
41
+ }
42
+ /**
43
+ * Every field two versions of one position disagree on.
44
+ **/
45
+ function diffPosition(onchain, offchain) {
46
+ return diffObjects(onchain, offchain, { keyOf: keyOfPositionArray }).map((diff) => tagDiff(diff, onchain.kind));
47
+ }
48
+ function compareWallet(input) {
49
+ const onchainRows = indexById(input.onchain.data);
50
+ const offchainRows = indexById(input.offchain.data);
51
+ const onlyOnchain = [];
52
+ const onlyOffchain = [];
53
+ const matched = [];
54
+ for (const [id, row] of onchainRows) {
55
+ const counterpart = offchainRows.get(id);
56
+ if (!counterpart) {
57
+ onlyOnchain.push(toRef(row));
58
+ continue;
59
+ }
60
+ const diffs = diffPosition(row, counterpart);
61
+ matched.push({
62
+ id,
63
+ kind: row.kind,
64
+ chainId: row.chainId,
65
+ onchainName: row.name,
66
+ offchainName: counterpart.name,
67
+ identical: diffs.length === 0,
68
+ clean: diffs.every((diff) => diff.expected),
69
+ diffs
70
+ });
71
+ }
72
+ for (const [id, row] of offchainRows) if (!onchainRows.has(id)) onlyOffchain.push(toRef(row));
73
+ onlyOnchain.sort((a, b) => a.id.localeCompare(b.id));
74
+ onlyOffchain.sort((a, b) => a.id.localeCompare(b.id));
75
+ matched.sort((a, b) => a.id.localeCompare(b.id));
76
+ return {
77
+ wallet: input.wallet,
78
+ onlyOnchain,
79
+ onlyOffchain,
80
+ matched
81
+ };
82
+ }
83
+ function indexById(rows) {
84
+ return new Map(rows.map((row) => [positionId(row), row]));
85
+ }
86
+ function toRef(row) {
87
+ const base = {
88
+ id: positionId(row),
89
+ kind: row.kind,
90
+ chainId: row.chainId,
91
+ name: row.name
92
+ };
93
+ switch (row.kind) {
94
+ case "pool": return {
95
+ ...base,
96
+ pool: row.pool
97
+ };
98
+ case "strategy": return {
99
+ ...base,
100
+ creditAccount: row.creditAccount,
101
+ creditManager: row.creditManager
102
+ };
103
+ case "liquidation": return base;
104
+ }
105
+ }
106
+ /**
107
+ * `collaterals[]` is keyed by the collateral token, not by a top-level
108
+ * address. Rewards-points programs already identify themselves by `id`.
109
+ **/
110
+ function keyOfPositionArray(path, value) {
111
+ if (path !== "collaterals" || !isRecord(value) || !isRecord(value.collateral)) return;
112
+ const token = value.collateral.token;
113
+ return isRecord(token) && typeof token.address === "string" ? token.address : void 0;
114
+ }
115
+ function summarize(inputs, wallets, failed) {
116
+ const compared = wallets.filter((wallet) => !wallet.error);
117
+ const allOnchain = inputs.flatMap((input) => input.onchain.data);
118
+ const allOffchain = inputs.flatMap((input) => input.offchain.data);
119
+ const onlyOnchain = compared.flatMap((wallet) => wallet.onlyOnchain);
120
+ const onlyOffchain = compared.flatMap((wallet) => wallet.onlyOffchain);
121
+ const matched = compared.flatMap((wallet) => wallet.matched);
122
+ const byChain = union(allOnchain.map((row) => String(row.chainId)), allOffchain.map((row) => String(row.chainId))).map((chainId) => ({
123
+ chainId: Number(chainId),
124
+ ...toCompareCounts(allOnchain.filter((row) => String(row.chainId) === chainId).length, allOffchain.filter((row) => String(row.chainId) === chainId).length, onlyOnchain.filter((ref) => String(ref.chainId) === chainId).length, onlyOffchain.filter((ref) => String(ref.chainId) === chainId).length, matched.filter((match) => String(match.chainId) === chainId))
125
+ })).sort((a, b) => a.chainId - b.chainId);
126
+ const byWallet = wallets.map((wallet) => ({
127
+ wallet: wallet.wallet,
128
+ ...wallet.error ? { error: wallet.error } : {},
129
+ ...toCompareCounts(wallet.matched.length + wallet.onlyOnchain.length, wallet.matched.length + wallet.onlyOffchain.length, wallet.onlyOnchain.length, wallet.onlyOffchain.length, wallet.matched)
130
+ }));
131
+ const walletsClean = compared.filter((wallet) => wallet.onlyOnchain.length === 0 && wallet.onlyOffchain.length === 0 && wallet.matched.every((match) => match.clean)).length;
132
+ return {
133
+ ...toCompareCounts(allOnchain.length, allOffchain.length, onlyOnchain.length, onlyOffchain.length, matched),
134
+ wallets: wallets.length,
135
+ walletsClean,
136
+ walletsFailed: failed,
137
+ byChain,
138
+ byWallet,
139
+ diffsByPath: countPaths(matched.flatMap((match) => match.diffs.map((diff) => ({
140
+ id: match.id,
141
+ diff
142
+ }))))
143
+ };
144
+ }
145
+ /**
146
+ * One metadata entry per chain, preferring a success so the report names the
147
+ * block. Later wallets of the same chain are ignored.
148
+ **/
149
+ function collectChains(wallets, side) {
150
+ const byChain = /* @__PURE__ */ new Map();
151
+ for (const wallet of wallets) for (const chain of wallet[side].meta.chains) {
152
+ const existing = byChain.get(chain.chainId);
153
+ if (!existing || existing.status !== "success" && chain.status === "success") byChain.set(chain.chainId, chain);
154
+ }
155
+ return [...byChain.values()].sort((a, b) => a.chainId - b.chainId);
156
+ }
157
+ //#endregion
158
+ export { comparePositions, diffPosition };
@@ -0,0 +1,91 @@
1
+ import { compareTagOf } from "../../model/compare.schema.js";
2
+ import { USD_RELATIVE_EPSILON, asFiniteNumber, collapseArrayKeys, isAmountWithinTolerance, isBpsWithinTolerance, isUsdWithinTolerance, withExpected, withinRelative } from "./fieldDiff.js";
3
+ //#region src/dev/mode-parity/compareRules.ts
4
+ /**
5
+ * Walks a schema and records every field that carries compare metadata.
6
+ *
7
+ * `"amount"` on an object (an Amount / TokenAmount) is stored
8
+ * at `<path>.value`; every other tag is stored at the field's own path.
9
+ **/
10
+ function compileCompareRules(schema) {
11
+ const rules = /* @__PURE__ */ new Map();
12
+ walk(schema, "", /* @__PURE__ */ new Set(), rules);
13
+ return rules;
14
+ }
15
+ /**
16
+ * Tags diffs using the rules compiled for each row kind.
17
+ *
18
+ * Mode tags match the path or anything nested under it. Tolerance tags match
19
+ * the path exactly and dispatch on {@link CompareTolerance}.
20
+ **/
21
+ function makeTagDiff(rulesByKind) {
22
+ return (diff, kind) => {
23
+ const rules = rulesByKind[kind];
24
+ if (!rules) return diff;
25
+ const path = collapseArrayKeys(diff.path);
26
+ if (isModeScoped(path, rules)) return withExpected(diff, "mode-scoped");
27
+ const tag = rules.get(path);
28
+ if (tag && typeof tag === "object" && withinTolerance(tag.tolerance, diff)) return withExpected(diff, "tolerance");
29
+ return diff;
30
+ };
31
+ }
32
+ function isModeScoped(path, rules) {
33
+ for (const [rulePath, tag] of rules) {
34
+ if (tag !== "offchainOnly" && tag !== "onchainOnly") continue;
35
+ if (path === rulePath || path.startsWith(`${rulePath}.`) || path.startsWith(`${rulePath}[`)) return true;
36
+ }
37
+ return false;
38
+ }
39
+ function withinTolerance(kind, diff) {
40
+ switch (kind) {
41
+ case "usd": return isUsdWithinTolerance(diff.onchain, diff.offchain);
42
+ case "bps": return isBpsWithinTolerance(diff.onchain, diff.offchain);
43
+ case "amount": return isAmountWithinTolerance(diff.onchain, diff.offchain);
44
+ case "float": return withinRelative(asFiniteNumber(diff.onchain), asFiniteNumber(diff.offchain), USD_RELATIVE_EPSILON);
45
+ }
46
+ }
47
+ function walk(schema, path, seen, rules) {
48
+ if (seen.has(schema)) return;
49
+ const nextSeen = new Set(seen);
50
+ nextSeen.add(schema);
51
+ const tag = compareTagOf(schema);
52
+ if (tag) record(path, tag, schema, rules);
53
+ const def = schema.def;
54
+ switch (def.type) {
55
+ case "optional":
56
+ case "nullable":
57
+ case "default":
58
+ case "prefault":
59
+ case "readonly":
60
+ if (def.innerType) walk(def.innerType, path, nextSeen, rules);
61
+ return;
62
+ case "array":
63
+ if (def.element) walk(def.element, `${path}[]`, nextSeen, rules);
64
+ return;
65
+ case "object":
66
+ if (def.shape) for (const [key, field] of Object.entries(def.shape)) walk(field, join(path, key), nextSeen, rules);
67
+ return;
68
+ case "union":
69
+ for (const option of def.options ?? []) walk(option, path, nextSeen, rules);
70
+ return;
71
+ case "pipe":
72
+ if (def.out) walk(def.out, path, nextSeen, rules);
73
+ return;
74
+ case "lazy":
75
+ if (def.getter) walk(def.getter(), path, nextSeen, rules);
76
+ return;
77
+ default: return;
78
+ }
79
+ }
80
+ function record(path, tag, schema, rules) {
81
+ if (typeof tag === "object" && tag.tolerance === "amount" && schema.def.type === "object") {
82
+ rules.set(join(path, "value"), tag);
83
+ return;
84
+ }
85
+ rules.set(path, tag);
86
+ }
87
+ function join(path, key) {
88
+ return path ? `${path}.${key}` : key;
89
+ }
90
+ //#endregion
91
+ export { compileCompareRules, makeTagDiff };
@@ -0,0 +1,270 @@
1
+ //#region src/dev/mode-parity/fieldDiff.ts
2
+ /**
3
+ * Relative drift allowed on {@link Amount.valueUsd} before a USD float is a
4
+ * real disagreement: 0.1%.
5
+ **/
6
+ const USD_RELATIVE_EPSILON = .001;
7
+ /**
8
+ * Relative drift allowed on lag-bounded bigint amounts: 0.05%.
9
+ **/
10
+ const AMOUNT_RELATIVE_EPSILON = 5e-4;
11
+ /**
12
+ * Absolute drift allowed on bps rates before rounding and a one-block lag are
13
+ * no longer enough to explain it.
14
+ **/
15
+ const BPS_ABSOLUTE_EPSILON = 1;
16
+ /**
17
+ * Every field two versions of one value disagree on. Nothing is filtered or
18
+ * tagged: the caller marks expected diffs afterwards.
19
+ **/
20
+ function diffValue(path, onchain, offchain, out, options) {
21
+ if (isAbsent(onchain) && isAbsent(offchain)) return;
22
+ if (isAbsent(onchain) || isAbsent(offchain)) {
23
+ out.push({
24
+ path,
25
+ onchain,
26
+ offchain,
27
+ kind: "presence"
28
+ });
29
+ return;
30
+ }
31
+ if (Array.isArray(onchain) && Array.isArray(offchain)) {
32
+ diffArray(path, onchain, offchain, out, options);
33
+ return;
34
+ }
35
+ if (isRecord(onchain) && isRecord(offchain)) {
36
+ for (const key of union(Object.keys(onchain), Object.keys(offchain))) diffValue(join(path, key), onchain[key], offchain[key], out, options);
37
+ return;
38
+ }
39
+ if (!sameScalar(onchain, offchain)) out.push({
40
+ path,
41
+ onchain,
42
+ offchain,
43
+ kind: scalarKind(path, onchain)
44
+ });
45
+ }
46
+ /**
47
+ * Deep-diff two values and return the disagreements, in path order of
48
+ * discovery.
49
+ **/
50
+ function diffObjects(onchain, offchain, options) {
51
+ const diffs = [];
52
+ diffValue("", onchain, offchain, diffs, options);
53
+ return diffs;
54
+ }
55
+ /**
56
+ * Mark a diff expected, so a later `clean` count can ignore it.
57
+ **/
58
+ function withExpected(diff, reason) {
59
+ return {
60
+ ...diff,
61
+ expected: true,
62
+ reason
63
+ };
64
+ }
65
+ /**
66
+ * How often each field differed, with array keys collapsed so that the same
67
+ * field of a hundred collateral tokens counts as one path. Sorted so the
68
+ * unexpected disagreements come first.
69
+ **/
70
+ function countPaths(diffs) {
71
+ const counts = /* @__PURE__ */ new Map();
72
+ for (const { id, diff } of diffs) {
73
+ const path = collapseArrayKeys(diff.path);
74
+ const entry = counts.get(path) ?? {
75
+ path,
76
+ kinds: [],
77
+ count: 0,
78
+ expected: 0,
79
+ unexpected: 0
80
+ };
81
+ entry.count += 1;
82
+ if (diff.expected) entry.expected += 1;
83
+ else entry.unexpected += 1;
84
+ if (!entry.kinds.includes(diff.kind)) entry.kinds.push(diff.kind);
85
+ recordWorst(entry, id, diff);
86
+ counts.set(path, entry);
87
+ }
88
+ return [...counts.values()].sort((a, b) => b.unexpected - a.unexpected || b.count - a.count || a.path.localeCompare(b.path));
89
+ }
90
+ function recordWorst(entry, id, diff) {
91
+ const bps = relativeDiffBps(diff.onchain, diff.offchain);
92
+ if (bps === void 0) return;
93
+ const worst = {
94
+ id,
95
+ path: diff.path,
96
+ bps,
97
+ onchain: diff.onchain,
98
+ offchain: diff.offchain
99
+ };
100
+ if (diff.expected) {
101
+ if (isWorse(worst, entry.worstExpected)) entry.worstExpected = worst;
102
+ return;
103
+ }
104
+ if (isWorse(worst, entry.worstUnexpected)) entry.worstUnexpected = worst;
105
+ }
106
+ function isWorse(candidate, current) {
107
+ if (!current) return true;
108
+ return candidate.bps > current.bps || candidate.bps === current.bps && candidate.id.localeCompare(current.id) < 0;
109
+ }
110
+ /**
111
+ * Collapse `collateralTokens[0xa0b8...].symbol` to `collateralTokens[].symbol`.
112
+ **/
113
+ function collapseArrayKeys(path) {
114
+ return path.replace(/\[[^\]]*\]/g, "[]");
115
+ }
116
+ /**
117
+ * Membership and match totals of one comparison, from already-built lists.
118
+ **/
119
+ function toCompareCounts(onchainRows, offchainRows, onlyOnchain, onlyOffchain, matched) {
120
+ const identical = matched.filter((match) => match.identical).length;
121
+ const clean = matched.filter((match) => match.clean).length;
122
+ return {
123
+ onchainRows,
124
+ offchainRows,
125
+ matched: matched.length,
126
+ identical,
127
+ clean,
128
+ differing: matched.length - identical,
129
+ onlyOnchain,
130
+ onlyOffchain
131
+ };
132
+ }
133
+ /**
134
+ * USD floats within {@link USD_RELATIVE_EPSILON}.
135
+ **/
136
+ function isUsdWithinTolerance(onchain, offchain) {
137
+ return withinRelative(asFiniteNumber(onchain), asFiniteNumber(offchain), USD_RELATIVE_EPSILON);
138
+ }
139
+ /**
140
+ * Bps rates that differ by at most {@link BPS_ABSOLUTE_EPSILON}.
141
+ **/
142
+ function isBpsWithinTolerance(onchain, offchain) {
143
+ const left = asFiniteNumber(onchain);
144
+ const right = asFiniteNumber(offchain);
145
+ return left !== void 0 && right !== void 0 && Math.abs(left - right) <= 1;
146
+ }
147
+ /**
148
+ * Bigint amounts within {@link AMOUNT_RELATIVE_EPSILON}.
149
+ **/
150
+ function isAmountWithinTolerance(onchain, offchain) {
151
+ return typeof onchain === "bigint" && typeof offchain === "bigint" && withinRelativeBigint(onchain, offchain, 5e-4);
152
+ }
153
+ /**
154
+ * Finite numbers within a relative epsilon of each other.
155
+ **/
156
+ function withinRelative(onchain, offchain, epsilon) {
157
+ if (onchain === void 0 || offchain === void 0) return false;
158
+ const scale = Math.max(Math.abs(onchain), Math.abs(offchain));
159
+ return scale === 0 ? true : Math.abs(onchain - offchain) / scale <= epsilon;
160
+ }
161
+ /**
162
+ * `diff / max(|a|, |b|) <= epsilon`, computed in integer arithmetic so a
163
+ * 1e18-scale amount does not round through `Number`.
164
+ **/
165
+ function withinRelativeBigint(onchain, offchain, epsilon) {
166
+ if (onchain === offchain) return true;
167
+ const diff = onchain > offchain ? onchain - offchain : offchain - onchain;
168
+ const scale = abs(onchain) > abs(offchain) ? abs(onchain) : abs(offchain);
169
+ if (scale === 0n) return true;
170
+ return diff * BigInt(Math.round(1 / epsilon)) <= scale;
171
+ }
172
+ function asFiniteNumber(value) {
173
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
174
+ }
175
+ /**
176
+ * Relative difference of two numbers or bigints, in bps:
177
+ * `|a − b| / max(|a|, |b|) * 10_000`. `undefined` when the values are not
178
+ * a comparable pair of finite numbers or of bigints.
179
+ **/
180
+ function relativeDiffBps(onchain, offchain) {
181
+ if (typeof onchain === "bigint" && typeof offchain === "bigint") return relativeDiffBpsBigint(onchain, offchain);
182
+ const left = asFiniteNumber(onchain);
183
+ const right = asFiniteNumber(offchain);
184
+ if (left === void 0 || right === void 0) return;
185
+ const scale = Math.max(Math.abs(left), Math.abs(right));
186
+ return scale === 0 ? 0 : Math.abs(left - right) / scale * 1e4;
187
+ }
188
+ /**
189
+ * Same formula as {@link relativeDiffBps} for bigints, computed in integer
190
+ * arithmetic to milli-bps so a 1e18-scale amount does not round through
191
+ * `Number`.
192
+ **/
193
+ function relativeDiffBpsBigint(onchain, offchain) {
194
+ if (onchain === offchain) return 0;
195
+ const diff = onchain > offchain ? onchain - offchain : offchain - onchain;
196
+ const scale = abs(onchain) > abs(offchain) ? abs(onchain) : abs(offchain);
197
+ if (scale === 0n) return 0;
198
+ return Number(diff * 10000n * 1000n / scale) / 1e3;
199
+ }
200
+ function union(left, right) {
201
+ return [.../* @__PURE__ */ new Set([...left, ...right])];
202
+ }
203
+ function isRecord(value) {
204
+ return typeof value === "object" && value !== null && !Array.isArray(value);
205
+ }
206
+ function diffArray(path, onchain, offchain, out, options) {
207
+ const onchainKeyed = keyElements(path, onchain, options?.keyOf);
208
+ const offchainKeyed = keyElements(path, offchain, options?.keyOf);
209
+ if (!onchainKeyed || !offchainKeyed) {
210
+ if (onchain.length !== offchain.length) {
211
+ out.push({
212
+ path,
213
+ onchain,
214
+ offchain,
215
+ kind: "other"
216
+ });
217
+ return;
218
+ }
219
+ onchain.forEach((element, index) => {
220
+ diffValue(`${path}[${index}]`, element, offchain[index], out, options);
221
+ });
222
+ return;
223
+ }
224
+ for (const key of union([...onchainKeyed.keys()], [...offchainKeyed.keys()])) diffValue(`${path}[${key}]`, onchainKeyed.get(key), offchainKeyed.get(key), out, options);
225
+ }
226
+ /**
227
+ * The array indexed by each element's own identity, or `undefined` when its
228
+ * elements have none and order is all there is to go by.
229
+ **/
230
+ function keyElements(path, values, keyOf) {
231
+ const keyed = /* @__PURE__ */ new Map();
232
+ for (const value of values) {
233
+ const identity = keyOf?.(path, value) ?? defaultIdentity(value);
234
+ if (typeof identity !== "string") return;
235
+ keyed.set(identity.toLowerCase(), value);
236
+ }
237
+ return keyed.size === values.length ? keyed : void 0;
238
+ }
239
+ function defaultIdentity(value) {
240
+ if (!isRecord(value)) return;
241
+ if (typeof value.address === "string") return value.address;
242
+ if (typeof value.id === "string") return value.id;
243
+ if (typeof value.token === "string") return value.token;
244
+ if (isRecord(value.token) && typeof value.token.address === "string") return value.token.address;
245
+ }
246
+ const ADDRESS = /^0x[0-9a-f]{40}$/i;
247
+ /**
248
+ * Only addresses are compared case-insensitively: the backend lowercases them
249
+ * while the chain hands out checksummed ones, which is not a disagreement. A
250
+ * symbol or a name spelled differently is.
251
+ **/
252
+ function sameScalar(onchain, offchain) {
253
+ if (typeof onchain === "string" && typeof offchain === "string" && ADDRESS.test(onchain) && ADDRESS.test(offchain)) return onchain.toLowerCase() === offchain.toLowerCase();
254
+ return onchain === offchain;
255
+ }
256
+ function scalarKind(path, onchain) {
257
+ if (path.endsWith("valueUsd")) return "usd";
258
+ return typeof onchain === "number" || typeof onchain === "bigint" ? "numeric" : "other";
259
+ }
260
+ function isAbsent(value) {
261
+ return value === void 0 || value === null;
262
+ }
263
+ function join(path, key) {
264
+ return path ? `${path}.${key}` : key;
265
+ }
266
+ function abs(value) {
267
+ return value < 0n ? -value : value;
268
+ }
269
+ //#endregion
270
+ export { AMOUNT_RELATIVE_EPSILON, BPS_ABSOLUTE_EPSILON, USD_RELATIVE_EPSILON, asFiniteNumber, collapseArrayKeys, countPaths, diffObjects, diffValue, isAmountWithinTolerance, isBpsWithinTolerance, isRecord, isUsdWithinTolerance, relativeDiffBps, toCompareCounts, union, withExpected, withinRelative, withinRelativeBigint };
@@ -0,0 +1,121 @@
1
+ import { getAlchemyUrl } from "../providers.js";
2
+ import { pino } from "pino";
3
+ //#region src/dev/mode-parity/scriptUtils.ts
4
+ const NETWORKS = [
5
+ "Mainnet",
6
+ "Monad",
7
+ "Plasma",
8
+ "Somnia",
9
+ "Etherlink"
10
+ ];
11
+ const BACKEND_URL = process.env.BACKEND_URL ?? "https://api.gear-dev.dev";
12
+ const TIMEOUT = 48e4;
13
+ function requireEnv(name) {
14
+ const value = process.env[name];
15
+ if (!value) throw new Error(`${name} is required to reach the chains this script reads`);
16
+ return value;
17
+ }
18
+ function rpcUrls() {
19
+ const alchemyKey = requireEnv("ALCHEMY_KEY");
20
+ const alchemy = (network) => {
21
+ const url = getAlchemyUrl(network, alchemyKey);
22
+ if (!url) throw new Error(`Alchemy serves no URL for ${network}`);
23
+ return url;
24
+ };
25
+ return {
26
+ Mainnet: alchemy("Mainnet"),
27
+ Monad: alchemy("Monad"),
28
+ Plasma: alchemy("Plasma"),
29
+ Somnia: requireEnv("SOMNIA_PROVIDER"),
30
+ Etherlink: requireEnv("ETHERLINK_PROVIDER")
31
+ };
32
+ }
33
+ function createLogger() {
34
+ return pino({
35
+ level: process.env.LOG_LEVEL ?? "info",
36
+ formatters: {
37
+ bindings: () => ({}),
38
+ level: (label) => ({ level: label })
39
+ }
40
+ });
41
+ }
42
+ function errorMessage(error) {
43
+ return error instanceof Error ? error.message : String(error);
44
+ }
45
+ /**
46
+ * Runs `fn` over `items` with at most `concurrency` in flight.
47
+ **/
48
+ async function mapPool(items, concurrency, fn) {
49
+ let next = 0;
50
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
51
+ while (true) {
52
+ const index = next;
53
+ next += 1;
54
+ if (index >= items.length) return;
55
+ await fn(items[index]);
56
+ }
57
+ });
58
+ await Promise.all(workers);
59
+ }
60
+ /**
61
+ * Formats a relative difference in bps as a percent, e.g. 12.5 → `"0.125%"`.
62
+ **/
63
+ function formatBpsAsPercent(bps) {
64
+ if (!Number.isFinite(bps)) return "";
65
+ const percent = bps / 100;
66
+ if (percent === 0) return "0%";
67
+ return `${Math.abs(percent) >= 1 ? trimTrailingZeros(percent.toFixed(3)) : trimTrailingZeros(percent.toPrecision(4))}%`;
68
+ }
69
+ function trimTrailingZeros(value) {
70
+ return value.includes(".") ? value.replace(/\.?0+$/, "") : value;
71
+ }
72
+ function worstColumns(worst) {
73
+ return {
74
+ "max diff": worst ? formatBpsAsPercent(worst.bps) : "",
75
+ "worst entity": worst?.id ?? ""
76
+ };
77
+ }
78
+ /**
79
+ * Prints the shared membership table, the fields that differed most often,
80
+ * and any chain that failed to answer.
81
+ **/
82
+ function printCompareSummary(noun, report, extraLines = []) {
83
+ const { summary } = report;
84
+ console.log(`\n${noun} from ${report.backendUrl} vs the chain`);
85
+ console.table(summary.byChain.map((chain) => ({
86
+ chain: chain.chainId,
87
+ onchain: chain.onchainRows,
88
+ offchain: chain.offchainRows,
89
+ matched: chain.matched,
90
+ identical: chain.identical,
91
+ clean: chain.clean,
92
+ differing: chain.differing,
93
+ "only onchain": chain.onlyOnchain,
94
+ "only offchain": chain.onlyOffchain
95
+ })));
96
+ for (const line of extraLines) console.log(line);
97
+ const unexpected = summary.diffsByPath.filter((entry) => entry.unexpected > 0);
98
+ const expected = summary.diffsByPath.filter((entry) => entry.unexpected === 0 && entry.expected > 0);
99
+ if (unexpected.length) {
100
+ console.log("\nunexpected fields that differed most often:");
101
+ console.table(unexpected.slice(0, 25).map((entry) => ({
102
+ field: entry.path,
103
+ unexpected: entry.unexpected,
104
+ expected: entry.expected,
105
+ kinds: entry.kinds.join(", "),
106
+ ...worstColumns(entry.worstUnexpected)
107
+ })));
108
+ }
109
+ if (expected.length) {
110
+ console.log("\nexpected fields (mode-scoped or within tolerance):");
111
+ console.table(expected.slice(0, 25).map((entry) => ({
112
+ field: entry.path,
113
+ rows: entry.expected,
114
+ kinds: entry.kinds.join(", "),
115
+ ...worstColumns(entry.worstExpected)
116
+ })));
117
+ }
118
+ for (const chain of [...report.onchainChains, ...report.offchainChains]) if (chain.status === "error") console.log(`chain ${chain.chainId} failed on ${chain.source}:`, chain.error);
119
+ }
120
+ //#endregion
121
+ export { BACKEND_URL, NETWORKS, TIMEOUT, createLogger, errorMessage, formatBpsAsPercent, mapPool, printCompareSummary, requireEnv, rpcUrls };
@@ -0,0 +1,29 @@
1
+ //#region src/model/compare.schema.ts
2
+ /**
3
+ * Marks a field that only the backend fills.
4
+ **/
5
+ function offchainOnly(schema) {
6
+ return schema.meta({ compare: "offchainOnly" });
7
+ }
8
+ /**
9
+ * Marks a field that only the chain fills.
10
+ **/
11
+ function onchainOnly(schema) {
12
+ return schema.meta({ compare: "onchainOnly" });
13
+ }
14
+ /**
15
+ * Marks a numeric field whose two sources may drift within {@link kind}.
16
+ **/
17
+ function tolerance(schema, kind) {
18
+ return schema.meta({ compare: { tolerance: kind } });
19
+ }
20
+ /**
21
+ * The compare tag registered on a schema, if any.
22
+ **/
23
+ function compareTagOf(schema) {
24
+ const meta = schema.meta();
25
+ if (!meta || !("compare" in meta)) return;
26
+ return meta.compare;
27
+ }
28
+ //#endregion
29
+ export { compareTagOf, offchainOnly, onchainOnly, tolerance };
@@ -1,4 +1,5 @@
1
1
  import { ZodAddress } from "../sdk/utils/zod.js";
2
+ import { offchainOnly } from "./compare.schema.js";
2
3
  import { z } from "zod/v4";
3
4
  //#region src/model/curators.schema.ts
4
5
  /**
@@ -30,7 +31,7 @@ const curatorNameSchema = z.enum([
30
31
  const curatorSchema = z.object({
31
32
  address: ZodAddress(),
32
33
  name: curatorNameSchema.optional(),
33
- url: z.string().nullable()
34
+ url: offchainOnly(z.string().nullable())
34
35
  });
35
36
  //#endregion
36
37
  export { curatorNameSchema, curatorSchema };