@perpscope/percolator-adapter 0.4.0

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.
@@ -0,0 +1,232 @@
1
+ import { normalizePercolatorSnapshot } from "./percolator-adapter.js";
2
+
3
+ const PERP_SCOPE_MAGIC_HEX = "50455243";
4
+ const MIN_SLAB_DATA_LENGTH = 128;
5
+ const MUTATING_KEYS = new Set([
6
+ "instruction",
7
+ "instructions",
8
+ "order",
9
+ "orders",
10
+ "send",
11
+ "sendtransaction",
12
+ "sign",
13
+ "secretkey",
14
+ "signature",
15
+ "signer",
16
+ "signtransaction",
17
+ "transaction",
18
+ "transactions",
19
+ "wallet",
20
+ "walletadapter",
21
+ "walletpath"
22
+ ]);
23
+
24
+ export function validateReadOnlyRpcRequest(request) {
25
+ const checked = validateReadOnlyRpcTarget(request);
26
+ const account = request.account || request.accountInfo || {};
27
+ const owner = stringOf(account.owner || account.programId);
28
+ const dataLength = numberOf(account.dataLength ?? account.dataLen ?? account.space);
29
+ const magic = stringOf(account.magic || account.discriminator || account.decoded?.header?.magic);
30
+
31
+ if (!owner) throw new Error("Read-only RPC request requires account owner.");
32
+ if (!dataLength) throw new Error("Read-only RPC request requires account data length.");
33
+ if (!magic) throw new Error("Read-only RPC request requires account magic.");
34
+ if (owner && owner !== checked.programId) {
35
+ throw new Error("RPC account owner does not match the requested program id.");
36
+ }
37
+ if (dataLength && dataLength < MIN_SLAB_DATA_LENGTH) {
38
+ throw new Error("RPC account data length is too small for a Percolator slab.");
39
+ }
40
+ if (magic && normalizeHex(magic) !== PERP_SCOPE_MAGIC_HEX) {
41
+ throw new Error("RPC account magic does not match a Percolator slab.");
42
+ }
43
+
44
+ return {
45
+ ...checked,
46
+ owner,
47
+ dataLength,
48
+ magic
49
+ };
50
+ }
51
+
52
+ function validateReadOnlyRpcTarget(request) {
53
+ if (!request || typeof request !== "object") {
54
+ throw new Error("Read-only RPC request must be an object.");
55
+ }
56
+
57
+ assertNoMutatingFields(request);
58
+
59
+ const slab = stringOf(request.slab || request.slabAddress || request.pubkey);
60
+ const programId = stringOf(request.programId || request.program || request.owner);
61
+
62
+ if (!slab) throw new Error("Read-only RPC request requires a slab address.");
63
+ if (!programId) throw new Error("Read-only RPC request requires a program id.");
64
+
65
+ return {
66
+ slab,
67
+ programId
68
+ };
69
+ }
70
+
71
+ export function buildReadOnlyRpcSnapshot(request) {
72
+ const checked = validateReadOnlyRpcRequest(request);
73
+ const account = request.account || request.accountInfo || {};
74
+ const decoded = account.decoded || {};
75
+ if (!Object.keys(decoded).length) {
76
+ throw new Error("Read-only RPC fixture requires decoded slab data.");
77
+ }
78
+
79
+ const market = {
80
+ ...(request.market || decoded.market || {}),
81
+ slab: checked.slab,
82
+ program: checked.programId
83
+ };
84
+ const commands = [
85
+ {
86
+ command: "slab:get",
87
+ output: {
88
+ slab: checked.slab,
89
+ dataLen: checked.dataLength,
90
+ header: decoded.header || {},
91
+ config: decoded.config || {},
92
+ market
93
+ }
94
+ }
95
+ ];
96
+
97
+ if (decoded.params) commands.push({ command: "slab:params", output: decoded.params });
98
+ if (decoded.engine) commands.push({ command: "slab:engine", output: decoded.engine });
99
+ if (decoded.bestPrice) commands.push({ command: "best-price", output: decoded.bestPrice });
100
+ if (decoded.accounts) commands.push({ command: "slab:accounts", output: decoded.accounts });
101
+ if (decoded.bitmap) commands.push({ command: "slab:bitmap", output: decoded.bitmap });
102
+
103
+ return normalizePercolatorSnapshot({
104
+ label: request.label || "Read-only RPC slab fixture",
105
+ cluster: request.cluster || "rpc fixture",
106
+ currentSlot: request.currentSlot || decoded.currentSlot,
107
+ market,
108
+ account: decoded.accountUsd,
109
+ commands
110
+ });
111
+ }
112
+
113
+ export function summarizeReadOnlyRpcDeployment(request) {
114
+ const checked = validateReadOnlyRpcRequest(request);
115
+ const account = request.account || request.accountInfo || {};
116
+ const decoded = account.decoded || {};
117
+ const expectations = request.expectations || {};
118
+ const requiredDecodedSections = Array.isArray(expectations.requiredDecodedSections)
119
+ ? expectations.requiredDecodedSections
120
+ : ["header", "config"];
121
+ const missingSections = requiredDecodedSections.filter((section) => !decoded[section]);
122
+ if (missingSections.length) {
123
+ throw new Error(`Read-only deployment fixture is missing decoded sections: ${missingSections.join(", ")}`);
124
+ }
125
+
126
+ const expectedOwner = stringOf(expectations.owner);
127
+ const expectedDataLength = numberOf(expectations.dataLength);
128
+ const expectedMagic = stringOf(expectations.magic);
129
+ if (expectedOwner && expectedOwner !== checked.owner) {
130
+ throw new Error("Read-only deployment owner expectation does not match account owner.");
131
+ }
132
+ if (expectedDataLength && expectedDataLength !== checked.dataLength) {
133
+ throw new Error("Read-only deployment data length expectation does not match account data length.");
134
+ }
135
+ if (expectedMagic && normalizeHex(expectedMagic) !== normalizeHex(checked.magic)) {
136
+ throw new Error("Read-only deployment magic expectation does not match account magic.");
137
+ }
138
+
139
+ const maxOracleAgeSec = firstNumber(
140
+ maybeNumber(expectations.maxOracleAgeSec),
141
+ maybeNumber(decoded.config?.maxStalenessSecs),
142
+ maybeNumber(decoded.config?.maxOracleAgeSec)
143
+ );
144
+ const oracleAgeSec = firstNumber(
145
+ maybeNumber(decoded.bestPrice?.oracle?.ageSecs),
146
+ maybeNumber(decoded.bestPrice?.oracle?.publishAgeSec),
147
+ maybeNumber(decoded.bestPrice?.oracle?.ageSec)
148
+ );
149
+ if (maxOracleAgeSec && oracleAgeSec > maxOracleAgeSec) {
150
+ throw new Error("Read-only deployment oracle freshness expectation failed.");
151
+ }
152
+
153
+ return {
154
+ label: request.label || "Read-only RPC slab fixture",
155
+ cluster: request.cluster || "rpc fixture",
156
+ slab: checked.slab,
157
+ programId: checked.programId,
158
+ owner: checked.owner,
159
+ dataLength: checked.dataLength,
160
+ magic: checked.magic,
161
+ market: request.market?.symbol || request.market?.name || "PERP",
162
+ method: request.rpcRead?.method || "getAccountInfo",
163
+ fixture: request.fixture || "",
164
+ maxOracleAgeSec,
165
+ oracleAgeSec,
166
+ freshness: maxOracleAgeSec
167
+ ? Math.max(0, Math.round((1 - oracleAgeSec / maxOracleAgeSec) * 100))
168
+ : 0
169
+ };
170
+ }
171
+
172
+ export async function fetchReadOnlyRpcSnapshot(request, client) {
173
+ if (!client || typeof client.getAccountInfo !== "function") {
174
+ throw new Error("Read-only RPC fetcher requires a client with getAccountInfo().");
175
+ }
176
+ const checked = validateReadOnlyRpcTarget(request);
177
+ const accountInfo = await client.getAccountInfo(checked.slab);
178
+ return buildReadOnlyRpcSnapshot({
179
+ ...request,
180
+ slab: checked.slab,
181
+ account: {
182
+ ...(request.account || {}),
183
+ ...accountInfo
184
+ }
185
+ });
186
+ }
187
+
188
+ function assertNoMutatingFields(value, path = "request") {
189
+ if (!value || typeof value !== "object") return;
190
+ for (const [key, child] of Object.entries(value)) {
191
+ const normalized = normalizeKey(key);
192
+ if (isMutatingKey(normalized)) {
193
+ throw new Error(`Refusing mutating field in read-only RPC request: ${path}.${key}`);
194
+ }
195
+ if (child && typeof child === "object") assertNoMutatingFields(child, `${path}.${key}`);
196
+ }
197
+ }
198
+
199
+ function isMutatingKey(key) {
200
+ if (MUTATING_KEYS.has(key)) return true;
201
+ return key.endsWith("secret") || key.endsWith("privatekey") || key.endsWith("keypair");
202
+ }
203
+
204
+ function stringOf(value) {
205
+ if (value === undefined || value === null) return "";
206
+ return String(value);
207
+ }
208
+
209
+ function numberOf(value) {
210
+ const next = Number(value);
211
+ return Number.isFinite(next) ? next : 0;
212
+ }
213
+
214
+ function maybeNumber(value) {
215
+ const next = Number(value);
216
+ return Number.isFinite(next) ? next : undefined;
217
+ }
218
+
219
+ function firstNumber(...values) {
220
+ for (const value of values) {
221
+ if (Number.isFinite(value)) return value;
222
+ }
223
+ return 0;
224
+ }
225
+
226
+ function normalizeHex(value) {
227
+ return String(value).toLowerCase().replace(/^0x/, "");
228
+ }
229
+
230
+ function normalizeKey(value) {
231
+ return String(value).toLowerCase().replace(/[^a-z0-9]/g, "");
232
+ }
@@ -0,0 +1,124 @@
1
+ export function buildWatchtowerSignals(market, stress) {
2
+ const receipts = market.execution.receipts || [];
3
+ const avgMarkout5m = receipts.length ? mean(receipts.map((receipt) => receipt.markout5mBps)) : market.execution.markout5mBps;
4
+ const avgLatency = receipts.length ? mean(receipts.map((receipt) => receipt.routeLatencyMs)) : market.execution.routeLatencyMs;
5
+ const avgFee = receipts.length ? mean(receipts.map((receipt) => receipt.priorityFeeMicrolamports)) : market.execution.priorityFeeMicrolamports;
6
+ const freshnessScore = Math.min(market.price.freshnessScore, market.crank.freshnessScore);
7
+ const impact10kBps = Number(market.execution.impact10kBps) || 0;
8
+ const impact50kBps = Number(market.execution.impact50kBps) || 0;
9
+ const hasImpactRatio = impact10kBps > 0 && impact50kBps > 0;
10
+ const hasImpactSignal = Math.abs(impact10kBps) > 0 || Math.abs(impact50kBps) > 0;
11
+ const impactRatio = hasImpactRatio ? impact50kBps / impact10kBps : 0;
12
+ const impactScore = hasImpactRatio
13
+ ? clamp(100 - impactRatio * 16, 0, 100)
14
+ : hasImpactSignal
15
+ ? clamp(100 - Math.abs(impact50kBps) * 1.25, 0, 100)
16
+ : 50;
17
+ const impactTone = hasImpactRatio
18
+ ? impactRatio >= 4.2 ? "danger" : impactRatio >= 2.7 ? "warning" : "good"
19
+ : !hasImpactSignal ? "warning" : Math.abs(impact50kBps) >= 55 ? "danger" : Math.abs(impact50kBps) >= 25 ? "warning" : "good";
20
+ const carryDaily = Math.abs(market.funding.dailyUsd);
21
+ const runwayScore = clamp(market.account.liquidationDistancePct * 6, 0, 100);
22
+ const projectedBuffer = stress?.projectedBufferUsd ?? market.account.marginBufferUsd;
23
+
24
+ return [
25
+ {
26
+ id: "runway",
27
+ label: "runway",
28
+ value: pct(market.account.liquidationDistancePct),
29
+ detail: `${money(market.account.marginBufferUsd, 0)} buffer`,
30
+ subvalue: `${money(projectedBuffer, 0)} stress`,
31
+ score: runwayScore,
32
+ tone: scoreTone(runwayScore)
33
+ },
34
+ {
35
+ id: "freshness",
36
+ label: "freshness",
37
+ value: `${Math.round(freshnessScore)}%`,
38
+ detail: `${fmtInt(market.crank.ageSlots)} slots`,
39
+ subvalue: `${market.price.publishAgeSec.toFixed(1)}s oracle`,
40
+ score: freshnessScore,
41
+ tone: scoreTone(freshnessScore)
42
+ },
43
+ {
44
+ id: "execution",
45
+ label: "execution",
46
+ value: signedBps(avgMarkout5m),
47
+ detail: `${Math.round(avgLatency)} ms route`,
48
+ subvalue: feeLabel(avgFee),
49
+ score: clamp(100 - Math.max(Math.abs(Math.min(avgMarkout5m, 0)) * 1.8, Math.max(avgLatency - 150, 0) / 5), 0, 100),
50
+ tone: avgMarkout5m >= 0 && avgLatency <= 220 ? "good" : avgMarkout5m < -18 || avgLatency > 420 ? "danger" : "warning"
51
+ },
52
+ {
53
+ id: "impact",
54
+ label: "impact curve",
55
+ value: hasImpactRatio ? `${impactRatio.toFixed(1)}x` : hasImpactSignal ? `${impact50kBps.toFixed(1)} bps` : "n/a",
56
+ detail: `${impact50kBps.toFixed(1)} bps at $50k`,
57
+ subvalue: `${impact10kBps.toFixed(1)} bps at $10k`,
58
+ score: impactScore,
59
+ tone: impactTone
60
+ },
61
+ {
62
+ id: "carry",
63
+ label: "carry",
64
+ value: `${signedBps(market.funding.bpsPerHour)} / hr`,
65
+ detail: `${money(carryDaily, 0)} daily`,
66
+ subvalue: `${signedBps(market.marketStructure.oiSkewPct)} OI skew`,
67
+ score: clamp(100 - Math.abs(market.funding.bpsPerHour) * 14 - Math.abs(market.marketStructure.oiSkewPct) * 0.55, 0, 100),
68
+ tone: Math.abs(market.funding.bpsPerHour) >= 3.2 || Math.abs(market.marketStructure.oiSkewPct) >= 48 ? "danger" : Math.abs(market.funding.bpsPerHour) >= 1.4 || Math.abs(market.marketStructure.oiSkewPct) >= 24 ? "warning" : "good"
69
+ },
70
+ {
71
+ id: "solvency",
72
+ label: "solvency",
73
+ value: `${Math.round(market.solvency.coveragePct)}%`,
74
+ detail: `${money(market.solvency.insuranceUsd, 0)} insurance`,
75
+ subvalue: `${money(market.solvency.socialLossUsd, 0)} social loss`,
76
+ score: clamp(Math.min(market.solvency.coveragePct, 120) * 0.82, 0, 100),
77
+ tone: market.solvency.coveragePct < 28 || market.solvency.socialLossUsd > 0 ? "danger" : market.solvency.coveragePct < 55 ? "warning" : "good"
78
+ }
79
+ ];
80
+ }
81
+
82
+ function scoreTone(score) {
83
+ if (score >= 68) return "good";
84
+ if (score >= 42) return "warning";
85
+ return "danger";
86
+ }
87
+
88
+ function money(value, digits = 2) {
89
+ const amount = Number(value);
90
+ const abs = Math.abs(amount);
91
+ const sign = amount < 0 ? "-" : "";
92
+ if (abs >= 1000000) return `${sign}$${(abs / 1000000).toFixed(2)}m`;
93
+ if (abs >= 10000) return `${sign}$${Math.round(abs).toLocaleString("en-US")}`;
94
+ return `${sign}$${abs.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits })}`;
95
+ }
96
+
97
+ function pct(value) {
98
+ return `${Number(value).toFixed(1)}%`;
99
+ }
100
+
101
+ function signedBps(value) {
102
+ const next = Number(value);
103
+ return `${next >= 0 ? "+" : ""}${next.toFixed(1)} bps`;
104
+ }
105
+
106
+ function feeLabel(value) {
107
+ const amount = Number(value) || 0;
108
+ if (Math.abs(amount) >= 1000) return `${(amount / 1000).toFixed(amount >= 10000 ? 0 : 1)}k uLamports`;
109
+ return `${Math.round(amount)} uLamports`;
110
+ }
111
+
112
+ function fmtInt(value) {
113
+ return Number(value).toLocaleString("en-US");
114
+ }
115
+
116
+ function mean(values) {
117
+ const numbers = values.map(Number).filter(Number.isFinite);
118
+ if (!numbers.length) return 0;
119
+ return numbers.reduce((sum, value) => sum + value, 0) / numbers.length;
120
+ }
121
+
122
+ function clamp(value, min, max) {
123
+ return Math.min(max, Math.max(min, value));
124
+ }