@oracle-agent/oracle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +98 -0
- package/LICENSE +202 -0
- package/README.md +384 -0
- package/SECURITY.md +89 -0
- package/SETUP.md +235 -0
- package/artifacts/inscription/oracle-was-here-preview.png +0 -0
- package/artifacts/inscription/oracle-was-here.svg +1 -0
- package/artifacts/specialist-packs/oracle-full-crypto.json +273 -0
- package/bin/desk-server.mjs +438 -0
- package/bin/oracle-data-mcp.mjs +603 -0
- package/bin/oracle-init.mjs +423 -0
- package/bin/oracle-public-server.mjs +36 -0
- package/bin/oracle-route.mjs +254 -0
- package/bin/oracle-scan.mjs +192 -0
- package/docs/adding-a-chain.md +229 -0
- package/docs/architecture.md +135 -0
- package/docs/profiles.md +132 -0
- package/examples/add-a-chain.mjs +65 -0
- package/examples/research-a-token.mjs +70 -0
- package/package.json +90 -0
- package/profiles/_template/SOUL.md +47 -0
- package/profiles/_template/profile.json +22 -0
- package/profiles/bitcoin-agent/SOUL.md +31 -0
- package/profiles/bitcoin-agent/profile.json +32 -0
- package/profiles/hyperliquid-agent/SOUL.md +34 -0
- package/profiles/hyperliquid-agent/profile.json +37 -0
- package/profiles/oracle/SOUL.md +65 -0
- package/profiles/oracle/profile.json +35 -0
- package/profiles/polymarket-agent/SOUL.md +35 -0
- package/profiles/polymarket-agent/profile.json +34 -0
- package/profiles/profile.schema.json +90 -0
- package/profiles/protocol-builder/SOUL.md +50 -0
- package/profiles/protocol-builder/profile.json +37 -0
- package/profiles/robinhood-agent/SOUL.md +45 -0
- package/profiles/robinhood-agent/profile.json +39 -0
- package/profiles/solana-agent/SOUL.md +37 -0
- package/profiles/solana-agent/profile.json +37 -0
- package/profiles/stable-agent/SOUL.md +43 -0
- package/profiles/stable-agent/profile.json +37 -0
- package/public/oracle-console/app.js +272 -0
- package/public/oracle-console/bitcoin-wallets.js +206 -0
- package/public/oracle-console/index.html +91 -0
- package/public/oracle-console/styles.css +239 -0
- package/public/oracle-splash/index.html +931 -0
- package/scripts/build-inscription.py +230 -0
- package/scripts/check-test-count.mjs +105 -0
- package/scripts/e2e-hl-markets.mjs +21 -0
- package/scripts/e2e-hl-perps.mjs +48 -0
- package/scripts/e2e-hypercore-staking.mjs +128 -0
- package/scripts/e2e-solana-bitcoin.mjs +183 -0
- package/scripts/public-api-scan.mjs +23 -0
- package/scripts/secret-scan.mjs +181 -0
- package/scripts/verify-v3-venues.mjs +192 -0
- package/skills/oracle-best-execution/SKILL.md +127 -0
- package/skills/oracle-bitcoin/SKILL.md +53 -0
- package/skills/oracle-chain-graphs-telegram-cards/SKILL.md +59 -0
- package/skills/oracle-circuit-breaker/SKILL.md +51 -0
- package/skills/oracle-contract-research/SKILL.md +55 -0
- package/skills/oracle-desk/SKILL.md +58 -0
- package/skills/oracle-dex-launch/SKILL.md +38 -0
- package/skills/oracle-grants/SKILL.md +69 -0
- package/skills/oracle-hypercore-staking/SKILL.md +57 -0
- package/skills/oracle-hyperliquid/SKILL.md +56 -0
- package/skills/oracle-meme-token-sniper/SKILL.md +73 -0
- package/skills/oracle-nft-gacha-launch/SKILL.md +48 -0
- package/skills/oracle-nft-mint-gas-war/SKILL.md +63 -0
- package/skills/oracle-polymarket/SKILL.md +60 -0
- package/skills/oracle-protocol-builder/SKILL.md +38 -0
- package/skills/oracle-protocol-security/SKILL.md +60 -0
- package/skills/oracle-public-product/SKILL.md +44 -0
- package/skills/oracle-receipts/SKILL.md +52 -0
- package/skills/oracle-rfq-tokenized-assets/SKILL.md +69 -0
- package/skills/oracle-smart-wallet-scanner/SKILL.md +49 -0
- package/skills/oracle-solana/SKILL.md +65 -0
- package/skills/oracle-solana-nft/SKILL.md +54 -0
- package/skills/oracle-token-research/SKILL.md +67 -0
- package/src/agent-auth.mjs +191 -0
- package/src/approval-guard.mjs +282 -0
- package/src/attestation-secret.mjs +88 -0
- package/src/audit-log.mjs +196 -0
- package/src/auto-slippage.mjs +378 -0
- package/src/capability-posture.mjs +125 -0
- package/src/chains.mjs +62 -0
- package/src/data/catalog.mjs +495 -0
- package/src/data/desk-data.mjs +623 -0
- package/src/data/http.mjs +200 -0
- package/src/data/provider-endpoint.mjs +94 -0
- package/src/data/providers/aerodrome.mjs +244 -0
- package/src/data/providers/balancer.mjs +208 -0
- package/src/data/providers/bitcoin-esplora.mjs +230 -0
- package/src/data/providers/bitcoin-meta.mjs +378 -0
- package/src/data/providers/blockscout.mjs +14 -0
- package/src/data/providers/bridges.mjs +241 -0
- package/src/data/providers/cowswap.mjs +501 -0
- package/src/data/providers/curve.mjs +200 -0
- package/src/data/providers/defillama.mjs +88 -0
- package/src/data/providers/dexscreener.mjs +43 -0
- package/src/data/providers/evm-rpc.mjs +203 -0
- package/src/data/providers/geckoterminal.mjs +34 -0
- package/src/data/providers/gmx.mjs +495 -0
- package/src/data/providers/hl-info.mjs +102 -0
- package/src/data/providers/hl-markets.mjs +210 -0
- package/src/data/providers/hl-perps.mjs +382 -0
- package/src/data/providers/hl-staking.mjs +352 -0
- package/src/data/providers/hl-ws.mjs +119 -0
- package/src/data/providers/hyperevm-dex.mjs +49 -0
- package/src/data/providers/jupiter.mjs +182 -0
- package/src/data/providers/lifi.mjs +150 -0
- package/src/data/providers/magiceden-sol.mjs +355 -0
- package/src/data/providers/morpho.mjs +173 -0
- package/src/data/providers/odos.mjs +155 -0
- package/src/data/providers/oneinch.mjs +173 -0
- package/src/data/providers/opensea-multichain.mjs +136 -0
- package/src/data/providers/opensea-nft.mjs +99 -0
- package/src/data/providers/paraswap.mjs +117 -0
- package/src/data/providers/pendle.mjs +187 -0
- package/src/data/providers/poly-public.mjs +96 -0
- package/src/data/providers/poly-ws.mjs +103 -0
- package/src/data/providers/rh-agent.mjs +59 -0
- package/src/data/providers/satflow.mjs +336 -0
- package/src/data/providers/solana-rpc.mjs +186 -0
- package/src/data/providers/uniswap-v3.mjs +303 -0
- package/src/data/providers/zerox.mjs +166 -0
- package/src/data/public-api-scan.mjs +61 -0
- package/src/data/quote-placeholder.mjs +31 -0
- package/src/exact-integer.mjs +72 -0
- package/src/exec-policy.mjs +444 -0
- package/src/flags.mjs +15 -0
- package/src/fresh-window.mjs +76 -0
- package/src/gmx-attestation.mjs +175 -0
- package/src/index.mjs +50 -0
- package/src/nft-gas-war-guard.mjs +139 -0
- package/src/onboarding/agent-keys.mjs +157 -0
- package/src/onboarding/index.mjs +18 -0
- package/src/onboarding/tiers.mjs +139 -0
- package/src/oracle-env.mjs +38 -0
- package/src/protocol-execution.mjs +84 -0
- package/src/public-api/buzz-integration.mjs +256 -0
- package/src/public-api/connect-agent.mjs +397 -0
- package/src/public-api/grants.mjs +142 -0
- package/src/public-api/http.mjs +374 -0
- package/src/public-control/aa-adapter.mjs +402 -0
- package/src/public-control/build-registry.mjs +227 -0
- package/src/public-control/bundler-client.mjs +372 -0
- package/src/public-control/grant-indexer.mjs +296 -0
- package/src/public-control/policy-render.mjs +69 -0
- package/src/public-control/policy-schema.mjs +318 -0
- package/src/public-control/runtime-config.mjs +265 -0
- package/src/public-control/session-key-model.mjs +374 -0
- package/src/public-control/session-orchestrator.mjs +412 -0
- package/src/route-attestation.mjs +132 -0
- package/src/router/best-execution.mjs +221 -0
- package/src/router/index.mjs +185 -0
- package/src/router/prepare-bridge.mjs +288 -0
- package/src/router/prepare-route.mjs +341 -0
- package/src/router/proposal.mjs +311 -0
- package/src/router/risk-classifier.mjs +119 -0
- package/src/router/route-sources.mjs +292 -0
- package/src/scanner/chains.config.mjs +381 -0
- package/src/scanner/contract.mjs +270 -0
- package/src/scanner/evm-scanner.mjs +394 -0
- package/src/scanner/index.mjs +9 -0
- package/src/scanner/v2-venue.mjs +335 -0
- package/src/scanner/v3-venue.mjs +290 -0
- package/src/scopes.mjs +44 -0
- package/src/sell-simulation.mjs +167 -0
- package/src/token-transfer-guard.mjs +188 -0
- package/src/vault-attestation.mjs +145 -0
- package/src/venues.mjs +206 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Best-execution router.
|
|
2
|
+
//
|
|
3
|
+
// The naive version of this picks the biggest amountOut and calls it a day. That is
|
|
4
|
+
// wrong often enough to matter, for three reasons:
|
|
5
|
+
//
|
|
6
|
+
// 1. GAS IS PART OF THE PRICE. A route that returns 0.2% more but costs $14 more
|
|
7
|
+
// in gas is worse on a $500 swap and better on a $50k one. The crossover point
|
|
8
|
+
// depends on trade size, so a fixed preference for "best quote" is wrong on one
|
|
9
|
+
// side of it. We convert gas to the destination asset and subtract.
|
|
10
|
+
//
|
|
11
|
+
// 2. QUOTES ARE NOT COMMITMENTS. Aggregators return an expected output that moves
|
|
12
|
+
// with the block. What protects you is minOut, so a route with a tight quote and
|
|
13
|
+
// a hard guarantee can beat a fatter quote with a loose one.
|
|
14
|
+
//
|
|
15
|
+
// 3. SOURCES DISAGREE ABOUT WHAT THEY MEAN. Some return amounts net of fees, some
|
|
16
|
+
// gross; some price gas in native, some in USD, some not at all. Comparing raw
|
|
17
|
+
// fields across them is comparing different things wearing the same name.
|
|
18
|
+
//
|
|
19
|
+
// So: normalize every source to one shape, mark what is MEASURED vs MISSING, and rank
|
|
20
|
+
// on net output. Where a cost is unknown we say so rather than treating it as zero --
|
|
21
|
+
// silently scoring an unknown as free is how the worst route wins a comparison.
|
|
22
|
+
|
|
23
|
+
import { EVIDENCE } from "../scanner/contract.mjs";
|
|
24
|
+
|
|
25
|
+
/** How a route's numbers were obtained. Drives both ranking and disclosure. */
|
|
26
|
+
export const QUALITY = Object.freeze({
|
|
27
|
+
// Output and gas both measured. Directly comparable.
|
|
28
|
+
FULL: "FULL",
|
|
29
|
+
// Output measured, gas unknown. Comparable only with the caveat stated.
|
|
30
|
+
NO_GAS: "NO_GAS",
|
|
31
|
+
// Source errored or returned nothing usable.
|
|
32
|
+
FAILED: "FAILED",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Chain -> the coingecko-ish key DefiLlama uses for the native asset. */
|
|
36
|
+
const NATIVE_PRICE_KEY = {
|
|
37
|
+
1: "coingecko:ethereum",
|
|
38
|
+
10: "coingecko:ethereum",
|
|
39
|
+
56: "coingecko:binancecoin",
|
|
40
|
+
137: "coingecko:matic-network",
|
|
41
|
+
8453: "coingecko:ethereum",
|
|
42
|
+
42161: "coingecko:ethereum",
|
|
43
|
+
43114: "coingecko:avalanche-2",
|
|
44
|
+
999: "coingecko:ethereum",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const bn = (v) => {
|
|
48
|
+
try {
|
|
49
|
+
if (v == null || v === "") return null;
|
|
50
|
+
return BigInt(String(v).split(".")[0]);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Normalize one source's response into the shape the ranker consumes.
|
|
58
|
+
*
|
|
59
|
+
* Every field is explicit about whether it was measured. `gasUsd: null` means "we do
|
|
60
|
+
* not know", never "free".
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeRoute(raw) {
|
|
63
|
+
const {
|
|
64
|
+
source,
|
|
65
|
+
amountOut,
|
|
66
|
+
minOut = null,
|
|
67
|
+
gasUsd = null,
|
|
68
|
+
feeUsd = null,
|
|
69
|
+
estimatedGas = null,
|
|
70
|
+
error = null,
|
|
71
|
+
executionReady = false,
|
|
72
|
+
meta = {},
|
|
73
|
+
} = raw || {};
|
|
74
|
+
|
|
75
|
+
if (error || amountOut == null) {
|
|
76
|
+
return {
|
|
77
|
+
source,
|
|
78
|
+
quality: QUALITY.FAILED,
|
|
79
|
+
amountOut: null,
|
|
80
|
+
error: error || "no amountOut returned",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const out = bn(amountOut);
|
|
85
|
+
if (out == null || out <= 0n) {
|
|
86
|
+
return { source, quality: QUALITY.FAILED, amountOut: null, error: "non-positive amountOut" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
source,
|
|
91
|
+
quality: gasUsd == null ? QUALITY.NO_GAS : QUALITY.FULL,
|
|
92
|
+
amountOut: out.toString(),
|
|
93
|
+
minOut: bn(minOut)?.toString() ?? null,
|
|
94
|
+
gasUsd,
|
|
95
|
+
feeUsd,
|
|
96
|
+
estimatedGas: estimatedGas == null ? null : String(estimatedGas),
|
|
97
|
+
executionReady,
|
|
98
|
+
meta,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Rank normalized routes by NET output.
|
|
104
|
+
*
|
|
105
|
+
* net = amountOut - (gasUsd + feeUsd converted into the destination asset)
|
|
106
|
+
*
|
|
107
|
+
* Requires the destination asset's USD price and decimals to do the conversion. When
|
|
108
|
+
* those are unavailable we fall back to gross ranking and SAY SO in the result, so a
|
|
109
|
+
* caller is never left believing gas was accounted for when it was not.
|
|
110
|
+
*/
|
|
111
|
+
export function rankRoutes(routes, { destPriceUsd = null, destDecimals = null } = {}) {
|
|
112
|
+
const usable = routes.filter((r) => r.quality !== QUALITY.FAILED);
|
|
113
|
+
const failed = routes.filter((r) => r.quality === QUALITY.FAILED);
|
|
114
|
+
|
|
115
|
+
const canNet =
|
|
116
|
+
destPriceUsd != null && destPriceUsd > 0 && destDecimals != null && destDecimals >= 0;
|
|
117
|
+
|
|
118
|
+
const scored = usable.map((r) => {
|
|
119
|
+
const gross = BigInt(r.amountOut);
|
|
120
|
+
let costInDest = 0n;
|
|
121
|
+
let costAccounted = false;
|
|
122
|
+
|
|
123
|
+
if (canNet) {
|
|
124
|
+
const totalCostUsd = (r.gasUsd ?? 0) + (r.feeUsd ?? 0);
|
|
125
|
+
if (totalCostUsd > 0) {
|
|
126
|
+
// usd -> destination units
|
|
127
|
+
const units = (totalCostUsd / destPriceUsd) * 10 ** destDecimals;
|
|
128
|
+
if (Number.isFinite(units) && units >= 0) {
|
|
129
|
+
costInDest = BigInt(Math.floor(units));
|
|
130
|
+
costAccounted = true;
|
|
131
|
+
}
|
|
132
|
+
} else if (r.gasUsd === 0) {
|
|
133
|
+
costAccounted = true; // genuinely gasless (e.g. an intent filled by a solver)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const net = gross > costInDest ? gross - costInDest : 0n;
|
|
138
|
+
return {
|
|
139
|
+
...r,
|
|
140
|
+
grossOut: gross.toString(),
|
|
141
|
+
netOut: net.toString(),
|
|
142
|
+
costInDest: costInDest.toString(),
|
|
143
|
+
costAccounted,
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Sort by net desc. A route whose gas we could not measure sorts on gross, which
|
|
148
|
+
// flatters it -- hence the explicit warning below rather than a silent ordering.
|
|
149
|
+
scored.sort((a, b) => (BigInt(b.netOut) > BigInt(a.netOut) ? 1 : -1));
|
|
150
|
+
|
|
151
|
+
const unaccounted = scored.filter((r) => !r.costAccounted).map((r) => r.source);
|
|
152
|
+
const warnings = [];
|
|
153
|
+
if (!canNet) {
|
|
154
|
+
warnings.push(
|
|
155
|
+
"destination price/decimals unavailable: ranked on GROSS output. Gas is not " +
|
|
156
|
+
"included, so a cheap-looking route may cost more to execute.",
|
|
157
|
+
);
|
|
158
|
+
} else if (unaccounted.length) {
|
|
159
|
+
warnings.push(
|
|
160
|
+
`gas not reported by: ${unaccounted.join(", ")}. These are ranked on gross ` +
|
|
161
|
+
"output and may be overstated relative to gas-measured routes.",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Spread between the winner and the runner-up -- but ONLY when that comparison is
|
|
166
|
+
// apples to apples.
|
|
167
|
+
//
|
|
168
|
+
// Subtle trap worth naming: computing this from the two best COST-ACCOUNTED routes
|
|
169
|
+
// while the actual winner has unknown gas produces a number that reads as "the
|
|
170
|
+
// winner beats second place by X" when it is really "third beats fourth by X".
|
|
171
|
+
// When the top route's cost is unmeasured, the honest answer is null.
|
|
172
|
+
let improvementBps = null;
|
|
173
|
+
let spreadBasis = null;
|
|
174
|
+
if (scored.length >= 2 && scored[0].costAccounted && scored[1].costAccounted) {
|
|
175
|
+
const best = BigInt(scored[0].netOut);
|
|
176
|
+
const next = BigInt(scored[1].netOut);
|
|
177
|
+
if (next > 0n) {
|
|
178
|
+
improvementBps = Number(((best - next) * 10_000n) / next);
|
|
179
|
+
spreadBasis = `${scored[0].source} vs ${scored[1].source}`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
best: scored[0] ?? null,
|
|
185
|
+
routes: scored,
|
|
186
|
+
failed,
|
|
187
|
+
improvementBps,
|
|
188
|
+
spreadBasis,
|
|
189
|
+
rankedOn: canNet ? "net-of-cost" : "gross",
|
|
190
|
+
warnings,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Run every candidate source concurrently, tolerating individual failures.
|
|
196
|
+
*
|
|
197
|
+
* One aggregator being down, rate-limited, or missing an API key must never sink the
|
|
198
|
+
* comparison -- that would make the router less reliable than calling a single venue
|
|
199
|
+
* directly, which defeats the purpose.
|
|
200
|
+
*/
|
|
201
|
+
export async function gatherRoutes(candidates, { timeoutMs = 12_000 } = {}) {
|
|
202
|
+
const withTimeout = (p, source) =>
|
|
203
|
+
Promise.race([
|
|
204
|
+
p,
|
|
205
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error(`${source}: timeout`)), timeoutMs)),
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
const settled = await Promise.allSettled(
|
|
209
|
+
candidates.map((c) => withTimeout(Promise.resolve().then(c.run), c.source)),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
return settled.map((s, i) => {
|
|
213
|
+
const source = candidates[i].source;
|
|
214
|
+
if (s.status === "rejected") {
|
|
215
|
+
return normalizeRoute({ source, error: String(s.reason?.message || s.reason) });
|
|
216
|
+
}
|
|
217
|
+
return normalizeRoute({ source, ...s.value });
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export { NATIVE_PRICE_KEY };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Public entry point: find the best swap or bridge route.
|
|
2
|
+
//
|
|
3
|
+
// Returns a ranked comparison, not just a winner. Showing the runners-up and the
|
|
4
|
+
// spread is what lets a caller sanity-check the result instead of trusting it -- and
|
|
5
|
+
// a one-source "best route" is not a comparison at all, so that case is labelled
|
|
6
|
+
// rather than presented as if it beat something.
|
|
7
|
+
|
|
8
|
+
import { gatherRoutes, rankRoutes, QUALITY } from "./best-execution.mjs";
|
|
9
|
+
import { swapCandidates, bridgeCandidates, nativeUsd } from "./route-sources.mjs";
|
|
10
|
+
import { llamaPrices } from "../data/providers/defillama.mjs";
|
|
11
|
+
|
|
12
|
+
/** DefiLlama key for an ERC-20 on a given chain. */
|
|
13
|
+
const LLAMA_CHAIN = {
|
|
14
|
+
1: "ethereum",
|
|
15
|
+
10: "optimism",
|
|
16
|
+
56: "bsc",
|
|
17
|
+
137: "polygon",
|
|
18
|
+
8453: "base",
|
|
19
|
+
42161: "arbitrum",
|
|
20
|
+
43114: "avax",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
async function destPrice(chainId, token, opts) {
|
|
24
|
+
const chain = LLAMA_CHAIN[Number(chainId)];
|
|
25
|
+
if (!chain) return { priceUsd: null, decimals: null };
|
|
26
|
+
const key = `${chain}:${token}`;
|
|
27
|
+
try {
|
|
28
|
+
const res = await llamaPrices([key], opts);
|
|
29
|
+
const hit = res?.coins?.[key];
|
|
30
|
+
const p = Number(hit?.price);
|
|
31
|
+
const d = Number(hit?.decimals);
|
|
32
|
+
return {
|
|
33
|
+
priceUsd: Number.isFinite(p) ? p : null,
|
|
34
|
+
decimals: Number.isFinite(d) ? d : null,
|
|
35
|
+
};
|
|
36
|
+
} catch {
|
|
37
|
+
return { priceUsd: null, decimals: null };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Best same-chain swap route.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} p
|
|
45
|
+
* @param {number} p.chainId
|
|
46
|
+
* @param {string} p.tokenIn
|
|
47
|
+
* @param {string} p.tokenOut
|
|
48
|
+
* @param {string|bigint} p.amountIn raw units of tokenIn
|
|
49
|
+
* @param {string} [p.taker] address used for quoting only
|
|
50
|
+
* @param {number} [p.decimalsIn] enables ParaSwap
|
|
51
|
+
* @param {number} [p.decimalsOut] enables ParaSwap + net ranking
|
|
52
|
+
*/
|
|
53
|
+
export async function bestSwapRoute(p, opts = {}) {
|
|
54
|
+
const { chainId, tokenIn, tokenOut, amountIn } = p;
|
|
55
|
+
if (chainId == null || !tokenIn || !tokenOut || amountIn == null) {
|
|
56
|
+
throw new Error("bestSwapRoute requires chainId, tokenIn, tokenOut, amountIn");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Prices and quotes run CONCURRENTLY, not one after the other.
|
|
60
|
+
//
|
|
61
|
+
// The obvious shape -- await the prices, then fan out the quotes -- adds the price
|
|
62
|
+
// latency to every single comparison. Measured on Base: ~890ms of DefiLlama in
|
|
63
|
+
// front of ~3.3s of quotes, for 4.7s total when the true floor is 3.3s.
|
|
64
|
+
//
|
|
65
|
+
// Neither price is needed to ASK for a quote:
|
|
66
|
+
// * destPrice is only used at RANK time, after every quote has returned
|
|
67
|
+
// * nativePriceUsd is only used by the 0x adapter to convert its native-wei gas
|
|
68
|
+
// figure, so it is passed as a PROMISE and awaited inside that one adapter
|
|
69
|
+
//
|
|
70
|
+
// So the whole price fetch hides behind the slowest quote instead of preceding it.
|
|
71
|
+
const nativePricePromise = nativeUsd(chainId, opts);
|
|
72
|
+
const destPromise = destPrice(chainId, tokenOut, opts);
|
|
73
|
+
|
|
74
|
+
const candidates = swapCandidates({
|
|
75
|
+
...p,
|
|
76
|
+
nativePriceUsd: nativePricePromise,
|
|
77
|
+
decimalsOut: p.decimalsOut,
|
|
78
|
+
destDecimalsPromise: destPromise,
|
|
79
|
+
opts,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const [routes, dest] = await Promise.all([
|
|
83
|
+
gatherRoutes(candidates, { timeoutMs: opts.timeoutMs ?? 12_000 }),
|
|
84
|
+
destPromise,
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
// Swallow an unused rejection so a failed price lookup cannot surface as an
|
|
88
|
+
// unhandled rejection when no adapter happened to await it.
|
|
89
|
+
nativePricePromise.catch(() => {});
|
|
90
|
+
|
|
91
|
+
const ranked = rankRoutes(routes, {
|
|
92
|
+
destPriceUsd: dest.priceUsd,
|
|
93
|
+
destDecimals: p.decimalsOut ?? dest.decimals,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
kind: "swap",
|
|
98
|
+
chainId,
|
|
99
|
+
tokenIn,
|
|
100
|
+
tokenOut,
|
|
101
|
+
amountIn: String(amountIn),
|
|
102
|
+
...ranked,
|
|
103
|
+
sourcesTried: candidates.length,
|
|
104
|
+
sourcesAnswered: routes.filter((r) => r.quality !== QUALITY.FAILED).length,
|
|
105
|
+
note: describe(ranked, candidates.length),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Best cross-chain bridge route.
|
|
111
|
+
*
|
|
112
|
+
* Duration is reported, never folded into the score. A route that saves $2 and takes
|
|
113
|
+
* 30 minutes is not strictly better than one costing $2 more that lands in 20
|
|
114
|
+
* seconds -- that trade-off belongs to the caller, and burying it in a single number
|
|
115
|
+
* would hide the only thing distinguishing two similar quotes.
|
|
116
|
+
*/
|
|
117
|
+
export async function bestBridgeRoute(p, opts = {}) {
|
|
118
|
+
const { fromChainId, toChainId, tokenIn, tokenOut, amountIn } = p;
|
|
119
|
+
if (fromChainId == null || toChainId == null || !tokenIn || !tokenOut || amountIn == null) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
"bestBridgeRoute requires fromChainId, toChainId, tokenIn, tokenOut, amountIn",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (Number(fromChainId) === Number(toChainId)) {
|
|
125
|
+
throw new Error("bestBridgeRoute is for cross-chain; use bestSwapRoute for same-chain");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Same concurrency rule as swaps: the destination price is only needed at RANK
|
|
129
|
+
// time, so fetching it before the quotes would add its latency to every bridge
|
|
130
|
+
// comparison for no reason.
|
|
131
|
+
const destPromise = destPrice(toChainId, tokenOut, opts);
|
|
132
|
+
const candidates = bridgeCandidates({ ...p, opts });
|
|
133
|
+
const [routes, dest] = await Promise.all([
|
|
134
|
+
gatherRoutes(candidates, { timeoutMs: opts.timeoutMs ?? 15_000 }),
|
|
135
|
+
destPromise,
|
|
136
|
+
]);
|
|
137
|
+
const ranked = rankRoutes(routes, {
|
|
138
|
+
destPriceUsd: dest.priceUsd,
|
|
139
|
+
destDecimals: p.decimalsOut ?? dest.decimals,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const durations = ranked.routes
|
|
143
|
+
.map((r) => ({ source: r.source, seconds: r.meta?.durationSeconds ?? null }))
|
|
144
|
+
.filter((d) => d.seconds != null);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
kind: "bridge",
|
|
148
|
+
fromChainId,
|
|
149
|
+
toChainId,
|
|
150
|
+
tokenIn,
|
|
151
|
+
tokenOut,
|
|
152
|
+
amountIn: String(amountIn),
|
|
153
|
+
...ranked,
|
|
154
|
+
durations,
|
|
155
|
+
sourcesTried: candidates.length,
|
|
156
|
+
sourcesAnswered: routes.filter((r) => r.quality !== QUALITY.FAILED).length,
|
|
157
|
+
note: describe(ranked, candidates.length),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function describe(ranked, tried) {
|
|
162
|
+
if (!ranked.best) return "no source returned a usable route";
|
|
163
|
+
const answered = ranked.routes.length;
|
|
164
|
+
if (answered === 1) {
|
|
165
|
+
return (
|
|
166
|
+
`only 1 of ${tried} sources answered, so this is the ONLY route, not a proven ` +
|
|
167
|
+
"best one. Treat it as unbenchmarked."
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (ranked.improvementBps != null) {
|
|
171
|
+
return (
|
|
172
|
+
`best of ${answered} routes, ranked on ${ranked.rankedOn}: ` +
|
|
173
|
+
`${(ranked.improvementBps / 100).toFixed(2)}% better than the runner-up ` +
|
|
174
|
+
`(${ranked.spreadBasis})`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
// No spread claim when the top two are not measured the same way. Saying "X% better"
|
|
178
|
+
// off a mismatched pair is worse than saying nothing.
|
|
179
|
+
return (
|
|
180
|
+
`best of ${answered} routes, ranked on ${ranked.rankedOn}. No spread quoted: the ` +
|
|
181
|
+
"top routes report cost differently, so the margin is not comparable."
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export { QUALITY };
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Prepare a cross-chain bridge route.
|
|
2
|
+
//
|
|
3
|
+
// Bridging differs from swapping in ways that matter for a signable artifact:
|
|
4
|
+
//
|
|
5
|
+
// 1. IT IS OFTEN MULTIPLE TRANSACTIONS. Relay can return an approval and a deposit
|
|
6
|
+
// as separate items. A caller handed a bare "transaction" would sign the first
|
|
7
|
+
// and believe they were done, leaving funds approved but not bridged. So the
|
|
8
|
+
// artifact is always a LIST, and its length is stated.
|
|
9
|
+
//
|
|
10
|
+
// 2. THE DESTINATION CHAIN IS PART OF THE TRUST DECISION. Every transaction here
|
|
11
|
+
// executes on the ORIGIN chain, but the value lands elsewhere. Signing an
|
|
12
|
+
// origin-chain tx that credits the wrong destination is unrecoverable, so
|
|
13
|
+
// toChainId is echoed alongside the destination address.
|
|
14
|
+
//
|
|
15
|
+
// 3. TIME IS A REAL COST. A bridge that saves $2 and takes 30 minutes is not
|
|
16
|
+
// obviously better than one costing $2 more that lands in 20 seconds. Duration
|
|
17
|
+
// is surfaced, never folded into a score.
|
|
18
|
+
//
|
|
19
|
+
// 4. FINALITY IS NOT ATOMIC. The origin transaction succeeding does NOT mean funds
|
|
20
|
+
// arrived. That gap is where users panic, so it is stated explicitly rather than
|
|
21
|
+
// left implied.
|
|
22
|
+
//
|
|
23
|
+
// Custody is unchanged: artifacts only. This module cannot sign or broadcast.
|
|
24
|
+
|
|
25
|
+
import { bestBridgeRoute } from "./index.mjs";
|
|
26
|
+
|
|
27
|
+
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Placeholder addresses: fine for quoting, never for preparing.
|
|
31
|
+
* Same reasoning as the swap path -- some venues catch this and some do not.
|
|
32
|
+
*/
|
|
33
|
+
const NON_TAKER_ADDRESSES = new Set(
|
|
34
|
+
[
|
|
35
|
+
"0x0000000000000000000000000000000000000000",
|
|
36
|
+
"0x000000000000000000000000000000000000dead",
|
|
37
|
+
"0x0000000000000000000000000000000000000001",
|
|
38
|
+
"0x00000000000000000000000000000000000a1ce5",
|
|
39
|
+
].map((a) => a.toLowerCase()),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const DEFAULT_DRIFT_TOLERANCE_BPS = 100;
|
|
43
|
+
|
|
44
|
+
const PREPARERS = {
|
|
45
|
+
relay: async ({ fromChainId, toChainId, tokenIn, tokenOut, amountIn, taker }, opts) => {
|
|
46
|
+
const { relayPrepare } = await import("../data/providers/bridges.mjs");
|
|
47
|
+
const p = await relayPrepare(
|
|
48
|
+
{
|
|
49
|
+
user: taker,
|
|
50
|
+
originChainId: fromChainId,
|
|
51
|
+
destinationChainId: toChainId,
|
|
52
|
+
originCurrency: tokenIn,
|
|
53
|
+
destinationCurrency: tokenOut,
|
|
54
|
+
amount: String(amountIn),
|
|
55
|
+
},
|
|
56
|
+
opts,
|
|
57
|
+
);
|
|
58
|
+
return {
|
|
59
|
+
transactions: p.transactions || [],
|
|
60
|
+
amountOut: p.quote?.details?.currencyOut?.amount ?? null,
|
|
61
|
+
minOut: p.quote?.details?.currencyOut?.minimumAmount ?? null,
|
|
62
|
+
durationSeconds: p.quote?.details?.timeEstimate ?? null,
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
lifi: async ({ fromChainId, toChainId, tokenIn, tokenOut, amountIn, taker }, opts) => {
|
|
67
|
+
const { lifiPrepare } = await import("../data/providers/lifi.mjs");
|
|
68
|
+
const p = await lifiPrepare(
|
|
69
|
+
{
|
|
70
|
+
fromChain: fromChainId,
|
|
71
|
+
toChain: toChainId,
|
|
72
|
+
fromToken: tokenIn,
|
|
73
|
+
toToken: tokenOut,
|
|
74
|
+
fromAmount: amountIn,
|
|
75
|
+
fromAddress: taker,
|
|
76
|
+
},
|
|
77
|
+
opts,
|
|
78
|
+
);
|
|
79
|
+
// LI.FI returns a single transactionRequest. Normalized into a list so callers
|
|
80
|
+
// handle one shape regardless of source -- a single-item list is honest, a bare
|
|
81
|
+
// object next to Relay's list is a trap.
|
|
82
|
+
return {
|
|
83
|
+
transactions: p.transaction ? [p.transaction] : [],
|
|
84
|
+
requiresApproval: p.requiresApproval ?? null,
|
|
85
|
+
amountOut: p.quote?.estimate?.toAmount ?? null,
|
|
86
|
+
minOut: p.quote?.estimate?.toAmountMin ?? null,
|
|
87
|
+
durationSeconds: p.quote?.estimate?.executionDuration ?? null,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export function supportedBridgePreparers() {
|
|
93
|
+
return Object.keys(PREPARERS);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Compare bridge routes, then prepare the winner.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} p same shape as bestBridgeRoute, plus:
|
|
100
|
+
* @param {string} p.taker the real wallet that will sign
|
|
101
|
+
* @param {string} [p.source] force a specific source
|
|
102
|
+
* @param {number} [p.driftToleranceBps]
|
|
103
|
+
*/
|
|
104
|
+
export async function prepareBestBridgeRoute(p, opts = {}) {
|
|
105
|
+
const { taker, fromChainId, toChainId } = p;
|
|
106
|
+
|
|
107
|
+
if (!taker) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
"prepareBestBridgeRoute requires taker: bridge transactions are built FOR an " +
|
|
110
|
+
"address, and the quote-only placeholder must never end up in one",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (!ADDRESS_RE.test(taker)) {
|
|
114
|
+
throw new Error(`taker must be a valid 20-byte address, got "${taker}"`);
|
|
115
|
+
}
|
|
116
|
+
if (NON_TAKER_ADDRESSES.has(taker.toLowerCase())) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`taker "${taker}" is a placeholder/burn address. Bridging to it would send ` +
|
|
119
|
+
"funds to an address nobody controls, on a chain where you cannot undo it.",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const comparison = await bestBridgeRoute(p, opts);
|
|
124
|
+
if (!comparison.best) {
|
|
125
|
+
return { ok: false, reason: "no source returned a usable bridge route", comparison };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const chosenSource = p.source || comparison.best.source;
|
|
129
|
+
const chosen = comparison.routes.find((r) => r.source === chosenSource);
|
|
130
|
+
if (!chosen) {
|
|
131
|
+
return { ok: false, reason: `source "${chosenSource}" returned no usable route`, comparison };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const prepare = PREPARERS[chosenSource];
|
|
135
|
+
if (!prepare) {
|
|
136
|
+
const alt = comparison.routes.find((r) => PREPARERS[r.source] && r.source !== chosenSource);
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
reason:
|
|
140
|
+
`${chosenSource} won the comparison but has no bridge prepare path in this ` +
|
|
141
|
+
`build. ` +
|
|
142
|
+
(alt
|
|
143
|
+
? `Next preparable route is ${alt.source}; pass source:"${alt.source}".`
|
|
144
|
+
: "No route in this comparison can be prepared."),
|
|
145
|
+
// Machine-readable siblings of the message above. A caller should not have to
|
|
146
|
+
// regex prose to find the fallback -- and the CLI/agent path reads these.
|
|
147
|
+
failureKind: "no-prepare-path",
|
|
148
|
+
...(alt ? { alternative: alt.source } : {}),
|
|
149
|
+
winner: chosenSource,
|
|
150
|
+
comparison,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let prepared;
|
|
155
|
+
try {
|
|
156
|
+
prepared = await prepare(p, opts);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const detail =
|
|
159
|
+
typeof err?.body === "string" ? err.body : err?.body ? JSON.stringify(err.body) : "";
|
|
160
|
+
const msg = [String(err?.message || err), detail].filter(Boolean).join(" :: ");
|
|
161
|
+
|
|
162
|
+
const allowanceIssue = /allowance/i.test(msg);
|
|
163
|
+
const balanceIssue = !allowanceIssue && /not enough|insufficient|balance/i.test(msg);
|
|
164
|
+
const alt = comparison.routes.find((r) => PREPARERS[r.source] && r.source !== chosenSource);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
reason: allowanceIssue
|
|
169
|
+
? `${chosenSource} will not build calldata until the token approval exists ` +
|
|
170
|
+
`on-chain. Approve first, then prepare again. Underlying: ${msg}`
|
|
171
|
+
: balanceIssue
|
|
172
|
+
? `${chosenSource} refused to build: ${msg}. Quoting works for any address; ` +
|
|
173
|
+
`preparing needs the real funded wallet on chain ${fromChainId}.`
|
|
174
|
+
: `${chosenSource} bridge prepare failed: ${msg}`,
|
|
175
|
+
failureKind: allowanceIssue
|
|
176
|
+
? "approval-required-first"
|
|
177
|
+
: balanceIssue
|
|
178
|
+
? "taker-not-funded"
|
|
179
|
+
: "provider-error",
|
|
180
|
+
...(alt ? { alternative: alt.source } : {}),
|
|
181
|
+
comparison,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const txs = prepared.transactions || [];
|
|
186
|
+
if (!txs.length) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
reason: `${chosenSource} returned no executable transaction for this route`,
|
|
190
|
+
failureKind: "provider-error",
|
|
191
|
+
comparison,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Every transaction must execute on the ORIGIN chain. A wrong chainId here means a
|
|
196
|
+
// wallet could be prompted on the wrong network, and the user would be signing
|
|
197
|
+
// something they cannot reason about.
|
|
198
|
+
const wrongChain = txs.filter(
|
|
199
|
+
(t) => t.chainId != null && Number(t.chainId) !== Number(fromChainId),
|
|
200
|
+
);
|
|
201
|
+
if (wrongChain.length) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
reason:
|
|
205
|
+
`${chosenSource} returned a transaction for chain ${wrongChain[0].chainId} but ` +
|
|
206
|
+
`the origin chain is ${fromChainId}. Refusing to hand over a mismatched ` +
|
|
207
|
+
`artifact.`,
|
|
208
|
+
failureKind: "chain-mismatch",
|
|
209
|
+
comparison,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Drift against the comparison. Same reasoning as swaps: the comparison is stale.
|
|
214
|
+
const tolerance = p.driftToleranceBps ?? DEFAULT_DRIFT_TOLERANCE_BPS;
|
|
215
|
+
let driftBps = null;
|
|
216
|
+
let driftWarning = null;
|
|
217
|
+
if (prepared.amountOut != null && chosen.grossOut != null) {
|
|
218
|
+
try {
|
|
219
|
+
const then = BigInt(chosen.grossOut);
|
|
220
|
+
const now = BigInt(prepared.amountOut);
|
|
221
|
+
if (then > 0n) {
|
|
222
|
+
driftBps = Number(((now - then) * 10_000n) / then);
|
|
223
|
+
if (driftBps < -tolerance) {
|
|
224
|
+
driftWarning =
|
|
225
|
+
`route moved ${(driftBps / 100).toFixed(2)}% against you since the ` +
|
|
226
|
+
`comparison (tolerance ${(tolerance / 100).toFixed(2)}%). Re-compare ` +
|
|
227
|
+
`before signing.`;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
/* no fabricated drift */
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const warnings = [
|
|
236
|
+
...(driftWarning ? [driftWarning] : []),
|
|
237
|
+
...(comparison.warnings || []),
|
|
238
|
+
// The single most common bridging surprise.
|
|
239
|
+
`Bridging is NOT atomic: the origin transaction succeeding does not mean funds ` +
|
|
240
|
+
`arrived on chain ${toChainId}. Expect ~${
|
|
241
|
+
prepared.durationSeconds != null ? `${prepared.durationSeconds}s` : "minutes"
|
|
242
|
+
} before the destination credits, and do not re-send if it is pending.`,
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
if (txs.length > 1) {
|
|
246
|
+
warnings.push(
|
|
247
|
+
`This route needs ${txs.length} transactions signed IN ORDER. Signing only the ` +
|
|
248
|
+
`first leaves funds approved but not bridged.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
ok: true,
|
|
254
|
+
chosen: {
|
|
255
|
+
source: chosenSource,
|
|
256
|
+
wasWinner: chosenSource === comparison.best.source,
|
|
257
|
+
netOut: chosen.netOut,
|
|
258
|
+
grossOut: chosen.grossOut,
|
|
259
|
+
gasUsd: chosen.gasUsd,
|
|
260
|
+
},
|
|
261
|
+
artifactKind: "unsigned-transaction-sequence",
|
|
262
|
+
fromChainId: Number(fromChainId),
|
|
263
|
+
toChainId: Number(toChainId),
|
|
264
|
+
transactionCount: txs.length,
|
|
265
|
+
transactions: txs,
|
|
266
|
+
requiresApproval: prepared.requiresApproval ?? null,
|
|
267
|
+
minOut: prepared.minOut ?? null,
|
|
268
|
+
durationSeconds: prepared.durationSeconds ?? null,
|
|
269
|
+
// Where value goes on the origin chain. The first destination is the bridge entry.
|
|
270
|
+
destination: txs[0]?.to ?? null,
|
|
271
|
+
unsigned: true,
|
|
272
|
+
signedBy: "user-wallet",
|
|
273
|
+
driftBps,
|
|
274
|
+
warnings,
|
|
275
|
+
runnersUp: comparison.routes
|
|
276
|
+
.filter((r) => r.source !== chosenSource)
|
|
277
|
+
.map((r) => ({ source: r.source, netOut: r.netOut, gasUsd: r.gasUsd })),
|
|
278
|
+
comparison: {
|
|
279
|
+
rankedOn: comparison.rankedOn,
|
|
280
|
+
sourcesAnswered: comparison.sourcesAnswered,
|
|
281
|
+
sourcesTried: comparison.sourcesTried,
|
|
282
|
+
improvementBps: comparison.improvementBps,
|
|
283
|
+
},
|
|
284
|
+
note:
|
|
285
|
+
`UNSIGNED. ${txs.length} transaction(s) on chain ${fromChainId}, crediting ` +
|
|
286
|
+
`chain ${toChainId}. Verify every destination address before signing.`,
|
|
287
|
+
};
|
|
288
|
+
}
|