perp-cli 0.11.0 → 0.12.1

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 (240) hide show
  1. package/README.md +30 -25
  2. package/dist/_stderr-filter.d.ts +0 -0
  3. package/dist/_stderr-filter.js +30 -0
  4. package/dist/agent-wallet/expiry.d.ts +15 -0
  5. package/dist/agent-wallet/expiry.js +26 -0
  6. package/dist/agent-wallet/lighter-keystore.d.ts +34 -0
  7. package/dist/agent-wallet/lighter-keystore.js +232 -0
  8. package/dist/agent-wallet/passphrase.d.ts +13 -0
  9. package/dist/agent-wallet/passphrase.js +54 -0
  10. package/dist/agent-wallet/signing-strategy.d.ts +67 -0
  11. package/dist/agent-wallet/signing-strategy.js +51 -0
  12. package/dist/agent-wallet/store.d.ts +37 -0
  13. package/dist/agent-wallet/store.js +172 -0
  14. package/dist/api/public/_http.d.ts +15 -0
  15. package/dist/api/public/_http.js +24 -0
  16. package/dist/api/public/hyperliquid.js +36 -24
  17. package/dist/api/public/lighter.d.ts +7 -1
  18. package/dist/api/public/lighter.js +85 -37
  19. package/dist/api/public/pacifica.js +47 -18
  20. package/dist/bridge-engine.js +102 -49
  21. package/dist/cli-spec.js +1 -2
  22. package/dist/commands/account.js +2 -500
  23. package/dist/commands/agent.d.ts +8 -2
  24. package/dist/commands/agent.js +1903 -366
  25. package/dist/commands/alerts.js +3 -1
  26. package/dist/commands/arb/index.js +37 -7
  27. package/dist/commands/arb-auto.js +44 -11
  28. package/dist/commands/arb.js +83 -34
  29. package/dist/commands/bot.d.ts +1 -1
  30. package/dist/commands/bot.js +18 -14
  31. package/dist/commands/bridge.d.ts +1 -1
  32. package/dist/commands/bridge.js +4 -4
  33. package/dist/commands/funds.d.ts +1 -1
  34. package/dist/commands/funds.js +18 -68
  35. package/dist/commands/jobs.d.ts +1 -1
  36. package/dist/commands/jobs.js +2 -2
  37. package/dist/commands/manage.d.ts +1 -1
  38. package/dist/commands/manage.js +12 -84
  39. package/dist/commands/plan.d.ts +1 -1
  40. package/dist/commands/plan.js +2 -2
  41. package/dist/commands/portfolio.d.ts +19 -3
  42. package/dist/commands/portfolio.js +747 -163
  43. package/dist/commands/rebalance.d.ts +1 -1
  44. package/dist/commands/rebalance.js +18 -12
  45. package/dist/commands/risk.js +2 -6
  46. package/dist/commands/settings.js +30 -53
  47. package/dist/commands/trade.js +16 -15
  48. package/dist/commands/wallet.d.ts +2 -1
  49. package/dist/commands/wallet.js +756 -28
  50. package/dist/errors.d.ts +64 -0
  51. package/dist/errors.js +82 -2
  52. package/dist/event-stream.js +12 -1
  53. package/dist/exchanges/aster-typed-data.d.ts +156 -0
  54. package/dist/exchanges/aster-typed-data.js +189 -0
  55. package/dist/exchanges/aster.d.ts +60 -11
  56. package/dist/exchanges/aster.js +278 -136
  57. package/dist/exchanges/hyperliquid.d.ts +14 -0
  58. package/dist/exchanges/hyperliquid.js +84 -34
  59. package/dist/exchanges/lighter-spot.js +14 -3
  60. package/dist/exchanges/lighter.d.ts +66 -0
  61. package/dist/exchanges/lighter.js +199 -27
  62. package/dist/exchanges/pacifica-typed-data.d.ts +75 -0
  63. package/dist/exchanges/pacifica-typed-data.js +74 -0
  64. package/dist/exchanges/pacifica.d.ts +29 -1
  65. package/dist/exchanges/pacifica.js +94 -6
  66. package/dist/funding/rates.js +59 -21
  67. package/dist/guardrail/perp-guardrail.js +9 -1
  68. package/dist/index.d.ts +1 -1
  69. package/dist/index.js +268 -360
  70. package/dist/jobs.js +3 -1
  71. package/dist/mcp-server.js +85 -83
  72. package/dist/position-history.js +47 -4
  73. package/dist/settings.d.ts +67 -3
  74. package/dist/settings.js +1 -2
  75. package/dist/signer/ows-evm.d.ts +14 -0
  76. package/dist/signer/ows-evm.js +28 -9
  77. package/dist/smart-order.d.ts +8 -2
  78. package/dist/smart-order.js +46 -11
  79. package/dist/strategies/funding-arb.js +29 -8
  80. package/dist/trade-validator.js +30 -5
  81. package/dist/utils.d.ts +10 -0
  82. package/dist/utils.js +19 -0
  83. package/package.json +2 -2
  84. package/skills/perp-cli/SKILL.md +23 -14
  85. package/skills/perp-cli/references/agent-operations.md +6 -4
  86. package/skills/perp-cli/references/commands.md +55 -34
  87. package/skills/perp-cli/scripts/preflight.sh +4 -4
  88. package/skills/perp-cli/scripts/spot-perp-scan.sh +2 -2
  89. package/dist/__tests__/alert-logic.test.d.ts +0 -1
  90. package/dist/__tests__/alert-logic.test.js +0 -107
  91. package/dist/__tests__/arb-auto-3dex.test.d.ts +0 -1
  92. package/dist/__tests__/arb-auto-3dex.test.js +0 -397
  93. package/dist/__tests__/arb-history-stats.test.d.ts +0 -1
  94. package/dist/__tests__/arb-history-stats.test.js +0 -176
  95. package/dist/__tests__/arb-logic.test.d.ts +0 -1
  96. package/dist/__tests__/arb-logic.test.js +0 -84
  97. package/dist/__tests__/arb-manage.test.d.ts +0 -1
  98. package/dist/__tests__/arb-manage.test.js +0 -253
  99. package/dist/__tests__/arb-new-features.test.d.ts +0 -1
  100. package/dist/__tests__/arb-new-features.test.js +0 -457
  101. package/dist/__tests__/arb-sizing.test.d.ts +0 -1
  102. package/dist/__tests__/arb-sizing.test.js +0 -48
  103. package/dist/__tests__/arb-state.test.d.ts +0 -1
  104. package/dist/__tests__/arb-state.test.js +0 -284
  105. package/dist/__tests__/arb-userflow.test.d.ts +0 -1
  106. package/dist/__tests__/arb-userflow.test.js +0 -945
  107. package/dist/__tests__/arb-utils.test.d.ts +0 -1
  108. package/dist/__tests__/arb-utils.test.js +0 -264
  109. package/dist/__tests__/bot-conditions.test.d.ts +0 -1
  110. package/dist/__tests__/bot-conditions.test.js +0 -341
  111. package/dist/__tests__/client-id-tracker.test.d.ts +0 -1
  112. package/dist/__tests__/client-id-tracker.test.js +0 -137
  113. package/dist/__tests__/commands/new-atomic-commands.test.d.ts +0 -1
  114. package/dist/__tests__/commands/new-atomic-commands.test.js +0 -502
  115. package/dist/__tests__/commands/order-intent.test.d.ts +0 -1
  116. package/dist/__tests__/commands/order-intent.test.js +0 -600
  117. package/dist/__tests__/commands/trade-commands.test.d.ts +0 -1
  118. package/dist/__tests__/commands/trade-commands.test.js +0 -821
  119. package/dist/__tests__/config.test.d.ts +0 -1
  120. package/dist/__tests__/config.test.js +0 -86
  121. package/dist/__tests__/cross-chain-margin.test.d.ts +0 -1
  122. package/dist/__tests__/cross-chain-margin.test.js +0 -287
  123. package/dist/__tests__/dex-asset-map.test.d.ts +0 -1
  124. package/dist/__tests__/dex-asset-map.test.js +0 -191
  125. package/dist/__tests__/errors.test.d.ts +0 -1
  126. package/dist/__tests__/errors.test.js +0 -110
  127. package/dist/__tests__/event-stream.test.d.ts +0 -1
  128. package/dist/__tests__/event-stream.test.js +0 -276
  129. package/dist/__tests__/exchanges/interface.test.d.ts +0 -1
  130. package/dist/__tests__/exchanges/interface.test.js +0 -132
  131. package/dist/__tests__/exchanges/mock-adapter.d.ts +0 -69
  132. package/dist/__tests__/exchanges/mock-adapter.js +0 -137
  133. package/dist/__tests__/execution-log.test.d.ts +0 -1
  134. package/dist/__tests__/execution-log.test.js +0 -106
  135. package/dist/__tests__/funding-calc.test.d.ts +0 -1
  136. package/dist/__tests__/funding-calc.test.js +0 -71
  137. package/dist/__tests__/funding-history.test.d.ts +0 -1
  138. package/dist/__tests__/funding-history.test.js +0 -343
  139. package/dist/__tests__/funding-rates.test.d.ts +0 -1
  140. package/dist/__tests__/funding-rates.test.js +0 -342
  141. package/dist/__tests__/funding.test.d.ts +0 -1
  142. package/dist/__tests__/funding.test.js +0 -173
  143. package/dist/__tests__/gap-logic.test.d.ts +0 -1
  144. package/dist/__tests__/gap-logic.test.js +0 -43
  145. package/dist/__tests__/hip3-dex.test.d.ts +0 -1
  146. package/dist/__tests__/hip3-dex.test.js +0 -234
  147. package/dist/__tests__/integration/agent-features.integration.test.d.ts +0 -1
  148. package/dist/__tests__/integration/agent-features.integration.test.js +0 -553
  149. package/dist/__tests__/integration/atomic-commands.integration.test.d.ts +0 -13
  150. package/dist/__tests__/integration/atomic-commands.integration.test.js +0 -246
  151. package/dist/__tests__/integration/bridge-simulation.integration.test.d.ts +0 -1
  152. package/dist/__tests__/integration/bridge-simulation.integration.test.js +0 -453
  153. package/dist/__tests__/integration/bridge-strict.integration.test.d.ts +0 -1
  154. package/dist/__tests__/integration/bridge-strict.integration.test.js +0 -812
  155. package/dist/__tests__/integration/bridge.integration.test.d.ts +0 -1
  156. package/dist/__tests__/integration/bridge.integration.test.js +0 -309
  157. package/dist/__tests__/integration/cli-e2e.integration.test.d.ts +0 -1
  158. package/dist/__tests__/integration/cli-e2e.integration.test.js +0 -202
  159. package/dist/__tests__/integration/dex-arb.integration.test.d.ts +0 -1
  160. package/dist/__tests__/integration/dex-arb.integration.test.js +0 -116
  161. package/dist/__tests__/integration/envelope-consistency.integration.test.d.ts +0 -13
  162. package/dist/__tests__/integration/envelope-consistency.integration.test.js +0 -205
  163. package/dist/__tests__/integration/hip3-dex.integration.test.d.ts +0 -1
  164. package/dist/__tests__/integration/hip3-dex.integration.test.js +0 -147
  165. package/dist/__tests__/integration/hyperliquid.integration.test.d.ts +0 -1
  166. package/dist/__tests__/integration/hyperliquid.integration.test.js +0 -79
  167. package/dist/__tests__/integration/lighter.integration.test.d.ts +0 -1
  168. package/dist/__tests__/integration/lighter.integration.test.js +0 -53
  169. package/dist/__tests__/integration/new-commands-e2e.integration.test.d.ts +0 -9
  170. package/dist/__tests__/integration/new-commands-e2e.integration.test.js +0 -236
  171. package/dist/__tests__/integration/order-verification.integration.test.d.ts +0 -1
  172. package/dist/__tests__/integration/order-verification.integration.test.js +0 -321
  173. package/dist/__tests__/integration/pacifica.integration.test.d.ts +0 -1
  174. package/dist/__tests__/integration/pacifica.integration.test.js +0 -75
  175. package/dist/__tests__/integration/response-shapes.integration.test.d.ts +0 -1
  176. package/dist/__tests__/integration/response-shapes.integration.test.js +0 -278
  177. package/dist/__tests__/liquidity.test.d.ts +0 -1
  178. package/dist/__tests__/liquidity.test.js +0 -225
  179. package/dist/__tests__/plan-executor.test.d.ts +0 -1
  180. package/dist/__tests__/plan-executor.test.js +0 -314
  181. package/dist/__tests__/position-history.test.d.ts +0 -1
  182. package/dist/__tests__/position-history.test.js +0 -367
  183. package/dist/__tests__/retry.test.d.ts +0 -1
  184. package/dist/__tests__/retry.test.js +0 -310
  185. package/dist/__tests__/risk-assessment.test.d.ts +0 -1
  186. package/dist/__tests__/risk-assessment.test.js +0 -288
  187. package/dist/__tests__/security-adversarial.test.d.ts +0 -1
  188. package/dist/__tests__/security-adversarial.test.js +0 -574
  189. package/dist/__tests__/strategies.test.d.ts +0 -1
  190. package/dist/__tests__/strategies.test.js +0 -539
  191. package/dist/__tests__/trade-execution.test.d.ts +0 -1
  192. package/dist/__tests__/trade-execution.test.js +0 -129
  193. package/dist/__tests__/trade-validator.test.d.ts +0 -1
  194. package/dist/__tests__/trade-validator.test.js +0 -655
  195. package/dist/__tests__/utils.test.d.ts +0 -1
  196. package/dist/__tests__/utils.test.js +0 -76
  197. package/dist/arb/reconcile.d.ts +0 -28
  198. package/dist/arb/reconcile.js +0 -162
  199. package/dist/commands/alert.d.ts +0 -2
  200. package/dist/commands/alert.js +0 -421
  201. package/dist/commands/analytics.d.ts +0 -3
  202. package/dist/commands/analytics.js +0 -311
  203. package/dist/commands/arb-manage.d.ts +0 -5
  204. package/dist/commands/arb-manage.js +0 -5
  205. package/dist/commands/dashboard.d.ts +0 -3
  206. package/dist/commands/dashboard.js +0 -176
  207. package/dist/commands/deposit.d.ts +0 -4
  208. package/dist/commands/deposit.js +0 -573
  209. package/dist/commands/dex.d.ts +0 -3
  210. package/dist/commands/dex.js +0 -120
  211. package/dist/commands/env.d.ts +0 -2
  212. package/dist/commands/env.js +0 -136
  213. package/dist/commands/funding.d.ts +0 -3
  214. package/dist/commands/funding.js +0 -554
  215. package/dist/commands/gap.d.ts +0 -2
  216. package/dist/commands/gap.js +0 -305
  217. package/dist/commands/health.d.ts +0 -2
  218. package/dist/commands/health.js +0 -67
  219. package/dist/commands/multileg.d.ts +0 -16
  220. package/dist/commands/multileg.js +0 -174
  221. package/dist/commands/ows.d.ts +0 -2
  222. package/dist/commands/ows.js +0 -887
  223. package/dist/commands/pnl.d.ts +0 -12
  224. package/dist/commands/pnl.js +0 -243
  225. package/dist/commands/run.d.ts +0 -3
  226. package/dist/commands/run.js +0 -202
  227. package/dist/commands/stream.d.ts +0 -5
  228. package/dist/commands/stream.js +0 -200
  229. package/dist/commands/withdraw.d.ts +0 -3
  230. package/dist/commands/withdraw.js +0 -187
  231. package/dist/exchanges/edgex.d.ts +0 -62
  232. package/dist/exchanges/edgex.js +0 -552
  233. package/dist/exchanges/nado.d.ts +0 -52
  234. package/dist/exchanges/nado.js +0 -223
  235. package/dist/funding/index.d.ts +0 -3
  236. package/dist/funding/index.js +0 -3
  237. package/dist/retry.d.ts +0 -74
  238. package/dist/retry.js +0 -129
  239. package/dist/ws/market-feed.d.ts +0 -74
  240. package/dist/ws/market-feed.js +0 -227
@@ -0,0 +1,37 @@
1
+ import type { AgentMeta } from "../settings.js";
2
+ /**
3
+ * Attempt to acquire the lockfile for an exchange's agent-approve operation.
4
+ * Returns void on success. Throws LOCK_HELD if another live process holds it.
5
+ * Auto-clears stale locks (>5 min old OR dead PID).
6
+ *
7
+ * Exported so callers can hold the lock across multiple operations (e.g. the
8
+ * full approve flow) before calling setAgent.
9
+ */
10
+ export declare function acquireLock(exchange: string): void;
11
+ export declare function releaseLock(exchange: string): void;
12
+ /**
13
+ * Returns the specific named agent, or the first non-partial agent when
14
+ * agentName is omitted.
15
+ */
16
+ export declare function getAgent(exchange: string, agentName?: string): AgentMeta | null;
17
+ /**
18
+ * Persist an agent meta entry for the given exchange.
19
+ * Acquires a lockfile before writing to prevent concurrent races (AC-14).
20
+ *
21
+ * Re-entrant: if the calling process already holds the lock (e.g. the approve
22
+ * flow acquired it at the start), this function writes directly without a
23
+ * double-acquire or release.
24
+ */
25
+ export declare function setAgent(exchange: string, meta: AgentMeta): void;
26
+ /**
27
+ * Delete an agent entry. Returns true if it existed and was removed, false if
28
+ * it was already absent (idempotent per AC-4).
29
+ */
30
+ export declare function deleteAgent(exchange: string, agentName: string): boolean;
31
+ /**
32
+ * List all registered agents, optionally filtered to a single exchange.
33
+ */
34
+ export declare function listAgents(exchange?: string): Array<{
35
+ exchange: string;
36
+ meta: AgentMeta;
37
+ }>;
@@ -0,0 +1,172 @@
1
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { loadSettings, saveSettings } from "../settings.js";
4
+ import { PerpError } from "../errors.js";
5
+ const PERP_DIR = resolve(process.env["HOME"] ?? "~", ".perp");
6
+ const LOCKS_DIR = resolve(PERP_DIR, "locks");
7
+ const LOCK_STALE_MS = 5 * 60 * 1000; // 5 minutes
8
+ function lockPath(exchange) {
9
+ return resolve(LOCKS_DIR, `agent-approve-${exchange}.lock`);
10
+ }
11
+ /**
12
+ * Attempt to acquire the lockfile for an exchange's agent-approve operation.
13
+ * Returns void on success. Throws LOCK_HELD if another live process holds it.
14
+ * Auto-clears stale locks (>5 min old OR dead PID).
15
+ *
16
+ * Exported so callers can hold the lock across multiple operations (e.g. the
17
+ * full approve flow) before calling setAgent.
18
+ */
19
+ export function acquireLock(exchange) {
20
+ mkdirSync(LOCKS_DIR, { recursive: true, mode: 0o700 });
21
+ const path = lockPath(exchange);
22
+ if (existsSync(path)) {
23
+ const contents = readFileSync(path, "utf-8");
24
+ const [pidStr, tsStr] = contents.trim().split("\n");
25
+ const pid = parseInt(pidStr ?? "", 10);
26
+ const ts = tsStr ? new Date(tsStr).getTime() : 0;
27
+ const age = Date.now() - ts;
28
+ const isStaleByTime = isNaN(ts) || age > LOCK_STALE_MS;
29
+ const isDeadPid = !isPidAlive(pid);
30
+ if (!isStaleByTime && !isDeadPid) {
31
+ throw new PerpError("LOCK_HELD", `Agent approve lock held by PID ${pid} (started ${tsStr ?? "unknown"})`, {
32
+ remediation: `Wait 5s and retry, or remove ~/.perp/locks/agent-approve-${exchange}.lock if stale`,
33
+ });
34
+ }
35
+ // Stale lock — clear it
36
+ unlinkSync(path);
37
+ }
38
+ writeFileSync(path, `${process.pid}\n${new Date().toISOString()}`, { mode: 0o600 });
39
+ }
40
+ export function releaseLock(exchange) {
41
+ const path = lockPath(exchange);
42
+ try {
43
+ if (existsSync(path)) {
44
+ unlinkSync(path);
45
+ }
46
+ }
47
+ catch {
48
+ // Best effort — ignore failures on lock release
49
+ }
50
+ }
51
+ /**
52
+ * Returns true if the current process already holds the lock for this exchange.
53
+ * Used by setAgent to implement re-entrant locking.
54
+ */
55
+ function callerHoldsLock(exchange) {
56
+ const path = lockPath(exchange);
57
+ if (!existsSync(path))
58
+ return false;
59
+ try {
60
+ const contents = readFileSync(path, "utf-8");
61
+ const [pidStr] = contents.trim().split("\n");
62
+ return parseInt(pidStr ?? "", 10) === process.pid;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ /** Check whether a PID is alive via signal 0.
69
+ *
70
+ * `process.kill(pid, 0)` throws:
71
+ * - ESRCH → no such process (dead)
72
+ * - EPERM → process exists but we lack permission (alive — e.g. PID 1 on macOS)
73
+ * Any other error is treated conservatively as "alive" to avoid clearing valid locks.
74
+ */
75
+ function isPidAlive(pid) {
76
+ if (isNaN(pid) || pid <= 0)
77
+ return false;
78
+ try {
79
+ process.kill(pid, 0);
80
+ return true;
81
+ }
82
+ catch (e) {
83
+ // ESRCH = no such process → dead
84
+ // EPERM = no permission to signal → process IS alive
85
+ const code = e.code;
86
+ return code !== "ESRCH";
87
+ }
88
+ }
89
+ /**
90
+ * Returns the specific named agent, or the first non-partial agent when
91
+ * agentName is omitted.
92
+ */
93
+ export function getAgent(exchange, agentName) {
94
+ const settings = loadSettings();
95
+ const byExchange = settings.agents?.[exchange];
96
+ if (!byExchange)
97
+ return null;
98
+ if (agentName !== undefined) {
99
+ return byExchange[agentName] ?? null;
100
+ }
101
+ // Return first non-partial agent
102
+ for (const meta of Object.values(byExchange)) {
103
+ if (meta.status !== "partial") {
104
+ return meta;
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ /**
110
+ * Persist an agent meta entry for the given exchange.
111
+ * Acquires a lockfile before writing to prevent concurrent races (AC-14).
112
+ *
113
+ * Re-entrant: if the calling process already holds the lock (e.g. the approve
114
+ * flow acquired it at the start), this function writes directly without a
115
+ * double-acquire or release.
116
+ */
117
+ export function setAgent(exchange, meta) {
118
+ const alreadyLocked = callerHoldsLock(exchange);
119
+ if (!alreadyLocked) {
120
+ acquireLock(exchange);
121
+ }
122
+ try {
123
+ const settings = loadSettings();
124
+ if (!settings.agents) {
125
+ settings.agents = {};
126
+ }
127
+ const key = exchange;
128
+ if (!settings.agents[key]) {
129
+ settings.agents[exchange] = {};
130
+ }
131
+ settings.agents[exchange][meta.agentName] = meta;
132
+ saveSettings(settings);
133
+ }
134
+ finally {
135
+ if (!alreadyLocked) {
136
+ releaseLock(exchange);
137
+ }
138
+ }
139
+ }
140
+ /**
141
+ * Delete an agent entry. Returns true if it existed and was removed, false if
142
+ * it was already absent (idempotent per AC-4).
143
+ */
144
+ export function deleteAgent(exchange, agentName) {
145
+ const settings = loadSettings();
146
+ const byExchange = settings.agents?.[exchange];
147
+ if (!byExchange || !(agentName in byExchange)) {
148
+ return false;
149
+ }
150
+ delete byExchange[agentName];
151
+ saveSettings(settings);
152
+ return true;
153
+ }
154
+ /**
155
+ * List all registered agents, optionally filtered to a single exchange.
156
+ */
157
+ export function listAgents(exchange) {
158
+ const settings = loadSettings();
159
+ if (!settings.agents)
160
+ return [];
161
+ const result = [];
162
+ for (const [ex, agents] of Object.entries(settings.agents)) {
163
+ if (exchange !== undefined && ex !== exchange)
164
+ continue;
165
+ if (agents && typeof agents === "object") {
166
+ for (const meta of Object.values(agents)) {
167
+ result.push({ exchange: ex, meta });
168
+ }
169
+ }
170
+ }
171
+ return result;
172
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Internal HTTP helpers for the public DEX fetchers.
3
+ *
4
+ * SSOT rule #2 boilerplate consolidation: every public fetcher needed the
5
+ * same 5-line "throw on non-2xx with body excerpt" guard. Centralized here
6
+ * so each call site is one assertOk(...) line.
7
+ */
8
+ /**
9
+ * Throw if the Response is not OK, with HTTP status + (truncated) body
10
+ * excerpt for diagnostics.
11
+ *
12
+ * Use BEFORE `res.json()` so a 5xx JSON error body cannot fulfill as if it
13
+ * were valid data.
14
+ */
15
+ export declare function assertOk(res: Response, label: string): Promise<void>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Internal HTTP helpers for the public DEX fetchers.
3
+ *
4
+ * SSOT rule #2 boilerplate consolidation: every public fetcher needed the
5
+ * same 5-line "throw on non-2xx with body excerpt" guard. Centralized here
6
+ * so each call site is one assertOk(...) line.
7
+ */
8
+ /**
9
+ * Throw if the Response is not OK, with HTTP status + (truncated) body
10
+ * excerpt for diagnostics.
11
+ *
12
+ * Use BEFORE `res.json()` so a 5xx JSON error body cannot fulfill as if it
13
+ * were valid data.
14
+ */
15
+ export async function assertOk(res, label) {
16
+ if (res.ok)
17
+ return;
18
+ let body = "";
19
+ try {
20
+ body = await res.text();
21
+ }
22
+ catch { /* body read failed; surface status alone */ }
23
+ throw new Error(`${label} returned HTTP ${res.status}${body ? `: ${body.slice(0, 200)}` : ""}`);
24
+ }
@@ -1,36 +1,44 @@
1
1
  import { HYPERLIQUID_API_URL } from "./urls.js";
2
+ import { assertOk } from "./_http.js";
2
3
  import { withCache, TTL_MARKET } from "../../cache.js";
3
4
  // ── Internal ──
4
- function hlPost(type) {
5
- return fetch(HYPERLIQUID_API_URL, {
5
+ async function hlPost(type) {
6
+ // SSOT rule #2: a non-2xx response is a real failure, not "no rows" — see assertOk.
7
+ const res = await fetch(HYPERLIQUID_API_URL, {
6
8
  method: "POST",
7
9
  headers: { "Content-Type": "application/json" },
8
10
  body: JSON.stringify({ type }),
9
- }).then(r => r.json());
11
+ });
12
+ await assertOk(res, `Hyperliquid info ${type}`);
13
+ return res.json();
10
14
  }
11
15
  // ── Fetchers ──
12
16
  export function fetchHyperliquidMeta() {
17
+ // SSOT rule #2: error must propagate. Row-level skipping handles missing
18
+ // markPx / funding so a 0 doesn't reach downstream comparisons.
13
19
  return withCache("pub:hl:metaAndAssetCtxs", TTL_MARKET, async () => {
14
- try {
15
- const json = await hlPost("metaAndAssetCtxs");
16
- const universe = (json[0] ?? {}).universe ?? [];
17
- const ctxs = (json[1] ?? []);
18
- return universe.map((asset, i) => {
19
- const ctx = (ctxs[i] ?? {});
20
- return {
21
- symbol: String(asset.name ?? ""),
22
- funding: Number(ctx.funding ?? 0),
23
- markPx: Number(ctx.markPx ?? 0),
24
- };
25
- });
26
- }
27
- catch {
28
- return [];
29
- }
20
+ const json = await hlPost("metaAndAssetCtxs");
21
+ const universe = (json[0] ?? {}).universe ?? [];
22
+ const ctxs = (json[1] ?? []);
23
+ const out = [];
24
+ universe.forEach((asset, i) => {
25
+ const ctx = (ctxs[i] ?? {});
26
+ const symbol = String(asset.name ?? "");
27
+ const funding = Number(ctx.funding);
28
+ const markPx = Number(ctx.markPx);
29
+ if (!symbol || !Number.isFinite(funding) || !Number.isFinite(markPx) || markPx <= 0)
30
+ return;
31
+ out.push({ symbol, funding, markPx });
32
+ });
33
+ return out;
30
34
  });
31
35
  }
32
36
  export function fetchHyperliquidMetaRaw() {
33
- return withCache("pub:hl:metaAndAssetCtxs:raw", TTL_MARKET, () => hlPost("metaAndAssetCtxs").catch(() => null));
37
+ // SSOT rule #2: caller is responsible for error handling. Removed silent
38
+ // `.catch(() => null)` so a network failure surfaces as a rejected Promise.
39
+ // Callers should wrap with Promise.allSettled to keep multi-DEX comparisons
40
+ // running when one DEX is down.
41
+ return withCache("pub:hl:metaAndAssetCtxs:raw", TTL_MARKET, () => hlPost("metaAndAssetCtxs"));
34
42
  }
35
43
  export function parseHyperliquidMetaRaw(raw) {
36
44
  const rates = new Map();
@@ -45,15 +53,19 @@ export function parseHyperliquidMetaRaw(raw) {
45
53
  const sym = String(a.name ?? "");
46
54
  if (!sym)
47
55
  return;
48
- rates.set(sym, Number(ctx.funding ?? 0));
49
- const mp = Number(ctx.markPx ?? 0);
50
- if (mp > 0)
56
+ // SSOT rule #2: skip rows missing a real funding rate; never publish 0.
57
+ const funding = Number(ctx.funding);
58
+ if (Number.isFinite(funding))
59
+ rates.set(sym, funding);
60
+ const mp = Number(ctx.markPx);
61
+ if (Number.isFinite(mp) && mp > 0)
51
62
  prices.set(sym, mp);
52
63
  });
53
64
  return { rates, prices };
54
65
  }
55
66
  export function fetchHyperliquidAllMidsRaw() {
56
- return withCache("pub:hl:allMids:raw", TTL_MARKET, () => hlPost("allMids").catch(() => null));
67
+ // SSOT rule #2: error must propagate; see fetchHyperliquidMetaRaw.
68
+ return withCache("pub:hl:allMids:raw", TTL_MARKET, () => hlPost("allMids"));
57
69
  }
58
70
  // ── Health check ──
59
71
  export async function pingHyperliquid() {
@@ -7,7 +7,13 @@ export interface LighterFundingEntry {
7
7
  marketId: number;
8
8
  symbol: string;
9
9
  rate: number;
10
- markPrice: number;
10
+ /**
11
+ * Lighter's funding-rates endpoint sends mark_price intermittently —
12
+ * not every row carries it. Consumers must resolve the actual mark price
13
+ * by falling back to orderBookDetails.lastTradePrice when this is null.
14
+ * SSOT rule #2 lives at the CONSUMER (resolve or skip), not here.
15
+ */
16
+ markPrice: number | null;
11
17
  }
12
18
  export declare function fetchLighterOrderBookDetails(): Promise<LighterMarketDetail[]>;
13
19
  export declare function fetchLighterOrderBookDetailsRaw(): Promise<unknown>;
@@ -1,52 +1,85 @@
1
1
  import { LIGHTER_API_URL } from "./urls.js";
2
+ import { assertOk } from "./_http.js";
2
3
  import { withCache, TTL_MARKET } from "../../cache.js";
3
4
  // ── Fetchers ──
4
5
  export function fetchLighterOrderBookDetails() {
6
+ // SSOT rule #2: error must propagate; callers cannot distinguish "API
7
+ // returned no markets" from "API down" otherwise. Row-level skipping is
8
+ // applied for missing/invalid last_trade_price so we never publish a
9
+ // 0 price downstream.
5
10
  return withCache("pub:lt:orderBookDetails", TTL_MARKET, async () => {
6
- try {
7
- const res = await fetch(`${LIGHTER_API_URL}/api/v1/orderBookDetails`);
8
- const json = await res.json();
9
- const details = (json.order_book_details ?? []);
10
- return details.map(m => ({
11
- marketId: Number(m.market_id),
12
- symbol: String(m.symbol ?? ""),
13
- lastTradePrice: Number(m.last_trade_price ?? 0),
14
- }));
15
- }
16
- catch {
17
- return [];
11
+ const res = await fetch(`${LIGHTER_API_URL}/api/v1/orderBookDetails`);
12
+ await assertOk(res, "Lighter orderBookDetails");
13
+ const json = await res.json();
14
+ const details = (json.order_book_details ?? []);
15
+ const out = [];
16
+ for (const m of details) {
17
+ const marketId = Number(m.market_id);
18
+ const symbol = String(m.symbol ?? "");
19
+ const lastTradePrice = Number(m.last_trade_price);
20
+ if (!Number.isFinite(marketId) || !symbol || !Number.isFinite(lastTradePrice) || lastTradePrice <= 0) {
21
+ continue;
22
+ }
23
+ out.push({ marketId, symbol, lastTradePrice });
18
24
  }
25
+ return out;
19
26
  });
20
27
  }
21
28
  export function fetchLighterOrderBookDetailsRaw() {
22
- return withCache("pub:lt:orderBookDetails:raw", TTL_MARKET, () => fetch(`${LIGHTER_API_URL}/api/v1/orderBookDetails`).then(r => r.json()).catch(() => null));
29
+ // SSOT rule #2: caller (arb / arb-auto) is responsible for error handling.
30
+ // Removed silent `.catch(() => null)` so a network failure surfaces as a
31
+ // rejected Promise instead of being indistinguishable from "API returned no
32
+ // markets". Non-2xx responses are also rejected so a 5xx JSON body cannot
33
+ // fulfill as if it were valid data.
34
+ return withCache("pub:lt:orderBookDetails:raw", TTL_MARKET, async () => {
35
+ const res = await fetch(`${LIGHTER_API_URL}/api/v1/orderBookDetails`);
36
+ await assertOk(res, "Lighter orderBookDetails");
37
+ return res.json();
38
+ });
23
39
  }
24
40
  export function fetchLighterFundingRates() {
41
+ // SSOT rule #2: error must propagate.
42
+ // Row-level: rate is required (it's the field the endpoint exists for); a
43
+ // missing rate means the row is malformed and we skip it. mark_price is
44
+ // documented as intermittent on Lighter's side — when missing, surface as
45
+ // null so the consumer can apply the orderBookDetails fallback (a
46
+ // documented price-source preference, NOT an error fallback).
25
47
  return withCache("pub:lt:fundingRates", TTL_MARKET, async () => {
26
- try {
27
- const res = await fetch(`${LIGHTER_API_URL}/api/v1/funding-rates`);
28
- const json = await res.json();
29
- const list = (json.funding_rates ?? []);
30
- const entries = [];
31
- for (const fr of list) {
32
- if (String(fr.exchange ?? "").toLowerCase() !== "lighter")
33
- continue;
34
- entries.push({
35
- marketId: Number(fr.market_id),
36
- symbol: String(fr.symbol ?? ""),
37
- rate: Number(fr.rate ?? fr.funding_rate ?? 0),
38
- markPrice: Number(fr.mark_price ?? 0),
39
- });
48
+ const res = await fetch(`${LIGHTER_API_URL}/api/v1/funding-rates`);
49
+ await assertOk(res, "Lighter funding-rates");
50
+ const json = await res.json();
51
+ const list = (json.funding_rates ?? []);
52
+ const entries = [];
53
+ for (const fr of list) {
54
+ if (String(fr.exchange ?? "").toLowerCase() !== "lighter")
55
+ continue;
56
+ const marketId = Number(fr.market_id);
57
+ // symbol is intentionally allowed to be empty — Lighter's funding-rates
58
+ // payload often omits symbol per row, and the consumer resolves it from
59
+ // orderBookDetails by marketId. Required: marketId + valid rate.
60
+ const symbol = String(fr.symbol ?? "");
61
+ const rateRaw = fr.rate ?? fr.funding_rate;
62
+ const rate = Number(rateRaw);
63
+ if (!Number.isFinite(marketId) || rateRaw === undefined || !Number.isFinite(rate)) {
64
+ continue;
40
65
  }
41
- return entries;
42
- }
43
- catch {
44
- return [];
66
+ const markPriceRaw = fr.mark_price;
67
+ const markPriceNumber = Number(markPriceRaw);
68
+ const markPrice = markPriceRaw !== undefined && Number.isFinite(markPriceNumber) && markPriceNumber > 0
69
+ ? markPriceNumber
70
+ : null;
71
+ entries.push({ marketId, symbol, rate, markPrice });
45
72
  }
73
+ return entries;
46
74
  });
47
75
  }
48
76
  export function fetchLighterFundingRatesRaw() {
49
- return withCache("pub:lt:fundingRates:raw", TTL_MARKET, () => fetch(`${LIGHTER_API_URL}/api/v1/funding-rates`).then(r => r.json()).catch(() => null));
77
+ // SSOT rule #2: error must propagate; see fetchLighterOrderBookDetailsRaw.
78
+ return withCache("pub:lt:fundingRates:raw", TTL_MARKET, async () => {
79
+ const res = await fetch(`${LIGHTER_API_URL}/api/v1/funding-rates`);
80
+ await assertOk(res, "Lighter funding-rates");
81
+ return res.json();
82
+ });
50
83
  }
51
84
  export function parseLighterRaw(detailsRaw, fundingRaw) {
52
85
  const rates = new Map();
@@ -57,9 +90,13 @@ export function parseLighterRaw(detailsRaw, fundingRaw) {
57
90
  const details = (detailsRaw.order_book_details ?? []);
58
91
  for (const m of details) {
59
92
  const mid = Number(m.market_id);
93
+ if (!Number.isFinite(mid))
94
+ continue;
60
95
  idToSym.set(mid, String(m.symbol ?? ""));
61
- const p = Number(m.last_trade_price ?? 0);
62
- if (p > 0)
96
+ const p = Number(m.last_trade_price);
97
+ // SSOT rule #2: only register prices that are real positive numbers;
98
+ // never silently default missing last_trade_price to 0.
99
+ if (Number.isFinite(p) && p > 0)
63
100
  idToPrice.set(mid, p);
64
101
  }
65
102
  }
@@ -72,9 +109,20 @@ export function parseLighterRaw(detailsRaw, fundingRaw) {
72
109
  const sym = String(fr.symbol ?? "") || idToSym.get(Number(fr.market_id)) || "";
73
110
  if (!sym || rates.has(sym))
74
111
  continue;
75
- rates.set(sym, Number(fr.rate ?? fr.funding_rate ?? 0));
76
- const mp = Number(fr.mark_price ?? 0) || idToPrice.get(Number(fr.market_id)) || 0;
77
- if (mp > 0)
112
+ const rateRaw = fr.rate ?? fr.funding_rate;
113
+ const rate = Number(rateRaw);
114
+ // SSOT rule #2: skip rows whose funding rate is missing — never publish 0.
115
+ if (rateRaw === undefined || !Number.isFinite(rate))
116
+ continue;
117
+ rates.set(sym, rate);
118
+ // Prefer fr.mark_price; fall back to the orderBookDetails last-trade
119
+ // price as a documented price-source preference (NOT an error fallback).
120
+ const directMark = Number(fr.mark_price);
121
+ const fallbackMark = idToPrice.get(Number(fr.market_id));
122
+ const mp = Number.isFinite(directMark) && directMark > 0
123
+ ? directMark
124
+ : (fallbackMark !== undefined && fallbackMark > 0 ? fallbackMark : undefined);
125
+ if (mp !== undefined)
78
126
  prices.set(sym, mp);
79
127
  }
80
128
  }
@@ -1,28 +1,51 @@
1
1
  import { PACIFICA_API_URL } from "./urls.js";
2
+ import { assertOk } from "./_http.js";
2
3
  import { withCache, TTL_MARKET } from "../../cache.js";
3
4
  // ── Fetchers ──
4
5
  export function fetchPacificaPrices() {
6
+ // SSOT rule #2: error must propagate. Row-level skipping is applied for
7
+ // missing/invalid mark or funding so a 0 doesn't reach downstream paths.
5
8
  return withCache("pub:pac:prices", TTL_MARKET, async () => {
6
- try {
7
- const res = await fetch(PACIFICA_API_URL);
8
- const json = await res.json();
9
- const data = json.data ?? json;
10
- if (!Array.isArray(data))
11
- return [];
12
- return data.map((p) => ({
13
- symbol: String(p.symbol ?? ""),
14
- funding: Number(p.next_funding ?? p.funding ?? 0),
15
- mark: Number(p.mark ?? 0),
16
- nextFunding: p.next_funding ? Number(p.next_funding) : undefined,
17
- }));
18
- }
19
- catch {
9
+ const res = await fetch(PACIFICA_API_URL);
10
+ await assertOk(res, "Pacifica prices");
11
+ const json = await res.json();
12
+ const data = json.data ?? json;
13
+ if (!Array.isArray(data))
20
14
  return [];
15
+ const out = [];
16
+ for (const p of data) {
17
+ const symbol = String(p.symbol ?? "");
18
+ const fundingRaw = p.next_funding ?? p.funding;
19
+ const funding = Number(fundingRaw);
20
+ const mark = Number(p.mark);
21
+ if (!symbol || fundingRaw === undefined || !Number.isFinite(funding) || !Number.isFinite(mark) || mark <= 0) {
22
+ continue;
23
+ }
24
+ const nextFundingRaw = p.next_funding;
25
+ const nextFundingNumber = Number(nextFundingRaw);
26
+ out.push({
27
+ symbol,
28
+ funding,
29
+ mark,
30
+ nextFunding: nextFundingRaw !== undefined && Number.isFinite(nextFundingNumber)
31
+ ? nextFundingNumber
32
+ : undefined,
33
+ });
21
34
  }
35
+ return out;
22
36
  });
23
37
  }
24
38
  export function fetchPacificaPricesRaw() {
25
- return withCache("pub:pac:prices:raw", TTL_MARKET, () => fetch(PACIFICA_API_URL).then(r => r.json()).catch(() => null));
39
+ // SSOT rule #2: caller is responsible for error handling. Removed silent
40
+ // `.catch(() => null)` so a network failure surfaces as a rejected Promise.
41
+ // Callers should wrap with Promise.allSettled to keep multi-DEX comparisons
42
+ // running when one DEX is down. Non-2xx responses are also rejected so a
43
+ // 5xx JSON body cannot fulfill as if it were valid data.
44
+ return withCache("pub:pac:prices:raw", TTL_MARKET, async () => {
45
+ const res = await fetch(PACIFICA_API_URL);
46
+ await assertOk(res, "Pacifica prices");
47
+ return res.json();
48
+ });
26
49
  }
27
50
  export function parsePacificaRaw(raw) {
28
51
  const rates = new Map();
@@ -34,9 +57,15 @@ export function parsePacificaRaw(raw) {
34
57
  const sym = String(p.symbol ?? "");
35
58
  if (!sym)
36
59
  continue;
37
- rates.set(sym, Number(p.next_funding ?? p.funding ?? 0));
38
- const mark = Number(p.mark ?? p.price ?? 0);
39
- if (mark > 0)
60
+ // SSOT rule #2: skip rows missing real funding/mark; never publish 0.
61
+ const fundingRaw = p.next_funding ?? p.funding;
62
+ const fundingNumber = Number(fundingRaw);
63
+ if (fundingRaw !== undefined && Number.isFinite(fundingNumber)) {
64
+ rates.set(sym, fundingNumber);
65
+ }
66
+ const markCandidates = [Number(p.mark), Number(p.price)];
67
+ const mark = markCandidates.find(v => Number.isFinite(v) && v > 0);
68
+ if (mark !== undefined)
40
69
  prices.set(sym, mark);
41
70
  }
42
71
  return { rates, prices };