@elizaos/plugin-wallet 2.0.0-beta.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.
- package/LICENSE +21 -0
- package/README.md +64 -0
- package/auto-enable.ts +76 -0
- package/dist/LpManagementService-BWrQ5-cO.mjs +353 -0
- package/dist/MockLpService-D_Apn4Fd.mjs +99 -0
- package/dist/aerodrome-CfnESC32.mjs +890 -0
- package/dist/chunk-hT5z_Zn9.mjs +35 -0
- package/dist/index.d.mts +34727 -0
- package/dist/index.mjs +21590 -0
- package/dist/lib/server-wallet-trade.d.mts +34 -0
- package/dist/lib/server-wallet-trade.mjs +306 -0
- package/dist/meteora-BPX39hZo.mjs +22640 -0
- package/dist/orca-Bybp1HXO.mjs +249 -0
- package/dist/pancakeswp-CkEXlXti.mjs +604 -0
- package/dist/plugin-ZO_MTyd0.mjs +529 -0
- package/dist/raydium-rfaM9yEf.mjs +539 -0
- package/dist/sdk/index.d.mts +32492 -0
- package/dist/sdk/index.mjs +6415 -0
- package/dist/types-D5252NZk.mjs +487 -0
- package/dist/uniswap-CReXgXVN.mjs +573 -0
- package/dist/wallet-action.d.mts +6 -0
- package/dist/wallet-action.mjs +820 -0
- package/package.json +152 -0
- package/src/actions/failure-codes.ts +79 -0
- package/src/actions/index.ts +1 -0
- package/src/analytics/birdeye/actions/wallet-search-address.ts +9 -0
- package/src/analytics/birdeye/birdeye-task.ts +175 -0
- package/src/analytics/birdeye/birdeye.ts +813 -0
- package/src/analytics/birdeye/constants.ts +74 -0
- package/src/analytics/birdeye/providers/agent-portfolio-provider.ts +18 -0
- package/src/analytics/birdeye/providers/market.ts +227 -0
- package/src/analytics/birdeye/providers/portfolio-factory.test.ts +138 -0
- package/src/analytics/birdeye/providers/portfolio-factory.ts +252 -0
- package/src/analytics/birdeye/providers/trending.ts +365 -0
- package/src/analytics/birdeye/providers/wallet.ts +14 -0
- package/src/analytics/birdeye/search-category.test.ts +207 -0
- package/src/analytics/birdeye/search-category.ts +506 -0
- package/src/analytics/birdeye/service.ts +992 -0
- package/src/analytics/birdeye/tasks/birdeye.ts +232 -0
- package/src/analytics/birdeye/types/api/common.ts +305 -0
- package/src/analytics/birdeye/types/api/defi.ts +220 -0
- package/src/analytics/birdeye/types/api/pair.ts +200 -0
- package/src/analytics/birdeye/types/api/search.ts +86 -0
- package/src/analytics/birdeye/types/api/token.ts +635 -0
- package/src/analytics/birdeye/types/api/trader.ts +76 -0
- package/src/analytics/birdeye/types/api/wallet.ts +181 -0
- package/src/analytics/birdeye/types/shared.ts +106 -0
- package/src/analytics/birdeye/utils.ts +700 -0
- package/src/analytics/dexscreener/errors.ts +28 -0
- package/src/analytics/dexscreener/index.ts +3 -0
- package/src/analytics/dexscreener/search-category.test.ts +49 -0
- package/src/analytics/dexscreener/search-category.ts +42 -0
- package/src/analytics/dexscreener/service.ts +595 -0
- package/src/analytics/dexscreener/types.ts +128 -0
- package/src/analytics/lpinfo/index.d.ts +7 -0
- package/src/analytics/lpinfo/index.ts +52 -0
- package/src/analytics/lpinfo/kamino/README.md +102 -0
- package/src/analytics/lpinfo/kamino/index.ts +24 -0
- package/src/analytics/lpinfo/kamino/providers/kaminoLiquidityProvider.ts +422 -0
- package/src/analytics/lpinfo/kamino/providers/kaminoPoolProvider.ts +365 -0
- package/src/analytics/lpinfo/kamino/providers/kaminoProvider.ts +496 -0
- package/src/analytics/lpinfo/kamino/services/kaminoLiquidityService.ts +1123 -0
- package/src/analytics/lpinfo/kamino/services/kaminoService.ts +758 -0
- package/src/analytics/lpinfo/steer/README.md +169 -0
- package/src/analytics/lpinfo/steer/index.ts +23 -0
- package/src/analytics/lpinfo/steer/providers/steerLiquidityProvider.ts +544 -0
- package/src/analytics/lpinfo/steer/services/steerLiquidityService.ts +1690 -0
- package/src/analytics/lpinfo/steer/steer-display-types.ts +99 -0
- package/src/analytics/news/index.ts +52 -0
- package/src/analytics/news/interfaces/types.ts +222 -0
- package/src/analytics/news/providers/defiNewsProvider.ts +734 -0
- package/src/analytics/news/services/newsDataService.ts +332 -0
- package/src/analytics/news/utils/formatters.ts +151 -0
- package/src/analytics/token-info/action.ts +240 -0
- package/src/analytics/token-info/index.ts +3 -0
- package/src/analytics/token-info/params.ts +215 -0
- package/src/analytics/token-info/providers.ts +681 -0
- package/src/analytics/token-info/service.ts +168 -0
- package/src/analytics/token-info/types.ts +74 -0
- package/src/audit/audit-log.ts +45 -0
- package/src/browser-shim/build-shim.ts +123 -0
- package/src/browser-shim/index.ts +5 -0
- package/src/browser-shim/shim.template.js +563 -0
- package/src/chains/evm/.github/workflows/npm-deploy.yml +112 -0
- package/src/chains/evm/LICENSE +21 -0
- package/src/chains/evm/README.md +106 -0
- package/src/chains/evm/actions/helpers.ts +147 -0
- package/src/chains/evm/actions/swap.ts +839 -0
- package/src/chains/evm/actions/transfer.ts +254 -0
- package/src/chains/evm/biome.json +61 -0
- package/src/chains/evm/bridge-router.ts +660 -0
- package/src/chains/evm/build.ts +89 -0
- package/src/chains/evm/chain-handler.ts +416 -0
- package/src/chains/evm/constants.ts +23 -0
- package/src/chains/evm/contracts/artifacts/OZGovernor.json +1707 -0
- package/src/chains/evm/contracts/artifacts/TimelockController.json +1007 -0
- package/src/chains/evm/contracts/artifacts/VoteToken.json +895 -0
- package/src/chains/evm/dex/aerodrome/index.ts +34 -0
- package/src/chains/evm/dex/aerodrome/services/AerodromeLpService.ts +558 -0
- package/src/chains/evm/dex/aerodrome/types.ts +318 -0
- package/src/chains/evm/dex/pancakeswp/index.ts +35 -0
- package/src/chains/evm/dex/pancakeswp/services/PancakeSwapV3LpService.ts +743 -0
- package/src/chains/evm/dex/pancakeswp/types.ts +65 -0
- package/src/chains/evm/dex/uniswap/index.ts +35 -0
- package/src/chains/evm/dex/uniswap/services/UniswapV3LpService.ts +759 -0
- package/src/chains/evm/dex/uniswap/types.ts +390 -0
- package/src/chains/evm/generated/specs/spec-helpers.ts +73 -0
- package/src/chains/evm/generated/specs/specs.ts +151 -0
- package/src/chains/evm/gov-router.ts +250 -0
- package/src/chains/evm/index.browser.ts +16 -0
- package/src/chains/evm/index.ts +31 -0
- package/src/chains/evm/prompts.ts +193 -0
- package/src/chains/evm/providers/get-balance.ts +123 -0
- package/src/chains/evm/providers/wallet.ts +715 -0
- package/src/chains/evm/routes/sign.ts +333 -0
- package/src/chains/evm/rpc-providers.ts +410 -0
- package/src/chains/evm/service.ts +140 -0
- package/src/chains/evm/templates/index.ts +10 -0
- package/src/chains/evm/types/index.ts +432 -0
- package/src/chains/evm/vitest.config.ts +18 -0
- package/src/chains/registry.ts +668 -0
- package/src/chains/solana/README.md +367 -0
- package/src/chains/wallet-action.ts +533 -0
- package/src/chains/wallet-router.test.ts +296 -0
- package/src/contracts.ts +65 -0
- package/src/core-augmentation.ts +10 -0
- package/src/index.ts +71 -0
- package/src/lib/server-wallet-trade.ts +192 -0
- package/src/lib/wallet-export-guard.ts +330 -0
- package/src/lp/actions/liquidity.ts +827 -0
- package/src/lp/e2e/real-token-tests.ts +428 -0
- package/src/lp/e2e/scenarios.ts +470 -0
- package/src/lp/e2e/test-utils.ts +145 -0
- package/src/lp/lp-manager-entry.ts +303 -0
- package/src/lp/services/ConcentratedLiquidityService.ts +120 -0
- package/src/lp/services/DexInteractionService.ts +226 -0
- package/src/lp/services/LpManagementService.test.ts +148 -0
- package/src/lp/services/LpManagementService.ts +632 -0
- package/src/lp/services/UserLpProfileService.ts +163 -0
- package/src/lp/services/VaultService.ts +153 -0
- package/src/lp/services/YieldOptimizationService.ts +344 -0
- package/src/lp/services/__tests__/MockLpService.ts +146 -0
- package/src/lp/tasks/LpAutoRebalanceTask.ts +117 -0
- package/src/lp/tasks/__tests__/LpAutoRebalanceTask.test.ts +370 -0
- package/src/lp/types.ts +582 -0
- package/src/lp/utils/solanaClient.ts +143 -0
- package/src/plugin.ts +125 -0
- package/src/policy/policy.ts +19 -0
- package/src/providers/canonical-provider.ts +27 -0
- package/src/providers/unified-wallet-provider.ts +79 -0
- package/src/register-routes.ts +11 -0
- package/src/routes/plugin.ts +47 -0
- package/src/routes/wallet-market-overview-route.ts +869 -0
- package/src/sdk/abi.ts +258 -0
- package/src/sdk/bridge/abis.ts +126 -0
- package/src/sdk/bridge/client.ts +518 -0
- package/src/sdk/bridge/index.ts +56 -0
- package/src/sdk/bridge/solana.ts +604 -0
- package/src/sdk/bridge/types.ts +202 -0
- package/src/sdk/convenience.ts +347 -0
- package/src/sdk/escrow/MutualStakeEscrow.ts +480 -0
- package/src/sdk/escrow/types.ts +64 -0
- package/src/sdk/escrow/verifiers.ts +73 -0
- package/src/sdk/identity/erc8004.ts +692 -0
- package/src/sdk/identity/reputation.ts +449 -0
- package/src/sdk/identity/uaid.ts +497 -0
- package/src/sdk/identity/validation.ts +372 -0
- package/src/sdk/index.ts +763 -0
- package/src/sdk/policy/SpendingPolicy.ts +260 -0
- package/src/sdk/policy/UptoBillingPolicy.ts +320 -0
- package/src/sdk/router/PaymentRouter.ts +215 -0
- package/src/sdk/router/index.ts +8 -0
- package/src/sdk/swap/SwapModule.ts +310 -0
- package/src/sdk/swap/abi.ts +117 -0
- package/src/sdk/swap/index.ts +34 -0
- package/src/sdk/swap/types.ts +135 -0
- package/src/sdk/tokens/decimals.ts +140 -0
- package/src/sdk/tokens/registry.ts +911 -0
- package/src/sdk/tokens/solana.ts +419 -0
- package/src/sdk/tokens/transfers.ts +327 -0
- package/src/sdk/types.ts +158 -0
- package/src/sdk/wallet-core.ts +115 -0
- package/src/sdk/x402/budget.ts +168 -0
- package/src/sdk/x402/chains/abstract/index.ts +280 -0
- package/src/sdk/x402/client.ts +320 -0
- package/src/sdk/x402/index.ts +46 -0
- package/src/sdk/x402/middleware.ts +92 -0
- package/src/sdk/x402/multi-asset.ts +144 -0
- package/src/sdk/x402/types.ts +156 -0
- package/src/services/wallet-backend-service.ts +328 -0
- package/src/types/wallet-router.ts +227 -0
- package/src/utils/intent-trajectory.ts +106 -0
- package/src/wallet/backend.ts +62 -0
- package/src/wallet/errors.ts +49 -0
- package/src/wallet/index.ts +27 -0
- package/src/wallet/local-eoa-backend.ts +201 -0
- package/src/wallet/pending.ts +60 -0
- package/src/wallet/select-backend.ts +47 -0
- package/src/wallet/steward-backend.ts +161 -0
- package/src/wallet-action.ts +1 -0
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
import type http from "node:http";
|
|
2
|
+
import { logger } from "@elizaos/core";
|
|
3
|
+
import { resolveCloudApiBaseUrl } from "@elizaos/plugin-elizacloud";
|
|
4
|
+
import type {
|
|
5
|
+
WalletMarketMover,
|
|
6
|
+
WalletMarketOverviewResponse,
|
|
7
|
+
WalletMarketOverviewSource,
|
|
8
|
+
WalletMarketPrediction,
|
|
9
|
+
WalletMarketPriceSnapshot,
|
|
10
|
+
} from "../contracts.js";
|
|
11
|
+
|
|
12
|
+
const MARKET_OVERVIEW_PATH = "/api/wallet/market-overview";
|
|
13
|
+
const CLOUD_MARKET_OVERVIEW_PREVIEW_PATH = "/market/preview/wallet-overview";
|
|
14
|
+
const MARKET_OVERVIEW_CACHE_TTL_MS = 120_000;
|
|
15
|
+
const MARKET_OVERVIEW_FETCH_TIMEOUT_MS = 8_000;
|
|
16
|
+
const MARKET_OVERVIEW_REFRESH_WINDOW_MS = 60_000;
|
|
17
|
+
const MARKET_OVERVIEW_REFRESH_LIMIT = 24;
|
|
18
|
+
const COINGECKO_MARKET_LIMIT = 80;
|
|
19
|
+
const POLYMARKET_MARKET_LIMIT = 10;
|
|
20
|
+
const CACHE_CONTROL_VALUE = "public, max-age=60, stale-while-revalidate=180";
|
|
21
|
+
const MARKET_PRICE_IDS = ["bitcoin", "ethereum", "solana"] as const;
|
|
22
|
+
const MARKET_PRICE_ID_SET = new Set<string>(MARKET_PRICE_IDS);
|
|
23
|
+
|
|
24
|
+
function isAbortError(error: unknown): boolean {
|
|
25
|
+
return error instanceof DOMException
|
|
26
|
+
? error.name === "AbortError" || error.name === "TimeoutError"
|
|
27
|
+
: error instanceof Error &&
|
|
28
|
+
(error.name === "AbortError" || error.name === "TimeoutError");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createTimeoutError(message: string): Error {
|
|
32
|
+
const timeoutError = new Error(message);
|
|
33
|
+
timeoutError.name = "TimeoutError";
|
|
34
|
+
return timeoutError;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function fetchWithTimeoutGuard(
|
|
38
|
+
input: string | URL,
|
|
39
|
+
init: RequestInit,
|
|
40
|
+
timeoutMs: number,
|
|
41
|
+
): Promise<Response> {
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const upstreamSignal = init.signal;
|
|
44
|
+
let timedOut = false;
|
|
45
|
+
|
|
46
|
+
const onAbort = () => {
|
|
47
|
+
controller.abort();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (upstreamSignal) {
|
|
51
|
+
if (upstreamSignal.aborted) {
|
|
52
|
+
controller.abort();
|
|
53
|
+
} else {
|
|
54
|
+
upstreamSignal.addEventListener("abort", onAbort, { once: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const timeoutHandle = setTimeout(() => {
|
|
59
|
+
timedOut = true;
|
|
60
|
+
controller.abort();
|
|
61
|
+
}, timeoutMs);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
return await fetch(input, {
|
|
65
|
+
...init,
|
|
66
|
+
signal: controller.signal,
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (timedOut && isAbortError(error)) {
|
|
70
|
+
throw createTimeoutError(
|
|
71
|
+
`Upstream request timed out after ${timeoutMs}ms`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
throw error;
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timeoutHandle);
|
|
77
|
+
if (upstreamSignal) {
|
|
78
|
+
upstreamSignal.removeEventListener("abort", onAbort);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const COINGECKO_SOURCE = {
|
|
84
|
+
providerId: "coingecko",
|
|
85
|
+
providerName: "CoinGecko",
|
|
86
|
+
providerUrl: "https://www.coingecko.com/",
|
|
87
|
+
} as const satisfies Pick<
|
|
88
|
+
WalletMarketOverviewSource,
|
|
89
|
+
"providerId" | "providerName" | "providerUrl"
|
|
90
|
+
>;
|
|
91
|
+
const POLYMARKET_SOURCE = {
|
|
92
|
+
providerId: "polymarket",
|
|
93
|
+
providerName: "Polymarket",
|
|
94
|
+
providerUrl: "https://polymarket.com/",
|
|
95
|
+
} as const satisfies Pick<
|
|
96
|
+
WalletMarketOverviewSource,
|
|
97
|
+
"providerId" | "providerName" | "providerUrl"
|
|
98
|
+
>;
|
|
99
|
+
const STABLE_ASSET_IDS = new Set([
|
|
100
|
+
"tether",
|
|
101
|
+
"usd-coin",
|
|
102
|
+
"binance-usd",
|
|
103
|
+
"first-digital-usd",
|
|
104
|
+
"dai",
|
|
105
|
+
"ethena-usde",
|
|
106
|
+
"true-usd",
|
|
107
|
+
"usds",
|
|
108
|
+
]);
|
|
109
|
+
const STABLE_ASSET_SYMBOLS = new Set([
|
|
110
|
+
"usdt",
|
|
111
|
+
"usdc",
|
|
112
|
+
"busd",
|
|
113
|
+
"fdusd",
|
|
114
|
+
"dai",
|
|
115
|
+
"usde",
|
|
116
|
+
"tusd",
|
|
117
|
+
"usds",
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
interface CoinGeckoMarketRecord {
|
|
121
|
+
id: string;
|
|
122
|
+
symbol: string;
|
|
123
|
+
name: string;
|
|
124
|
+
currentPriceUsd: number;
|
|
125
|
+
change24hPct: number;
|
|
126
|
+
marketCapRank: number | null;
|
|
127
|
+
imageUrl: string | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface PolymarketMarketRecord {
|
|
131
|
+
slug: string | null;
|
|
132
|
+
question: string;
|
|
133
|
+
outcomeLabels: string[];
|
|
134
|
+
outcomeProbabilities: number[];
|
|
135
|
+
volume24hUsd: number;
|
|
136
|
+
totalVolumeUsd: number | null;
|
|
137
|
+
endsAt: string | null;
|
|
138
|
+
imageUrl: string | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface CachedWalletMarketOverview {
|
|
142
|
+
response: WalletMarketOverviewResponse;
|
|
143
|
+
expiresAt: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface RateLimitBucket {
|
|
147
|
+
count: number;
|
|
148
|
+
resetAt: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type WalletMarketOverviewFetch = typeof fetchWithTimeoutGuard;
|
|
152
|
+
|
|
153
|
+
let cachedWalletMarketOverview: CachedWalletMarketOverview | null = null;
|
|
154
|
+
let walletMarketOverviewInFlight: Promise<WalletMarketOverviewResponse> | null =
|
|
155
|
+
null;
|
|
156
|
+
const walletMarketRefreshBuckets = new Map<string, RateLimitBucket>();
|
|
157
|
+
let walletMarketOverviewFetch: WalletMarketOverviewFetch =
|
|
158
|
+
fetchWithTimeoutGuard;
|
|
159
|
+
|
|
160
|
+
function scrubStackFields(value: unknown): unknown {
|
|
161
|
+
if (value instanceof Error) {
|
|
162
|
+
return { error: value.message || "Internal error" };
|
|
163
|
+
}
|
|
164
|
+
if (Array.isArray(value)) {
|
|
165
|
+
return value.map(scrubStackFields);
|
|
166
|
+
}
|
|
167
|
+
if (value && typeof value === "object") {
|
|
168
|
+
const out: Record<string, unknown> = {};
|
|
169
|
+
for (const [key, nestedValue] of Object.entries(
|
|
170
|
+
value as Record<string, unknown>,
|
|
171
|
+
)) {
|
|
172
|
+
if (key === "stack" || key === "stackTrace") continue;
|
|
173
|
+
out[key] = scrubStackFields(nestedValue);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sendJson(
|
|
181
|
+
res: http.ServerResponse,
|
|
182
|
+
status: number,
|
|
183
|
+
body: unknown,
|
|
184
|
+
): void {
|
|
185
|
+
if (res.headersSent) return;
|
|
186
|
+
res.statusCode = status;
|
|
187
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
188
|
+
res.end(JSON.stringify(scrubStackFields(body)));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function sendJsonError(
|
|
192
|
+
res: http.ServerResponse,
|
|
193
|
+
status: number,
|
|
194
|
+
message: string,
|
|
195
|
+
): void {
|
|
196
|
+
sendJson(res, status, { error: message });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function marketOverviewErrorMessage(error: unknown): string {
|
|
200
|
+
return error instanceof Error && error.message.trim().length > 0
|
|
201
|
+
? error.message.trim()
|
|
202
|
+
: "Upstream market feed failed";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function buildMarketOverviewSource(
|
|
206
|
+
source: Pick<
|
|
207
|
+
WalletMarketOverviewSource,
|
|
208
|
+
"providerId" | "providerName" | "providerUrl"
|
|
209
|
+
>,
|
|
210
|
+
{
|
|
211
|
+
available,
|
|
212
|
+
stale,
|
|
213
|
+
error,
|
|
214
|
+
}: Pick<WalletMarketOverviewSource, "available" | "stale" | "error">,
|
|
215
|
+
): WalletMarketOverviewSource {
|
|
216
|
+
return {
|
|
217
|
+
...source,
|
|
218
|
+
available,
|
|
219
|
+
stale,
|
|
220
|
+
error,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function markMarketOverviewSourcesStale(
|
|
225
|
+
sources: WalletMarketOverviewResponse["sources"],
|
|
226
|
+
): WalletMarketOverviewResponse["sources"] {
|
|
227
|
+
return {
|
|
228
|
+
prices: {
|
|
229
|
+
...sources.prices,
|
|
230
|
+
stale: true,
|
|
231
|
+
},
|
|
232
|
+
movers: {
|
|
233
|
+
...sources.movers,
|
|
234
|
+
stale: true,
|
|
235
|
+
},
|
|
236
|
+
predictions: {
|
|
237
|
+
...sources.predictions,
|
|
238
|
+
stale: true,
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
244
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
245
|
+
? (value as Record<string, unknown>)
|
|
246
|
+
: null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function numberFromUnknown(value: unknown): number | null {
|
|
250
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
251
|
+
if (typeof value !== "string" || value.trim().length === 0) return null;
|
|
252
|
+
const parsed = Number.parseFloat(value);
|
|
253
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function integerFromUnknown(value: unknown): number | null {
|
|
257
|
+
const parsed = numberFromUnknown(value);
|
|
258
|
+
if (parsed === null) return null;
|
|
259
|
+
return Number.isInteger(parsed) ? parsed : Math.round(parsed);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function stringFromUnknown(value: unknown): string | null {
|
|
263
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function parseStringArray(value: unknown): string[] {
|
|
267
|
+
if (Array.isArray(value)) {
|
|
268
|
+
return value.filter((item): item is string => typeof item === "string");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (typeof value !== "string" || value.trim().length === 0) return [];
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const parsed: unknown = JSON.parse(value);
|
|
275
|
+
return Array.isArray(parsed)
|
|
276
|
+
? parsed.filter((item): item is string => typeof item === "string")
|
|
277
|
+
: [];
|
|
278
|
+
} catch {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function clampProbability(value: number | null): number | null {
|
|
284
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
285
|
+
return Math.min(1, Math.max(0, value));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isStableAsset(market: CoinGeckoMarketRecord): boolean {
|
|
289
|
+
const id = market.id.toLowerCase();
|
|
290
|
+
const symbol = market.symbol.toLowerCase();
|
|
291
|
+
return STABLE_ASSET_IDS.has(id) || STABLE_ASSET_SYMBOLS.has(symbol);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function mapCoinGeckoMarket(input: unknown): CoinGeckoMarketRecord | null {
|
|
295
|
+
const record = asRecord(input);
|
|
296
|
+
if (!record) return null;
|
|
297
|
+
|
|
298
|
+
const id = stringFromUnknown(record.id);
|
|
299
|
+
const symbol = stringFromUnknown(record.symbol);
|
|
300
|
+
const name = stringFromUnknown(record.name);
|
|
301
|
+
const currentPriceUsd = numberFromUnknown(record.current_price);
|
|
302
|
+
const change24hPct = numberFromUnknown(record.price_change_percentage_24h);
|
|
303
|
+
|
|
304
|
+
if (
|
|
305
|
+
!id ||
|
|
306
|
+
!symbol ||
|
|
307
|
+
!name ||
|
|
308
|
+
currentPriceUsd === null ||
|
|
309
|
+
change24hPct === null
|
|
310
|
+
) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
id,
|
|
316
|
+
symbol: symbol.toUpperCase(),
|
|
317
|
+
name,
|
|
318
|
+
currentPriceUsd,
|
|
319
|
+
change24hPct,
|
|
320
|
+
marketCapRank: integerFromUnknown(record.market_cap_rank),
|
|
321
|
+
imageUrl: stringFromUnknown(record.image),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function mapPolymarketMarket(input: unknown): PolymarketMarketRecord | null {
|
|
326
|
+
const record = asRecord(input);
|
|
327
|
+
if (!record) return null;
|
|
328
|
+
|
|
329
|
+
const question = stringFromUnknown(record.question);
|
|
330
|
+
if (!question) return null;
|
|
331
|
+
|
|
332
|
+
const outcomeLabels = parseStringArray(record.outcomes);
|
|
333
|
+
const outcomeProbabilities = parseStringArray(record.outcomePrices)
|
|
334
|
+
.map((value) => clampProbability(numberFromUnknown(value)))
|
|
335
|
+
.filter((value): value is number => value !== null);
|
|
336
|
+
const volume24hUsd = numberFromUnknown(record.volume24hr);
|
|
337
|
+
|
|
338
|
+
if (volume24hUsd === null) return null;
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
slug: stringFromUnknown(record.slug),
|
|
342
|
+
question,
|
|
343
|
+
outcomeLabels,
|
|
344
|
+
outcomeProbabilities,
|
|
345
|
+
volume24hUsd,
|
|
346
|
+
totalVolumeUsd: numberFromUnknown(record.volume),
|
|
347
|
+
endsAt: stringFromUnknown(record.endDate),
|
|
348
|
+
imageUrl: stringFromUnknown(record.image) ?? stringFromUnknown(record.icon),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function highlightedPredictionOutcome(market: PolymarketMarketRecord): {
|
|
353
|
+
label: string;
|
|
354
|
+
probability: number | null;
|
|
355
|
+
} {
|
|
356
|
+
const yesIndex = market.outcomeLabels.findIndex(
|
|
357
|
+
(label) => label.trim().toLowerCase() === "yes",
|
|
358
|
+
);
|
|
359
|
+
if (yesIndex >= 0) {
|
|
360
|
+
return {
|
|
361
|
+
label: market.outcomeLabels[yesIndex] ?? "Yes",
|
|
362
|
+
probability: market.outcomeProbabilities[yesIndex] ?? null,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let highestIndex = -1;
|
|
367
|
+
let highestProbability = -1;
|
|
368
|
+
for (const [index, probability] of market.outcomeProbabilities.entries()) {
|
|
369
|
+
if (probability > highestProbability) {
|
|
370
|
+
highestIndex = index;
|
|
371
|
+
highestProbability = probability;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (highestIndex >= 0) {
|
|
376
|
+
return {
|
|
377
|
+
label: market.outcomeLabels[highestIndex] ?? "Top",
|
|
378
|
+
probability: market.outcomeProbabilities[highestIndex] ?? null,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return { label: "Top", probability: null };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function fetchCoinGeckoMarkets(): Promise<CoinGeckoMarketRecord[]> {
|
|
386
|
+
const url = new URL("https://api.coingecko.com/api/v3/coins/markets");
|
|
387
|
+
url.searchParams.set("vs_currency", "usd");
|
|
388
|
+
url.searchParams.set("order", "market_cap_desc");
|
|
389
|
+
url.searchParams.set("per_page", String(COINGECKO_MARKET_LIMIT));
|
|
390
|
+
url.searchParams.set("page", "1");
|
|
391
|
+
url.searchParams.set("price_change_percentage", "24h");
|
|
392
|
+
|
|
393
|
+
const response = await walletMarketOverviewFetch(
|
|
394
|
+
url,
|
|
395
|
+
{
|
|
396
|
+
method: "GET",
|
|
397
|
+
headers: {
|
|
398
|
+
accept: "application/json",
|
|
399
|
+
"user-agent": "Eliza Wallet Market Feed/1.0",
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
MARKET_OVERVIEW_FETCH_TIMEOUT_MS,
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
if (!response.ok) {
|
|
406
|
+
throw new Error(`CoinGecko responded ${response.status}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const payload: unknown = await response.json();
|
|
410
|
+
if (!Array.isArray(payload)) {
|
|
411
|
+
throw new Error("CoinGecko payload was not an array");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return payload
|
|
415
|
+
.map(mapCoinGeckoMarket)
|
|
416
|
+
.filter((market): market is CoinGeckoMarketRecord => market !== null);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function fetchPolymarketMarkets(): Promise<PolymarketMarketRecord[]> {
|
|
420
|
+
const url = new URL("https://gamma-api.polymarket.com/markets");
|
|
421
|
+
url.searchParams.set("active", "true");
|
|
422
|
+
url.searchParams.set("closed", "false");
|
|
423
|
+
url.searchParams.set("order", "volume24hr");
|
|
424
|
+
url.searchParams.set("ascending", "false");
|
|
425
|
+
url.searchParams.set("limit", String(POLYMARKET_MARKET_LIMIT));
|
|
426
|
+
|
|
427
|
+
const response = await walletMarketOverviewFetch(
|
|
428
|
+
url,
|
|
429
|
+
{
|
|
430
|
+
method: "GET",
|
|
431
|
+
headers: {
|
|
432
|
+
accept: "application/json",
|
|
433
|
+
"user-agent": "Eliza Wallet Market Feed/1.0",
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
MARKET_OVERVIEW_FETCH_TIMEOUT_MS,
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
if (!response.ok) {
|
|
440
|
+
throw new Error(`Polymarket responded ${response.status}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const payload: unknown = await response.json();
|
|
444
|
+
if (!Array.isArray(payload)) {
|
|
445
|
+
throw new Error("Polymarket payload was not an array");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return payload
|
|
449
|
+
.map(mapPolymarketMarket)
|
|
450
|
+
.filter((market): market is PolymarketMarketRecord => market !== null);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function buildPriceSnapshots(
|
|
454
|
+
markets: CoinGeckoMarketRecord[],
|
|
455
|
+
): WalletMarketPriceSnapshot[] {
|
|
456
|
+
const byId = new Map(markets.map((market) => [market.id, market]));
|
|
457
|
+
return MARKET_PRICE_IDS.reduce<WalletMarketPriceSnapshot[]>((items, id) => {
|
|
458
|
+
const market = byId.get(id);
|
|
459
|
+
if (!market) return items;
|
|
460
|
+
items.push({
|
|
461
|
+
id: market.id,
|
|
462
|
+
symbol: market.symbol,
|
|
463
|
+
name: market.name,
|
|
464
|
+
priceUsd: market.currentPriceUsd,
|
|
465
|
+
change24hPct: market.change24hPct,
|
|
466
|
+
imageUrl: market.imageUrl,
|
|
467
|
+
});
|
|
468
|
+
return items;
|
|
469
|
+
}, []);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function buildMovers(markets: CoinGeckoMarketRecord[]): WalletMarketMover[] {
|
|
473
|
+
return markets
|
|
474
|
+
.filter((market) => !MARKET_PRICE_ID_SET.has(market.id))
|
|
475
|
+
.filter((market) => !isStableAsset(market))
|
|
476
|
+
.filter(
|
|
477
|
+
(market) => market.marketCapRank === null || market.marketCapRank <= 200,
|
|
478
|
+
)
|
|
479
|
+
.sort(
|
|
480
|
+
(left, right) =>
|
|
481
|
+
Math.abs(right.change24hPct) - Math.abs(left.change24hPct),
|
|
482
|
+
)
|
|
483
|
+
.slice(0, 6)
|
|
484
|
+
.map((market) => ({
|
|
485
|
+
id: market.id,
|
|
486
|
+
symbol: market.symbol,
|
|
487
|
+
name: market.name,
|
|
488
|
+
priceUsd: market.currentPriceUsd,
|
|
489
|
+
change24hPct: market.change24hPct,
|
|
490
|
+
marketCapRank: market.marketCapRank,
|
|
491
|
+
imageUrl: market.imageUrl,
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function buildPredictions(
|
|
496
|
+
markets: PolymarketMarketRecord[],
|
|
497
|
+
): WalletMarketPrediction[] {
|
|
498
|
+
const seenQuestions = new Set<string>();
|
|
499
|
+
const predictions: WalletMarketPrediction[] = [];
|
|
500
|
+
|
|
501
|
+
for (const market of markets) {
|
|
502
|
+
const normalizedQuestion = market.question.trim().toLowerCase();
|
|
503
|
+
if (seenQuestions.has(normalizedQuestion)) continue;
|
|
504
|
+
seenQuestions.add(normalizedQuestion);
|
|
505
|
+
|
|
506
|
+
const highlightedOutcome = highlightedPredictionOutcome(market);
|
|
507
|
+
predictions.push({
|
|
508
|
+
id: market.slug ?? normalizedQuestion,
|
|
509
|
+
slug: market.slug,
|
|
510
|
+
question: market.question,
|
|
511
|
+
highlightedOutcomeLabel: highlightedOutcome.label,
|
|
512
|
+
highlightedOutcomeProbability: highlightedOutcome.probability,
|
|
513
|
+
volume24hUsd: market.volume24hUsd,
|
|
514
|
+
totalVolumeUsd: market.totalVolumeUsd,
|
|
515
|
+
endsAt: market.endsAt,
|
|
516
|
+
imageUrl: market.imageUrl,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return predictions.slice(0, 6);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function isWalletMarketOverviewSource(
|
|
524
|
+
value: unknown,
|
|
525
|
+
): value is WalletMarketOverviewResponse["sources"][keyof WalletMarketOverviewResponse["sources"]] {
|
|
526
|
+
const record = asRecord(value);
|
|
527
|
+
return (
|
|
528
|
+
record !== null &&
|
|
529
|
+
typeof record.providerId === "string" &&
|
|
530
|
+
typeof record.providerName === "string" &&
|
|
531
|
+
typeof record.providerUrl === "string" &&
|
|
532
|
+
typeof record.available === "boolean" &&
|
|
533
|
+
typeof record.stale === "boolean" &&
|
|
534
|
+
(typeof record.error === "string" || record.error === null)
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function isWalletMarketOverviewResponse(
|
|
539
|
+
value: unknown,
|
|
540
|
+
): value is WalletMarketOverviewResponse {
|
|
541
|
+
const record = asRecord(value);
|
|
542
|
+
const sources = asRecord(record?.sources);
|
|
543
|
+
return (
|
|
544
|
+
record !== null &&
|
|
545
|
+
typeof record.generatedAt === "string" &&
|
|
546
|
+
typeof record.cacheTtlSeconds === "number" &&
|
|
547
|
+
typeof record.stale === "boolean" &&
|
|
548
|
+
sources !== null &&
|
|
549
|
+
isWalletMarketOverviewSource(sources.prices) &&
|
|
550
|
+
isWalletMarketOverviewSource(sources.movers) &&
|
|
551
|
+
isWalletMarketOverviewSource(sources.predictions) &&
|
|
552
|
+
Array.isArray(record.prices) &&
|
|
553
|
+
Array.isArray(record.movers) &&
|
|
554
|
+
Array.isArray(record.predictions)
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function resolveWalletMarketOverviewCloudPreviewUrl(): string {
|
|
559
|
+
return `${resolveCloudApiBaseUrl(process.env.ELIZAOS_CLOUD_BASE_URL)}${CLOUD_MARKET_OVERVIEW_PREVIEW_PATH}`;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function fetchCloudWalletMarketOverview(
|
|
563
|
+
clientAddress: string,
|
|
564
|
+
): Promise<WalletMarketOverviewResponse> {
|
|
565
|
+
const response = await walletMarketOverviewFetch(
|
|
566
|
+
resolveWalletMarketOverviewCloudPreviewUrl(),
|
|
567
|
+
{
|
|
568
|
+
method: "GET",
|
|
569
|
+
headers: {
|
|
570
|
+
accept: "application/json",
|
|
571
|
+
"user-agent": "Eliza Wallet Market Feed/1.0",
|
|
572
|
+
...(clientAddress !== "unknown"
|
|
573
|
+
? { "x-forwarded-for": clientAddress }
|
|
574
|
+
: {}),
|
|
575
|
+
},
|
|
576
|
+
},
|
|
577
|
+
MARKET_OVERVIEW_FETCH_TIMEOUT_MS,
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
if (!response.ok) {
|
|
581
|
+
throw new Error(`Cloud preview responded ${response.status}`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const payload: unknown = await response.json();
|
|
585
|
+
if (!isWalletMarketOverviewResponse(payload)) {
|
|
586
|
+
throw new Error("Cloud preview payload was invalid");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return payload;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function buildWalletMarketOverview(
|
|
593
|
+
clientAddress: string,
|
|
594
|
+
): Promise<WalletMarketOverviewResponse> {
|
|
595
|
+
const [cloudPreviewResult, polymarketResult] = await Promise.allSettled([
|
|
596
|
+
fetchCloudWalletMarketOverview(clientAddress),
|
|
597
|
+
fetchPolymarketMarkets(),
|
|
598
|
+
]);
|
|
599
|
+
const polymarketMarkets =
|
|
600
|
+
polymarketResult.status === "fulfilled" ? polymarketResult.value : [];
|
|
601
|
+
const polymarketError =
|
|
602
|
+
polymarketResult.status === "rejected"
|
|
603
|
+
? marketOverviewErrorMessage(polymarketResult.reason)
|
|
604
|
+
: null;
|
|
605
|
+
|
|
606
|
+
if (cloudPreviewResult.status === "fulfilled") {
|
|
607
|
+
if (polymarketError) {
|
|
608
|
+
logger.warn(
|
|
609
|
+
`[WalletMarketOverviewRoute] Polymarket feed unavailable (${polymarketError})`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
...cloudPreviewResult.value,
|
|
615
|
+
sources: {
|
|
616
|
+
...cloudPreviewResult.value.sources,
|
|
617
|
+
predictions: buildMarketOverviewSource(POLYMARKET_SOURCE, {
|
|
618
|
+
available: polymarketError === null,
|
|
619
|
+
stale: false,
|
|
620
|
+
error: polymarketError,
|
|
621
|
+
}),
|
|
622
|
+
},
|
|
623
|
+
predictions:
|
|
624
|
+
polymarketError === null ? buildPredictions(polymarketMarkets) : [],
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
{
|
|
629
|
+
const error = cloudPreviewResult.reason;
|
|
630
|
+
logger.warn(
|
|
631
|
+
`[WalletMarketOverviewRoute] Cloud preview unavailable (${marketOverviewErrorMessage(error)}); falling back to direct feeds`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const [coinGeckoResult] = await Promise.allSettled([fetchCoinGeckoMarkets()]);
|
|
636
|
+
const coinGeckoMarkets =
|
|
637
|
+
coinGeckoResult.status === "fulfilled" ? coinGeckoResult.value : [];
|
|
638
|
+
const coinGeckoError =
|
|
639
|
+
coinGeckoResult.status === "rejected"
|
|
640
|
+
? marketOverviewErrorMessage(coinGeckoResult.reason)
|
|
641
|
+
: null;
|
|
642
|
+
|
|
643
|
+
if (coinGeckoError) {
|
|
644
|
+
logger.warn(
|
|
645
|
+
`[WalletMarketOverviewRoute] CoinGecko feed unavailable (${coinGeckoError})`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (polymarketError) {
|
|
650
|
+
logger.warn(
|
|
651
|
+
`[WalletMarketOverviewRoute] Polymarket feed unavailable (${polymarketError})`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (coinGeckoError && polymarketError) {
|
|
656
|
+
throw new Error(
|
|
657
|
+
`CoinGecko: ${coinGeckoError}; Polymarket: ${polymarketError}`,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
generatedAt: new Date().toISOString(),
|
|
663
|
+
cacheTtlSeconds: Math.floor(MARKET_OVERVIEW_CACHE_TTL_MS / 1000),
|
|
664
|
+
stale: false,
|
|
665
|
+
sources: {
|
|
666
|
+
prices: buildMarketOverviewSource(COINGECKO_SOURCE, {
|
|
667
|
+
available: coinGeckoError === null,
|
|
668
|
+
stale: false,
|
|
669
|
+
error: coinGeckoError,
|
|
670
|
+
}),
|
|
671
|
+
movers: buildMarketOverviewSource(COINGECKO_SOURCE, {
|
|
672
|
+
available: coinGeckoError === null,
|
|
673
|
+
stale: false,
|
|
674
|
+
error: coinGeckoError,
|
|
675
|
+
}),
|
|
676
|
+
predictions: buildMarketOverviewSource(POLYMARKET_SOURCE, {
|
|
677
|
+
available: polymarketError === null,
|
|
678
|
+
stale: false,
|
|
679
|
+
error: polymarketError,
|
|
680
|
+
}),
|
|
681
|
+
},
|
|
682
|
+
prices: buildPriceSnapshots(coinGeckoMarkets),
|
|
683
|
+
movers: buildMovers(coinGeckoMarkets),
|
|
684
|
+
predictions: buildPredictions(polymarketMarkets),
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function freshCachedWalletMarketOverview(): WalletMarketOverviewResponse | null {
|
|
689
|
+
if (
|
|
690
|
+
!cachedWalletMarketOverview ||
|
|
691
|
+
cachedWalletMarketOverview.expiresAt <= Date.now()
|
|
692
|
+
) {
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return cachedWalletMarketOverview.response;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function staleCachedWalletMarketOverview(): WalletMarketOverviewResponse | null {
|
|
700
|
+
if (!cachedWalletMarketOverview) return null;
|
|
701
|
+
return {
|
|
702
|
+
...cachedWalletMarketOverview.response,
|
|
703
|
+
stale: true,
|
|
704
|
+
sources: markMarketOverviewSourcesStale(
|
|
705
|
+
cachedWalletMarketOverview.response.sources,
|
|
706
|
+
),
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function resolveClientAddress(req: http.IncomingMessage): string {
|
|
711
|
+
const forwardedFor = req.headers["x-forwarded-for"];
|
|
712
|
+
if (typeof forwardedFor === "string" && forwardedFor.trim().length > 0) {
|
|
713
|
+
return forwardedFor.split(",")[0]?.trim() || "unknown";
|
|
714
|
+
}
|
|
715
|
+
if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
|
|
716
|
+
return forwardedFor[0]?.trim() || "unknown";
|
|
717
|
+
}
|
|
718
|
+
return req.socket.remoteAddress ?? "unknown";
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function consumeRefreshSlot(clientAddress: string): {
|
|
722
|
+
allowed: boolean;
|
|
723
|
+
retryAfterSeconds: number;
|
|
724
|
+
} {
|
|
725
|
+
const now = Date.now();
|
|
726
|
+
|
|
727
|
+
for (const [key, bucket] of walletMarketRefreshBuckets) {
|
|
728
|
+
if (bucket.resetAt <= now) {
|
|
729
|
+
walletMarketRefreshBuckets.delete(key);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const bucket = walletMarketRefreshBuckets.get(clientAddress);
|
|
734
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
735
|
+
walletMarketRefreshBuckets.set(clientAddress, {
|
|
736
|
+
count: 1,
|
|
737
|
+
resetAt: now + MARKET_OVERVIEW_REFRESH_WINDOW_MS,
|
|
738
|
+
});
|
|
739
|
+
return {
|
|
740
|
+
allowed: true,
|
|
741
|
+
retryAfterSeconds: Math.ceil(MARKET_OVERVIEW_REFRESH_WINDOW_MS / 1000),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (bucket.count >= MARKET_OVERVIEW_REFRESH_LIMIT) {
|
|
746
|
+
return {
|
|
747
|
+
allowed: false,
|
|
748
|
+
retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
bucket.count += 1;
|
|
753
|
+
walletMarketRefreshBuckets.set(clientAddress, bucket);
|
|
754
|
+
return {
|
|
755
|
+
allowed: true,
|
|
756
|
+
retryAfterSeconds: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function setPublicMarketHeaders(res: http.ServerResponse): void {
|
|
761
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
762
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
763
|
+
res.setHeader("Cache-Control", CACHE_CONTROL_VALUE);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function loadWalletMarketOverview(
|
|
767
|
+
clientAddress: string,
|
|
768
|
+
): Promise<WalletMarketOverviewResponse> {
|
|
769
|
+
const fresh = freshCachedWalletMarketOverview();
|
|
770
|
+
if (fresh) return fresh;
|
|
771
|
+
|
|
772
|
+
if (!walletMarketOverviewInFlight) {
|
|
773
|
+
walletMarketOverviewInFlight = buildWalletMarketOverview(clientAddress)
|
|
774
|
+
.then((response) => {
|
|
775
|
+
cachedWalletMarketOverview = {
|
|
776
|
+
response,
|
|
777
|
+
expiresAt: Date.now() + MARKET_OVERVIEW_CACHE_TTL_MS,
|
|
778
|
+
};
|
|
779
|
+
return response;
|
|
780
|
+
})
|
|
781
|
+
.catch((error) => {
|
|
782
|
+
const stale = staleCachedWalletMarketOverview();
|
|
783
|
+
if (stale) {
|
|
784
|
+
logger.warn(
|
|
785
|
+
`[WalletMarketOverviewRoute] Refresh failed; serving stale market overview (${error instanceof Error ? error.message : String(error)})`,
|
|
786
|
+
);
|
|
787
|
+
return stale;
|
|
788
|
+
}
|
|
789
|
+
throw error;
|
|
790
|
+
})
|
|
791
|
+
.finally(() => {
|
|
792
|
+
walletMarketOverviewInFlight = null;
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return walletMarketOverviewInFlight;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export async function handleWalletMarketOverviewRoute(
|
|
800
|
+
req: http.IncomingMessage,
|
|
801
|
+
res: http.ServerResponse,
|
|
802
|
+
): Promise<boolean> {
|
|
803
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
804
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
805
|
+
|
|
806
|
+
if (url.pathname !== MARKET_OVERVIEW_PATH) {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
setPublicMarketHeaders(res);
|
|
811
|
+
|
|
812
|
+
if (method === "OPTIONS") {
|
|
813
|
+
res.statusCode = 204;
|
|
814
|
+
res.end();
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (method !== "GET") {
|
|
819
|
+
sendJsonError(res, 405, "Method not allowed");
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const clientAddress = resolveClientAddress(req);
|
|
824
|
+
|
|
825
|
+
const fresh = freshCachedWalletMarketOverview();
|
|
826
|
+
if (fresh) {
|
|
827
|
+
sendJson(res, 200, fresh);
|
|
828
|
+
return true;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (!walletMarketOverviewInFlight) {
|
|
832
|
+
const rateLimit = consumeRefreshSlot(clientAddress);
|
|
833
|
+
if (!rateLimit.allowed) {
|
|
834
|
+
const stale = staleCachedWalletMarketOverview();
|
|
835
|
+
if (stale) {
|
|
836
|
+
sendJson(res, 200, stale);
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
res.setHeader("Retry-After", String(rateLimit.retryAfterSeconds));
|
|
840
|
+
sendJsonError(res, 429, "Too many market overview refreshes");
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
const overview = await loadWalletMarketOverview(clientAddress);
|
|
847
|
+
sendJson(res, 200, overview);
|
|
848
|
+
} catch (error) {
|
|
849
|
+
logger.error(
|
|
850
|
+
`[WalletMarketOverviewRoute] Failed to load market overview (${error instanceof Error ? error.message : String(error)})`,
|
|
851
|
+
);
|
|
852
|
+
sendJsonError(res, 502, "Failed to load market overview");
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export function __resetWalletMarketOverviewCacheForTests(): void {
|
|
859
|
+
cachedWalletMarketOverview = null;
|
|
860
|
+
walletMarketOverviewInFlight = null;
|
|
861
|
+
walletMarketRefreshBuckets.clear();
|
|
862
|
+
walletMarketOverviewFetch = fetchWithTimeoutGuard;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export function __setWalletMarketOverviewFetchForTests(
|
|
866
|
+
fetcher: WalletMarketOverviewFetch,
|
|
867
|
+
): void {
|
|
868
|
+
walletMarketOverviewFetch = fetcher;
|
|
869
|
+
}
|