@dev.sail.money/sailor 1.3.0-98 → 1.4.0-244
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/AGENTS.md +7 -7
- package/LICENSE +1 -1
- package/README.md +64 -472
- package/package.json +5 -6
- package/packages/cli/README.md +1 -1
- package/packages/cli/dist/index.cjs +1481 -463
- package/packages/cli/dist/server.cjs +16088 -17900
- package/packages/sdk/README.md +1 -1
- package/packages/sdk/dist/abis/ConfigurablePermission.d.ts +130 -0
- package/packages/sdk/dist/abis/ConfigurablePermission.d.ts.map +1 -0
- package/packages/sdk/dist/abis/ConfigurablePermission.js +79 -0
- package/packages/sdk/dist/abis/ConfigurablePermission.js.map +1 -0
- package/packages/sdk/dist/abis/SailKernel.d.ts +42 -0
- package/packages/sdk/dist/abis/SailKernel.d.ts.map +1 -1
- package/packages/sdk/dist/abis/SailKernel.js +34 -0
- package/packages/sdk/dist/abis/SailKernel.js.map +1 -1
- package/packages/sdk/dist/abis/index.d.ts +1 -0
- package/packages/sdk/dist/abis/index.d.ts.map +1 -1
- package/packages/sdk/dist/abis/index.js +1 -0
- package/packages/sdk/dist/abis/index.js.map +1 -1
- package/packages/sdk/dist/chains.d.ts +9 -7
- package/packages/sdk/dist/chains.d.ts.map +1 -1
- package/packages/sdk/dist/chains.js +103 -32
- package/packages/sdk/dist/chains.js.map +1 -1
- package/packages/sdk/dist/client.d.ts +1 -1
- package/packages/sdk/dist/client.d.ts.map +1 -1
- package/packages/sdk/dist/client.js +137 -16
- package/packages/sdk/dist/client.js.map +1 -1
- package/packages/sdk/dist/deployments.d.ts +6 -4
- package/packages/sdk/dist/deployments.d.ts.map +1 -1
- package/packages/sdk/dist/deployments.js +120 -118
- package/packages/sdk/dist/deployments.js.map +1 -1
- package/packages/sdk/dist/eip712.d.ts +84 -0
- package/packages/sdk/dist/eip712.d.ts.map +1 -1
- package/packages/sdk/dist/eip712.js +148 -0
- package/packages/sdk/dist/eip712.js.map +1 -1
- package/packages/sdk/dist/fees.d.ts +2 -1
- package/packages/sdk/dist/fees.d.ts.map +1 -1
- package/packages/sdk/dist/fees.js +2 -1
- package/packages/sdk/dist/fees.js.map +1 -1
- package/packages/sdk/dist/index.d.ts +4 -3
- package/packages/sdk/dist/index.d.ts.map +1 -1
- package/packages/sdk/dist/index.js +4 -3
- package/packages/sdk/dist/index.js.map +1 -1
- package/packages/sdk/dist/intelligence.d.ts +1 -1
- package/packages/sdk/dist/intelligence.js +1 -1
- package/packages/sdk/dist/safe.d.ts +49 -0
- package/packages/sdk/dist/safe.d.ts.map +1 -1
- package/packages/sdk/dist/safe.js +74 -0
- package/packages/sdk/dist/safe.js.map +1 -1
- package/packages/sdk/dist/types.d.ts +7 -0
- package/packages/sdk/dist/types.d.ts.map +1 -1
- package/packages/sdk/package.json +6 -2
- package/packages/ui/dist/assets/{add-BX5reKtK.js → add-sHg4oAtj.js} +2 -2
- package/packages/ui/dist/assets/{all-wallets-IZ3PlVF-.js → all-wallets-BhPcqrMm.js} +2 -2
- package/packages/ui/dist/assets/{app-store-ARgRdMdF.js → app-store-B_PQMwCf.js} +1 -1
- package/packages/ui/dist/assets/{apple-VCaqsI9w.js → apple-CqAFK7Fj.js} +2 -2
- package/packages/ui/dist/assets/arrow-bottom-CbxIflTv.js +8 -0
- package/packages/ui/dist/assets/{arrow-bottom-circle-h5vFiZ2I.js → arrow-bottom-circle-BFgY35YA.js} +2 -2
- package/packages/ui/dist/assets/arrow-left-DCG2m2BY.js +8 -0
- package/packages/ui/dist/assets/arrow-right-NrPOhG6W.js +8 -0
- package/packages/ui/dist/assets/arrow-top-BHTmGsDP.js +8 -0
- package/packages/ui/dist/assets/{bank-6c28yOM9.js → bank-BqDvUazs.js} +2 -2
- package/packages/ui/dist/assets/{basic-CJnbTfSd.js → basic-CHFjQMod.js} +177 -177
- package/packages/ui/dist/assets/{browser-CDKlxM3D.js → browser-C_VdKE7p.js} +2 -2
- package/packages/ui/dist/assets/{card-DQdED02q.js → card-w-1brQoV.js} +2 -2
- package/packages/ui/dist/assets/ccip-jvDTeGaI.js +1 -0
- package/packages/ui/dist/assets/{checkmark-JoRNVoFV.js → checkmark-CLBmxdAF.js} +2 -2
- package/packages/ui/dist/assets/{checkmark-bold-B6bGHVIs.js → checkmark-bold-Ca_e1PMs.js} +2 -2
- package/packages/ui/dist/assets/chevron-bottom-BhvFRdSL.js +8 -0
- package/packages/ui/dist/assets/chevron-left-cjMaNbHC.js +8 -0
- package/packages/ui/dist/assets/chevron-right-CRuHR0b-.js +8 -0
- package/packages/ui/dist/assets/chevron-top-Bq0a2jj-.js +8 -0
- package/packages/ui/dist/assets/{chrome-store-D-fiJ9qJ.js → chrome-store-Cieq6j7x.js} +2 -2
- package/packages/ui/dist/assets/{clock-xCPkzwjj.js → clock-Dkj5WbVg.js} +2 -2
- package/packages/ui/dist/assets/{close-BkqmPpWG.js → close-C8eE6Dfz.js} +2 -2
- package/packages/ui/dist/assets/{coinPlaceholder-PIvN9oq_.js → coinPlaceholder-DndKH-8C.js} +2 -2
- package/packages/ui/dist/assets/{compass-CW5kWcxW.js → compass-DzoLByzm.js} +2 -2
- package/packages/ui/dist/assets/{copy-DUZlAQJJ.js → copy-Y7UMmVLt.js} +2 -2
- package/packages/ui/dist/assets/core-BkWdAWjX.js +907 -0
- package/packages/ui/dist/assets/cursor-BAQNxqYB.js +3 -0
- package/packages/ui/dist/assets/{cursor-transparent-BUAMB0G1.js → cursor-transparent-BUHY29Re.js} +2 -2
- package/packages/ui/dist/assets/{desktop-BxdlwV8P.js → desktop-DkZLCs5P.js} +2 -2
- package/packages/ui/dist/assets/{disconnect-GelNh3mn.js → disconnect-CphsO-wg.js} +2 -2
- package/packages/ui/dist/assets/{discord-N0jnxkzC.js → discord-CYH46bgt.js} +2 -2
- package/packages/ui/dist/assets/{etherscan-B41u7wF4.js → etherscan-2-3-Icbx.js} +2 -2
- package/packages/ui/dist/assets/events-DQ172AOg.js +1 -0
- package/packages/ui/dist/assets/{exclamation-triangle-BWJC56FC.js → exclamation-triangle-Bx9-1mpn.js} +2 -2
- package/packages/ui/dist/assets/{extension-G6I6oogw.js → extension-BuDZDNPV.js} +2 -2
- package/packages/ui/dist/assets/external-link-k6oTiNch.js +8 -0
- package/packages/ui/dist/assets/{facebook-47twy32b.js → facebook-BVB4qCju.js} +2 -2
- package/packages/ui/dist/assets/{farcaster-DYqnqCqs.js → farcaster-CxfWmDK3.js} +2 -2
- package/packages/ui/dist/assets/{filters-dF6xeJnE.js → filters-CXyIzkBz.js} +2 -2
- package/packages/ui/dist/assets/{github-DbW4Ihz2.js → github-Bga1HQTm.js} +2 -2
- package/packages/ui/dist/assets/{google-TKm7G8LF.js → google-CKLjkN5t.js} +2 -2
- package/packages/ui/dist/assets/{help-circle-CY3qTmsG.js → help-circle-CW4A601v.js} +1 -1
- package/packages/ui/dist/assets/{id-JpHW4q14.js → id-BaRFLHqy.js} +2 -2
- package/packages/ui/dist/assets/{image-Dip2fG98.js → image-4pE_c0pP.js} +2 -2
- package/packages/ui/dist/assets/index-B07bCKJ8.css +1 -0
- package/packages/ui/dist/assets/index-BKPTb_0U.js +1 -0
- package/packages/ui/dist/assets/index-BV8s6ez1.js +1 -0
- package/packages/ui/dist/assets/index-BaLMTrx4.js +47 -0
- package/packages/ui/dist/assets/index-CguqCMNp.js +16 -0
- package/packages/ui/dist/assets/index-RMrT9ktE.js +1780 -0
- package/packages/ui/dist/assets/{index-q0mGElxZ.js → index-_n5fH6AD.js} +3 -3
- package/packages/ui/dist/assets/index.es-rQvUXQTw.js +26 -0
- package/packages/ui/dist/assets/{info-B1enFsqh.js → info-UMAWEFh_.js} +2 -2
- package/packages/ui/dist/assets/{info-circle-D4j3FISs.js → info-circle-DuTp8NTC.js} +2 -2
- package/packages/ui/dist/assets/{lightbulb-DZbKr-vS.js → lightbulb-CkYYJbAa.js} +2 -2
- package/packages/ui/dist/assets/{mail-D-TO_uVQ.js → mail-CntZNMWz.js} +2 -2
- package/packages/ui/dist/assets/metamask-sdk-DIj7UxWU.js +542 -0
- package/packages/ui/dist/assets/{mobile-Bkmwzav2.js → mobile-DdyrzTfW.js} +2 -2
- package/packages/ui/dist/assets/{more-D1_I_jiB.js → more-DpZ1Bqpv.js} +2 -2
- package/packages/ui/dist/assets/{network-placeholder-DUaFjoXh.js → network-placeholder-B8yy8U56.js} +2 -2
- package/packages/ui/dist/assets/{nftPlaceholder-Cc0iUryh.js → nftPlaceholder-BXFbCJ-Z.js} +2 -2
- package/packages/ui/dist/assets/{off-BvX71sse.js → off-DavoQSqH.js} +2 -2
- package/packages/ui/dist/assets/{play-store-XzLbtbTd.js → play-store-BJeHSxBp.js} +2 -2
- package/packages/ui/dist/assets/{plus-B77_RmYR.js → plus-B-L3uTWl.js} +2 -2
- package/packages/ui/dist/assets/{qr-code-BNeIhB1d.js → qr-code-CcQBy6o3.js} +1 -1
- package/packages/ui/dist/assets/{recycle-horizontal-B9sX-yty.js → recycle-horizontal-BLC3_x1m.js} +2 -2
- package/packages/ui/dist/assets/{refresh-BWRygnjm.js → refresh-78cbzBlS.js} +2 -2
- package/packages/ui/dist/assets/{reown-logo-DCAEsGFm.js → reown-logo-Bx5RmN6G.js} +2 -2
- package/packages/ui/dist/assets/{search-ZXIqrawS.js → search-XWB8l5E2.js} +2 -2
- package/packages/ui/dist/assets/{secp256k1-Doz05h4g.js → secp256k1-u9EmDRXd.js} +1 -1
- package/packages/ui/dist/assets/{send-BQcO0jNC.js → send-BjGwACTt.js} +2 -2
- package/packages/ui/dist/assets/{swapHorizontal-4yZEzz2K.js → swapHorizontal-DJWcWn3A.js} +2 -2
- package/packages/ui/dist/assets/{swapHorizontalBold-DxBKu3sl.js → swapHorizontalBold-CG930oAl.js} +2 -2
- package/packages/ui/dist/assets/{swapHorizontalMedium-DAQYJ2JK.js → swapHorizontalMedium-CHIdtnCI.js} +2 -2
- package/packages/ui/dist/assets/{swapHorizontalRoundedBold-CUlZzFJ6.js → swapHorizontalRoundedBold-B2oGt4O4.js} +2 -2
- package/packages/ui/dist/assets/{swapVertical-CjWHAaDM.js → swapVertical-CnjOFXH3.js} +2 -2
- package/packages/ui/dist/assets/{telegram-JBeabiJP.js → telegram-BtOilYdH.js} +2 -2
- package/packages/ui/dist/assets/{three-dots-D3R8OjSx.js → three-dots-D_VsAJlo.js} +2 -2
- package/packages/ui/dist/assets/{twitch-1f9z7xVO.js → twitch-BIX3n7hL.js} +2 -2
- package/packages/ui/dist/assets/{twitterIcon-BW7n2xnp.js → twitterIcon-C3DDaPem.js} +2 -2
- package/packages/ui/dist/assets/{verify-Cs5owI38.js → verify-CtzMScZy.js} +2 -2
- package/packages/ui/dist/assets/{verify-filled-Ci5sx5qS.js → verify-filled-CVjdmBEW.js} +2 -2
- package/packages/ui/dist/assets/{w3m-modal-DnWtss8u.js → w3m-modal-BZ3BUhSD.js} +1 -1
- package/packages/ui/dist/assets/{wallet-MaGNqaW-.js → wallet-hURaFgJp.js} +2 -2
- package/packages/ui/dist/assets/{wallet-placeholder-kTOy12_L.js → wallet-placeholder-BcF2KreB.js} +2 -2
- package/packages/ui/dist/assets/{walletconnect-CyuP92jg.js → walletconnect-C8XCABRo.js} +3 -3
- package/packages/ui/dist/assets/{warning-circle-BImUCdpG.js → warning-circle-BMz6LCU4.js} +2 -2
- package/packages/ui/dist/assets/{x-II45JHpV.js → x-deAHZeLU.js} +2 -2
- package/packages/ui/dist/index.html +2 -2
- package/templates/default/.agents/skills/sail-mandates/SKILL.md +1 -1
- package/templates/default/.agents/skills/sail-mandates/references/approvals.md +3 -3
- package/templates/default/.agents/skills/sail-mandates/references/examples-index.md +3 -3
- package/templates/default/.agents/skills/sail-onboarding/SKILL.md +34 -33
- package/templates/default/.agents/skills/sail-servers/SKILL.md +1 -0
- package/templates/default/.agents/skills/sail-swap-quote/SKILL.md +76 -0
- package/templates/default/.agents/skills/sail-template-approve-batch/SKILL.md +98 -0
- package/templates/default/.agents/skills/sail-template-borrow/SKILL.md +68 -0
- package/templates/default/.agents/skills/sail-template-deposit/SKILL.md +61 -0
- package/templates/default/.agents/skills/sail-template-swap/SKILL.md +207 -0
- package/templates/default/.agents/skills/sail-template-swap-no-oracle/SKILL.md +100 -0
- package/templates/default/.agents/skills/sail-template-transfer/SKILL.md +52 -0
- package/templates/default/.agents/skills/sail-template-withdraw/SKILL.md +50 -0
- package/templates/default/.agents/skills/sail-templates/SKILL.md +142 -0
- package/templates/default/.agents/skills/sail-templates/catalog.mjs +200 -0
- package/templates/default/.agents/skills/sail-templates/deployed.json +133 -0
- package/templates/default/.agents/skills/sail-templates/oracles/IOracle.sol +13 -0
- package/templates/default/.agents/skills/sail-templates/oracles/UniV3TwapOracle.sol +114 -0
- package/templates/default/.agents/skills/sail-templates/oracles/libs/FullMath.sol +62 -0
- package/templates/default/.agents/skills/sail-templates/oracles/libs/TickMath.sol +44 -0
- package/templates/default/.agents/skills/sail-templates/references/config-schemas.md +139 -0
- package/templates/default/.agents/skills/sail-templates/references/reuse-flow.md +139 -0
- package/templates/default/.agents/skills/sail-token-resolve/SKILL.md +174 -0
- package/templates/default/.env.example +9 -0
- package/templates/default/AGENTS.md +107 -13
- package/templates/default/docs/PERMISSION_MODEL.md +2 -2
- package/{examples → templates/default/examples}/custom-mandate/.sail/contracts/interfaces/IPermission.sol +4 -0
- package/{examples → templates/default/examples}/custom-mandate/README.md +4 -1
- package/{examples → templates/default/examples}/permissions/interfaces/IBatchPermission.sol +2 -0
- package/{examples → templates/default/examples}/permissions/interfaces/IPermission.sol +4 -0
- package/templates/default/scripts/quote-swap.mjs +211 -0
- package/templates/default/scripts/resolve-token.mjs +992 -0
- package/templates/default/scripts/shared-template-addr.mjs +110 -0
- package/templates/default/test/BoundedCallPermission.t.sol +2 -1
- package/docs/PERMISSION_MODEL.md +0 -93
- package/examples/README.md +0 -24
- package/examples/lifi-permissions/LifiBoundedApprovePermissionCloneable.sol +0 -84
- package/examples/lifi-permissions/LifiDiamondSwapPermissionCloneable.sol +0 -97
- package/examples/lifi-permissions/README.md +0 -53
- package/packages/ui/dist/assets/arrow-bottom-Cwptw8fW.js +0 -8
- package/packages/ui/dist/assets/arrow-left-bM1TRcV9.js +0 -8
- package/packages/ui/dist/assets/arrow-right-BYd_N4Jr.js +0 -8
- package/packages/ui/dist/assets/arrow-top-CLUnwVE2.js +0 -8
- package/packages/ui/dist/assets/ccip-BL7RIPfY.js +0 -1
- package/packages/ui/dist/assets/chevron-bottom-BMCelIEu.js +0 -8
- package/packages/ui/dist/assets/chevron-left-ryPLhHCc.js +0 -8
- package/packages/ui/dist/assets/chevron-right-CdDNRv7f.js +0 -8
- package/packages/ui/dist/assets/chevron-top-BxWIjjGk.js +0 -8
- package/packages/ui/dist/assets/core-von2c1sc.js +0 -907
- package/packages/ui/dist/assets/cursor-BzRVY7BD.js +0 -3
- package/packages/ui/dist/assets/events-C9icG2YB.js +0 -1
- package/packages/ui/dist/assets/external-link-BPaGQf4a.js +0 -8
- package/packages/ui/dist/assets/fallback-BTfJeGjh.js +0 -1
- package/packages/ui/dist/assets/index-BGJ_8fMk.js +0 -1
- package/packages/ui/dist/assets/index-Bq3PAopp.js +0 -16
- package/packages/ui/dist/assets/index-C4_cA5XW.js +0 -1
- package/packages/ui/dist/assets/index-CaDN0gL4.js +0 -1
- package/packages/ui/dist/assets/index-DLElIcBr.js +0 -1789
- package/packages/ui/dist/assets/index-lhNfD4jA.css +0 -1
- package/packages/ui/dist/assets/index.es-BaCKS_tW.js +0 -26
- package/packages/ui/dist/assets/metamask-sdk-DljW3QD1.js +0 -542
- package/packages/ui/dist/assets/native-CJ5et6AR.js +0 -1
- package/packages/ui/dist/assets/parseSignature-BAS-wKoj.js +0 -1
- package/scripts/check-docs.mjs +0 -309
- package/scripts/check-init.mjs +0 -123
- package/scripts/check-update.mjs +0 -177
- package/scripts/clean.mjs +0 -17
- package/scripts/pack.mjs +0 -21
- package/templates/default/.agents/skills/sail-overview/SKILL.md +0 -100
- package/templates/default/.agents/skills/sail-sailor/SKILL.md +0 -190
- package/templates/default/.agents/skills/sail-setup/SKILL.md +0 -38
- package/templates/default/.gitlab-ci.yml +0 -58
- /package/{examples → templates/default/examples}/custom-mandate/foundry.toml +0 -0
- /package/{examples → templates/default/examples}/custom-mandate/mandates/BoundedCallPermission.sol +0 -0
- /package/{examples → templates/default/examples}/custom-mandate/mandates/README.md +0 -0
- /package/{examples → templates/default/examples}/custom-mandate/mandates/SailCalldata.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/BoundedApproveAndCallBatch.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/BoundedBorrow_AaveV3_Arbitrum.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/BoundedLimitless_Base.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/BoundedPerp_GMXv2_Arbitrum.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/BoundedStake_Venice_Base.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/BoundedSupply_AaveV3_Arbitrum.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/BoundedSwapNative_UniswapV3_Base.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/BoundedSwap_UniswapV3_Base.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/BoundedSwap_UniswapV4_Unichain.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/BoundedTransfer_ERC20_Ethereum.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/BoundedVault_ERC4626_Base.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/README.md +0 -0
- /package/{examples → templates/default/examples}/permissions/SailCalldata.sol +0 -0
- /package/{examples → templates/default/examples}/permissions/foundry.toml +0 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// resolve-token.mjs — resolve one or more token symbols/addresses to on-chain
|
|
3
|
+
// metadata and map WHERE each is swap-ready: which chain, which DEX/protocol, how
|
|
4
|
+
// deep the pool is. Built for prompts like "a portfolio of USDC, UNI, HYPE, MORPHO"
|
|
5
|
+
// or "a DCA of X and Y" — pass every symbol at once.
|
|
6
|
+
//
|
|
7
|
+
// Pure JS, no dependencies (works in a fresh project before Foundry is set up).
|
|
8
|
+
// Reads RPC + chain from .sail/.env.local or .sail/config.json, or --rpc/--chain.
|
|
9
|
+
//
|
|
10
|
+
// node scripts/resolve-token.mjs WETH # single token, configured chain(s)
|
|
11
|
+
// node scripts/resolve-token.mjs LINK --chain unichain # force one chain
|
|
12
|
+
// node scripts/resolve-token.mjs 0x4200…0006 --chain base # address input
|
|
13
|
+
// node scripts/resolve-token.mjs USDC UNI MORPHO # portfolio (rich JSON)
|
|
14
|
+
// node scripts/resolve-token.mjs UNI --all-chains --json # scan every Sail mainnet
|
|
15
|
+
//
|
|
16
|
+
// Liquidity venues (chain + protocol + pool + depth) come from GeckoTerminal
|
|
17
|
+
// (CoinGecko's keyless DEX index — Uniswap V3/V4, Sushiswap, PancakeSwap, Aerodrome…).
|
|
18
|
+
// Swap-readiness is CONFIRMED on-chain only for Uniswap V3 (USDC→token via QuoterV2),
|
|
19
|
+
// Sail's executable fast-path route; other venues are surfaced as informational.
|
|
20
|
+
//
|
|
21
|
+
// Output: JSON on stdout (machine-readable); human notes on stderr.
|
|
22
|
+
|
|
23
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
24
|
+
import { resolve as resolvePath } from "node:path";
|
|
25
|
+
|
|
26
|
+
// ── Curated registry (verified live June 2026). Always re-verify decimals on-chain. ──
|
|
27
|
+
// Per-chain Uniswap V3 infrastructure + the common tokens. Addresses are PER-CHAIN.
|
|
28
|
+
// `gecko` is the GeckoTerminal network id used for the cross-DEX liquidity lookup.
|
|
29
|
+
const CHAINS = {
|
|
30
|
+
ethereum: {
|
|
31
|
+
chainId: 1,
|
|
32
|
+
gecko: "eth",
|
|
33
|
+
quoterV2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
|
|
34
|
+
usdc: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
|
|
35
|
+
tokens: {
|
|
36
|
+
USDC: { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals: 6 },
|
|
37
|
+
WETH: { address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", decimals: 18 },
|
|
38
|
+
UNI: { address: "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984", decimals: 18 },
|
|
39
|
+
LINK: { address: "0x514910771AF9Ca656af840dff83E8264EcF986CA", decimals: 18 },
|
|
40
|
+
WBTC: { address: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", decimals: 8 },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
unichain: {
|
|
44
|
+
chainId: 130,
|
|
45
|
+
gecko: "unichain",
|
|
46
|
+
quoterV2: "0x385a5cf5f83e99f7bb2852b6a19c3538b9fa7658",
|
|
47
|
+
usdc: "0x078D782b760474a361dDA0AF3839290b0EF57AD6",
|
|
48
|
+
tokens: {
|
|
49
|
+
USDC: { address: "0x078D782b760474a361dDA0AF3839290b0EF57AD6", decimals: 6 },
|
|
50
|
+
WETH: { address: "0x4200000000000000000000000000000000000006", decimals: 18 },
|
|
51
|
+
UNI: { address: "0x8f187aA05619a017077f5308904739877ce9eA21", decimals: 18 },
|
|
52
|
+
LINK: { address: "0x5a53B6D19D8EDCb7923F0D840EeBB3f09BBeEfB7", decimals: 18 },
|
|
53
|
+
MORPHO: { address: "0x6695a2692dCD2A53E7766492447B5254A56425aD", decimals: 18 },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
base: {
|
|
57
|
+
chainId: 8453,
|
|
58
|
+
gecko: "base",
|
|
59
|
+
quoterV2: "0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a",
|
|
60
|
+
usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
61
|
+
tokens: {
|
|
62
|
+
USDC: { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6 },
|
|
63
|
+
WETH: { address: "0x4200000000000000000000000000000000000006", decimals: 18 },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
arbitrum: {
|
|
67
|
+
chainId: 42161,
|
|
68
|
+
gecko: "arbitrum",
|
|
69
|
+
quoterV2: "0x61fFE014bA17989E743c5F6cB21bF9697530B21e",
|
|
70
|
+
usdc: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
|
71
|
+
tokens: {
|
|
72
|
+
USDC: { address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", decimals: 6 },
|
|
73
|
+
USDC_E: { address: "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", decimals: 6 },
|
|
74
|
+
WETH: { address: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1", decimals: 18 },
|
|
75
|
+
ARB: { address: "0x912CE59144191C1204E64559FE8253a0e49E6548", decimals: 18 },
|
|
76
|
+
LINK: { address: "0xf97f4df75117a78c1A5a0DBb814Af92458539FB4", decimals: 18 },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const FEE_TIERS = [500, 3000, 10000];
|
|
82
|
+
const PROBE_AMOUNT_USDC = 25n * 10n ** 6n; // 25 USDC — a representative DCA size
|
|
83
|
+
const ADDR_ZERO = "0x" + "0".repeat(40);
|
|
84
|
+
const MAX_VENUES = 8; // cap the per-chain venue list so portfolio JSON stays readable
|
|
85
|
+
|
|
86
|
+
// ── Minimal ABI encoding (no deps) ──────────────────────────────────────────────
|
|
87
|
+
// selector(string) → first 4 bytes of keccak256. We only call three functions, so
|
|
88
|
+
// hardcode the selectors (verified via `cast sig`).
|
|
89
|
+
const SEL = {
|
|
90
|
+
symbol: "0x95d89b41", // symbol()
|
|
91
|
+
decimals: "0x313ce567", // decimals()
|
|
92
|
+
quoteExactInputSingle: "0xc6a5026a", // quoteExactInputSingle((address,address,uint256,uint24,uint160))
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
function pad32(hexOrAddr) {
|
|
96
|
+
// left-pad an address or hex number to 32 bytes (64 hex chars)
|
|
97
|
+
let h = hexOrAddr.toLowerCase().replace(/^0x/, "");
|
|
98
|
+
if (h.length < 64) h = "0".repeat(64 - h.length) + h;
|
|
99
|
+
return h;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function uintToHex(n) {
|
|
103
|
+
// bigint → 32-byte hex word
|
|
104
|
+
let h = n.toString(16);
|
|
105
|
+
if (h.length % 2) h = "0" + h;
|
|
106
|
+
return pad32(h);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function encodeQuoteCall(tokenIn, tokenOut, amountIn, fee) {
|
|
110
|
+
// quoteExactInputSingle((address tokenIn, address tokenOut, uint256 amountIn, uint24 fee, uint160 sqrtPriceLimitX96))
|
|
111
|
+
// Single tuple arg, all static fields → selector + 5 words, no offset table.
|
|
112
|
+
return (
|
|
113
|
+
SEL.quoteExactInputSingle +
|
|
114
|
+
pad32(tokenIn) +
|
|
115
|
+
pad32(tokenOut) +
|
|
116
|
+
uintToHex(amountIn) +
|
|
117
|
+
uintToHex(BigInt(fee)) +
|
|
118
|
+
uintToHex(0n) // sqrtPriceLimitX96 = 0
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function decodeUint256Return(hex, wordIndex = 0) {
|
|
123
|
+
const h = hex.toLowerCase().replace(/^0x/, "");
|
|
124
|
+
const word = h.slice(wordIndex * 64, wordIndex * 64 + 64);
|
|
125
|
+
return word ? BigInt("0x" + word) : 0n;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function decodeStringReturn(hex) {
|
|
129
|
+
// string return: offset(0x20) + length + data
|
|
130
|
+
const h = hex.toLowerCase().replace(/^0x/, "");
|
|
131
|
+
const len = Number(BigInt("0x" + h.slice(64, 128)));
|
|
132
|
+
if (!len) return "";
|
|
133
|
+
const data = h.slice(128, 128 + len * 2);
|
|
134
|
+
// Interpret as UTF-8 bytes.
|
|
135
|
+
const bytes = new Uint8Array(len);
|
|
136
|
+
for (let i = 0; i < len; i++) bytes[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
|
|
137
|
+
return new TextDecoder().decode(bytes);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── JSON-RPC (eth_call) via fetch ───────────────────────────────────────────────
|
|
141
|
+
async function ethCall(rpc, to, data, from = ADDR_ZERO) {
|
|
142
|
+
const body = {
|
|
143
|
+
jsonrpc: "2.0",
|
|
144
|
+
id: 1,
|
|
145
|
+
method: "eth_call",
|
|
146
|
+
params: [{ to, data, from }, "latest"],
|
|
147
|
+
};
|
|
148
|
+
const res = await fetch(rpc, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "Content-Type": "application/json" },
|
|
151
|
+
body: JSON.stringify(body),
|
|
152
|
+
signal: AbortSignal.timeout(15_000),
|
|
153
|
+
});
|
|
154
|
+
if (!res.ok) throw new Error(`eth_call HTTP ${res.status} to ${to}`);
|
|
155
|
+
const json = await res.json();
|
|
156
|
+
if (json.error) throw new Error(`eth_call reverted: ${JSON.stringify(json.error)}`);
|
|
157
|
+
if (!json.result) throw new Error(`eth_call returned no result`);
|
|
158
|
+
return json.result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── resolve project RPC + chain ─────────────────────────────────────────────────
|
|
162
|
+
function readSailEnv(projectRoot = process.cwd()) {
|
|
163
|
+
const envPath = resolvePath(projectRoot, ".sail", ".env.local");
|
|
164
|
+
const out = {};
|
|
165
|
+
if (existsSync(envPath)) {
|
|
166
|
+
for (const line of readFileSync(envPath, "utf8").split("\n")) {
|
|
167
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
168
|
+
if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readSailConfig(projectRoot = process.cwd()) {
|
|
175
|
+
const p = resolvePath(projectRoot, ".sail", "config.json");
|
|
176
|
+
if (!existsSync(p)) return {};
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
179
|
+
} catch {
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveChain(chainFlag) {
|
|
185
|
+
if (chainFlag) {
|
|
186
|
+
const c = CHAINS[chainFlag.toLowerCase()];
|
|
187
|
+
if (c) return { name: chainFlag.toLowerCase(), ...c };
|
|
188
|
+
// numeric chain id
|
|
189
|
+
for (const [name, cfg] of Object.entries(CHAINS)) {
|
|
190
|
+
if (String(cfg.chainId) === String(chainFlag)) return { name, ...cfg };
|
|
191
|
+
}
|
|
192
|
+
throw new Error(`Unknown chain "${chainFlag}". Known: ${Object.keys(CHAINS).join(", ")}`);
|
|
193
|
+
}
|
|
194
|
+
// From .sail/.env.local CHAIN_ID, or .sail/config.json
|
|
195
|
+
const env = readSailEnv();
|
|
196
|
+
const cfg = readSailConfig();
|
|
197
|
+
const id = env.CHAIN_ID ?? cfg.chainId;
|
|
198
|
+
if (id) {
|
|
199
|
+
for (const [name, c] of Object.entries(CHAINS)) {
|
|
200
|
+
if (String(c.chainId) === String(id)) return { name, ...c };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Could not resolve chain. Pass --chain <${Object.keys(CHAINS).join("|")}> or set CHAIN_ID in .sail/.env.local.`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolveRpc(chain, rpcFlag) {
|
|
209
|
+
if (rpcFlag) return rpcFlag;
|
|
210
|
+
const env = readSailEnv();
|
|
211
|
+
// Mirrors packages/cli/src/lib/chain.ts getRpcUrl():
|
|
212
|
+
// 1. named chain var (UNICHAIN_RPC_URL)
|
|
213
|
+
// 2. chainId-keyed var (RPC_URL_130) — written by the UI's save-config
|
|
214
|
+
// 3. generic RPC_URL (single-chain fallback)
|
|
215
|
+
const nameVar = `${chain.name.toUpperCase().replace("-", "_")}_RPC_URL`;
|
|
216
|
+
const idVar = `RPC_URL_${chain.chainId}`;
|
|
217
|
+
return env[nameVar] ?? env[idVar] ?? env.RPC_URL ?? null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// All chains with a chain-SPECIFIC RPC configured in .sail/.env.local (named or
|
|
221
|
+
// chainId-keyed). Generic RPC_URL does NOT count — it is one endpoint for one
|
|
222
|
+
// chain, not a per-chain wiring. Used to detect multi-chain projects so a symbol
|
|
223
|
+
// can be resolved on every active chain at once.
|
|
224
|
+
function configuredChains() {
|
|
225
|
+
const env = readSailEnv();
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const [name, cfg] of Object.entries(CHAINS)) {
|
|
228
|
+
const nameVar = `${name.toUpperCase().replace("-", "_")}_RPC_URL`;
|
|
229
|
+
const idVar = `RPC_URL_${cfg.chainId}`;
|
|
230
|
+
const rpc = env[nameVar] ?? env[idVar] ?? null;
|
|
231
|
+
if (rpc) out.push({ name, ...cfg, rpc });
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── GeckoTerminal (CoinGecko's keyless DEX API) — throttled + cached + retried ───
|
|
237
|
+
// Free tier is rate-limited (~10–30 calls/min before 429). We serialize calls with
|
|
238
|
+
// a minimum spacing, cache per-URL within a run, and back off on 429/timeout. One
|
|
239
|
+
// token-pools call returns ALL venues for a token, so a portfolio stays cheap.
|
|
240
|
+
const GECKO_API = "https://api.geckoterminal.com/api/v2";
|
|
241
|
+
const GECKO_SPACING_MS = Number(process.env.GECKO_MIN_SPACING_MS || 2500);
|
|
242
|
+
const ADDR_RE = /^0x[a-fA-F0-9]{40}$/;
|
|
243
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
244
|
+
|
|
245
|
+
const geckoCache = new Map();
|
|
246
|
+
let geckoLock = Promise.resolve();
|
|
247
|
+
let lastGeckoTs = 0;
|
|
248
|
+
|
|
249
|
+
async function geckoGet(url) {
|
|
250
|
+
if (geckoCache.has(url)) return geckoCache.get(url);
|
|
251
|
+
const task = geckoLock.then(async () => {
|
|
252
|
+
if (geckoCache.has(url)) return geckoCache.get(url); // filled while we queued
|
|
253
|
+
const since = Date.now() - lastGeckoTs;
|
|
254
|
+
if (since < GECKO_SPACING_MS) await sleep(GECKO_SPACING_MS - since);
|
|
255
|
+
let lastErr;
|
|
256
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
257
|
+
try {
|
|
258
|
+
const res = await fetch(url, {
|
|
259
|
+
headers: { accept: "application/json", "user-agent": "sailor-resolve-token" },
|
|
260
|
+
signal: AbortSignal.timeout(20_000),
|
|
261
|
+
});
|
|
262
|
+
if (res.status === 429) {
|
|
263
|
+
const ra = Number(res.headers.get("retry-after") || 0);
|
|
264
|
+
lastErr = new Error("GeckoTerminal 429 (rate-limited)");
|
|
265
|
+
await sleep(ra > 0 ? ra * 1000 : 8000);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (!res.ok) throw new Error(`GeckoTerminal HTTP ${res.status} for ${url}`);
|
|
269
|
+
const json = await res.json();
|
|
270
|
+
geckoCache.set(url, json);
|
|
271
|
+
return json;
|
|
272
|
+
} catch (e) {
|
|
273
|
+
lastErr = e;
|
|
274
|
+
await sleep(1500);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
throw lastErr || new Error("GeckoTerminal request failed");
|
|
278
|
+
});
|
|
279
|
+
// The next queued call waits for this one to finish (and the spacing it imposes),
|
|
280
|
+
// regardless of success/failure.
|
|
281
|
+
geckoLock = task.then(
|
|
282
|
+
() => {
|
|
283
|
+
lastGeckoTs = Date.now();
|
|
284
|
+
},
|
|
285
|
+
() => {
|
|
286
|
+
lastGeckoTs = Date.now();
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
return task;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// dexId (e.g. "uniswap-v3-base", "aerodrome-base", "uniswap-v4-unichain") → a
|
|
293
|
+
// canonical protocol family and whether Sail's fast-path SwapPermission can route
|
|
294
|
+
// it. Sail confirms swaps on-chain via Uniswap V3 QuoterV2 everywhere, plus the
|
|
295
|
+
// Uniswap V4 Universal Router on Unichain. Everything else is surfaced as an
|
|
296
|
+
// informational venue (the user can still build a custom mandate for it).
|
|
297
|
+
function classifyDex(dexId, chainName) {
|
|
298
|
+
const id = (dexId || "").toLowerCase();
|
|
299
|
+
let protocol = "other";
|
|
300
|
+
if (id.startsWith("uniswap-v3") || id.startsWith("uniswap_v3")) protocol = "uniswap-v3";
|
|
301
|
+
else if (id.startsWith("uniswap-v4") || id.startsWith("uniswap_v4")) protocol = "uniswap-v4";
|
|
302
|
+
else if (id.startsWith("uniswap-v2") || id === "uniswap") protocol = "uniswap-v2";
|
|
303
|
+
else if (id.includes("sushiswap")) protocol = "sushiswap";
|
|
304
|
+
else if (id.includes("pancakeswap")) protocol = "pancakeswap";
|
|
305
|
+
else if (id.includes("aerodrome")) protocol = "aerodrome";
|
|
306
|
+
let sailRoutable = false;
|
|
307
|
+
if (protocol === "uniswap-v3") sailRoutable = true;
|
|
308
|
+
else if (protocol === "uniswap-v4" && chainName === "unichain") sailRoutable = true;
|
|
309
|
+
return { protocol, sailRoutable };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// "WETH / USDC 0.3%" → 3000 (basis points). null when no fee is present (e.g. some
|
|
313
|
+
// V2/Solidly pools name themselves without a fee tier).
|
|
314
|
+
function parseFeeBps(name) {
|
|
315
|
+
const m = (name || "").match(/(\d+(?:\.\d+)?)\s*%/);
|
|
316
|
+
if (!m) return null;
|
|
317
|
+
return Math.round(parseFloat(m[1]) * 10000);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function addrFromGeckoId(id) {
|
|
321
|
+
// GeckoTerminal ids look like "base_0xabc…". Return the lowercase address part.
|
|
322
|
+
return id && id.includes("_") ? id.slice(id.indexOf("_") + 1).toLowerCase() : "";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// All swap venues for a token on a chain, ranked by USD liquidity, with their DEX
|
|
326
|
+
// protocol. One GeckoTerminal call covers every DEX it indexes.
|
|
327
|
+
async function fetchVenues(geckoNet, tokenAddrLower, chainName, ourSymbolUp) {
|
|
328
|
+
if (!geckoNet) return [];
|
|
329
|
+
const url = `${GECKO_API}/networks/${geckoNet}/tokens/${tokenAddrLower}/pools?page=1`;
|
|
330
|
+
const json = await geckoGet(url);
|
|
331
|
+
const pools = Array.isArray(json.data) ? json.data : [];
|
|
332
|
+
const venues = [];
|
|
333
|
+
for (const p of pools) {
|
|
334
|
+
const at = p.attributes || {};
|
|
335
|
+
const rel = p.relationships || {};
|
|
336
|
+
const dexId = (((rel.dex || {}).data) || {}).id || "";
|
|
337
|
+
const { protocol, sailRoutable } = classifyDex(dexId, chainName);
|
|
338
|
+
const name = at.name || "";
|
|
339
|
+
const baseAddr = addrFromGeckoId((((rel.base_token || {}).data) || {}).id || "");
|
|
340
|
+
const quoteAddr = addrFromGeckoId((((rel.quote_token || {}).data) || {}).id || "");
|
|
341
|
+
let pairedToken = "";
|
|
342
|
+
if (baseAddr === tokenAddrLower) pairedToken = quoteAddr;
|
|
343
|
+
else if (quoteAddr === tokenAddrLower) pairedToken = baseAddr;
|
|
344
|
+
// else: our token isn't actually base/quote of this pool (a GeckoTerminal data
|
|
345
|
+
// quirk) — leave pairedToken unset rather than guessing quoteAddr, which would
|
|
346
|
+
// let isUsdcPair() mislabel a pool the token isn't even in as USDC-paired.
|
|
347
|
+
const syms = name
|
|
348
|
+
.split("/")
|
|
349
|
+
.map((s) => s.trim().split(/\s+/)[0].toUpperCase())
|
|
350
|
+
.filter(Boolean);
|
|
351
|
+
const pairedSymbol = syms.find((s) => s !== ourSymbolUp) || syms[syms.length - 1] || null;
|
|
352
|
+
const feeTier = parseFeeBps(name);
|
|
353
|
+
// A pool with an absurd fee (>10%) is a spam/scam pool, never a real swap route —
|
|
354
|
+
// don't treat it as Sail-routable even if the DEX family normally is.
|
|
355
|
+
const exotic = feeTier != null && feeTier > 100000;
|
|
356
|
+
venues.push({
|
|
357
|
+
protocol,
|
|
358
|
+
dexId,
|
|
359
|
+
pool: at.address || null,
|
|
360
|
+
feeTier,
|
|
361
|
+
pairedSymbol,
|
|
362
|
+
pairedToken: pairedToken || null,
|
|
363
|
+
liquidityUsd: Math.round(Number(at.reserve_in_usd || 0)),
|
|
364
|
+
volume24hUsd: Math.round(Number((at.volume_usd || {}).h24 || 0)),
|
|
365
|
+
sailRoutable: sailRoutable && !exotic,
|
|
366
|
+
quoteVerified: false,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
venues.sort((a, b) => b.liquidityUsd - a.liquidityUsd);
|
|
370
|
+
return venues;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// symbol + decimals for a token on a chain we have no RPC for (--all-chains scan of
|
|
374
|
+
// an unconfigured Sail mainnet). Clearly NOT the on-chain source of truth.
|
|
375
|
+
async function fetchTokenMeta(geckoNet, tokenAddrLower) {
|
|
376
|
+
const url = `${GECKO_API}/networks/${geckoNet}/tokens/${tokenAddrLower}`;
|
|
377
|
+
const json = await geckoGet(url);
|
|
378
|
+
const at = ((json.data || {}).attributes) || {};
|
|
379
|
+
return { symbol: at.symbol || null, decimals: at.decimals != null ? Number(at.decimals) : null };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// symbol → address via GeckoTerminal search (fallback when not in the curated
|
|
383
|
+
// registry). Ranks candidate addresses by pool reserve (deepest = most canonical);
|
|
384
|
+
// the on-chain symbol() check in resolveOnChain is the final authority.
|
|
385
|
+
async function resolveSymbolViaGeckoTerminal(symbolUp, geckoNet) {
|
|
386
|
+
if (!geckoNet) return [];
|
|
387
|
+
const url = `${GECKO_API}/search/pools?query=${encodeURIComponent(symbolUp)}&network=${encodeURIComponent(geckoNet)}`;
|
|
388
|
+
const json = await geckoGet(url);
|
|
389
|
+
const pools = Array.isArray(json.data) ? json.data : [];
|
|
390
|
+
const byAddr = new Map(); // address -> max reserve_in_usd
|
|
391
|
+
for (const p of pools) {
|
|
392
|
+
const name = (p.attributes && p.attributes.name) || "";
|
|
393
|
+
const parts = name.split("/").map((s) => s.trim().split(/\s+/)[0].toUpperCase());
|
|
394
|
+
const rel = p.relationships || {};
|
|
395
|
+
const baseAddr = addrFromGeckoId((((rel.base_token || {}).data) || {}).id || "");
|
|
396
|
+
const quoteAddr = addrFromGeckoId((((rel.quote_token || {}).data) || {}).id || "");
|
|
397
|
+
const liq = Number((p.attributes && p.attributes.reserve_in_usd) || 0);
|
|
398
|
+
if (parts[0] === symbolUp && ADDR_RE.test(baseAddr)) {
|
|
399
|
+
const prev = byAddr.get(baseAddr);
|
|
400
|
+
if (prev === undefined || liq > prev) byAddr.set(baseAddr, liq);
|
|
401
|
+
}
|
|
402
|
+
if (parts[1] === symbolUp && ADDR_RE.test(quoteAddr)) {
|
|
403
|
+
const prev = byAddr.get(quoteAddr);
|
|
404
|
+
if (prev === undefined || liq > prev) byAddr.set(quoteAddr, liq);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return [...byAddr.entries()].sort((a, b) => b[1] - a[1]).map((e) => e[0]);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── on-chain verify: symbol() + decimals() — always the source of truth ────────
|
|
411
|
+
async function verifyTokenOnChain(rpc, address) {
|
|
412
|
+
const symHex = await ethCall(rpc, address, SEL.symbol);
|
|
413
|
+
const decHex = await ethCall(rpc, address, SEL.decimals);
|
|
414
|
+
return {
|
|
415
|
+
symbol: decodeStringReturn(symHex) || null,
|
|
416
|
+
decimals: Number(decodeUint256Return(decHex)),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isUsdcPair(venue, chain) {
|
|
421
|
+
if (!venue) return false;
|
|
422
|
+
if (venue.pairedSymbol === "USDC" || venue.pairedSymbol === "USDC.E") return true;
|
|
423
|
+
return !!(venue.pairedToken && venue.pairedToken.toLowerCase() === chain.usdc.toLowerCase());
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// The single venue that best represents swap-readiness FROM USDC (Sail's DCA sell
|
|
427
|
+
// leg): deepest Sail-routable USDC pool, else deepest routable pool, else deepest
|
|
428
|
+
// pool overall. Drives the cross-chain depth ranking, so it must be USDC-relevant —
|
|
429
|
+
// not, say, a huge WETH/BEAT pool that we'd never route a USDC DCA through.
|
|
430
|
+
function pickBestVenue(venues, chain) {
|
|
431
|
+
const routableUsdc = venues.filter((v) => v.sailRoutable && isUsdcPair(v, chain));
|
|
432
|
+
const routable = venues.filter((v) => v.sailRoutable);
|
|
433
|
+
const top = routableUsdc[0] || routable[0] || venues[0] || null; // venues are pre-sorted by depth
|
|
434
|
+
if (!top) return null;
|
|
435
|
+
return {
|
|
436
|
+
protocol: top.protocol,
|
|
437
|
+
feeTier: top.feeTier,
|
|
438
|
+
liquidityUsd: top.liquidityUsd,
|
|
439
|
+
pool: top.pool,
|
|
440
|
+
pairedSymbol: top.pairedSymbol,
|
|
441
|
+
sailRoutable: top.sailRoutable,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── per-chain resolution (shared by single-, multi-chain and portfolio modes) ──
|
|
446
|
+
// Resolves symbolOrAddr on ONE chain. With an RPC it is the authority: on-chain
|
|
447
|
+
// symbol()/decimals() + a live Uniswap V3 USDC→token QuoterV2 probe. Without an RPC
|
|
448
|
+
// (an --all-chains scan of an unconfigured chain) it falls back to GeckoTerminal
|
|
449
|
+
// metadata and treats a deep Sail-routable venue as swap-ready (unverified).
|
|
450
|
+
async function resolveOnChain(symbolOrAddr, chain, rpc) {
|
|
451
|
+
const geckoNet = chain.gecko || null;
|
|
452
|
+
const onchain = !!rpc;
|
|
453
|
+
const isAddrInput = ADDR_RE.test(symbolOrAddr);
|
|
454
|
+
const wantSym = isAddrInput ? null : symbolOrAddr.toUpperCase();
|
|
455
|
+
let address, verifiedSymbol, decimals, source, decimalsSource;
|
|
456
|
+
|
|
457
|
+
if (isAddrInput) {
|
|
458
|
+
address = symbolOrAddr;
|
|
459
|
+
source = "address-input";
|
|
460
|
+
} else {
|
|
461
|
+
const entry = chain.tokens[wantSym];
|
|
462
|
+
if (entry) {
|
|
463
|
+
address = entry.address;
|
|
464
|
+
source = "registry";
|
|
465
|
+
} else {
|
|
466
|
+
// Fallback: resolve symbol → address via GeckoTerminal (keyless DEX API).
|
|
467
|
+
const candidates = await resolveSymbolViaGeckoTerminal(wantSym, geckoNet);
|
|
468
|
+
if (candidates.length === 0) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`"${symbolOrAddr}" is not in the curated ${chain.name} registry and GeckoTerminal found no pool for it on ${chain.name}. ` +
|
|
471
|
+
`Pass its 0x address directly: node scripts/resolve-token.mjs 0x... --chain ${chain.name}`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
if (onchain) {
|
|
475
|
+
// Verify each candidate on-chain; keep the first whose symbol() matches the query.
|
|
476
|
+
// This is the authority — a wrong DEX-side match is rejected, not trusted.
|
|
477
|
+
const tried = [];
|
|
478
|
+
let resolved = null;
|
|
479
|
+
for (const cand of candidates) {
|
|
480
|
+
try {
|
|
481
|
+
const v = await verifyTokenOnChain(rpc, cand);
|
|
482
|
+
tried.push({ address: cand, symbol: v.symbol || "" });
|
|
483
|
+
if (v.symbol && v.symbol.toUpperCase() === wantSym) {
|
|
484
|
+
resolved = { address: cand, symbol: v.symbol, decimals: v.decimals };
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
} catch {
|
|
488
|
+
// not a real contract on this chain — skip silently
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (!resolved) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
`GeckoTerminal returned ${candidates.length} candidate address(es) for "${symbolOrAddr}" on ${chain.name}, but none verified on-chain with symbol() == "${wantSym}" ` +
|
|
494
|
+
`(tried: ${tried.map((t) => `${t.address}→${t.symbol || "no-contract"}`).join(", ")}). ` +
|
|
495
|
+
`Pass the token's 0x address directly: node scripts/resolve-token.mjs 0x... --chain ${chain.name}`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
address = resolved.address;
|
|
499
|
+
verifiedSymbol = resolved.symbol;
|
|
500
|
+
decimals = resolved.decimals;
|
|
501
|
+
source = "geckoterminal";
|
|
502
|
+
decimalsSource = "onchain";
|
|
503
|
+
} else {
|
|
504
|
+
// No RPC: candidates are ranked by pool depth, but depth alone can't tell two
|
|
505
|
+
// different contracts sharing a ticker apart (a collision). Cross-check each
|
|
506
|
+
// candidate's own GeckoTerminal token metadata — independent of the pool-name
|
|
507
|
+
// parsing used to build the candidate list — and prefer the first whose symbol
|
|
508
|
+
// actually matches, rather than blindly trusting the deepest pool.
|
|
509
|
+
let verified = null;
|
|
510
|
+
for (const cand of candidates) {
|
|
511
|
+
try {
|
|
512
|
+
const m = await fetchTokenMeta(geckoNet, cand.toLowerCase());
|
|
513
|
+
if (m.symbol && m.symbol.toUpperCase() === wantSym) {
|
|
514
|
+
verified = cand;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
} catch {
|
|
518
|
+
// metadata lookup failed for this candidate — try the next
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
address = verified || candidates[0];
|
|
522
|
+
source = verified ? "geckoterminal-unverified" : "geckoterminal-unverified-collision";
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Metadata: on-chain symbol()+decimals() is the source of truth when we have an RPC.
|
|
528
|
+
if (onchain && source !== "geckoterminal") {
|
|
529
|
+
try {
|
|
530
|
+
const v = await verifyTokenOnChain(rpc, address);
|
|
531
|
+
if (v.symbol) verifiedSymbol = v.symbol;
|
|
532
|
+
decimals = v.decimals;
|
|
533
|
+
decimalsSource = "onchain";
|
|
534
|
+
} catch (err) {
|
|
535
|
+
throw new Error(
|
|
536
|
+
`On-chain verify failed for ${address} on ${chain.name}: ${errMsg(err)}. The contract may not exist on this chain.`,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
} else if (!onchain && decimals === undefined) {
|
|
540
|
+
// gecko-only path: best-effort metadata from GeckoTerminal.
|
|
541
|
+
try {
|
|
542
|
+
const m = await fetchTokenMeta(geckoNet, address.toLowerCase());
|
|
543
|
+
if (m.symbol) verifiedSymbol = m.symbol;
|
|
544
|
+
decimals = m.decimals;
|
|
545
|
+
} catch {
|
|
546
|
+
decimals = null;
|
|
547
|
+
}
|
|
548
|
+
decimalsSource = "geckoterminal-unverified";
|
|
549
|
+
}
|
|
550
|
+
if (verifiedSymbol === undefined) verifiedSymbol = wantSym || null;
|
|
551
|
+
if (decimals === undefined) decimals = null;
|
|
552
|
+
|
|
553
|
+
// Liquidity venue map across every DEX GeckoTerminal indexes.
|
|
554
|
+
let venues = [];
|
|
555
|
+
let venuesError = null;
|
|
556
|
+
if (geckoNet) {
|
|
557
|
+
try {
|
|
558
|
+
venues = await fetchVenues(geckoNet, address.toLowerCase(), chain.name, (verifiedSymbol || wantSym || "").toUpperCase());
|
|
559
|
+
} catch (e) {
|
|
560
|
+
venuesError = errMsg(e);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const isUsdc = address.toLowerCase() === chain.usdc.toLowerCase();
|
|
565
|
+
|
|
566
|
+
// Swap-readiness. On-chain: a live Uniswap V3 USDC→token QuoterV2 quote across fee
|
|
567
|
+
// tiers (Sail's executable route). Off-chain (--all-chains scan): a deep Sail-routable
|
|
568
|
+
// venue exists, but is NOT live-quoted (quoteVerified stays false).
|
|
569
|
+
let best = null; // { fee, amountOut }
|
|
570
|
+
const tried = [];
|
|
571
|
+
let swapReady;
|
|
572
|
+
let quote = null;
|
|
573
|
+
|
|
574
|
+
if (isUsdc) {
|
|
575
|
+
swapReady = true; // USDC is the quote asset itself
|
|
576
|
+
} else if (onchain) {
|
|
577
|
+
const tokenIn = chain.usdc;
|
|
578
|
+
for (const fee of FEE_TIERS) {
|
|
579
|
+
const data = encodeQuoteCall(tokenIn, address, PROBE_AMOUNT_USDC, fee);
|
|
580
|
+
let amountOut = 0n;
|
|
581
|
+
let ok = true;
|
|
582
|
+
try {
|
|
583
|
+
const ret = await ethCall(rpc, chain.quoterV2, data);
|
|
584
|
+
amountOut = decodeUint256Return(ret); // first word = amountOut
|
|
585
|
+
} catch {
|
|
586
|
+
ok = false; // revert = no pool at this tier
|
|
587
|
+
}
|
|
588
|
+
tried.push({ fee, amountOut: amountOut.toString(), ok });
|
|
589
|
+
if (ok && amountOut > 0n && (!best || amountOut > best.amountOut)) {
|
|
590
|
+
best = { fee, amountOut };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
swapReady = best !== null;
|
|
594
|
+
if (best) {
|
|
595
|
+
// Flag the matching venue in the map as live-quoted.
|
|
596
|
+
const m = venues.find(
|
|
597
|
+
(v) => v.protocol === "uniswap-v3" && v.feeTier === best.fee && isUsdcPair(v, chain),
|
|
598
|
+
);
|
|
599
|
+
if (m) m.quoteVerified = true;
|
|
600
|
+
quote = {
|
|
601
|
+
tokenIn: "USDC",
|
|
602
|
+
tokenInAddress: tokenIn,
|
|
603
|
+
amountIn: PROBE_AMOUNT_USDC.toString(),
|
|
604
|
+
amountOut: best.amountOut.toString(),
|
|
605
|
+
note:
|
|
606
|
+
"feeTier was chosen using this small probe amount. A thin low-fee pool that wins " +
|
|
607
|
+
"at this size can be the worst tier for a much larger trade — for large amounts, " +
|
|
608
|
+
"re-quote across fee tiers at the actual trade size via quote-swap.mjs before dispatch.",
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
} else {
|
|
612
|
+
// gecko-only: a Sail-routable USDC-paired venue ⇒ swap-ready (unverified).
|
|
613
|
+
const r = venues.find((v) => v.sailRoutable && isUsdcPair(v, chain));
|
|
614
|
+
swapReady = !!r;
|
|
615
|
+
if (r) best = { fee: r.feeTier, amountOut: null };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Best venue (USDC-relevant) computed from the FULL list, then cap the exposed
|
|
619
|
+
// list so portfolio JSON stays readable.
|
|
620
|
+
const bestVenue = pickBestVenue(venues, chain);
|
|
621
|
+
const venuesTotal = venues.length;
|
|
622
|
+
const topVenues = venues.slice(0, MAX_VENUES);
|
|
623
|
+
|
|
624
|
+
return {
|
|
625
|
+
symbol: verifiedSymbol,
|
|
626
|
+
address,
|
|
627
|
+
decimals,
|
|
628
|
+
source, // registry | geckoterminal | geckoterminal-unverified | geckoterminal-unverified-collision | address-input
|
|
629
|
+
decimalsSource, // onchain | geckoterminal-unverified
|
|
630
|
+
chain: chain.name,
|
|
631
|
+
chainId: chain.chainId,
|
|
632
|
+
onchainVerified: onchain,
|
|
633
|
+
swapReady,
|
|
634
|
+
feeTier: best ? best.fee : null,
|
|
635
|
+
quote,
|
|
636
|
+
probedTiers: tried,
|
|
637
|
+
venues: topVenues,
|
|
638
|
+
venuesTotal,
|
|
639
|
+
venuesError,
|
|
640
|
+
bestVenue,
|
|
641
|
+
recommendation: perChainRecommendation({
|
|
642
|
+
symbol: verifiedSymbol,
|
|
643
|
+
chain,
|
|
644
|
+
swapReady,
|
|
645
|
+
best,
|
|
646
|
+
bestVenue,
|
|
647
|
+
onchain,
|
|
648
|
+
isUsdc,
|
|
649
|
+
decimalsSource,
|
|
650
|
+
source,
|
|
651
|
+
}),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function perChainRecommendation({ symbol, chain, swapReady, best, bestVenue, onchain, isUsdc, decimalsSource, source }) {
|
|
656
|
+
const unverified =
|
|
657
|
+
source === "geckoterminal-unverified-collision"
|
|
658
|
+
? ` NOTE: multiple "${symbol}" contracts were found on ${chain.name} and none of their GeckoTerminal metadata matched the symbol — this address is the deepest pool, NOT a verified match. Confirm the address before signing.`
|
|
659
|
+
: decimalsSource === "geckoterminal-unverified"
|
|
660
|
+
? ` NOTE: address/decimals are NOT on-chain verified on ${chain.name} — confirm before signing.`
|
|
661
|
+
: "";
|
|
662
|
+
if (isUsdc) {
|
|
663
|
+
return `${symbol} is the USDC quote asset on ${chain.name} — no swap needed to source it.`;
|
|
664
|
+
}
|
|
665
|
+
if (swapReady && onchain) {
|
|
666
|
+
return `Swap-ready on ${chain.name} (live Uniswap V3 USDC quote${best ? `, fee ${best.fee}` : ""}). Hand to quote-swap.mjs for an exact quote + amountOutMinimum.${unverified}`;
|
|
667
|
+
}
|
|
668
|
+
if (swapReady && !onchain) {
|
|
669
|
+
return `${chain.name} has a Sail-routable ${bestVenue ? bestVenue.protocol : "Uniswap"} pool (~${fmtUsd(bestVenue ? bestVenue.liquidityUsd : 0)}), but it was not live-quoted on-chain (no RPC / verify failed). Configure an RPC or SMA on ${chain.name} to confirm.${unverified}`;
|
|
670
|
+
}
|
|
671
|
+
if (bestVenue) {
|
|
672
|
+
return `No Sail-routable USDC pool for ${symbol} on ${chain.name}, but liquidity exists on ${bestVenue.protocol} (~${fmtUsd(bestVenue.liquidityUsd)}). That DEX is not on Sail's fast path — use a custom mandate or hold the leg.${unverified}`;
|
|
673
|
+
}
|
|
674
|
+
return `No pool for ${symbol} on ${chain.name}. If liquidity is on another Sail chain, deploy/scan there; otherwise configure as a held leg.`;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// ── cross-chain recommendation across the chains we mapped for one token ────────
|
|
678
|
+
function fmtUsd(n) {
|
|
679
|
+
return "$" + (Math.round(Number(n) || 0)).toLocaleString("en-US");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function recommendCrossChain(chains, configuredNames) {
|
|
683
|
+
const entries = Object.entries(chains).filter(([, o]) => !o.error);
|
|
684
|
+
const routable = entries
|
|
685
|
+
.filter(([, o]) => o.swapReady)
|
|
686
|
+
.map(([name, o]) => ({
|
|
687
|
+
name,
|
|
688
|
+
depth: o.bestVenue && o.bestVenue.sailRoutable ? o.bestVenue.liquidityUsd : 0,
|
|
689
|
+
configured: configuredNames.includes(name),
|
|
690
|
+
o,
|
|
691
|
+
}));
|
|
692
|
+
const liqChains = entries.filter(([, o]) => o.venues && o.venues.length).map(([name]) => name);
|
|
693
|
+
|
|
694
|
+
const configuredRoutable = routable.filter((c) => c.configured).sort((a, b) => b.depth - a.depth);
|
|
695
|
+
if (configuredRoutable.length) {
|
|
696
|
+
const t = configuredRoutable[0];
|
|
697
|
+
return {
|
|
698
|
+
action: "route",
|
|
699
|
+
deepestChain: t.name,
|
|
700
|
+
routableChains: configuredRoutable.map((c) => c.name),
|
|
701
|
+
note:
|
|
702
|
+
`Swap-ready on your configured chain(s): ${configuredRoutable.map((c) => c.name).join(", ")}. ` +
|
|
703
|
+
`Deepest: ${t.name}${t.o.bestVenue ? ` (${t.o.bestVenue.protocol}, ${fmtUsd(t.o.bestVenue.liquidityUsd)})` : ""}.` +
|
|
704
|
+
(configuredRoutable.length > 1 ? " Liquidity on more than one configured chain — pick by depth or by where the rest of the portfolio lives." : ""),
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const anyRoutable = routable.slice().sort((a, b) => b.depth - a.depth);
|
|
709
|
+
if (anyRoutable.length) {
|
|
710
|
+
const t = anyRoutable[0];
|
|
711
|
+
return {
|
|
712
|
+
action: "suggest-sma",
|
|
713
|
+
deepestChain: t.name,
|
|
714
|
+
routableChains: anyRoutable.map((c) => c.name),
|
|
715
|
+
note:
|
|
716
|
+
`No Sail-routable pool on your configured chain(s). Deepest routable liquidity is on ${t.name}` +
|
|
717
|
+
`${t.o.bestVenue ? ` (${t.o.bestVenue.protocol}, ${fmtUsd(t.o.bestVenue.liquidityUsd)})` : ""} — ` +
|
|
718
|
+
`consider deploying an SMA on ${t.name} for this leg.`,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (liqChains.length) {
|
|
723
|
+
const detail = entries
|
|
724
|
+
.filter(([, o]) => o.bestVenue)
|
|
725
|
+
.map(([name, o]) => `${name}: ${o.bestVenue.protocol} (${fmtUsd(o.bestVenue.liquidityUsd)})`)
|
|
726
|
+
.join("; ");
|
|
727
|
+
return {
|
|
728
|
+
action: "manual-address",
|
|
729
|
+
note: `Liquidity exists but only on DEXes Sail's fast path can't route (${detail}). Build a custom mandate against that pool/router, or hold the leg.`,
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return {
|
|
734
|
+
action: "hold-skip",
|
|
735
|
+
note: `No pool found on any scanned Sail chain. The token may live on a non-Sail chain (e.g. HYPE on HyperEVM). Configure it as a held leg or drop it from the strategy.`,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Resolve one token across a set of chains → the rich per-token wrapper.
|
|
740
|
+
async function resolveToken(symbolOrAddr, chainSet, configuredNames) {
|
|
741
|
+
const chains = {};
|
|
742
|
+
for (const c of chainSet) {
|
|
743
|
+
try {
|
|
744
|
+
chains[c.name] = await resolveOnChain(symbolOrAddr, c, c.rpc || null);
|
|
745
|
+
} catch (e) {
|
|
746
|
+
// If the on-chain path failed (flaky RPC, etc.) but the chain has a GeckoTerminal
|
|
747
|
+
// network, degrade to a GeckoTerminal-only map rather than dropping the chain.
|
|
748
|
+
if (c.rpc && c.gecko) {
|
|
749
|
+
try {
|
|
750
|
+
const fallback = await resolveOnChain(symbolOrAddr, c, null);
|
|
751
|
+
fallback.onchainError = errMsg(e);
|
|
752
|
+
chains[c.name] = fallback;
|
|
753
|
+
continue;
|
|
754
|
+
} catch {
|
|
755
|
+
// gecko-only also failed — fall through to the error entry
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
chains[c.name] = { chain: c.name, chainId: c.chainId, error: errMsg(e) };
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const chainsWithLiquidity = Object.entries(chains)
|
|
762
|
+
.filter(([, o]) => !o.error && ((o.venues && o.venues.length) || o.swapReady))
|
|
763
|
+
.map(([name]) => name);
|
|
764
|
+
const crossChain = recommendCrossChain(chains, configuredNames);
|
|
765
|
+
return {
|
|
766
|
+
query: symbolOrAddr,
|
|
767
|
+
chains,
|
|
768
|
+
chainsWithLiquidity,
|
|
769
|
+
onSailChain: chainsWithLiquidity.length > 0,
|
|
770
|
+
crossChain,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function buildSummary(tokens, configuredNames, allScanned) {
|
|
775
|
+
const route = [];
|
|
776
|
+
const sma = [];
|
|
777
|
+
const manual = [];
|
|
778
|
+
const hold = [];
|
|
779
|
+
for (const t of tokens) {
|
|
780
|
+
const a = t.crossChain.action;
|
|
781
|
+
if (a === "route") route.push(t.query);
|
|
782
|
+
else if (a === "suggest-sma") sma.push(`${t.query}→${t.crossChain.deepestChain}`);
|
|
783
|
+
else if (a === "manual-address") manual.push(t.query);
|
|
784
|
+
else hold.push(t.query);
|
|
785
|
+
}
|
|
786
|
+
const parts = [];
|
|
787
|
+
if (route.length) parts.push(`Ready to route on your chain(s): ${route.join(", ")}.`);
|
|
788
|
+
if (sma.length) parts.push(`Liquidity lives on another Sail chain — consider an SMA there: ${sma.join(", ")}.`);
|
|
789
|
+
if (manual.length) parts.push(`Liquidity only on non-routable DEXes (custom mandate or hold): ${manual.join(", ")}.`);
|
|
790
|
+
if (hold.length) parts.push(`Not found on any scanned Sail chain — hold or drop: ${hold.join(", ")}.`);
|
|
791
|
+
return {
|
|
792
|
+
configuredChains: configuredNames,
|
|
793
|
+
allChainsScanned: allScanned,
|
|
794
|
+
recommendation: parts.join(" ") || "No tokens resolved.",
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ── emitters ────────────────────────────────────────────────────────────────────
|
|
799
|
+
function emitSingle(out) {
|
|
800
|
+
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
801
|
+
const venueLine =
|
|
802
|
+
out.venues && out.venues.length
|
|
803
|
+
? out.venues
|
|
804
|
+
.slice(0, 3)
|
|
805
|
+
.map((v) => `${v.protocol}${v.feeTier ? ` ${v.feeTier / 10000}%` : ""} ${fmtUsd(v.liquidityUsd)}${v.sailRoutable ? "" : " (info)"}`)
|
|
806
|
+
.join(", ")
|
|
807
|
+
: out.venuesError
|
|
808
|
+
? `unavailable (${out.venuesError.includes("429") ? "rate-limited" : "error"} — re-run --chain ${out.chain})`
|
|
809
|
+
: "none indexed";
|
|
810
|
+
process.stderr.write(
|
|
811
|
+
`\n${out.symbol} on ${out.chain} (${out.chainId}):\n` +
|
|
812
|
+
` address: ${out.address} (source: ${out.source})\n` +
|
|
813
|
+
` decimals: ${out.decimals} (${out.decimalsSource || "onchain"})\n` +
|
|
814
|
+
` swap-ready: ${out.swapReady ? `yes — fee ${out.feeTier} (deepest Uniswap V3)` : "NO USDC V3 pool on this chain"}\n` +
|
|
815
|
+
` venues: ${venueLine}\n` +
|
|
816
|
+
` ${out.recommendation}\n`,
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function emitTokenHuman(token) {
|
|
821
|
+
const lines = [`\n${token.query}:`];
|
|
822
|
+
for (const [name, o] of Object.entries(token.chains)) {
|
|
823
|
+
if (o.error) {
|
|
824
|
+
lines.push(` ${name}: FAILED — ${o.error}`);
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
const venues =
|
|
828
|
+
o.venues && o.venues.length
|
|
829
|
+
? o.venues
|
|
830
|
+
.slice(0, 3)
|
|
831
|
+
.map((v) => `${v.protocol}${v.feeTier ? ` ${v.feeTier / 10000}%` : ""}/${v.pairedSymbol || "?"} ${fmtUsd(v.liquidityUsd)}${v.sailRoutable ? (v.quoteVerified ? "✓" : "") : "(info)"}`)
|
|
832
|
+
.join(", ")
|
|
833
|
+
: o.venuesError
|
|
834
|
+
? `unavailable (${o.venuesError.includes("429") ? "rate-limited — re-run" : "error"})`
|
|
835
|
+
: "none";
|
|
836
|
+
lines.push(
|
|
837
|
+
` ${name} (${o.chainId}): ${o.address} dec ${o.decimals} [${o.decimalsSource}], ${o.swapReady ? `swap-ready fee ${o.feeTier}` : "no USDC V3 pool"}`,
|
|
838
|
+
);
|
|
839
|
+
lines.push(` venues: ${venues}`);
|
|
840
|
+
}
|
|
841
|
+
lines.push(` → [${token.crossChain.action}] ${token.crossChain.note}`);
|
|
842
|
+
return lines.join("\n");
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// ── main ────────────────────────────────────────────────────────────────────────
|
|
846
|
+
async function main() {
|
|
847
|
+
const args = process.argv.slice(2);
|
|
848
|
+
if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
|
|
849
|
+
process.stderr.write(
|
|
850
|
+
"Usage: node scripts/resolve-token.mjs <SYMBOL|ADDRESS> [<SYMBOL|ADDRESS> ...]\n" +
|
|
851
|
+
" [--chain ethereum|unichain|base|arbitrum] [--rpc URL] [--all-chains] [--json]\n" +
|
|
852
|
+
"\n" +
|
|
853
|
+
" one symbol → configured chain(s); bare object / array (back-compat)\n" +
|
|
854
|
+
" many symbols, or --json → rich portfolio JSON (per-token, per-chain venue map)\n" +
|
|
855
|
+
" --all-chains → also scan every Sail mainnet via GeckoTerminal\n",
|
|
856
|
+
);
|
|
857
|
+
process.exit(args.length === 0 ? 1 : 0);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const tokens = [];
|
|
861
|
+
let chainFlag = null;
|
|
862
|
+
let rpcFlag = null;
|
|
863
|
+
let allChains = false;
|
|
864
|
+
let jsonMode = false;
|
|
865
|
+
for (let i = 0; i < args.length; i++) {
|
|
866
|
+
const a = args[i];
|
|
867
|
+
if (a === "--chain") chainFlag = args[++i];
|
|
868
|
+
else if (a === "--rpc") rpcFlag = args[++i];
|
|
869
|
+
else if (a === "--all-chains") allChains = true;
|
|
870
|
+
else if (a === "--json") jsonMode = true;
|
|
871
|
+
else if (!a.startsWith("--")) tokens.push(a);
|
|
872
|
+
}
|
|
873
|
+
if (tokens.length === 0) throw new Error("Pass at least one token symbol or address.");
|
|
874
|
+
|
|
875
|
+
const configured = configuredChains();
|
|
876
|
+
const configuredNames = configured.map((c) => c.name);
|
|
877
|
+
|
|
878
|
+
// ── legacy single-token path (back-compat with sail-swap-quote / sail-template-swap)
|
|
879
|
+
// One token, no --json/--all-chains → today's bare object or array.
|
|
880
|
+
const richMode = tokens.length >= 2 || jsonMode || allChains;
|
|
881
|
+
if (!richMode) {
|
|
882
|
+
const symbolOrAddr = tokens[0];
|
|
883
|
+
if (chainFlag) {
|
|
884
|
+
const chain = resolveChain(chainFlag);
|
|
885
|
+
const rpc = resolveRpc(chain, rpcFlag);
|
|
886
|
+
if (!rpc) throw new Error(rpcHint(chain));
|
|
887
|
+
emitSingle(await resolveOnChain(symbolOrAddr, chain, rpc));
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (configured.length >= 2) {
|
|
891
|
+
const results = [];
|
|
892
|
+
for (const entry of configured) {
|
|
893
|
+
try {
|
|
894
|
+
results.push(await resolveOnChain(symbolOrAddr, entry, entry.rpc));
|
|
895
|
+
} catch (err) {
|
|
896
|
+
results.push({ chain: entry.name, chainId: entry.chainId, error: errMsg(err) });
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
process.stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
900
|
+
process.stderr.write(
|
|
901
|
+
`\nResolved "${symbolOrAddr}" on ${configured.length} configured chains:\n` +
|
|
902
|
+
results
|
|
903
|
+
.map((r) =>
|
|
904
|
+
r.error
|
|
905
|
+
? ` ${r.chain} (${r.chainId}): FAILED — ${r.error}`
|
|
906
|
+
: ` ${r.chain} (${r.chainId}): ${r.address} dec ${r.decimals}, ${r.swapReady ? `swap-ready fee ${r.feeTier}` : "no USDC V3 pool"}`,
|
|
907
|
+
)
|
|
908
|
+
.join("\n") +
|
|
909
|
+
"\n (JSON array — one entry per chain; pass --chain <name> for a single object, or --json for the venue map.)\n",
|
|
910
|
+
);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
// single configured chain (or generic RPC_URL / CHAIN_ID)
|
|
914
|
+
const chain = resolveChain(null);
|
|
915
|
+
const rpc = resolveRpc(chain, null);
|
|
916
|
+
if (!rpc) throw new Error(rpcHint(chain));
|
|
917
|
+
emitSingle(await resolveOnChain(symbolOrAddr, chain, rpc));
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ── rich mode: build the chain set we will map ────────────────────────────────
|
|
922
|
+
let chainSet = [];
|
|
923
|
+
if (chainFlag) {
|
|
924
|
+
const c = resolveChain(chainFlag);
|
|
925
|
+
chainSet = [{ ...c, rpc: resolveRpc(c, rpcFlag) }];
|
|
926
|
+
} else {
|
|
927
|
+
chainSet = configured.slice();
|
|
928
|
+
if (chainSet.length === 0) {
|
|
929
|
+
// single-chain project (generic RPC_URL / CHAIN_ID) — map that one chain.
|
|
930
|
+
try {
|
|
931
|
+
const c = resolveChain(null);
|
|
932
|
+
const rpc = resolveRpc(c, null);
|
|
933
|
+
if (rpc) chainSet = [{ ...c, rpc }];
|
|
934
|
+
} catch {
|
|
935
|
+
// no configured chain — only valid with --all-chains
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (allChains) {
|
|
940
|
+
const env = readSailEnv();
|
|
941
|
+
const have = new Set(chainSet.map((c) => c.name));
|
|
942
|
+
for (const [name, cfg] of Object.entries(CHAINS)) {
|
|
943
|
+
if (cfg.gecko && !have.has(name)) {
|
|
944
|
+
// Only a chain-SPECIFIC RPC (named or chainId-keyed) — never the generic
|
|
945
|
+
// RPC_URL, which would point this chain at the wrong network. No specific
|
|
946
|
+
// var ⇒ GeckoTerminal-only (rpc null).
|
|
947
|
+
const rpc = env[`${name.toUpperCase().replace("-", "_")}_RPC_URL`] ?? env[`RPC_URL_${cfg.chainId}`] ?? null;
|
|
948
|
+
chainSet.push({ name, ...cfg, rpc });
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
if (chainSet.length === 0) {
|
|
953
|
+
throw new Error(
|
|
954
|
+
"No chain configured. Set RPC vars in .sail/.env.local, pass --chain <name>, or use --all-chains to scan every Sail mainnet via GeckoTerminal.",
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const scannedNames = chainSet.map((c) => c.name);
|
|
959
|
+
process.stderr.write(
|
|
960
|
+
`Mapping ${tokens.length} token(s) across ${scannedNames.length} chain(s): ${scannedNames.join(", ")}` +
|
|
961
|
+
` — GeckoTerminal calls are throttled (~${Math.round(60000 / GECKO_SPACING_MS)}/min), this may take a moment.\n`,
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
const resolved = [];
|
|
965
|
+
for (const t of tokens) {
|
|
966
|
+
resolved.push(await resolveToken(t, chainSet, configuredNames));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// single token + (--json/--all-chains) → the token wrapper; many tokens → portfolio.
|
|
970
|
+
if (tokens.length === 1) {
|
|
971
|
+
process.stdout.write(JSON.stringify(resolved[0], null, 2) + "\n");
|
|
972
|
+
process.stderr.write(emitTokenHuman(resolved[0]) + "\n");
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const summary = buildSummary(resolved, configuredNames, allChains);
|
|
977
|
+
process.stdout.write(JSON.stringify({ tokens: resolved, summary }, null, 2) + "\n");
|
|
978
|
+
process.stderr.write(resolved.map(emitTokenHuman).join("\n") + `\n\nSummary: ${summary.recommendation}\n`);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function rpcHint(chain) {
|
|
982
|
+
return `No RPC for chain "${chain.name}". Pass --rpc <url> or set RPC_URL / ${chain.name.toUpperCase()}_RPC_URL / RPC_URL_${chain.chainId} in .sail/.env.local.`;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function errMsg(e) {
|
|
986
|
+
return e && typeof e.message === "string" ? e.message : String(e);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
main().catch((err) => {
|
|
990
|
+
process.stderr.write(`\nresolve-token failed: ${errMsg(err)}\n`);
|
|
991
|
+
process.exit(1);
|
|
992
|
+
});
|