@oracle-agent/oracle 0.9.7 → 0.9.8
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/package.json +1 -1
- package/public/oracle-splash/index.html +105 -1
- package/src/cards.mjs +369 -0
- package/src/cli/__pycache__/oracle-harness.cpython-311.pyc +0 -0
- package/src/router/best-execution.mjs +105 -0
- package/src/router/index.mjs +42 -1
- package/src/router/prepare-route.mjs +17 -1
- package/protocols/templates/safe-erc20/README.md +0 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oracle-agent/oracle",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
4
4
|
"description": "Oracle: prepare-only multichain agent control plane. Policy-bounded intents for a user-signed wallet. Self-custody by default — the public package never takes your key. Built for Hermes; no model key required.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -401,6 +401,18 @@ main{position:relative;z-index:2;padding-top:84px}
|
|
|
401
401
|
}
|
|
402
402
|
.evolve-pill.on{border-color:rgba(124,196,255,.35);color:var(--blue2)}
|
|
403
403
|
|
|
404
|
+
.venue-rail-head{
|
|
405
|
+
font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;
|
|
406
|
+
color:var(--cool);text-align:center;padding:14px 16px 0;opacity:.75;
|
|
407
|
+
}
|
|
408
|
+
.venue-rail-head b{color:var(--blue2);font-weight:600}
|
|
409
|
+
.venue-rail{padding:10px 16px 4px;max-height:96px;overflow:hidden;position:relative}
|
|
410
|
+
.venue-rail .evolve-pill{font-size:9px;padding:6px 10px;opacity:.9}
|
|
411
|
+
.venue-rail::after{
|
|
412
|
+
content:"";position:absolute;left:0;right:0;bottom:0;height:34px;pointer-events:none;
|
|
413
|
+
background:linear-gradient(to bottom,transparent,var(--bg));
|
|
414
|
+
}
|
|
415
|
+
|
|
404
416
|
.wires{height:40px;position:relative}
|
|
405
417
|
.wires svg{width:100%;height:100%;display:block;overflow:visible}
|
|
406
418
|
.wires path{
|
|
@@ -735,6 +747,98 @@ footer{margin-top:clamp(72px,12vh,120px);border-top:1px solid var(--line);paddin
|
|
|
735
747
|
<span class="evolve-pill on">more chains</span>
|
|
736
748
|
</div>
|
|
737
749
|
|
|
750
|
+
<div class="venue-rail-head">solana venues routed · <b id="sol-count">101</b> programs verified on-chain · 88 venues</div>
|
|
751
|
+
<div class="evolve-rail venue-rail" id="sol-venue-rail">
|
|
752
|
+
<span class="evolve-pill on">1DEX</span>
|
|
753
|
+
<span class="evolve-pill on">Aldrin</span>
|
|
754
|
+
<span class="evolve-pill on">AlphaQ</span>
|
|
755
|
+
<span class="evolve-pill on">Aquifer</span>
|
|
756
|
+
<span class="evolve-pill on">BinaryFi</span>
|
|
757
|
+
<span class="evolve-pill on">BisonFi</span>
|
|
758
|
+
<span class="evolve-pill on">Bonkswap</span>
|
|
759
|
+
<span class="evolve-pill on">Boop.fun</span>
|
|
760
|
+
<span class="evolve-pill on">Byreal</span>
|
|
761
|
+
<span class="evolve-pill on">Carrot</span>
|
|
762
|
+
<span class="evolve-pill on">Crema</span>
|
|
763
|
+
<span class="evolve-pill on">Cropper</span>
|
|
764
|
+
<span class="evolve-pill on">DefiTuna</span>
|
|
765
|
+
<span class="evolve-pill on">Denali</span>
|
|
766
|
+
<span class="evolve-pill on">Deriverse</span>
|
|
767
|
+
<span class="evolve-pill on">DexLab</span>
|
|
768
|
+
<span class="evolve-pill on">Dynamic Bonding Curve</span>
|
|
769
|
+
<span class="evolve-pill on">Flint</span>
|
|
770
|
+
<span class="evolve-pill on">Flux</span>
|
|
771
|
+
<span class="evolve-pill on">FluxBeam</span>
|
|
772
|
+
<span class="evolve-pill on">GatorSwap</span>
|
|
773
|
+
<span class="evolve-pill on">Gavel</span>
|
|
774
|
+
<span class="evolve-pill on">GoonFi</span>
|
|
775
|
+
<span class="evolve-pill on">GooseFX GAMMA</span>
|
|
776
|
+
<span class="evolve-pill on">Guacswap</span>
|
|
777
|
+
<span class="evolve-pill on">Hadron</span>
|
|
778
|
+
<span class="evolve-pill on">Heaven</span>
|
|
779
|
+
<span class="evolve-pill on">Helium Network</span>
|
|
780
|
+
<span class="evolve-pill on">Huma</span>
|
|
781
|
+
<span class="evolve-pill on">HumidiFi</span>
|
|
782
|
+
<span class="evolve-pill on">Hylo Exchange</span>
|
|
783
|
+
<span class="evolve-pill on">Hylo Stability</span>
|
|
784
|
+
<span class="evolve-pill on">Invariant</span>
|
|
785
|
+
<span class="evolve-pill on">Jupiter Lend</span>
|
|
786
|
+
<span class="evolve-pill on">JupiterRfqV2</span>
|
|
787
|
+
<span class="evolve-pill on">JupLend</span>
|
|
788
|
+
<span class="evolve-pill on">Kipseli</span>
|
|
789
|
+
<span class="evolve-pill on">LemmingsFi</span>
|
|
790
|
+
<span class="evolve-pill on">M Swap</span>
|
|
791
|
+
<span class="evolve-pill on">Manifest</span>
|
|
792
|
+
<span class="evolve-pill on">Mercurial</span>
|
|
793
|
+
<span class="evolve-pill on">MetaDAO</span>
|
|
794
|
+
<span class="evolve-pill on">Meteora</span>
|
|
795
|
+
<span class="evolve-pill on">Meteora DAMM</span>
|
|
796
|
+
<span class="evolve-pill on">Metric</span>
|
|
797
|
+
<span class="evolve-pill on">Moonit</span>
|
|
798
|
+
<span class="evolve-pill on">Obric</span>
|
|
799
|
+
<span class="evolve-pill on">Obsidian</span>
|
|
800
|
+
<span class="evolve-pill on">Omnipair</span>
|
|
801
|
+
<span class="evolve-pill on">OpenBook</span>
|
|
802
|
+
<span class="evolve-pill on">Orca</span>
|
|
803
|
+
<span class="evolve-pill on">PancakeSwap</span>
|
|
804
|
+
<span class="evolve-pill on">Penguin</span>
|
|
805
|
+
<span class="evolve-pill on">Perena</span>
|
|
806
|
+
<span class="evolve-pill on">Perena Star</span>
|
|
807
|
+
<span class="evolve-pill on">Perps</span>
|
|
808
|
+
<span class="evolve-pill on">Phoenix</span>
|
|
809
|
+
<span class="evolve-pill on">Pump.fun</span>
|
|
810
|
+
<span class="evolve-pill on">Quantum</span>
|
|
811
|
+
<span class="evolve-pill on">Raydium</span>
|
|
812
|
+
<span class="evolve-pill on">Riptide</span>
|
|
813
|
+
<span class="evolve-pill on">RunnerRodeo</span>
|
|
814
|
+
<span class="evolve-pill on">Saber</span>
|
|
815
|
+
<span class="evolve-pill on">Saber (Decimals)</span>
|
|
816
|
+
<span class="evolve-pill on">Sanctum</span>
|
|
817
|
+
<span class="evolve-pill on">Sanctum Infinity</span>
|
|
818
|
+
<span class="evolve-pill on">Saros</span>
|
|
819
|
+
<span class="evolve-pill on">Scale</span>
|
|
820
|
+
<span class="evolve-pill on">Scale Vmm</span>
|
|
821
|
+
<span class="evolve-pill on">Scorch</span>
|
|
822
|
+
<span class="evolve-pill on">Solayer</span>
|
|
823
|
+
<span class="evolve-pill on">SolFi</span>
|
|
824
|
+
<span class="evolve-pill on">Stabble</span>
|
|
825
|
+
<span class="evolve-pill on">Stabble Stable Swap</span>
|
|
826
|
+
<span class="evolve-pill on">Stabble Weighted Swap</span>
|
|
827
|
+
<span class="evolve-pill on">StepN</span>
|
|
828
|
+
<span class="evolve-pill on">TaurusFi</span>
|
|
829
|
+
<span class="evolve-pill on">TesseraV</span>
|
|
830
|
+
<span class="evolve-pill on">Token Swap</span>
|
|
831
|
+
<span class="evolve-pill on">Trends</span>
|
|
832
|
+
<span class="evolve-pill on">VaultLiquidUnstake</span>
|
|
833
|
+
<span class="evolve-pill on">Virtuals</span>
|
|
834
|
+
<span class="evolve-pill on">Voltr</span>
|
|
835
|
+
<span class="evolve-pill on">WhaleStreet</span>
|
|
836
|
+
<span class="evolve-pill on">Whirlpool</span>
|
|
837
|
+
<span class="evolve-pill on">Woofi</span>
|
|
838
|
+
<span class="evolve-pill on">XOrca</span>
|
|
839
|
+
<span class="evolve-pill on">ZeroFi</span>
|
|
840
|
+
</div>
|
|
841
|
+
|
|
738
842
|
<div class="graph-canvas">
|
|
739
843
|
<div class="orch" aria-label="Oracle multi-profile graph">
|
|
740
844
|
<div class="row">
|
|
@@ -807,7 +911,7 @@ footer{margin-top:clamp(72px,12vh,120px);border-top:1px solid var(--line);paddin
|
|
|
807
911
|
<div class="tag">profile</div>
|
|
808
912
|
<div class="name">solana agent</div>
|
|
809
913
|
<div class="sub">jup · nfts · dexs</div>
|
|
810
|
-
<div class="chains"><span class="ch">Solana <img src="https://icons.llamao.fi/icons/chains/rsz_solana?w=24&h=24" alt="" loading="lazy"></span></div>
|
|
914
|
+
<div class="chains"><span class="ch">Solana <img src="https://icons.llamao.fi/icons/chains/rsz_solana?w=24&h=24" alt="" loading="lazy"></span><span class="ch" id="sol-venue-count">88 venues routed</span></div>
|
|
811
915
|
</div>
|
|
812
916
|
<div class="node route p-btc">
|
|
813
917
|
<div class="tag">profile</div>
|
package/src/cards.mjs
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// Telegram card TEXT renderers.
|
|
2
|
+
//
|
|
3
|
+
// Implements skills/oracle-chain-graphs-telegram-cards/SKILL.md. That spec has
|
|
4
|
+
// existed with no code behind it, so Oracle users were getting raw JSON instead
|
|
5
|
+
// of readable alert cards. This module is the text half only: chart/image
|
|
6
|
+
// rendering lives elsewhere and MUST NOT be required for a card to send.
|
|
7
|
+
//
|
|
8
|
+
// Contract for every exported renderer:
|
|
9
|
+
// - pure, synchronous, string in / string out
|
|
10
|
+
// - NO network, NO signing, NO filesystem, NO env reads, NO mutation of input
|
|
11
|
+
// - unknown data renders as the literal string UNKNOWN — never blank, never
|
|
12
|
+
// guessed, never interpolated from a neighbouring field
|
|
13
|
+
// - the only identifier (contract address / mint / market id) is NEVER
|
|
14
|
+
// truncated: a half-address is worse than no address because it still looks
|
|
15
|
+
// actionable
|
|
16
|
+
// - chart failure is cosmetic: the text card always returns
|
|
17
|
+
// - buy/sell affordances only when a valid local grant/session is supplied
|
|
18
|
+
//
|
|
19
|
+
// Markdown dialect: Telegram *legacy* Markdown. Values are escaped for `_`,
|
|
20
|
+
// `*`, `[`, `]` and backtick; card chrome supplies its own markers. We never
|
|
21
|
+
// emit `$` at all (the spec calls out repeated `$` spans as a formatting trap)
|
|
22
|
+
// — amounts are suffixed with USD instead.
|
|
23
|
+
|
|
24
|
+
import { chainById } from "./chains.mjs";
|
|
25
|
+
|
|
26
|
+
/** The one and only stand-in for missing data. */
|
|
27
|
+
export const UNKNOWN = "UNKNOWN";
|
|
28
|
+
|
|
29
|
+
export const CARD_KINDS = Object.freeze(["token", "launch", "hip3", "hip4", "polymarket"]);
|
|
30
|
+
|
|
31
|
+
const LEGACY_MD_SPECIALS = /[_*[\]`]/g;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Escape Telegram legacy-Markdown control characters in a *value*.
|
|
35
|
+
* Card chrome (bold headers) is written unescaped by the renderers themselves.
|
|
36
|
+
* @param {unknown} value
|
|
37
|
+
* @returns {string} escaped text, or UNKNOWN when there is nothing to show
|
|
38
|
+
*/
|
|
39
|
+
export function escapeMd(value) {
|
|
40
|
+
if (!isPresent(value)) return UNKNOWN;
|
|
41
|
+
return String(value).replace(LEGACY_MD_SPECIALS, (c) => `\\${c}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isPresent(value) {
|
|
45
|
+
if (value === null || value === undefined) return false;
|
|
46
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
47
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A value that must survive verbatim (addresses, mints, market ids). */
|
|
52
|
+
function code(value) {
|
|
53
|
+
if (!isPresent(value)) return UNKNOWN;
|
|
54
|
+
// Backticks would close the span; strip rather than truncate the identifier.
|
|
55
|
+
const raw = String(value).replace(/`/g, "");
|
|
56
|
+
return raw === "" ? UNKNOWN : `\`${raw}\``;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function num(value, { decimals = 2 } = {}) {
|
|
60
|
+
if (!isPresent(value)) return UNKNOWN;
|
|
61
|
+
const n = typeof value === "bigint" ? Number(value) : Number(value);
|
|
62
|
+
if (!Number.isFinite(n)) return UNKNOWN;
|
|
63
|
+
return n.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: decimals });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Money. Deliberately no `$` — repeated dollar spans break Telegram parsing. */
|
|
67
|
+
function usd(value, { decimals = 2 } = {}) {
|
|
68
|
+
const n = num(value, { decimals });
|
|
69
|
+
return n === UNKNOWN ? UNKNOWN : `${n} USD`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function pct(value, { decimals = 2 } = {}) {
|
|
73
|
+
const n = num(value, { decimals });
|
|
74
|
+
return n === UNKNOWN ? UNKNOWN : `${n}%`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function bps(value) {
|
|
78
|
+
const n = num(value, { decimals: 0 });
|
|
79
|
+
return n === UNKNOWN ? UNKNOWN : `${n} bps`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Exact base-unit -> decimal string. BigInt only: a raw quote like
|
|
84
|
+
* 24325001237995579150138 loses precision the moment it touches a float.
|
|
85
|
+
*/
|
|
86
|
+
export function formatUnits(raw, decimals) {
|
|
87
|
+
if (!isPresent(raw)) return UNKNOWN;
|
|
88
|
+
const d = Number(decimals);
|
|
89
|
+
if (!Number.isInteger(d) || d < 0 || d > 77) return UNKNOWN;
|
|
90
|
+
let value;
|
|
91
|
+
try {
|
|
92
|
+
value = BigInt(typeof raw === "string" ? raw.trim() : raw);
|
|
93
|
+
} catch {
|
|
94
|
+
return UNKNOWN;
|
|
95
|
+
}
|
|
96
|
+
const neg = value < 0n;
|
|
97
|
+
const abs = neg ? -value : value;
|
|
98
|
+
const base = 10n ** BigInt(d);
|
|
99
|
+
const whole = (abs / base).toString();
|
|
100
|
+
const frac = (abs % base).toString().padStart(d, "0").replace(/0+$/, "");
|
|
101
|
+
return `${neg ? "-" : ""}${whole}${frac ? `.${frac}` : ""}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Normalize confidence to a stated band. Every card must state one. */
|
|
105
|
+
export function normalizeConfidence(value) {
|
|
106
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
107
|
+
if (value < 0 || value > 1) return UNKNOWN;
|
|
108
|
+
if (value >= 0.75) return "HIGH";
|
|
109
|
+
if (value >= 0.4) return "MEDIUM";
|
|
110
|
+
return "LOW";
|
|
111
|
+
}
|
|
112
|
+
if (typeof value === "string") {
|
|
113
|
+
const v = value.trim().toUpperCase();
|
|
114
|
+
if (v === "HIGH" || v === "MEDIUM" || v === "LOW") return v;
|
|
115
|
+
}
|
|
116
|
+
return UNKNOWN;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function chainLine(data = {}) {
|
|
120
|
+
const id = data.chainId;
|
|
121
|
+
if (!isPresent(id) || !Number.isFinite(Number(id))) {
|
|
122
|
+
return isPresent(data.chain) ? escapeMd(data.chain) : UNKNOWN;
|
|
123
|
+
}
|
|
124
|
+
const known = chainById(id);
|
|
125
|
+
const name = known?.name || (isPresent(data.chain) ? String(data.chain) : UNKNOWN);
|
|
126
|
+
return `${escapeMd(name)} (chainId ${Number(id)})`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function venueLine(data = {}) {
|
|
130
|
+
return escapeMd(data.venue ?? data.dex ?? data.pool?.venue);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Chart status. A graph is evidence, not a dependency — if the image failed we
|
|
135
|
+
* say so and the text card still stands.
|
|
136
|
+
*/
|
|
137
|
+
function chartLine(chart) {
|
|
138
|
+
if (chart === null || chart === undefined) return "NONE (text card only)";
|
|
139
|
+
if (typeof chart === "string") return chart.trim() ? code(chart) : "NONE (text card only)";
|
|
140
|
+
if (chart.error || chart.ok === false || chart.available === false) {
|
|
141
|
+
const why = isPresent(chart.error) ? ` — ${escapeMd(chart.error)}` : "";
|
|
142
|
+
return `UNAVAILABLE${why} (text card stands)`;
|
|
143
|
+
}
|
|
144
|
+
if (isPresent(chart.url)) return code(chart.url);
|
|
145
|
+
return "NONE (text card only)";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Buy/sell affordances are gated on a valid LOCAL grant or session. Absent or
|
|
150
|
+
* expired grant => prepare-only. This function is the single gate; renderers
|
|
151
|
+
* never decide on their own.
|
|
152
|
+
* @returns {{ allowed: boolean, reason: string, actions: string[] }}
|
|
153
|
+
*/
|
|
154
|
+
export function cardActions(data = {}, { now = Date.now() } = {}) {
|
|
155
|
+
const grant = data.grant ?? data.session ?? null;
|
|
156
|
+
if (!grant || typeof grant !== "object") {
|
|
157
|
+
return { allowed: false, reason: "no local grant/session", actions: [] };
|
|
158
|
+
}
|
|
159
|
+
if (grant.local === false) {
|
|
160
|
+
return { allowed: false, reason: "grant is not local", actions: [] };
|
|
161
|
+
}
|
|
162
|
+
if (grant.revoked === true) {
|
|
163
|
+
return { allowed: false, reason: "grant revoked", actions: [] };
|
|
164
|
+
}
|
|
165
|
+
const expiry = grant.expiresAt ?? grant.expiry;
|
|
166
|
+
if (isPresent(expiry)) {
|
|
167
|
+
const at = typeof expiry === "number" ? expiry : Date.parse(expiry);
|
|
168
|
+
if (!Number.isFinite(at)) return { allowed: false, reason: "grant expiry unreadable", actions: [] };
|
|
169
|
+
if (at <= now) return { allowed: false, reason: "grant expired", actions: [] };
|
|
170
|
+
}
|
|
171
|
+
const actions = Array.isArray(grant.actions) && grant.actions.length ? grant.actions.slice() : ["BUY", "SELL"];
|
|
172
|
+
return { allowed: true, reason: "valid local grant", actions };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function actionsLine(data) {
|
|
176
|
+
const gate = cardActions(data);
|
|
177
|
+
return gate.allowed
|
|
178
|
+
? `${gate.actions.map((a) => escapeMd(a)).join(" / ")} (${escapeMd(gate.reason)})`
|
|
179
|
+
: `prepare-only — ${escapeMd(gate.reason)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function build(title, rows, data = {}) {
|
|
183
|
+
const lines = [`*${title}*`];
|
|
184
|
+
for (const [label, value] of rows) {
|
|
185
|
+
// Labels are authored here and contain no markdown specials by construction.
|
|
186
|
+
lines.push(`${label}: ${value === undefined || value === null || value === "" ? UNKNOWN : value}`);
|
|
187
|
+
}
|
|
188
|
+
lines.push(`Chart: ${chartLine(data.chart)}`);
|
|
189
|
+
lines.push(`Actions: ${actionsLine(data)}`);
|
|
190
|
+
if (Array.isArray(data.warnings) && data.warnings.length) {
|
|
191
|
+
lines.push(`Warnings: ${data.warnings.map((w) => escapeMd(w)).join("; ")}`);
|
|
192
|
+
}
|
|
193
|
+
if (isPresent(data.source) || isPresent(data.fetchedAt)) {
|
|
194
|
+
lines.push(`Source: ${escapeMd(data.source)} at ${escapeMd(data.fetchedAt)}`);
|
|
195
|
+
}
|
|
196
|
+
return lines.join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function confidenceRow(data) {
|
|
200
|
+
return ["Confidence", normalizeConfidence(data.confidence)];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function quoteRows(data) {
|
|
204
|
+
const q = data.quote;
|
|
205
|
+
if (!q || typeof q !== "object") return [];
|
|
206
|
+
const human = isPresent(q.decimals) ? formatUnits(q.amountOutRaw ?? q.out ?? q.raw, q.decimals) : UNKNOWN;
|
|
207
|
+
return [
|
|
208
|
+
["Quote out (raw)", code(q.amountOutRaw ?? q.out ?? q.raw)],
|
|
209
|
+
["Quote out", human === UNKNOWN ? UNKNOWN : `${escapeMd(human)} ${escapeMd(q.symbol ?? "")}`.trim()],
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function slippageRow(data) {
|
|
214
|
+
const s = data.autoSlippage;
|
|
215
|
+
if (!s || typeof s !== "object") return ["Auto-slippage", UNKNOWN];
|
|
216
|
+
const sel = bps(s.selectedBps);
|
|
217
|
+
const cap = bps(s.capBps);
|
|
218
|
+
return ["Auto-slippage", sel === UNKNOWN && cap === UNKNOWN ? UNKNOWN : `${sel} selected, cap ${cap}`];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Per-chain token card: price, volume, liquidity, market cap, age, venue. */
|
|
222
|
+
export function renderTokenCard(data = {}) {
|
|
223
|
+
const d = data || {};
|
|
224
|
+
return build(
|
|
225
|
+
`ORACLE TOKEN — ${escapeMd(d.symbol ?? d.token ?? d.name)}`,
|
|
226
|
+
[
|
|
227
|
+
["Chain", chainLine(d)],
|
|
228
|
+
["Venue", venueLine(d)],
|
|
229
|
+
["Name", escapeMd(d.name)],
|
|
230
|
+
["Address", code(d.address ?? d.mint ?? d.contract)],
|
|
231
|
+
["Price", usd(d.priceUsd, { decimals: 8 })],
|
|
232
|
+
["Market cap", usd(d.marketCapUsd)],
|
|
233
|
+
["Liquidity", usd(d.liquidityUsd)],
|
|
234
|
+
["Volume 24h", usd(d.volume24hUsd)],
|
|
235
|
+
["Change 24h", pct(d.priceChange24h)],
|
|
236
|
+
["Fee tier", isPresent(d.feeTier) ? `${bps(Number(d.feeTier) / 100)} (${num(d.feeTier, { decimals: 0 })})` : UNKNOWN],
|
|
237
|
+
["Age", escapeMd(d.age)],
|
|
238
|
+
...quoteRows(d),
|
|
239
|
+
slippageRow(d),
|
|
240
|
+
confidenceRow(d),
|
|
241
|
+
],
|
|
242
|
+
d,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Launch/sniper card: route readiness, sellability, overlap, risk, ticket. */
|
|
247
|
+
export function renderLaunchCard(data = {}) {
|
|
248
|
+
const d = data || {};
|
|
249
|
+
return build(
|
|
250
|
+
`ORACLE LAUNCH — ${escapeMd(d.symbol ?? d.token ?? d.name)}`,
|
|
251
|
+
[
|
|
252
|
+
["Chain", chainLine(d)],
|
|
253
|
+
["Venue", venueLine(d)],
|
|
254
|
+
["Address", code(d.address ?? d.mint ?? d.contract)],
|
|
255
|
+
["Pool", code(d.pool?.address ?? d.poolAddress ?? d.pool)],
|
|
256
|
+
["Liquidity", usd(d.liquidityUsd)],
|
|
257
|
+
["Route ready", escapeMd(d.routeReady)],
|
|
258
|
+
["Sellable", escapeMd(d.sellable)],
|
|
259
|
+
["Sell sim", escapeMd(d.sellSimulation ?? d.sellSim)],
|
|
260
|
+
["Smart wallets", escapeMd(d.smartWalletOverlap)],
|
|
261
|
+
["Risk", escapeMd(d.risk ?? d.riskStatus)],
|
|
262
|
+
["Prepared ticket", escapeMd(d.preparedTicket ?? d.ticketStatus)],
|
|
263
|
+
slippageRow(d),
|
|
264
|
+
confidenceRow(d),
|
|
265
|
+
],
|
|
266
|
+
d,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Hyperliquid HIP-3 builder-dex card. Requires perpDexs + metaAndAssetCtxs. */
|
|
271
|
+
export function renderHip3Card(data = {}) {
|
|
272
|
+
const d = data || {};
|
|
273
|
+
return build(
|
|
274
|
+
`ORACLE HIP-3 — ${escapeMd(d.market ?? d.coin ?? d.name)}`,
|
|
275
|
+
[
|
|
276
|
+
["Venue", `Hyperliquid builder-dex ${escapeMd(d.dex)}`],
|
|
277
|
+
["Market", escapeMd(d.market ?? d.coin)],
|
|
278
|
+
["Mark", usd(d.markPx, { decimals: 6 })],
|
|
279
|
+
["Oracle", usd(d.oraclePx, { decimals: 6 })],
|
|
280
|
+
["Funding", pct(d.funding, { decimals: 6 })],
|
|
281
|
+
["Open interest", usd(d.openInterestUsd)],
|
|
282
|
+
["Depth", escapeMd(d.depth)],
|
|
283
|
+
["Liquidation notes", escapeMd(d.liquidationNotes ?? d.riskNotes)],
|
|
284
|
+
["Account context", escapeMd(d.accountContext)],
|
|
285
|
+
confidenceRow(d),
|
|
286
|
+
],
|
|
287
|
+
d,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Hyperliquid HIP-4 outcome-market card. Public reads are keyless. */
|
|
292
|
+
export function renderHip4Card(data = {}) {
|
|
293
|
+
const d = data || {};
|
|
294
|
+
return build(
|
|
295
|
+
`ORACLE HIP-4 — ${escapeMd(d.event ?? d.market ?? d.name)}`,
|
|
296
|
+
[
|
|
297
|
+
["Venue", `Hyperliquid HIP-4 ${escapeMd(d.dex ?? "outcome")}`],
|
|
298
|
+
["Event", escapeMd(d.event)],
|
|
299
|
+
["Outcome", escapeMd(d.outcome)],
|
|
300
|
+
["Market id", code(d.marketId ?? d.market)],
|
|
301
|
+
["Bid", usd(d.bid, { decimals: 6 })],
|
|
302
|
+
["Ask", usd(d.ask, { decimals: 6 })],
|
|
303
|
+
["Depth", escapeMd(d.depth)],
|
|
304
|
+
["Edge", pct(d.edge)],
|
|
305
|
+
["Position", escapeMd(d.position)],
|
|
306
|
+
confidenceRow(d),
|
|
307
|
+
],
|
|
308
|
+
d,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Polymarket card. Public reads keyless; orders stay prepared/user-signed. */
|
|
313
|
+
export function renderPolymarketCard(data = {}) {
|
|
314
|
+
const d = data || {};
|
|
315
|
+
return build(
|
|
316
|
+
`ORACLE POLYMARKET — ${escapeMd(d.event ?? d.market ?? d.name)}`,
|
|
317
|
+
[
|
|
318
|
+
["Venue", "Polymarket CLOB"],
|
|
319
|
+
["Event", escapeMd(d.event)],
|
|
320
|
+
["Market", escapeMd(d.market)],
|
|
321
|
+
["Market id", code(d.marketId ?? d.conditionId ?? d.tokenId)],
|
|
322
|
+
["Yes", usd(d.yesPrice, { decimals: 4 })],
|
|
323
|
+
["No", usd(d.noPrice, { decimals: 4 })],
|
|
324
|
+
["Best bid / ask", `${usd(d.bestBid, { decimals: 4 })} / ${usd(d.bestAsk, { decimals: 4 })}`],
|
|
325
|
+
["Volume", usd(d.volumeUsd)],
|
|
326
|
+
["Resolution risk", escapeMd(d.resolutionRisk)],
|
|
327
|
+
["Order intent", escapeMd(d.orderIntent)],
|
|
328
|
+
confidenceRow(d),
|
|
329
|
+
],
|
|
330
|
+
d,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const RENDERERS = Object.freeze({
|
|
335
|
+
token: renderTokenCard,
|
|
336
|
+
launch: renderLaunchCard,
|
|
337
|
+
hip3: renderHip3Card,
|
|
338
|
+
hip4: renderHip4Card,
|
|
339
|
+
polymarket: renderPolymarketCard,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Dispatch by surface kind.
|
|
344
|
+
* @param {"token"|"launch"|"hip3"|"hip4"|"polymarket"} kind
|
|
345
|
+
*/
|
|
346
|
+
export function renderCard(kind, data = {}) {
|
|
347
|
+
const fn = RENDERERS[String(kind)];
|
|
348
|
+
if (!fn) throw new Error(`unknown card kind: ${kind} (expected one of ${CARD_KINDS.join(", ")})`);
|
|
349
|
+
return fn(data);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Total soft-fail wrapper. An alert that cannot render is still an alert the
|
|
354
|
+
* user needs to see, so a malformed payload degrades to a minimal card rather
|
|
355
|
+
* than throwing and dropping the notification.
|
|
356
|
+
*/
|
|
357
|
+
export function safeRenderCard(kind, data = {}) {
|
|
358
|
+
try {
|
|
359
|
+
return renderCard(kind, data);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
return [
|
|
362
|
+
`*ORACLE CARD — DEGRADED*`,
|
|
363
|
+
`Kind: ${escapeMd(kind)}`,
|
|
364
|
+
`Chain: ${UNKNOWN}`,
|
|
365
|
+
`Confidence: ${UNKNOWN}`,
|
|
366
|
+
`Render error: ${escapeMd(error?.message)}`,
|
|
367
|
+
].join("\n");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
Binary file
|
|
@@ -99,6 +99,111 @@ export function normalizeRoute(raw) {
|
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Measure how badly a route's price decays with size.
|
|
104
|
+
*
|
|
105
|
+
* WHY THIS EXISTS: a quote against a drained pool is indistinguishable, in shape,
|
|
106
|
+
* from a quote against a deep one -- same fields, no error, just a smaller number.
|
|
107
|
+
* Verified on Robinhood 2026-08-02: a dead Uniswap V3 SQUEEZE pool holding 7.03
|
|
108
|
+
* USDG happily quoted a 205,374-token sell, and every layer downstream treated
|
|
109
|
+
* that as truth. `minOut` does NOT protect against this: it guards the price
|
|
110
|
+
* MOVING between quote and fill, so against a garbage quote it faithfully locks
|
|
111
|
+
* in the garbage.
|
|
112
|
+
*
|
|
113
|
+
* The probe: quote a small slice of the same trade, compare per-unit prices. Real
|
|
114
|
+
* slippage decays smoothly with size; a drained pool falls off a cliff.
|
|
115
|
+
*
|
|
116
|
+
* `probeFn(amountIn) -> amountOut | null`. Returns null when the comparison could
|
|
117
|
+
* not be made, which callers MUST treat as unproven rather than as a pass.
|
|
118
|
+
*/
|
|
119
|
+
export async function measurePriceImpact(probeFn, amountIn, { divisor = 1000n } = {}) {
|
|
120
|
+
const full = bn(amountIn);
|
|
121
|
+
if (full == null || full <= 0n) return null;
|
|
122
|
+
|
|
123
|
+
const probeAmt = full / divisor > 0n ? full / divisor : 1n;
|
|
124
|
+
if (probeAmt >= full) return null; // too small to compare against itself
|
|
125
|
+
|
|
126
|
+
let smallOut;
|
|
127
|
+
let fullOut;
|
|
128
|
+
try {
|
|
129
|
+
[smallOut, fullOut] = await Promise.all([probeFn(probeAmt), probeFn(full)]);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const s = bn(smallOut);
|
|
135
|
+
const f = bn(fullOut);
|
|
136
|
+
if (s == null || f == null || s <= 0n) return null;
|
|
137
|
+
|
|
138
|
+
// Scale to compare per-unit prices without floating point until the last step.
|
|
139
|
+
const smallPx = Number(s) / Number(probeAmt);
|
|
140
|
+
const fullPx = Number(f) / Number(full);
|
|
141
|
+
if (!Number.isFinite(smallPx) || !Number.isFinite(fullPx) || smallPx <= 0) return null;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
impactPct: (1 - fullPx / smallPx) * 100,
|
|
145
|
+
probeAmountIn: probeAmt.toString(),
|
|
146
|
+
probeAmountOut: s.toString(),
|
|
147
|
+
fullAmountOut: f.toString(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reject routes whose price impact exceeds a ceiling.
|
|
153
|
+
*
|
|
154
|
+
* Applied AFTER ranking so the rejection is visible: a caller can see that the
|
|
155
|
+
* nominal winner was dropped and why, rather than silently receiving second place.
|
|
156
|
+
* A route with no impact measurement is NOT dropped here -- absence of evidence is
|
|
157
|
+
* surfaced through `unmeasured` so the caller decides, but it is never presented
|
|
158
|
+
* as a passing measurement.
|
|
159
|
+
*/
|
|
160
|
+
export function applyImpactCeiling(ranked, impacts, { maxImpactPct = 25 } = {}) {
|
|
161
|
+
const rejected = [];
|
|
162
|
+
const unmeasured = [];
|
|
163
|
+
|
|
164
|
+
const kept = (ranked.routes ?? []).filter((r) => {
|
|
165
|
+
const m = impacts?.[r.source];
|
|
166
|
+
if (!m) {
|
|
167
|
+
unmeasured.push(r.source);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (m.impactPct > maxImpactPct) {
|
|
171
|
+
rejected.push({
|
|
172
|
+
source: r.source,
|
|
173
|
+
impactPct: Number(m.impactPct.toFixed(2)),
|
|
174
|
+
reason:
|
|
175
|
+
`price impact ${m.impactPct.toFixed(1)}% exceeds ${maxImpactPct}% ceiling -- ` +
|
|
176
|
+
"the venue cannot absorb this size near the quoted price",
|
|
177
|
+
});
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const warnings = [...(ranked.warnings ?? [])];
|
|
184
|
+
if (rejected.length) {
|
|
185
|
+
warnings.push(
|
|
186
|
+
`dropped ${rejected.length} route(s) on price impact: ` +
|
|
187
|
+
rejected.map((r) => `${r.source} (${r.impactPct}%)`).join(", "),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (unmeasured.length) {
|
|
191
|
+
warnings.push(
|
|
192
|
+
`price impact NOT measured for: ${unmeasured.join(", ")}. These are unproven ` +
|
|
193
|
+
"against thin liquidity, not proven safe.",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
...ranked,
|
|
199
|
+
routes: kept,
|
|
200
|
+
best: kept[0] ?? null,
|
|
201
|
+
impactRejected: rejected,
|
|
202
|
+
impactUnmeasured: unmeasured,
|
|
203
|
+
warnings,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
102
207
|
/**
|
|
103
208
|
* Rank normalized routes by NET output.
|
|
104
209
|
*
|
package/src/router/index.mjs
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// a one-source "best route" is not a comparison at all, so that case is labelled
|
|
6
6
|
// rather than presented as if it beat something.
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
gatherRoutes, rankRoutes, QUALITY, measurePriceImpact, applyImpactCeiling,
|
|
10
|
+
} from "./best-execution.mjs";
|
|
9
11
|
import { swapCandidates, bridgeCandidates, nativeUsd } from "./route-sources.mjs";
|
|
10
12
|
import { llamaPrices } from "../data/providers/defillama.mjs";
|
|
11
13
|
|
|
@@ -93,6 +95,45 @@ export async function bestSwapRoute(p, opts = {}) {
|
|
|
93
95
|
destDecimals: p.decimalsOut ?? dest.decimals,
|
|
94
96
|
});
|
|
95
97
|
|
|
98
|
+
// Liquidity check. Ranking alone will happily crown a quote taken against a
|
|
99
|
+
// drained pool, because a thin venue returns a well-formed number rather than
|
|
100
|
+
// an error. Re-quote the winner at 1/1000th size and compare per-unit price:
|
|
101
|
+
// real slippage decays smoothly, a dead pool falls off a cliff.
|
|
102
|
+
//
|
|
103
|
+
// Opt out with `maxImpactPct: null` for callers that genuinely want the raw
|
|
104
|
+
// ranking (analytics, spread display) rather than an executable route.
|
|
105
|
+
const maxImpactPct = opts.maxImpactPct === undefined ? 25 : opts.maxImpactPct;
|
|
106
|
+
if (maxImpactPct != null && ranked.best) {
|
|
107
|
+
const winner = ranked.best.source;
|
|
108
|
+
const probe = async (amt) => {
|
|
109
|
+
const sub = swapCandidates({
|
|
110
|
+
...p,
|
|
111
|
+
amountIn: amt.toString(),
|
|
112
|
+
nativePriceUsd: nativePricePromise,
|
|
113
|
+
decimalsOut: p.decimalsOut,
|
|
114
|
+
destDecimalsPromise: destPromise,
|
|
115
|
+
opts,
|
|
116
|
+
}).filter((c) => c.source === winner);
|
|
117
|
+
if (!sub.length) return null;
|
|
118
|
+
const got = await gatherRoutes(sub, { timeoutMs: opts.timeoutMs ?? 12_000 });
|
|
119
|
+
return got?.[0]?.amountOut ?? null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const impact = await measurePriceImpact(probe, amountIn);
|
|
123
|
+
if (impact) {
|
|
124
|
+
const guarded = applyImpactCeiling(ranked, { [winner]: impact }, { maxImpactPct });
|
|
125
|
+
return {
|
|
126
|
+
kind: "swap",
|
|
127
|
+
chainId,
|
|
128
|
+
tokenIn,
|
|
129
|
+
tokenOut,
|
|
130
|
+
amountIn: String(amountIn),
|
|
131
|
+
...guarded,
|
|
132
|
+
priceImpact: { [winner]: Number(impact.impactPct.toFixed(2)) },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
96
137
|
return {
|
|
97
138
|
kind: "swap",
|
|
98
139
|
chainId,
|
|
@@ -196,7 +196,18 @@ export async function prepareBestRoute(p, opts = {}) {
|
|
|
196
196
|
|
|
197
197
|
const comparison = await bestSwapRoute(p, opts);
|
|
198
198
|
if (!comparison.best) {
|
|
199
|
-
|
|
199
|
+
// A liquidity rejection is a DIFFERENT failure from "nobody answered", and
|
|
200
|
+
// conflating them hides the reason a live venue was refused.
|
|
201
|
+
const blocked = comparison.impactRejected ?? [];
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
reason: blocked.length
|
|
205
|
+
? blocked[0].reason
|
|
206
|
+
: "no source returned a usable route",
|
|
207
|
+
priceImpactBlocked: blocked.length > 0,
|
|
208
|
+
impactRejected: blocked,
|
|
209
|
+
comparison,
|
|
210
|
+
};
|
|
200
211
|
}
|
|
201
212
|
|
|
202
213
|
// Honour an explicit override, but never silently: picking a non-winner is a
|
|
@@ -331,6 +342,11 @@ export async function prepareBestRoute(p, opts = {}) {
|
|
|
331
342
|
grossOut: chosen.grossOut,
|
|
332
343
|
gasUsd: chosen.gasUsd,
|
|
333
344
|
},
|
|
345
|
+
// Carry the liquidity measurement through to the caller. Without this the
|
|
346
|
+
// guard runs but its result is invisible, so a consumer cannot tell a route
|
|
347
|
+
// that PASSED the ceiling from one that was never measured at all.
|
|
348
|
+
priceImpact: comparison.priceImpact ?? null,
|
|
349
|
+
impactUnmeasured: comparison.impactUnmeasured ?? [],
|
|
334
350
|
...prepared,
|
|
335
351
|
unsigned: true,
|
|
336
352
|
signedBy: "user-wallet",
|