@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.
- package/dist/cjs/dev/mode-parity/compareOpportunities.js +105 -0
- package/dist/cjs/dev/mode-parity/comparePositions.js +160 -0
- package/dist/cjs/dev/mode-parity/compareRules.js +93 -0
- package/dist/cjs/dev/mode-parity/fieldDiff.js +288 -0
- package/dist/cjs/dev/mode-parity/scriptUtils.js +131 -0
- package/dist/cjs/model/compare.schema.js +33 -0
- package/dist/cjs/model/curators.schema.js +2 -1
- package/dist/cjs/model/index.js +5 -0
- package/dist/cjs/model/opportunities.schema.js +13 -12
- package/dist/cjs/model/positions.schema.js +15 -14
- package/dist/cjs/model/primitives.schema.js +2 -1
- package/dist/cjs/sdk/market/math.js +8 -1
- package/dist/cjs/sdk/pools/PoolService.js +1 -1
- package/dist/cjs/sdk/positions/PositionsService.js +6 -1
- package/dist/esm/common-utils/index.js +3 -3
- package/dist/esm/common-utils/utils/index.js +1 -1
- package/dist/esm/dev/mode-parity/compareOpportunities.js +103 -0
- package/dist/esm/dev/mode-parity/comparePositions.js +158 -0
- package/dist/esm/dev/mode-parity/compareRules.js +91 -0
- package/dist/esm/dev/mode-parity/fieldDiff.js +270 -0
- package/dist/esm/dev/mode-parity/scriptUtils.js +121 -0
- package/dist/esm/model/compare.schema.js +29 -0
- package/dist/esm/model/curators.schema.js +2 -1
- package/dist/esm/model/index.js +2 -1
- package/dist/esm/model/opportunities.schema.js +13 -12
- package/dist/esm/model/positions.schema.js +15 -14
- package/dist/esm/model/primitives.schema.js +2 -1
- package/dist/esm/plugins/apy/ApyPlugin.js +2 -2
- package/dist/esm/sdk/market/math.js +8 -1
- package/dist/esm/sdk/pools/PoolService.js +1 -1
- package/dist/esm/sdk/positions/PositionsService.js +6 -1
- package/dist/types/dev/{compareOpportunities.d.ts → mode-parity/compareOpportunities.d.ts} +18 -66
- package/dist/types/dev/mode-parity/comparePositions.d.ts +149 -0
- package/dist/types/dev/mode-parity/compareRules.d.ts +33 -0
- package/dist/types/dev/mode-parity/fieldDiff.d.ts +209 -0
- package/dist/types/dev/mode-parity/scriptUtils.d.ts +47 -0
- package/dist/types/model/compare.schema.d.ts +47 -0
- package/dist/types/model/index.d.ts +2 -1
- package/dist/types/sdk/market/math.d.ts +2 -0
- package/package.json +1 -1
- package/dist/cjs/dev/compareOpportunities.js +0 -218
- 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 };
|