dexterm-cli 0.0.0-stage → 0.1.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 +661 -0
- package/dist/packages/agents/src/audit.js +122 -0
- package/dist/packages/agents/src/bus.js +379 -0
- package/dist/packages/agents/src/executor.js +177 -0
- package/dist/packages/agents/src/gate.js +176 -0
- package/dist/packages/agents/src/identity.js +179 -0
- package/dist/packages/agents/src/index.js +15 -0
- package/dist/packages/agents/src/proposals.js +153 -0
- package/dist/packages/agents/src/schema.js +87 -0
- package/dist/packages/agents/src/sdk.js +140 -0
- package/dist/packages/agents/src/tiers.js +114 -0
- package/dist/packages/agents/src/tools.js +69 -0
- package/dist/packages/agents/src/ws.js +158 -0
- package/dist/packages/cli/assets/claude/analyst-system.md +76 -0
- package/dist/packages/cli/assets/claude/plugin/.claude-plugin/plugin.json +5 -0
- package/dist/packages/cli/assets/claude/plugin/skills/REGISTRY.json +16 -0
- package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +67 -0
- package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +64 -0
- package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +63 -0
- package/dist/packages/cli/assets/claude/plugin/skills/trench-scan/SKILL.md +44 -0
- package/dist/packages/cli/assets/claude/schemas/larp.schema.json +117 -0
- package/dist/packages/cli/assets/claude/schemas/trench-scan.schema.json +71 -0
- package/dist/packages/cli/assets/claude/schemas/verdict.schema.json +115 -0
- package/dist/packages/cli/bin/dexterm.mjs +11 -0
- package/dist/packages/cli/src/agent-cmd.js +303 -0
- package/dist/packages/cli/src/agent-roster.js +26 -0
- package/dist/packages/cli/src/analyst/backend.js +526 -0
- package/dist/packages/cli/src/analyst/results.js +38 -0
- package/dist/packages/cli/src/analyst/session.js +319 -0
- package/dist/packages/cli/src/audit.js +51 -0
- package/dist/packages/cli/src/backtest-cmd.js +149 -0
- package/dist/packages/cli/src/boot-state.js +62 -0
- package/dist/packages/cli/src/custom.js +261 -0
- package/dist/packages/cli/src/display.js +191 -0
- package/dist/packages/cli/src/doctor.js +361 -0
- package/dist/packages/cli/src/feed.js +634 -0
- package/dist/packages/cli/src/fixtures.js +275 -0
- package/dist/packages/cli/src/follow-cmd.js +221 -0
- package/dist/packages/cli/src/images-config.js +59 -0
- package/dist/packages/cli/src/index.js +31 -0
- package/dist/packages/cli/src/kb-skill-cmd.js +240 -0
- package/dist/packages/cli/src/larp-record.js +21 -0
- package/dist/packages/cli/src/live/copy.js +80 -0
- package/dist/packages/cli/src/live/flow.js +233 -0
- package/dist/packages/cli/src/live/index.js +509 -0
- package/dist/packages/cli/src/live/intel.js +227 -0
- package/dist/packages/cli/src/live/newpairs.js +334 -0
- package/dist/packages/cli/src/live/screens/newpairs-live.js +219 -0
- package/dist/packages/cli/src/live/screens/token-live.js +459 -0
- package/dist/packages/cli/src/live/screens/watchlist-live.js +149 -0
- package/dist/packages/cli/src/live/token.js +585 -0
- package/dist/packages/cli/src/live/trade.js +929 -0
- package/dist/packages/cli/src/live/watchlist.js +178 -0
- package/dist/packages/cli/src/main.js +5583 -0
- package/dist/packages/cli/src/narrative-cmd.js +76 -0
- package/dist/packages/cli/src/pack-cmd.js +323 -0
- package/dist/packages/cli/src/plan-cmd.js +278 -0
- package/dist/packages/cli/src/popup.js +810 -0
- package/dist/packages/cli/src/rules-cmd.js +131 -0
- package/dist/packages/cli/src/rules.js +339 -0
- package/dist/packages/cli/src/scan/command.js +57 -0
- package/dist/packages/cli/src/scan/engine.js +419 -0
- package/dist/packages/cli/src/scan/observe.js +64 -0
- package/dist/packages/cli/src/scan/ui.js +98 -0
- package/dist/packages/cli/src/screener/filter.js +241 -0
- package/dist/packages/cli/src/screener/presets.js +80 -0
- package/dist/packages/cli/src/screener/rows.js +146 -0
- package/dist/packages/cli/src/screener/rug.js +50 -0
- package/dist/packages/cli/src/screener/sort.js +52 -0
- package/dist/packages/cli/src/screener/view.js +183 -0
- package/dist/packages/cli/src/screens/analyst-session.js +343 -0
- package/dist/packages/cli/src/screens/analyst.js +326 -0
- package/dist/packages/cli/src/screens/boot.js +74 -0
- package/dist/packages/cli/src/screens/bubbles.js +255 -0
- package/dist/packages/cli/src/screens/coin.js +33 -0
- package/dist/packages/cli/src/screens/follows.js +201 -0
- package/dist/packages/cli/src/screens/kol.js +90 -0
- package/dist/packages/cli/src/screens/narrative.js +90 -0
- package/dist/packages/cli/src/screens/newpairs.js +150 -0
- package/dist/packages/cli/src/screens/overlay.js +257 -0
- package/dist/packages/cli/src/screens/plans.js +126 -0
- package/dist/packages/cli/src/screens/positions.js +176 -0
- package/dist/packages/cli/src/screens/rules.js +170 -0
- package/dist/packages/cli/src/screens/screener.js +459 -0
- package/dist/packages/cli/src/screens/social-panel.js +107 -0
- package/dist/packages/cli/src/screens/ticket.js +342 -0
- package/dist/packages/cli/src/screens/token.js +312 -0
- package/dist/packages/cli/src/screens/trending.js +181 -0
- package/dist/packages/cli/src/screens/watchlist.js +104 -0
- package/dist/packages/cli/src/social/demo.js +96 -0
- package/dist/packages/cli/src/social/view.js +250 -0
- package/dist/packages/cli/src/state.js +463 -0
- package/dist/packages/cli/src/theme-cmd.js +126 -0
- package/dist/packages/cli/src/trade.js +319 -0
- package/dist/packages/cli/src/voice-hud.js +42 -0
- package/dist/packages/cli/src/wallet-cmd.js +440 -0
- package/dist/packages/cli/src/wallet-command.js +31 -0
- package/dist/packages/cli/src/widgets.js +208 -0
- package/dist/packages/cli/src/workspace-cmd.js +68 -0
- package/dist/packages/cli/src/x-cmd.js +154 -0
- package/dist/packages/custom/src/config.js +213 -0
- package/dist/packages/custom/src/index.js +10 -0
- package/dist/packages/custom/src/keymap.js +107 -0
- package/dist/packages/custom/src/layout.js +235 -0
- package/dist/packages/custom/src/packs.js +633 -0
- package/dist/packages/custom/src/rules.js +704 -0
- package/dist/packages/custom/src/schema.js +515 -0
- package/dist/packages/custom/src/themes.js +176 -0
- package/dist/packages/custom/src/widget-html.js +222 -0
- package/dist/packages/custom/src/workspace.js +141 -0
- package/dist/packages/data/src/analyst-actions.js +133 -0
- package/dist/packages/data/src/budget.js +88 -0
- package/dist/packages/data/src/cache.js +142 -0
- package/dist/packages/data/src/candles.js +168 -0
- package/dist/packages/data/src/clock.js +64 -0
- package/dist/packages/data/src/credits.js +137 -0
- package/dist/packages/data/src/detail.js +97 -0
- package/dist/packages/data/src/fast-lane.js +321 -0
- package/dist/packages/data/src/fixtures.js +135 -0
- package/dist/packages/data/src/health.js +245 -0
- package/dist/packages/data/src/http.js +277 -0
- package/dist/packages/data/src/images/cache.js +141 -0
- package/dist/packages/data/src/images/decode-worker.js +28 -0
- package/dist/packages/data/src/images/decode.js +81 -0
- package/dist/packages/data/src/images/fetch.js +162 -0
- package/dist/packages/data/src/images/gif.js +214 -0
- package/dist/packages/data/src/images/index.js +10 -0
- package/dist/packages/data/src/images/jpeg.js +544 -0
- package/dist/packages/data/src/images/png.js +310 -0
- package/dist/packages/data/src/images/service.js +334 -0
- package/dist/packages/data/src/images/types.js +27 -0
- package/dist/packages/data/src/index.js +208 -0
- package/dist/packages/data/src/intel/helius-history.js +59 -0
- package/dist/packages/data/src/intel/holder-class.js +125 -0
- package/dist/packages/data/src/intel/rules.js +421 -0
- package/dist/packages/data/src/intel/scan.js +170 -0
- package/dist/packages/data/src/larp/evidence.js +288 -0
- package/dist/packages/data/src/larp/links.js +137 -0
- package/dist/packages/data/src/larp/robots.js +90 -0
- package/dist/packages/data/src/larp/safe-fetch.js +389 -0
- package/dist/packages/data/src/limiter.js +341 -0
- package/dist/packages/data/src/model.js +12 -0
- package/dist/packages/data/src/paper.js +331 -0
- package/dist/packages/data/src/rpc-config.js +126 -0
- package/dist/packages/data/src/rpc-pool.js +166 -0
- package/dist/packages/data/src/social/chain.js +83 -0
- package/dist/packages/data/src/social/dedupe.js +205 -0
- package/dist/packages/data/src/social/feed.js +462 -0
- package/dist/packages/data/src/social/firewall.js +110 -0
- package/dist/packages/data/src/social/index.js +11 -0
- package/dist/packages/data/src/social/kol.js +269 -0
- package/dist/packages/data/src/social/model.js +11 -0
- package/dist/packages/data/src/social/rank.js +184 -0
- package/dist/packages/data/src/social/rss.js +113 -0
- package/dist/packages/data/src/social/summary.js +104 -0
- package/dist/packages/data/src/social/venues.js +186 -0
- package/dist/packages/data/src/sources/dexscreener.js +170 -0
- package/dist/packages/data/src/sources/geckoterminal.js +133 -0
- package/dist/packages/data/src/sources/goplus.js +161 -0
- package/dist/packages/data/src/sources/jupiter-swap.js +236 -0
- package/dist/packages/data/src/sources/jupiter.js +198 -0
- package/dist/packages/data/src/sources/pda.js +113 -0
- package/dist/packages/data/src/sources/pump-curve.js +58 -0
- package/dist/packages/data/src/sources/pumpportal.js +247 -0
- package/dist/packages/data/src/sources/rugcheck.js +71 -0
- package/dist/packages/data/src/sources/solana-rpc.js +299 -0
- package/dist/packages/data/src/sources/solana-ws.js +287 -0
- package/dist/packages/data/src/store.js +289 -0
- package/dist/packages/data/src/trade/bridge.js +464 -0
- package/dist/packages/data/src/trade/caps.js +323 -0
- package/dist/packages/data/src/trade/copy/auto.js +385 -0
- package/dist/packages/data/src/trade/copy/follows.js +103 -0
- package/dist/packages/data/src/trade/copy/index.js +9 -0
- package/dist/packages/data/src/trade/copy/mirror.js +91 -0
- package/dist/packages/data/src/trade/copy/stats.js +120 -0
- package/dist/packages/data/src/trade/copy/tracker.js +158 -0
- package/dist/packages/data/src/trade/copy/trades.js +87 -0
- package/dist/packages/data/src/trade/copy/types.js +6 -0
- package/dist/packages/data/src/trade/executor.js +933 -0
- package/dist/packages/data/src/trade/kill-switch.js +47 -0
- package/dist/packages/data/src/trade/link-store.js +52 -0
- package/dist/packages/data/src/trade/plans/confirm.js +157 -0
- package/dist/packages/data/src/trade/plans/engine.js +784 -0
- package/dist/packages/data/src/trade/plans/executor-live.js +220 -0
- package/dist/packages/data/src/trade/plans/executor-paper.js +44 -0
- package/dist/packages/data/src/trade/plans/executor.js +11 -0
- package/dist/packages/data/src/trade/plans/index.js +12 -0
- package/dist/packages/data/src/trade/plans/journal.js +77 -0
- package/dist/packages/data/src/trade/plans/mandate.js +129 -0
- package/dist/packages/data/src/trade/plans/plan.js +179 -0
- package/dist/packages/data/src/trade/plans/service.js +168 -0
- package/dist/packages/data/src/trade/plans/types.js +6 -0
- package/dist/packages/data/src/trade/rpc-exec.js +229 -0
- package/dist/packages/data/src/trade/signer/client.js +197 -0
- package/dist/packages/data/src/trade/signer/fast-wallet.js +154 -0
- package/dist/packages/data/src/trade/signer/host.js +97 -0
- package/dist/packages/data/src/trade/signer/main.js +121 -0
- package/dist/packages/data/src/trade/signer/probe-builder.js +85 -0
- package/dist/packages/data/src/trade/signer/protocol.js +92 -0
- package/dist/packages/data/src/trade/signer/service.js +400 -0
- package/dist/packages/data/src/trade/signer/sol-usd-feed.js +98 -0
- package/dist/packages/data/src/trade/sol-usd.js +86 -0
- package/dist/packages/data/src/trade/swap-build.js +108 -0
- package/dist/packages/data/src/trade/token-delta.js +201 -0
- package/dist/packages/data/src/trade/tx-decode.js +632 -0
- package/dist/packages/data/src/util.js +24 -0
- package/dist/packages/data/src/watchlist.js +176 -0
- package/dist/packages/data/src/x/api.js +256 -0
- package/dist/packages/data/src/x/budget.js +66 -0
- package/dist/packages/data/src/x/config.js +49 -0
- package/dist/packages/data/src/x/index.js +10 -0
- package/dist/packages/data/src/x/mock-server.js +217 -0
- package/dist/packages/data/src/x/oauth.js +242 -0
- package/dist/packages/data/src/x/pkce.js +21 -0
- package/dist/packages/data/src/x/source.js +285 -0
- package/dist/packages/data/src/x/token-store.js +134 -0
- package/dist/packages/data/src/x/transport.js +71 -0
- package/dist/packages/data/vendor/solana-web3/LICENSE +20 -0
- package/dist/packages/data/vendor/solana-web3/NOTICE.md +13 -0
- package/dist/packages/data/vendor/solana-web3/index.iife.min.js +20 -0
- package/dist/packages/mcp/assets/cases.json +1194 -0
- package/dist/packages/mcp/assets/kb/entries/cabal-and-coordinated-launches.md +28 -0
- package/dist/packages/mcp/assets/kb/entries/common-rug-patterns.md +40 -0
- package/dist/packages/mcp/assets/kb/entries/dbc-mechanics.md +30 -0
- package/dist/packages/mcp/assets/kb/entries/dev-wallet-behaviour.md +33 -0
- package/dist/packages/mcp/assets/kb/entries/glossary.md +35 -0
- package/dist/packages/mcp/assets/kb/entries/holder-concentration.md +30 -0
- package/dist/packages/mcp/assets/kb/entries/jito-bundles.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/launchlab-curve.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/lp-burn-vs-lock.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/metadata-and-copycats.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/mint-freeze-authority.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/narrative-lifecycle.md +32 -0
- package/dist/packages/mcp/assets/kb/entries/pump-bonding-curve.md +33 -0
- package/dist/packages/mcp/assets/kb/entries/pumpswap-migration.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/sniping-and-snipers.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/token2022-extensions-risk.md +32 -0
- package/dist/packages/mcp/assets/kb/entries/wash-trading-fake-volume.md +33 -0
- package/dist/packages/mcp/assets/skills/rug-check.md +52 -0
- package/dist/packages/mcp/assets/skills/token-dossier.md +57 -0
- package/dist/packages/mcp/assets/skills/trench-scan.md +40 -0
- package/dist/packages/mcp/bin/dexterm-mcp.mjs +16 -0
- package/dist/packages/mcp/src/dossier.js +78 -0
- package/dist/packages/mcp/src/firewall.js +21 -0
- package/dist/packages/mcp/src/index.js +22 -0
- package/dist/packages/mcp/src/server.js +849 -0
- package/dist/packages/mcp/src/user-content.js +434 -0
- package/dist/packages/packs/src/backtest.js +183 -0
- package/dist/packages/packs/src/digest.js +95 -0
- package/dist/packages/packs/src/firewall.js +57 -0
- package/dist/packages/packs/src/index.js +19 -0
- package/dist/packages/packs/src/larp-eval.js +100 -0
- package/dist/packages/packs/src/narrative.js +309 -0
- package/dist/packages/packs/src/reason.js +135 -0
- package/dist/packages/packs/src/rug-model.js +137 -0
- package/dist/packages/packs/src/schema.js +54 -0
- package/dist/packages/packs/src/token-dossier.js +174 -0
- package/dist/packages/packs/src/verdicts.js +17 -0
- package/dist/packages/packs/src/wallet-dossier.js +105 -0
- package/dist/packages/popup/src/config.js +71 -0
- package/dist/packages/popup/src/dock.js +271 -0
- package/dist/packages/popup/src/host.js +210 -0
- package/dist/packages/popup/src/index.js +12 -0
- package/dist/packages/popup/src/loopback.js +53 -0
- package/dist/packages/popup/src/open.js +183 -0
- package/dist/packages/popup/src/pages/bubbles-lib.js +160 -0
- package/dist/packages/popup/src/pages/bubbles.css +23 -0
- package/dist/packages/popup/src/pages/bubbles.html +36 -0
- package/dist/packages/popup/src/pages/bubbles.js +214 -0
- package/dist/packages/popup/src/pages/chart-lib.js +262 -0
- package/dist/packages/popup/src/pages/chart.css +50 -0
- package/dist/packages/popup/src/pages/chart.html +56 -0
- package/dist/packages/popup/src/pages/chart.js +554 -0
- package/dist/packages/popup/src/pages/common.css +34 -0
- package/dist/packages/popup/src/pages/common.js +187 -0
- package/dist/packages/popup/src/pages/feeds.html +20 -0
- package/dist/packages/popup/src/pages/feeds.js +77 -0
- package/dist/packages/popup/src/pages/narrative.html +20 -0
- package/dist/packages/popup/src/pages/narrative.js +61 -0
- package/dist/packages/popup/src/pages/plans.css +30 -0
- package/dist/packages/popup/src/pages/plans.html +26 -0
- package/dist/packages/popup/src/pages/plans.js +82 -0
- package/dist/packages/popup/src/pages/roster.css +41 -0
- package/dist/packages/popup/src/pages/roster.html +25 -0
- package/dist/packages/popup/src/pages/roster.js +75 -0
- package/dist/packages/popup/src/pages/token.html +20 -0
- package/dist/packages/popup/src/pages/token.js +112 -0
- package/dist/packages/popup/src/pages/widget-sdk.js +224 -0
- package/dist/packages/popup/src/pages/widget.css +5 -0
- package/dist/packages/popup/src/pages/widget.html +21 -0
- package/dist/packages/popup/src/pages/widget.js +85 -0
- package/dist/packages/popup/src/protocol.js +31 -0
- package/dist/packages/popup/src/selftest.js +156 -0
- package/dist/packages/popup/src/server.js +582 -0
- package/dist/packages/popup/src/sinkproxy.js +167 -0
- package/dist/packages/popup/src/widget-html.js +231 -0
- package/dist/packages/popup/vendor/lightweight-charts/LICENSE +201 -0
- package/dist/packages/popup/vendor/lightweight-charts/NOTICE +2 -0
- package/dist/packages/popup/vendor/lightweight-charts/README.md +30 -0
- package/dist/packages/popup/vendor/lightweight-charts/VERSION +1 -0
- package/dist/packages/popup/vendor/lightweight-charts/lightweight-charts.standalone.production.js +7 -0
- package/dist/packages/recorder/bin/dexterm-recorder.mjs +11 -0
- package/dist/packages/recorder/src/cli.js +113 -0
- package/dist/packages/recorder/src/export.js +71 -0
- package/dist/packages/recorder/src/index.js +20 -0
- package/dist/packages/recorder/src/labels.js +151 -0
- package/dist/packages/recorder/src/open.js +29 -0
- package/dist/packages/recorder/src/recorder.js +553 -0
- package/dist/packages/recorder/src/schedule.js +43 -0
- package/dist/packages/recorder/src/schema.js +135 -0
- package/dist/packages/recorder/src/snapshot.js +191 -0
- package/dist/packages/recorder/src/sweep.js +52 -0
- package/dist/packages/recorder/src/verdicts.js +52 -0
- package/dist/packages/tui/src/bubbles.js +415 -0
- package/dist/packages/tui/src/canvas.js +370 -0
- package/dist/packages/tui/src/chrome.js +85 -0
- package/dist/packages/tui/src/color.js +133 -0
- package/dist/packages/tui/src/flush.js +62 -0
- package/dist/packages/tui/src/fmt.js +188 -0
- package/dist/packages/tui/src/fx-trade.js +278 -0
- package/dist/packages/tui/src/fx.js +527 -0
- package/dist/packages/tui/src/images/blocks.js +141 -0
- package/dist/packages/tui/src/images/coin.js +22 -0
- package/dist/packages/tui/src/images/detect.js +140 -0
- package/dist/packages/tui/src/images/index.js +7 -0
- package/dist/packages/tui/src/images/layer.js +146 -0
- package/dist/packages/tui/src/images/pixels.js +157 -0
- package/dist/packages/tui/src/images/protocols.js +87 -0
- package/dist/packages/tui/src/index.js +15 -0
- package/dist/packages/tui/src/keymap.js +355 -0
- package/dist/packages/tui/src/layout.js +11 -0
- package/dist/packages/tui/src/sound.js +177 -0
- package/dist/packages/tui/src/theme.js +182 -0
- package/dist/packages/tui/src/widgets.js +598 -0
- package/dist/packages/voice/src/controller.js +395 -0
- package/dist/packages/voice/src/doctor.js +63 -0
- package/dist/packages/voice/src/engines.js +44 -0
- package/dist/packages/voice/src/index.js +16 -0
- package/dist/packages/voice/src/router.js +502 -0
- package/dist/packages/voice/src/stt/apple-helper.swift.js +103 -0
- package/dist/packages/voice/src/stt/apple.js +179 -0
- package/dist/packages/voice/src/stt/fake.js +34 -0
- package/dist/packages/voice/src/stt/whisper.js +102 -0
- package/dist/packages/voice/src/summary.js +44 -0
- package/dist/packages/voice/src/tts/fake.js +30 -0
- package/dist/packages/voice/src/tts/say.js +96 -0
- package/dist/packages/voice/src/types.js +3 -0
- package/package.json +42 -4
- package/README.md +0 -3
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Paper trading (task-1310 T0, SPEC §6.0/§6.1/§6.10). A SIMULATED wallet priced from live keyless
|
|
2
|
+
// Jupiter quotes. Nothing here signs, holds a key, or sends a transaction — there is a hard boundary
|
|
3
|
+
// (the TradeExecutor interface) behind which a real signing executor can later be plugged, and this
|
|
4
|
+
// file imports NO wallet/signing/crypto library of any kind (invariant SPEC §6.0, acceptance (d)).
|
|
5
|
+
//
|
|
6
|
+
// What this module owns:
|
|
7
|
+
// - pure ticket/fill/PnL math (computeFill, buildQuoteRequest, position maths) — unit-tested;
|
|
8
|
+
// - TradeExecutor: the one method the UI ever calls (execute an armed order against a fresh quote);
|
|
9
|
+
// - PaperExecutor: the T0 implementation that books the fill into a local SQLite wallet;
|
|
10
|
+
// - PaperWallet: the node:sqlite store (same DB file as the cache/watchlist; one --db controls all).
|
|
11
|
+
//
|
|
12
|
+
// Fills are computed from the quote amounts at confirm time (SPEC §6.1 step 6: book from the fill,
|
|
13
|
+
// never from a stale on-screen quote). A small fixed SOL fee is modelled and clearly labelled as a
|
|
14
|
+
// SIMULATION; no real network/priority fee is ever paid.
|
|
15
|
+
import { mkdirSync } from 'node:fs';
|
|
16
|
+
import { dirname } from 'node:path';
|
|
17
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
18
|
+
import { systemClock } from "./clock.js";
|
|
19
|
+
/** Wrapped SOL: the quote's SOL leg for both sides (buy = SOL→token, sell = token→SOL). */
|
|
20
|
+
export const SOL_MINT = 'So11111111111111111111111111111111111111112';
|
|
21
|
+
export const LAMPORTS_PER_SOL = 1000000000n;
|
|
22
|
+
/** Default starting balance for a fresh paper wallet (SPEC: configurable, default 10 SOL). */
|
|
23
|
+
export const DEFAULT_START_LAMPORTS = 10n * LAMPORTS_PER_SOL;
|
|
24
|
+
/** Simulated fixed SOL fee per fill (one base signature fee). Clearly a simulation; never paid. */
|
|
25
|
+
export const DEFAULT_SIM_FEE_LAMPORTS = 5000n;
|
|
26
|
+
// --------------------------------------------------------------------------- pure conversions
|
|
27
|
+
export function lamportsToSol(l) {
|
|
28
|
+
return Number(l) / 1e9;
|
|
29
|
+
}
|
|
30
|
+
export function solToLamports(sol) {
|
|
31
|
+
if (!Number.isFinite(sol) || sol < 0)
|
|
32
|
+
return 0n;
|
|
33
|
+
return BigInt(Math.round(sol * 1e9));
|
|
34
|
+
}
|
|
35
|
+
export function rawToTokens(raw, decimals) {
|
|
36
|
+
return Number(raw) / 10 ** decimals;
|
|
37
|
+
}
|
|
38
|
+
export function tokensToRaw(tokens, decimals) {
|
|
39
|
+
if (!Number.isFinite(tokens) || tokens < 0)
|
|
40
|
+
return 0n;
|
|
41
|
+
// Round in float then to bigint; paper amounts never need sub-unit exactness.
|
|
42
|
+
return BigInt(Math.round(tokens * 10 ** decimals));
|
|
43
|
+
}
|
|
44
|
+
/** The keyless quote request for an order: SOL↔token with the right side and ExactIn amount. */
|
|
45
|
+
export function buildQuoteRequest(order) {
|
|
46
|
+
return order.side === 'buy'
|
|
47
|
+
? { inputMint: SOL_MINT, outputMint: order.mint, amount: order.amountRaw, slippageBps: order.slippageBps, mode: 'ExactIn' }
|
|
48
|
+
: { inputMint: order.mint, outputMint: SOL_MINT, amount: order.amountRaw, slippageBps: order.slippageBps, mode: 'ExactIn' };
|
|
49
|
+
}
|
|
50
|
+
/** True when a quote actually matches the order (same side/mint/amount) — a guard before booking. */
|
|
51
|
+
export function quoteMatchesOrder(order, q) {
|
|
52
|
+
const req = buildQuoteRequest(order);
|
|
53
|
+
return q.inputMint === req.inputMint && q.outputMint === req.outputMint && q.inAmount === req.amount;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Compute the fill a quote would produce for an order, with the simulated fee applied. Pure: no
|
|
57
|
+
* wallet, no persistence, no validation — the executor validates balance before applying this.
|
|
58
|
+
*/
|
|
59
|
+
export function computeFill(order, q, now, feeLamports = DEFAULT_SIM_FEE_LAMPORTS) {
|
|
60
|
+
const buy = order.side === 'buy';
|
|
61
|
+
const gross = buy ? q.inAmount : q.outAmount; // SOL leg (lamports)
|
|
62
|
+
const tokenAbs = buy ? q.outAmount : q.inAmount; // token leg (raw base units)
|
|
63
|
+
const solDelta = buy ? -(gross + feeLamports) : gross - feeLamports;
|
|
64
|
+
const tokenDelta = buy ? tokenAbs : -tokenAbs;
|
|
65
|
+
const tokensWhole = rawToTokens(tokenAbs, order.decimals);
|
|
66
|
+
const priceSol = tokensWhole > 0 ? lamportsToSol(gross) / tokensWhole : 0;
|
|
67
|
+
const priceUsd = q.usdValue !== null && tokensWhole > 0 ? q.usdValue / tokensWhole : null;
|
|
68
|
+
return {
|
|
69
|
+
t: now,
|
|
70
|
+
mint: order.mint,
|
|
71
|
+
symbol: order.symbol,
|
|
72
|
+
side: order.side,
|
|
73
|
+
decimals: order.decimals,
|
|
74
|
+
solDelta,
|
|
75
|
+
tokenDelta,
|
|
76
|
+
grossLamports: gross,
|
|
77
|
+
feeLamports,
|
|
78
|
+
priceSol,
|
|
79
|
+
priceUsd,
|
|
80
|
+
priceImpactPct: q.priceImpactPct,
|
|
81
|
+
slippageBps: q.slippageBps,
|
|
82
|
+
route: q.route,
|
|
83
|
+
realizedLamports: 0n, // set by the executor for sells, once the cost basis is known
|
|
84
|
+
paper: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function emptyPosition(mint, symbol, decimals, now) {
|
|
88
|
+
return { mint, symbol, decimals, tokensRaw: 0n, costLamports: 0n, realizedLamports: 0n, updatedAt: now };
|
|
89
|
+
}
|
|
90
|
+
/** Apply a buy fill to a position: add tokens, add the SOL spent (incl. fee) to the cost basis. */
|
|
91
|
+
export function applyBuy(pos, fill) {
|
|
92
|
+
return {
|
|
93
|
+
...pos,
|
|
94
|
+
tokensRaw: pos.tokensRaw + fill.tokenDelta,
|
|
95
|
+
costLamports: pos.costLamports + (-fill.solDelta), // solDelta is negative on a buy
|
|
96
|
+
updatedAt: fill.t,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Apply a sell fill to a position: remove tokens, remove the proportional cost basis, and book the
|
|
101
|
+
* realized PnL (net proceeds − cost removed). Returns the new position and the realized lamports.
|
|
102
|
+
*/
|
|
103
|
+
export function applySell(pos, fill) {
|
|
104
|
+
const sold = -fill.tokenDelta; // positive raw amount sold
|
|
105
|
+
const have = pos.tokensRaw;
|
|
106
|
+
const soldClamped = sold > have ? have : sold;
|
|
107
|
+
const costRemoved = have > 0n ? (pos.costLamports * soldClamped) / have : 0n;
|
|
108
|
+
const proceeds = fill.solDelta; // positive net lamports in
|
|
109
|
+
const realized = proceeds - costRemoved;
|
|
110
|
+
const tokensRaw = have - soldClamped;
|
|
111
|
+
const costLamports = tokensRaw === 0n ? 0n : pos.costLamports - costRemoved;
|
|
112
|
+
return {
|
|
113
|
+
pos: { ...pos, tokensRaw, costLamports, realizedLamports: pos.realizedLamports + realized, updatedAt: fill.t },
|
|
114
|
+
realizedLamports: realized,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* PnL for a position given a current price in SOL per whole token (null when no live price).
|
|
119
|
+
* Pure: the positions screen feeds the live mark and renders the result.
|
|
120
|
+
*/
|
|
121
|
+
export function positionPnl(pos, priceSolPerToken) {
|
|
122
|
+
const tokensWhole = rawToTokens(pos.tokensRaw, pos.decimals);
|
|
123
|
+
const costSol = lamportsToSol(pos.costLamports);
|
|
124
|
+
const realizedSol = lamportsToSol(pos.realizedLamports);
|
|
125
|
+
const avgEntrySol = tokensWhole > 0 ? costSol / tokensWhole : null;
|
|
126
|
+
if (priceSolPerToken === null || !Number.isFinite(priceSolPerToken)) {
|
|
127
|
+
return { valueSol: 0, costSol, unrealizedSol: null, unrealizedPct: null, realizedSol, avgEntrySol };
|
|
128
|
+
}
|
|
129
|
+
const valueSol = tokensWhole * priceSolPerToken;
|
|
130
|
+
const unrealizedSol = valueSol - costSol;
|
|
131
|
+
const unrealizedPct = costSol > 0 ? unrealizedSol / costSol : null;
|
|
132
|
+
return { valueSol, costSol, unrealizedSol, unrealizedPct, realizedSol, avgEntrySol };
|
|
133
|
+
}
|
|
134
|
+
// --------------------------------------------------------------------------- executor boundary
|
|
135
|
+
/** Raised when an order cannot be filled against the simulated wallet. */
|
|
136
|
+
export class InsufficientFundsError extends Error {
|
|
137
|
+
constructor(message) {
|
|
138
|
+
super(message);
|
|
139
|
+
this.name = 'InsufficientFundsError';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** The T0 paper executor: validates against the simulated wallet, books the fill, records history. */
|
|
143
|
+
export class PaperExecutor {
|
|
144
|
+
kind = 'paper';
|
|
145
|
+
wallet;
|
|
146
|
+
clock;
|
|
147
|
+
feeLamports;
|
|
148
|
+
constructor(wallet, opts = {}) {
|
|
149
|
+
this.wallet = wallet;
|
|
150
|
+
this.clock = opts.clock ?? systemClock;
|
|
151
|
+
this.feeLamports = opts.feeLamports ?? DEFAULT_SIM_FEE_LAMPORTS;
|
|
152
|
+
}
|
|
153
|
+
previewFill(order, quote) {
|
|
154
|
+
return computeFill(order, quote, this.clock.now(), this.feeLamports);
|
|
155
|
+
}
|
|
156
|
+
execute(order, quote) {
|
|
157
|
+
if (!quoteMatchesOrder(order, quote)) {
|
|
158
|
+
return Promise.reject(new Error('quote does not match the order (mint/side/amount changed) — re-quote and confirm again'));
|
|
159
|
+
}
|
|
160
|
+
const fill = computeFill(order, quote, this.clock.now(), this.feeLamports);
|
|
161
|
+
try {
|
|
162
|
+
const rec = this.wallet.book(order, fill);
|
|
163
|
+
return Promise.resolve(rec);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return Promise.reject(err);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The simulated wallet, persisted in node:sqlite (same file as the cache and watchlist, so one
|
|
172
|
+
* `--db` controls everything). A single-row balance, one row per position, and an append-only
|
|
173
|
+
* trade log. Booking a fill (balance + position + history) runs in one SQL transaction.
|
|
174
|
+
*/
|
|
175
|
+
export class PaperWallet {
|
|
176
|
+
db;
|
|
177
|
+
clock;
|
|
178
|
+
constructor(opts = {}) {
|
|
179
|
+
const path = opts.path ?? ':memory:';
|
|
180
|
+
if (path !== ':memory:')
|
|
181
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
182
|
+
this.db = new DatabaseSync(path);
|
|
183
|
+
this.clock = opts.clock ?? systemClock;
|
|
184
|
+
this.db.exec(`
|
|
185
|
+
PRAGMA journal_mode = WAL;
|
|
186
|
+
CREATE TABLE IF NOT EXISTS paper_wallet (
|
|
187
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
188
|
+
balance_lamports TEXT NOT NULL,
|
|
189
|
+
start_lamports TEXT NOT NULL,
|
|
190
|
+
created_at INTEGER NOT NULL
|
|
191
|
+
);
|
|
192
|
+
CREATE TABLE IF NOT EXISTS paper_positions (
|
|
193
|
+
mint TEXT PRIMARY KEY, symbol TEXT NOT NULL, decimals INTEGER NOT NULL,
|
|
194
|
+
tokens_raw TEXT NOT NULL, cost_lamports TEXT NOT NULL, realized_lamports TEXT NOT NULL DEFAULT '0',
|
|
195
|
+
updated_at INTEGER NOT NULL
|
|
196
|
+
) WITHOUT ROWID;
|
|
197
|
+
CREATE TABLE IF NOT EXISTS paper_trades (
|
|
198
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, t INTEGER NOT NULL, mint TEXT NOT NULL, symbol TEXT NOT NULL,
|
|
199
|
+
side TEXT NOT NULL, decimals INTEGER NOT NULL, sol_delta TEXT NOT NULL, token_delta TEXT NOT NULL,
|
|
200
|
+
gross_lamports TEXT NOT NULL, fee_lamports TEXT NOT NULL, price_sol REAL NOT NULL, price_usd REAL,
|
|
201
|
+
price_impact_pct REAL, slippage_bps INTEGER NOT NULL, route TEXT NOT NULL DEFAULT '', realized_lamports TEXT NOT NULL DEFAULT '0'
|
|
202
|
+
);
|
|
203
|
+
CREATE INDEX IF NOT EXISTS paper_trades_mint ON paper_trades(mint, id);
|
|
204
|
+
`);
|
|
205
|
+
const start = opts.startLamports ?? DEFAULT_START_LAMPORTS;
|
|
206
|
+
this.db
|
|
207
|
+
.prepare('INSERT OR IGNORE INTO paper_wallet (id, balance_lamports, start_lamports, created_at) VALUES (1, ?, ?, ?)')
|
|
208
|
+
.run(start.toString(), start.toString(), this.clock.now());
|
|
209
|
+
}
|
|
210
|
+
summary() {
|
|
211
|
+
const row = this.db.prepare('SELECT balance_lamports, start_lamports, created_at FROM paper_wallet WHERE id = 1').get();
|
|
212
|
+
return { balanceLamports: BigInt(String(row.balance_lamports)), startLamports: BigInt(String(row.start_lamports)), createdAt: Number(row.created_at) };
|
|
213
|
+
}
|
|
214
|
+
balance() {
|
|
215
|
+
return this.summary().balanceLamports;
|
|
216
|
+
}
|
|
217
|
+
/** Reset the wallet to a fresh starting balance and clear positions + history (opt-in). */
|
|
218
|
+
reset(startLamports = DEFAULT_START_LAMPORTS) {
|
|
219
|
+
this.db.exec('BEGIN');
|
|
220
|
+
try {
|
|
221
|
+
this.db.prepare('DELETE FROM paper_positions').run();
|
|
222
|
+
this.db.prepare('DELETE FROM paper_trades').run();
|
|
223
|
+
this.db.prepare('UPDATE paper_wallet SET balance_lamports = ?, start_lamports = ?, created_at = ? WHERE id = 1')
|
|
224
|
+
.run(startLamports.toString(), startLamports.toString(), this.clock.now());
|
|
225
|
+
this.db.exec('COMMIT');
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
this.db.exec('ROLLBACK');
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
position(mint) {
|
|
233
|
+
const row = this.db.prepare('SELECT mint, symbol, decimals, tokens_raw, cost_lamports, realized_lamports, updated_at FROM paper_positions WHERE mint = ?').get(mint);
|
|
234
|
+
return row ? rowToPosition(row) : undefined;
|
|
235
|
+
}
|
|
236
|
+
/** Open positions (non-zero balance) plus any mint with realized PnL, newest activity first. */
|
|
237
|
+
positions() {
|
|
238
|
+
const rows = this.db.prepare('SELECT mint, symbol, decimals, tokens_raw, cost_lamports, realized_lamports, updated_at FROM paper_positions ORDER BY updated_at DESC').all();
|
|
239
|
+
return rows.map(rowToPosition);
|
|
240
|
+
}
|
|
241
|
+
trades(limit = 100) {
|
|
242
|
+
const rows = this.db.prepare('SELECT * FROM paper_trades ORDER BY id DESC LIMIT ?').all(limit);
|
|
243
|
+
return rows.map(rowToTrade);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Book a fill: validate funds, update the balance and position, append the trade — one SQL
|
|
247
|
+
* transaction. Throws InsufficientFundsError when the simulated wallet cannot cover the order.
|
|
248
|
+
*/
|
|
249
|
+
book(order, fill) {
|
|
250
|
+
const balance = this.balance();
|
|
251
|
+
if (order.side === 'buy') {
|
|
252
|
+
const need = fill.grossLamports + fill.feeLamports;
|
|
253
|
+
if (balance < need)
|
|
254
|
+
throw new InsufficientFundsError(`insufficient paper balance: need ${lamportsToSol(need).toFixed(4)} SOL, have ${lamportsToSol(balance).toFixed(4)} SOL`);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
const have = this.position(order.mint)?.tokensRaw ?? 0n;
|
|
258
|
+
const sold = -fill.tokenDelta;
|
|
259
|
+
if (have < sold)
|
|
260
|
+
throw new InsufficientFundsError(`insufficient ${order.symbol} to sell: have ${rawToTokens(have, order.decimals)}, selling ${rawToTokens(sold, order.decimals)}`);
|
|
261
|
+
}
|
|
262
|
+
const existing = this.position(order.mint) ?? emptyPosition(order.mint, order.symbol, order.decimals, fill.t);
|
|
263
|
+
let realized = 0n;
|
|
264
|
+
let next;
|
|
265
|
+
if (order.side === 'buy') {
|
|
266
|
+
next = applyBuy(existing, fill);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
const r = applySell(existing, fill);
|
|
270
|
+
next = r.pos;
|
|
271
|
+
realized = r.realizedLamports;
|
|
272
|
+
}
|
|
273
|
+
const booked = { ...fill, realizedLamports: realized };
|
|
274
|
+
this.db.exec('BEGIN');
|
|
275
|
+
try {
|
|
276
|
+
this.db.prepare('UPDATE paper_wallet SET balance_lamports = ? WHERE id = 1').run((balance + booked.solDelta).toString());
|
|
277
|
+
this.db
|
|
278
|
+
.prepare(`INSERT INTO paper_positions (mint, symbol, decimals, tokens_raw, cost_lamports, realized_lamports, updated_at)
|
|
279
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
280
|
+
ON CONFLICT(mint) DO UPDATE SET symbol = excluded.symbol, decimals = excluded.decimals,
|
|
281
|
+
tokens_raw = excluded.tokens_raw, cost_lamports = excluded.cost_lamports,
|
|
282
|
+
realized_lamports = excluded.realized_lamports, updated_at = excluded.updated_at`)
|
|
283
|
+
.run(next.mint, next.symbol, next.decimals, next.tokensRaw.toString(), next.costLamports.toString(), next.realizedLamports.toString(), next.updatedAt);
|
|
284
|
+
const res = this.db
|
|
285
|
+
.prepare(`INSERT INTO paper_trades (t, mint, symbol, side, decimals, sol_delta, token_delta, gross_lamports, fee_lamports, price_sol, price_usd, price_impact_pct, slippage_bps, route, realized_lamports)
|
|
286
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
287
|
+
.run(booked.t, booked.mint, booked.symbol, booked.side, booked.decimals, booked.solDelta.toString(), booked.tokenDelta.toString(), booked.grossLamports.toString(), booked.feeLamports.toString(), booked.priceSol, booked.priceUsd, booked.priceImpactPct, booked.slippageBps, booked.route.join(' → '), booked.realizedLamports.toString());
|
|
288
|
+
this.db.exec('COMMIT');
|
|
289
|
+
return { ...booked, id: Number(res.lastInsertRowid) };
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
this.db.exec('ROLLBACK');
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
close() {
|
|
297
|
+
this.db.close();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function rowToPosition(r) {
|
|
301
|
+
return {
|
|
302
|
+
mint: String(r.mint),
|
|
303
|
+
symbol: String(r.symbol),
|
|
304
|
+
decimals: Number(r.decimals),
|
|
305
|
+
tokensRaw: BigInt(String(r.tokens_raw)),
|
|
306
|
+
costLamports: BigInt(String(r.cost_lamports)),
|
|
307
|
+
realizedLamports: BigInt(String(r.realized_lamports ?? '0')),
|
|
308
|
+
updatedAt: Number(r.updated_at),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function rowToTrade(r) {
|
|
312
|
+
return {
|
|
313
|
+
id: Number(r.id),
|
|
314
|
+
t: Number(r.t),
|
|
315
|
+
mint: String(r.mint),
|
|
316
|
+
symbol: String(r.symbol),
|
|
317
|
+
side: r.side === 'sell' ? 'sell' : 'buy',
|
|
318
|
+
decimals: Number(r.decimals),
|
|
319
|
+
solDelta: BigInt(String(r.sol_delta)),
|
|
320
|
+
tokenDelta: BigInt(String(r.token_delta)),
|
|
321
|
+
grossLamports: BigInt(String(r.gross_lamports)),
|
|
322
|
+
feeLamports: BigInt(String(r.fee_lamports)),
|
|
323
|
+
priceSol: Number(r.price_sol),
|
|
324
|
+
priceUsd: r.price_usd === null || r.price_usd === undefined ? null : Number(r.price_usd),
|
|
325
|
+
priceImpactPct: r.price_impact_pct === null || r.price_impact_pct === undefined ? null : Number(r.price_impact_pct),
|
|
326
|
+
slippageBps: Number(r.slippage_bps),
|
|
327
|
+
route: String(r.route ?? '').length ? String(r.route).split(' → ') : [],
|
|
328
|
+
realizedLamports: BigInt(String(r.realized_lamports ?? '0')),
|
|
329
|
+
paper: true,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// RPC endpoint resolution (task-1298). The Helius key lives ONLY at ~/.dexterm/helius.key
|
|
2
|
+
// (mode 600, outside every repo). It is read here, in code, and the URL is built in memory.
|
|
3
|
+
// Nothing in this module logs, returns for display, or writes the key; `redactSecrets()`
|
|
4
|
+
// strips `api-key=<value>` from any string that might reach a log, an error, a recording
|
|
5
|
+
// or the screen, and every URL-bearing message in the data layer goes through it.
|
|
6
|
+
//
|
|
7
|
+
// DEXTERM_RPC_URL wins when set (any read-only JSON-RPC endpoint)
|
|
8
|
+
// ~/.dexterm/helius.key -> https://mainnet.helius-rpc.com/?api-key=<key>
|
|
9
|
+
// otherwise the public mainnet RPC (heavily throttled: holders usually unavailable)
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { SOLANA_PUBLIC_RPC } from "./sources/solana-rpc.js";
|
|
14
|
+
export const HELIUS_RPC_HOST = 'mainnet.helius-rpc.com';
|
|
15
|
+
export const HELIUS_KEY_FILE = join('.dexterm', 'helius.key');
|
|
16
|
+
/**
|
|
17
|
+
* Keyless public Solana RPCs added to the pool alongside the default endpoint (task-1309). These answer
|
|
18
|
+
* `getAccountInfo` (mint/freeze authorities) without a key, so the authorities panel keeps working even
|
|
19
|
+
* when the official endpoint is throttling. None of them reliably serve `getTokenLargestAccounts`
|
|
20
|
+
* (the ranked holder list) keyless — that needs a free key — so the holders panel falls back to the
|
|
21
|
+
* Jupiter aggregate (count + top-10 share). Probed live 2026-10-07; see the task-1309 handoff.
|
|
22
|
+
*/
|
|
23
|
+
export const PUBLIC_RPC_POOL = [
|
|
24
|
+
{ url: SOLANA_PUBLIC_RPC, label: 'api.mainnet-beta.solana.com (public)', basis: 'official public RPC; getTokenLargestAccounts throttled per-IP' },
|
|
25
|
+
{ url: 'https://solana-rpc.publicnode.com', label: 'solana-rpc.publicnode.com (public)', basis: 'PublicNode keyless; serves getAccountInfo, blocks getTokenLargestAccounts' },
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* What a free (user-supplied, no-card) key adds for each keyed capability, and how to get it in ~2 minutes.
|
|
29
|
+
* Shown next to any "needs key" cell so the gap is honest and actionable (task-1309). No key is ever shipped.
|
|
30
|
+
*/
|
|
31
|
+
export const FREE_KEY_GUIDANCE = {
|
|
32
|
+
'top-holders': {
|
|
33
|
+
adds: 'the ranked top-20 holder list with owner wallets (keyless you still get holder count + top-10 % via Jupiter)',
|
|
34
|
+
how: 'free Helius key → ~/.dexterm/helius.key, or any free RPC URL in DEXTERM_RPC_URL (Alchemy/QuickNode/Ankr free tier)',
|
|
35
|
+
},
|
|
36
|
+
forensics: {
|
|
37
|
+
adds: 'snipers, bundled-buy %, and creator dev-history (prior launches / rugs)',
|
|
38
|
+
how: 'free Helius key (1M credits/mo, no card) → ~/.dexterm/helius.key',
|
|
39
|
+
},
|
|
40
|
+
'dev-history': {
|
|
41
|
+
adds: "the creator's prior launches and rug record",
|
|
42
|
+
how: 'free Helius key → ~/.dexterm/helius.key (Jupiter also gives a partial dev flag keyless)',
|
|
43
|
+
},
|
|
44
|
+
'live-tape': {
|
|
45
|
+
adds: 'richer, wallet-labelled trade flow (keyless you get the GeckoTerminal tape)',
|
|
46
|
+
how: 'free Helius key → ~/.dexterm/helius.key',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
/** The public mainnet RPC WebSocket (keyless), for the fast lane's accountSubscribe fallback. */
|
|
50
|
+
export const SOLANA_PUBLIC_RPC_WS = 'wss://api.mainnet-beta.solana.com';
|
|
51
|
+
/**
|
|
52
|
+
* WebSocket URL for an RPC HTTP endpoint (http→ws, https→wss), preserving any `?api-key=` query so the
|
|
53
|
+
* same key authenticates the stream. Never log the result (it may carry a key); pass through redactSecrets.
|
|
54
|
+
* Returns null when the URL is unparseable.
|
|
55
|
+
*/
|
|
56
|
+
export function wsUrlFor(httpUrl) {
|
|
57
|
+
try {
|
|
58
|
+
const u = new URL(httpUrl);
|
|
59
|
+
u.protocol = u.protocol === 'https:' ? 'wss:' : u.protocol === 'http:' ? 'ws:' : u.protocol;
|
|
60
|
+
return u.toString();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Extra user-supplied free RPC endpoints, from DEXTERM_RPC_URLS (comma-separated, read-only). */
|
|
67
|
+
export function extraRpcUrls(env = process.env) {
|
|
68
|
+
return (env.DEXTERM_RPC_URLS ?? '')
|
|
69
|
+
.split(',')
|
|
70
|
+
.map((s) => s.trim())
|
|
71
|
+
.filter((s) => s.length > 0 && isLikelyUrl(s));
|
|
72
|
+
}
|
|
73
|
+
function isLikelyUrl(s) {
|
|
74
|
+
try {
|
|
75
|
+
const u = new URL(s);
|
|
76
|
+
return u.protocol === 'https:' || u.protocol === 'http:';
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Resolve the RPC endpoint. Never throws: a missing or unreadable key file means the public RPC. */
|
|
83
|
+
export function resolveRpcUrl(opts = {}) {
|
|
84
|
+
const env = opts.env ?? process.env;
|
|
85
|
+
const fromEnv = env.DEXTERM_RPC_URL?.trim();
|
|
86
|
+
if (fromEnv) {
|
|
87
|
+
let provider = 'custom';
|
|
88
|
+
try {
|
|
89
|
+
if (new URL(fromEnv).hostname === HELIUS_RPC_HOST)
|
|
90
|
+
provider = 'helius';
|
|
91
|
+
}
|
|
92
|
+
catch { /* keep custom */ }
|
|
93
|
+
return { url: fromEnv, provider, basis: 'env DEXTERM_RPC_URL', fallbackUrl: SOLANA_PUBLIC_RPC };
|
|
94
|
+
}
|
|
95
|
+
const path = join(opts.home ?? homedir(), HELIUS_KEY_FILE);
|
|
96
|
+
let key = '';
|
|
97
|
+
try {
|
|
98
|
+
key = (opts.readFile ?? ((p) => readFileSync(p, 'utf8')))(path).trim();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
key = '';
|
|
102
|
+
}
|
|
103
|
+
if (key && isPlausibleKey(key)) {
|
|
104
|
+
return { url: `https://${HELIUS_RPC_HOST}/?api-key=${encodeURIComponent(key)}`, provider: 'helius', basis: '~/.dexterm/helius.key', fallbackUrl: SOLANA_PUBLIC_RPC };
|
|
105
|
+
}
|
|
106
|
+
return { url: SOLANA_PUBLIC_RPC, provider: 'public', basis: 'public mainnet (no key)', fallbackUrl: null };
|
|
107
|
+
}
|
|
108
|
+
/** A key is one token of URL-safe characters; anything else (a pasted sentence, a blank file) is ignored. */
|
|
109
|
+
export function isPlausibleKey(key) {
|
|
110
|
+
return /^[A-Za-z0-9._~-]{8,128}$/.test(key);
|
|
111
|
+
}
|
|
112
|
+
const API_KEY_RE = /([?&](?:api[-_]?key|apikey|key|token)=)[^&\s"'<>]*/gi;
|
|
113
|
+
/** Replace the value of any `api-key=` style query parameter with `REDACTED`. Safe on any string. */
|
|
114
|
+
export function redactSecrets(text) {
|
|
115
|
+
return text.replace(API_KEY_RE, '$1REDACTED');
|
|
116
|
+
}
|
|
117
|
+
/** True when `text` still carries an api-key value other than the REDACTED marker (self-check before writing files). */
|
|
118
|
+
export function containsSecret(text) {
|
|
119
|
+
API_KEY_RE.lastIndex = 0;
|
|
120
|
+
for (const m of text.matchAll(API_KEY_RE)) {
|
|
121
|
+
const value = m[0].slice(m[1].length);
|
|
122
|
+
if (value && value !== 'REDACTED')
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// RPC pool with rotation, per-endpoint health scoring and 429/Retry-After backoff (task-1309).
|
|
2
|
+
//
|
|
3
|
+
// Why a pool: a single public RPC is throttled hard (getTokenLargestAccounts 429s per IP), and a
|
|
4
|
+
// user may supply several free endpoints (Helius / Alchemy / QuickNode / Ankr free tiers, each with
|
|
5
|
+
// its own quota tied to their own key and IP). The pool spreads reads across every endpoint it has,
|
|
6
|
+
// prefers the healthiest, and when one 429s it is paused (honouring Retry-After) while the others keep
|
|
7
|
+
// serving. Each endpoint has its own rate-limit bucket so one endpoint's pause never stalls the rest.
|
|
8
|
+
//
|
|
9
|
+
// FREE-FOREVER: the pool works with zero endpoints configured beyond the keyless public RPC. Adding a
|
|
10
|
+
// free key only adds endpoints (more quota, and the methods the public RPC blocks, e.g. the ranked
|
|
11
|
+
// holder list); it never gates a feature. Endpoint URLs may carry a key: never log one — redactSecrets().
|
|
12
|
+
import { systemClock } from "./clock.js";
|
|
13
|
+
import { HttpError } from "./http.js";
|
|
14
|
+
import { SolanaRpc } from "./sources/solana-rpc.js";
|
|
15
|
+
import { redactSecrets } from "./rpc-config.js";
|
|
16
|
+
/** Default bucket config for a pool endpoint when the spec does not give one. */
|
|
17
|
+
const DEFAULT_RPM = 30;
|
|
18
|
+
const DEFAULT_BURST = 1;
|
|
19
|
+
/** Pool-level pause after a 429 with no Retry-After; doubles per consecutive 429, capped. */
|
|
20
|
+
const BASE_429_PAUSE_MS = 2_000;
|
|
21
|
+
const MAX_429_PAUSE_MS = 120_000;
|
|
22
|
+
/** Pool-level cooldown after a non-429 failure (network/5xx), so rotation moves on without hammering. */
|
|
23
|
+
const ERROR_COOLDOWN_MS = 3_000;
|
|
24
|
+
const MAX_ERROR_COOLDOWN_MS = 30_000;
|
|
25
|
+
/** Thrown when every endpoint in the pool is paused or exhausted and none could serve the call. */
|
|
26
|
+
export class RpcPoolExhaustedError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'RpcPoolExhaustedError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class RpcPool {
|
|
33
|
+
endpoints;
|
|
34
|
+
clock;
|
|
35
|
+
constructor(http, limiter, specs, opts = {}) {
|
|
36
|
+
this.clock = opts.clock ?? systemClock;
|
|
37
|
+
if (specs.length === 0)
|
|
38
|
+
throw new Error('RpcPool needs at least one endpoint');
|
|
39
|
+
this.endpoints = specs.map((spec, i) => {
|
|
40
|
+
const bucket = spec.bucket ?? bucketFor(spec.url);
|
|
41
|
+
limiter.ensureBucket(bucket, { rpm: spec.rpm ?? DEFAULT_RPM, burst: spec.burst ?? DEFAULT_BURST, basis: spec.basis });
|
|
42
|
+
return {
|
|
43
|
+
spec,
|
|
44
|
+
rpc: new SolanaRpc(http, { url: spec.url, source: spec.source, bucket }),
|
|
45
|
+
priority: i,
|
|
46
|
+
consecutiveFailures: 0,
|
|
47
|
+
okCount: 0,
|
|
48
|
+
errorCount: 0,
|
|
49
|
+
lastLatencyMs: null,
|
|
50
|
+
pausedUntil: 0,
|
|
51
|
+
lastError: null,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
get size() {
|
|
56
|
+
return this.endpoints.length;
|
|
57
|
+
}
|
|
58
|
+
/** True when at least one endpoint carries a user key (unlocks the ranked holder list etc.). */
|
|
59
|
+
get hasKeyed() {
|
|
60
|
+
return this.endpoints.some((e) => e.spec.keyed);
|
|
61
|
+
}
|
|
62
|
+
/** The first endpoint's adapter — the "primary" for callers that still expect a single SolanaRpc. */
|
|
63
|
+
get primary() {
|
|
64
|
+
return this.endpoints[0].rpc;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Run `fn` against endpoints in health order, failing over on error. A 429 pauses that endpoint
|
|
68
|
+
* (Retry-After when the server sent one, else a doubling backoff) and moves to the next. The last
|
|
69
|
+
* error propagates when every currently-available endpoint has failed.
|
|
70
|
+
*/
|
|
71
|
+
async run(fn, label = 'rpc') {
|
|
72
|
+
const now = this.clock.now();
|
|
73
|
+
const order = this.order(now);
|
|
74
|
+
const available = order.filter((e) => e.pausedUntil <= now);
|
|
75
|
+
const tryList = available.length > 0 ? available : order.slice(0, 1); // all paused: still try the soonest
|
|
76
|
+
let lastError = new RpcPoolExhaustedError(`${label}: no RPC endpoint available`);
|
|
77
|
+
for (const e of tryList) {
|
|
78
|
+
const t0 = this.clock.now();
|
|
79
|
+
try {
|
|
80
|
+
const value = await fn(e.rpc);
|
|
81
|
+
e.okCount++;
|
|
82
|
+
e.consecutiveFailures = 0;
|
|
83
|
+
e.pausedUntil = 0;
|
|
84
|
+
e.lastError = null;
|
|
85
|
+
e.lastLatencyMs = this.clock.now() - t0;
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
lastError = err;
|
|
90
|
+
this.penalize(e, err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw lastError;
|
|
94
|
+
}
|
|
95
|
+
mintInfo(mint) {
|
|
96
|
+
return this.run((rpc) => rpc.mintInfo(mint), 'mintInfo');
|
|
97
|
+
}
|
|
98
|
+
topHolders(mint, opts = {}) {
|
|
99
|
+
return this.run((rpc) => rpc.topHolders(mint, opts), 'topHolders');
|
|
100
|
+
}
|
|
101
|
+
/** Raw base64 account data through the pool (rotation + failover). Fast-lane price seeding. */
|
|
102
|
+
accountsRaw(accounts, commitment = 'processed') {
|
|
103
|
+
return this.run((rpc) => rpc.accountsRaw(accounts, commitment), 'accountsRaw');
|
|
104
|
+
}
|
|
105
|
+
/** Account meta (owner program / existence) through the pool (task-1340 wallet intel). */
|
|
106
|
+
accountMeta(accounts, commitment = 'confirmed') {
|
|
107
|
+
return this.run((rpc) => rpc.accountMeta(accounts, commitment), 'accountMeta');
|
|
108
|
+
}
|
|
109
|
+
/** The first endpoint whose source is `helius` (the keyed history endpoint), or null. Its URL carries the key: never print it. */
|
|
110
|
+
get heliusUrl() {
|
|
111
|
+
return this.endpoints.find((e) => e.spec.source === 'helius')?.spec.url ?? null;
|
|
112
|
+
}
|
|
113
|
+
status() {
|
|
114
|
+
const now = this.clock.now();
|
|
115
|
+
return this.endpoints.map((e) => ({
|
|
116
|
+
label: e.spec.label,
|
|
117
|
+
source: e.spec.source,
|
|
118
|
+
keyed: !!e.spec.keyed,
|
|
119
|
+
okCount: e.okCount,
|
|
120
|
+
errorCount: e.errorCount,
|
|
121
|
+
consecutiveFailures: e.consecutiveFailures,
|
|
122
|
+
lastLatencyMs: e.lastLatencyMs,
|
|
123
|
+
pausedUntil: e.pausedUntil > now ? e.pausedUntil : 0,
|
|
124
|
+
available: e.pausedUntil <= now,
|
|
125
|
+
lastError: e.lastError,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
/** Available endpoints first, then fewest consecutive failures, then declared priority, then latency. */
|
|
129
|
+
order(now) {
|
|
130
|
+
return [...this.endpoints].sort((a, b) => {
|
|
131
|
+
const aAvail = a.pausedUntil <= now ? 0 : 1;
|
|
132
|
+
const bAvail = b.pausedUntil <= now ? 0 : 1;
|
|
133
|
+
if (aAvail !== bAvail)
|
|
134
|
+
return aAvail - bAvail;
|
|
135
|
+
if (a.consecutiveFailures !== b.consecutiveFailures)
|
|
136
|
+
return a.consecutiveFailures - b.consecutiveFailures;
|
|
137
|
+
if (a.priority !== b.priority)
|
|
138
|
+
return a.priority - b.priority;
|
|
139
|
+
return (a.lastLatencyMs ?? 0) - (b.lastLatencyMs ?? 0);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
penalize(e, err) {
|
|
143
|
+
e.errorCount++;
|
|
144
|
+
e.consecutiveFailures++;
|
|
145
|
+
e.lastError = redactSecrets(err instanceof Error ? err.message : String(err));
|
|
146
|
+
const now = this.clock.now();
|
|
147
|
+
const rateLimited = err instanceof HttpError && (err.status === 429 || err.status === 503);
|
|
148
|
+
if (rateLimited) {
|
|
149
|
+
const backoff = Math.min(BASE_429_PAUSE_MS * 2 ** (e.consecutiveFailures - 1), MAX_429_PAUSE_MS);
|
|
150
|
+
const retry = err instanceof HttpError ? err.retryAfterMs : undefined;
|
|
151
|
+
e.pausedUntil = now + Math.max(backoff, retry ?? 0);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
e.pausedUntil = now + Math.min(ERROR_COOLDOWN_MS * e.consecutiveFailures, MAX_ERROR_COOLDOWN_MS);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** Per-host bucket name for an endpoint, derived from the hostname (never the key). */
|
|
159
|
+
export function bucketFor(url) {
|
|
160
|
+
try {
|
|
161
|
+
return `rpc:${new URL(url).hostname}`;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return 'rpc:unknown';
|
|
165
|
+
}
|
|
166
|
+
}
|