hookit-mcp 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -3
  2. package/index.js +210 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -35,8 +35,8 @@ Optional env: `HOOKIT_API_URL` (default `https://www.hookit.fun`), `HOOKIT_RPC_U
35
35
  | `claim_agent_pass` | Step 2: `{ id, answers, agentId?, requestHash? }` within 30 seconds; signs the answers with the wallet and claims the pass on chain. With `agentId`, gets Hookit's ERC-8004 validation instead (below). |
36
36
  | `list_coins` | Coins to trade: newest, top market cap, volume or 24h gainers; filters for AI-agents-only, launched by agents, Backed Floor and Boss Raid coins, a pair, or a name. |
37
37
  | `coin_info` | One coin: market data, what it trades in, hooks, Backed Floor price and reserve, loans, launch gate, and the wallet's balance and loans on it. |
38
- | `buy_token` | Step 3 (or any time): buys a coin, `{ token, amount, payWith?, slippageBps?, dryRun? }`. Pays with ETH, USDG, kBTC, kHYPE or a tokenized stock such as wNVDAx. USDG reaches v2 coins priced in a stock in one transaction (bridged on chain); anything else the coin does not trade in is swapped through Relay first (USDG for a coin priced in ETH, ETH for a coin priced in a stock), then the coin is bought: two transactions. Checks the coin's launch gate first and sends nothing when the wallet is not allowed yet. Always an exact-input buy, the only kind a capped gate window accepts. |
39
- | `sell_token` | Sells a coin, `{ token, amount, receive?, slippageBps?, dryRun? }`, `amount` in coins, `"max"` or `"50%"`. Receives the coin's pair, USDG through the same on-chain bridges, or anything else through Relay after the sale (two transactions). Says when the Backed Floor pays more. |
38
+ | `buy_token` | Step 3 (or any time): buys a coin, `{ token, amount, payWith?, slippageBps?, dryRun? }`. Pays with ETH, USDG, kBTC or a tokenized stock such as wNVDAx. USDG reaches v2 coins priced in a stock in one transaction (bridged on chain); anything else the coin does not trade in is swapped through Relay first (USDG for a coin priced in ETH, ETH for a coin priced in a stock), then the coin is bought: two transactions. On a coin with several markets the Relay swap lands where the coin is cheapest, and a buy is refused when the market of the asset paid with prices the coin far above its other markets. Checks the coin's launch gate first and sends nothing when the wallet is not allowed yet. Always an exact-input buy, the only kind a capped gate window accepts. |
39
+ | `sell_token` | Sells a coin, `{ token, amount, receive?, slippageBps?, dryRun? }`, `amount` in coins, `"max"` or `"50%"`. Receives the coin's pair, USDG through the same on-chain bridges, or anything else through Relay after the sale (two transactions). A multi-pair coin sells by default in the market that pays the most (most of its markets only pay out what their buyers paid in); when the market of the asset asked for cannot pay, it sells in the best one and swaps through Relay. Says when the Backed Floor pays more. |
40
40
  | `redeem_floor` | Burns coins for their Backed Floor value from the floor reserve, `{ token, amount, dryRun? }`: exact payout, no slippage. Compares with selling. |
41
41
  | `borrow_against_floor` | Locks coins and borrows their floor value, `{ token, amount, days?, dryRun? }` (7, 14, 30, 60 or 90 days, fee 1% to 7% upfront). v2 coins launched with loans. |
42
42
  | `repay_loan` | Repays a floor loan before expiry and gets the coins back, `{ loanId?, dryRun? }`. |
@@ -47,7 +47,7 @@ Paying or receiving a token approves exactly that amount to the contract that tr
47
47
 
48
48
  `coin_info` lists what a coin trades in (`tradeIn`). When `payWith` or `receive` is not one of them, the server swaps through [Relay](https://relay.link) on Ink, the same route hookit.fun's own swap box uses: it asks `https://www.hookit.fun/api/relay/quote` for the swap (Relay's API key stays on that server), then signs Relay's transactions itself. A buy swaps first and buys with what Relay returned; a sale sells for the coin's pair first, then swaps the proceeds. `dryRun` shows both legs.
49
49
 
50
- Before signing, Relay's transactions are checked twice. First their shape: Ink only, calls only to Relay's Ink contracts (router, approval proxy, receiver, multicaller, depository), an approval only of Relay's approval proxy (replaced by an exact approval of the amount), and no more ETH than the amount. Then their effect: the approval and every call run together in one `eth_simulateV1` from the wallet, and the swap is refused unless the wallet would give at most the amount, get at least Relay's own minimum, and lose nothing else. Anything else is refused and nothing is sent. No allowance to Relay is left behind, even when a swap fails. One Relay swap converts at most the per-buy cap (`HOOKIT_MAX_BUY_ETH` / `HOOKIT_MAX_BUY_USD`), with at most 3% slippage. Relay swaps ETH or USDG with each other, kBTC, kHYPE and the tokenized stocks. The RPC in `HOOKIT_RPC_URL` must support `eth_simulateV1` (Ink's public RPC does).
50
+ Before signing, Relay's transactions are checked twice. First their shape: Ink only, calls only to Relay's Ink contracts (router, approval proxy, receiver, multicaller, depository), an approval only of Relay's approval proxy (replaced by an exact approval of the amount), and no more ETH than the amount. Then their effect: the approval and every call run together in one `eth_simulateV1` from the wallet, and the swap is refused unless the wallet would give at most the amount, get at least Relay's own minimum, and lose nothing else. Anything else is refused and nothing is sent. No allowance to Relay is left behind, even when a swap fails. One Relay swap converts at most the per-buy cap (`HOOKIT_MAX_BUY_ETH` / `HOOKIT_MAX_BUY_USD`), with at most 3% slippage. Relay swaps ETH or USDG with each other, kBTC and the tokenized stocks. The RPC in `HOOKIT_RPC_URL` must support `eth_simulateV1` (Ink's public RPC does).
51
51
 
52
52
  When the first leg went through and the second did not, the reply is `partial: true` with what the wallet now holds: do not retry the call, or the first leg runs twice.
53
53
 
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // hookit-mcp: lets an AI agent launch a coin on hookit.fun (Ink) with its own hook modules, find coins, buy and sell
3
- // them with ETH, USDG, kBTC, kHYPE or tokenized stocks (swapped through Relay when the coin trades in something else;
3
+ // them with ETH, USDG, kBTC or tokenized stocks (swapped through Relay when the coin trades in something else;
4
4
  // AI-agents-only coins too, with a Hookit Agent Pass), redeem them at their Backed Floor or borrow against it, and
5
5
  // collect its creator fees.
6
6
  // The key in HOOKIT_PRIVATE_KEY signs on this machine and is never sent anywhere.
@@ -34,7 +34,7 @@ import { z } from "zod";
34
34
  const API = (process.env.HOOKIT_API_URL || "https://www.hookit.fun").replace(/\/$/, "");
35
35
  const RPC = process.env.HOOKIT_RPC_URL || "https://rpc-gel.inkonchain.com";
36
36
  const KEY = process.env.HOOKIT_PRIVATE_KEY?.trim();
37
- const VERSION = "0.2.0";
37
+ const VERSION = "0.2.1";
38
38
  const EXPLORER = "https://explorer.inkonchain.com";
39
39
  /**
40
40
  * The Ink deployments this server signs against. Pinned here, never taken from hookit.fun's catalog: a compromised
@@ -65,8 +65,11 @@ const KNOWN_ASSETS = {
65
65
  kbtc: "0x73E0C0d45E048D25Fc26Fa3159b0aA04BfA4Db98",
66
66
  khype: "0xAd09Cd20e513E4d8cB78036F77Ab9AfdE8555929",
67
67
  waaplx: "0x943BF64D566c32A2Bcd41AC92FB63C111cC9De8f",
68
+ wamznx: "0x910cabdE3EBa7Fc1Ce64fD14bD680b9f60fA0F90",
68
69
  wgooglx: "0xf8c5308F80E459bb53d9EbE689854d9cBb2Caa6f",
70
+ wmcdx: "0xc6639026a3a862cd4fcbae3f67cB2D25A2959d37",
69
71
  wmstrx: "0x30987adF0B11dc698438a99BA04ec3a1AB2c7EaB",
72
+ wnflxx: "0x7d87fD6A379714194a797c0bBB8B40c30D250856",
70
73
  wnvdax: "0xa8ddb5Cd96b5222AFe198316E9A57CAA642850D5",
71
74
  wspyx: "0xE7E553Cd128F0011777323A0b44a7b96EA1CB540",
72
75
  wtslax: "0xc3FdBe3A68EE5dE461D30415a8165cf9Aefe1171",
@@ -139,7 +142,11 @@ const swapRouterAbi = parseAbi([
139
142
  /** Uniswap's V4Quoter: what a one-pool exact-input swap returns, hook included (eth_call only). */
140
143
  const quoterAbi = parseAbi([
141
144
  "function quoteExactInputSingle(((address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks) poolKey, bool zeroForOne, uint128 exactAmount, bytes hookData) params) returns (uint256 amountOut, uint256 gasEstimate)",
145
+ "error UnexpectedRevertBytes(bytes revertData)",
146
+ "error NotEnoughLiquidity(bytes32 poolId)",
142
147
  ]);
148
+ /** NotEnoughLiquidity(bytes32): the pool could not fill the whole amount (V4Quoter wraps it in UnexpectedRevertBytes). */
149
+ const NOT_ENOUGH_LIQUIDITY = "7a5ed734";
143
150
  /** BalancedAggregatorC: USDG in or out on a v2 coin, bridged to the market's quote when it is a stock or ETH. */
144
151
  const aggregatorAbi = parseAbi([
145
152
  "struct RouteLeg { uint8 marketIndex; uint8 venue; uint256 amountIn; uint256 minAmountOut; }",
@@ -707,13 +714,61 @@ async function launches() {
707
714
  return body;
708
715
  }
709
716
 
710
- /** The USD price of one unit of `asset` (a stock or kBTC), from a hookit.fun coin priced in it; null if unknown. */
717
+ /**
718
+ * The USD price of one unit of `asset` (ETH, a stock, kBTC): hookit.fun's price for a coin priced in it, else its
719
+ * USDG pool on Ink (a stock that only backs secondary markets); null if unknown.
720
+ */
711
721
  async function usdPrice(c, asset) {
712
722
  if (same(asset, at(c, "usdg"))) return 1;
713
723
  const body = await launches().catch(() => null);
714
724
  if (asset === zeroAddress) return body?.ethUsd > 0 ? body.ethUsd : null;
715
725
  const pool = body?.pools.find((p) => same(p.quoteAddress, asset) && p.quoteUsd > 0);
716
- return pool ? pool.quoteUsd : null;
726
+ if (pool) return pool.quoteUsd;
727
+ const key = asset.toLowerCase();
728
+ const hit = poolPriceCache.get(key);
729
+ if (hit && Date.now() - hit.at < 30_000) return hit.price;
730
+ const price = await usdgPoolPrice(c, asset).catch(() => null);
731
+ poolPriceCache.set(key, { at: Date.now(), price });
732
+ return price;
733
+ }
734
+ const poolPriceCache = new Map();
735
+
736
+ /**
737
+ * One unit of `asset` in USDG from its Uniswap v4 USDG pool (the bridge RouteConfigC names for it), or null: a
738
+ * thousandth of a unit sold for USDG, else one USDG spent on it when the USDG side is too thin.
739
+ */
740
+ async function usdgPoolPrice(c, asset) {
741
+ const bridge = await read(at(c, "routeConfig"), routeConfigAbi, "bridgeOf", [asset]);
742
+ if (!bridge.enabled) return null;
743
+ const usdg = at(c, "usdg");
744
+ const usdgIs0 = BigInt(usdg) < BigInt(asset);
745
+ const poolKey = { currency0: usdgIs0 ? usdg : asset, currency1: usdgIs0 ? asset : usdg, fee: bridge.fee, tickSpacing: bridge.tickSpacing, hooks: bridge.hooks };
746
+ const quote = (zeroForOne, exactAmount) =>
747
+ publicClient
748
+ .simulateContract({ address: at(c, "v4Quoter"), abi: quoterAbi, functionName: "quoteExactInputSingle", args: [{ poolKey, zeroForOne, exactAmount, hookData: "0x" }] })
749
+ .then(({ result }) => result[0])
750
+ .catch(() => 0n);
751
+ const unit = 10n ** BigInt((await assetMeta(asset)).decimals);
752
+ const sold = await quote(!usdgIs0, unit / 1000n);
753
+ if (sold > 0n) return Number(formatUnits(sold * 1000n, 6));
754
+ const bought = await quote(usdgIs0, 1_000_000n);
755
+ return bought > 0n ? Number(unit) / Number(bought) : null;
756
+ }
757
+
758
+ /** `amount` of `from` in units of `to`, through their US dollar prices; null when either has no price. */
759
+ async function convertAtUsd(c, from, to, amount) {
760
+ if (same(from.address, to.address)) return amount;
761
+ const [pf, pt] = await Promise.all([usdPrice(c, from.address), usdPrice(c, to.address)]);
762
+ if (!pf || !pt) return null;
763
+ const v = (Number(formatUnits(amount, from.decimals)) * pf) / pt;
764
+ return Number.isFinite(v) && v > 0 ? parseUnits(v.toFixed(to.decimals), to.decimals) : null;
765
+ }
766
+
767
+ /** Whether hookit.fun's Relay proxy swaps `from` into `to` on Ink: one side must be ETH or USDG. */
768
+ function relayable(c, from, to) {
769
+ if (same(from, to)) return false;
770
+ const funding = (a) => same(a, zeroAddress) || same(a, at(c, "usdg"));
771
+ return funding(from) || funding(to);
717
772
  }
718
773
 
719
774
  /** The v2 aggregator that trades `coin` against USDG, or null (v1 coins, or a hookit.fun without one). */
@@ -791,10 +846,97 @@ async function planTrade(c, coin, isBuy, asset, amountIn) {
791
846
  if (result[0] <= 0n) return { error: `The pool would return nothing for this ${what}.` };
792
847
  return { route: { kind: "router", spender: at(c, "swapRouter"), market, zeroForOne, out: result[0] } };
793
848
  } catch (err) {
849
+ if (errorName(err) === "NotEnoughLiquidity" || errorText(err).includes(NOT_ENOUGH_LIQUIDITY)) {
850
+ const q = await assetMeta(market.quote);
851
+ return {
852
+ thin: true,
853
+ error: isBuy
854
+ ? `The coin's ${q.symbol} market cannot fill a buy this big. Buy less.`
855
+ : `The coin's ${q.symbol} market does not hold enough ${q.symbol} to pay for this sale (a market only pays out what its buyers paid in).`,
856
+ };
857
+ }
794
858
  return { error: swapFailure(err, what) };
795
859
  }
796
860
  }
797
861
 
862
+ /**
863
+ * The sale of `value` coins through each of `assets` (assets the coin trades in), best first by US dollar value,
864
+ * unpriced ones last: { ranked: [{ asset, route, usd }], firstError, thinOnly }. Markets that cannot pay are left out;
865
+ * `thinOnly` says every one that failed lacked liquidity.
866
+ */
867
+ async function rankSales(c, coin, value, assets) {
868
+ let firstError = null;
869
+ const scored = await Promise.all(
870
+ assets.map(async (asset, i) => {
871
+ const planned = await planTrade(c, coin, false, asset.address, value);
872
+ if (planned.error) return { i, error: planned.error, thin: planned.thin };
873
+ const price = await usdPrice(c, asset.address);
874
+ return { i, asset, route: planned.route, usd: price ? Number(formatUnits(planned.route.out, asset.decimals)) * price : null };
875
+ }),
876
+ );
877
+ for (const s of scored) if (s.error) firstError ??= s.error;
878
+ const ranked = scored
879
+ .filter((s) => s.route)
880
+ .sort((a, b) => (a.usd !== null && b.usd !== null ? b.usd - a.usd : a.usd !== null ? -1 : b.usd !== null ? 1 : a.i - b.i));
881
+ return { ranked, firstError, thinOnly: scored.every((s) => s.route || s.thin) };
882
+ }
883
+
884
+ /**
885
+ * The most coins one market of `assets` can pay for now, up to `value` (a binary search on quotes): { asset, amount }
886
+ * for the market that takes the most, or null when none takes any.
887
+ */
888
+ async function largestSale(c, coin, assets, value) {
889
+ const fits = await Promise.all(
890
+ assets.map(async (asset) => {
891
+ let lo = 0n;
892
+ let hi = value;
893
+ for (let i = 0; i < 14 && hi - lo > value / 1000n; i++) {
894
+ const mid = (lo + hi) / 2n;
895
+ if ((await planTrade(c, coin, false, asset.address, mid)).route) lo = mid;
896
+ else hi = mid;
897
+ }
898
+ return { asset, amount: (lo * 98n) / 100n };
899
+ }),
900
+ );
901
+ const best = fits.reduce((a, b) => (b.amount > a.amount ? b : a), { amount: 0n });
902
+ return best.amount > 0n ? best : null;
903
+ }
904
+
905
+ /** Why no single market takes a sale of `value` coins, and what to sell instead (`most` from largestSale). */
906
+ function saleTooBig(coin, value, most) {
907
+ if (!most) return "None of this coin's markets that could pay for this sale has anything to pay with yet: a market only pays out what its buyers paid in. Receive another asset.";
908
+ return `No single market of this coin can pay for ${amountText(value, coin.meta)} now: a market only pays out what its buyers paid in. The most one takes is about ${amountText(most.amount, coin.meta)} (its ${most.asset.symbol} market): sell that much, then the rest in another call.`;
909
+ }
910
+
911
+ /** `assets` (where a Relay swap of `amount` of `pay` could land) sorted by how many coins each would buy, best first. */
912
+ async function rankBuyLandings(c, coin, pay, amount, assets) {
913
+ const scored = await Promise.all(
914
+ assets.map(async (asset, i) => {
915
+ const legAmount = await convertAtUsd(c, pay, asset, amount).catch(() => null);
916
+ const planned = legAmount ? await planTrade(c, coin, true, asset.address, legAmount) : null;
917
+ return { i, asset, out: planned?.route ? planned.route.out : -1n };
918
+ }),
919
+ );
920
+ return scored.sort((a, b) => (b.out > a.out ? 1 : b.out < a.out ? -1 : a.i - b.i)).map((s) => s.asset);
921
+ }
922
+
923
+ /**
924
+ * Another market of `coin` where the same dollars buy at least a quarter more coins than `out` (what `amount` of
925
+ * `pay` buys in its own market), or null: { asset, out, ratio }. The markets of one coin can drift far apart.
926
+ */
927
+ async function cheaperMarket(c, coin, pay, amount, out) {
928
+ let best = null;
929
+ for (const m of coin.markets) {
930
+ if (same(m.quote, pay.address)) continue;
931
+ const asset = await assetMeta(m.quote);
932
+ const legAmount = await convertAtUsd(c, pay, asset, amount).catch(() => null);
933
+ if (!legAmount) continue;
934
+ const planned = await planTrade(c, coin, true, asset.address, legAmount);
935
+ if (planned.route && planned.route.out * 4n >= out * 5n && (!best || planned.route.out > best.out)) best = { asset, out: planned.route.out };
936
+ }
937
+ return best ? { ...best, ratio: Number((best.out * 100n) / out) / 100 } : null;
938
+ }
939
+
798
940
  /** The call that executes `route` for `amountIn` with at least `minOut` back, to this wallet. */
799
941
  async function tradeCall(coin, route, isBuy, asset, amountIn, minOut) {
800
942
  if (route.kind === "router") {
@@ -1384,7 +1526,7 @@ server.registerTool(
1384
1526
  server.registerTool(
1385
1527
  "list_pairs",
1386
1528
  {
1387
- description: "What a coin can be priced in (ETH, USDG, kHYPE, kBTC, HKT, tokenized stocks), and which can be combined in a multi-pair launch. Needs no key.",
1529
+ description: "What a coin can be priced in (ETH, USDG, kBTC, tokenized stocks), and which can be combined in a multi-pair launch. Needs no key.",
1388
1530
  inputSchema: {},
1389
1531
  annotations: { readOnlyHint: true },
1390
1532
  },
@@ -1745,7 +1887,10 @@ server.registerTool(
1745
1887
  }),
1746
1888
  );
1747
1889
 
1748
- /** The asset a coin trades against by default: its only market's quote, USDG for a multi-pair coin. */
1890
+ /**
1891
+ * The asset a coin trades against by default: its only market's quote, USDG for a multi-pair coin (a sale of a
1892
+ * multi-pair coin without the v2 aggregator goes to the market that pays the most instead: see sell_token).
1893
+ */
1749
1894
  function defaultAsset(c, coin) {
1750
1895
  return coin.markets.length === 1 ? coin.markets[0].quote : at(c, "usdg");
1751
1896
  }
@@ -1770,7 +1915,7 @@ server.registerTool(
1770
1915
  "buy_token",
1771
1916
  {
1772
1917
  description:
1773
- "Buys a hookit.fun coin from this wallet, exact-input: you set what you spend. Pay with ETH, USDG, kBTC, kHYPE or a tokenized stock such as wNVDAx; by default it pays with the coin's own pair (USDG for a multi-pair coin). USDG buys v2 coins priced in a tokenized stock in one transaction (bridged on chain, best route picked). When the coin does not trade in what you pay with (ETH for a coin priced in a stock, USDG for a coin priced in ETH…), it first swaps it through Relay into an asset the coin trades in, then buys: two transactions, both shown in a dryRun. The wallet signs, so it is what a launch gate checks. Step 3 for AI-agents-only coins: if the coin needs an Agent Pass this wallet does not hold, it sends nothing and says to run agent_pass_challenge then claim_agent_pass first. Paying with a token approves exactly the amount first. Use dryRun to quote without sending.",
1918
+ "Buys a hookit.fun coin from this wallet, exact-input: you set what you spend. Pay with ETH, USDG, kBTC or a tokenized stock such as wNVDAx; by default it pays with the coin's own pair (USDG for a multi-pair coin). USDG buys v2 coins priced in a tokenized stock in one transaction (bridged on chain, best route picked). When the coin does not trade in what you pay with (ETH for a coin priced in a stock, USDG for a coin priced in ETH…), it first swaps it through Relay into an asset the coin trades in, then buys: two transactions, both shown in a dryRun. On a coin with several markets, the Relay swap lands in the market where the coin is cheapest, and a buy is refused when the market of the asset you pay with prices the coin far above its other markets. The wallet signs, so it is what a launch gate checks. Step 3 for AI-agents-only coins: if the coin needs an Agent Pass this wallet does not hold, it sends nothing and says to run agent_pass_challenge then claim_agent_pass first. Paying with a token approves exactly the amount first. Use dryRun to quote without sending.",
1774
1919
  inputSchema: {
1775
1920
  token: tokenArg,
1776
1921
  amount: z
@@ -1821,7 +1966,10 @@ server.registerTool(
1821
1966
  let relay = null;
1822
1967
  if (!(await directAssets(c, coin)).some((a) => same(a.address, asset))) {
1823
1968
  let firstError = null;
1824
- for (const landing of (await landingAssets(c, coin, asset)).slice(0, 3)) {
1969
+ // Land where the coin is cheapest: the markets of one coin can be priced far apart.
1970
+ const landings = (await landingAssets(c, coin, asset)).filter((a) => relayable(c, asset, a.address));
1971
+ if (!landings.length) firstError = `Relay does not swap ${pay.symbol} into what this coin trades in through hookit.fun (one side must be ETH or USDG).`;
1972
+ for (const landing of (await rankBuyLandings(c, coin, pay, value, landings)).slice(0, 3)) {
1825
1973
  const q = await relayQuote(asset, landing.address, value, relayBps);
1826
1974
  const checked = q.quote ? await relayCheck(asset, landing.address, value, q.quote) : q;
1827
1975
  if (q.quote && !checked.error) {
@@ -1845,7 +1993,7 @@ server.registerTool(
1845
1993
  usd = price === null ? null : Number(formatUnits(legAmount, leg.decimals)) * price;
1846
1994
  }
1847
1995
  if (usd === null) {
1848
- return fail(`Could not price ${pay.symbol} in US dollars for the $${MAX_BUY_USD} per-buy cap (hookit.fun's coin list did not answer). Nothing was sent; retry later.`);
1996
+ return fail(`Could not price ${pay.symbol} in US dollars for the $${MAX_BUY_USD} per-buy cap: hookit.fun's coin list has no price for it and no USDG pool on Ink quoted it. Nothing was sent; pay with ETH or USDG instead.`);
1849
1997
  }
1850
1998
  if (usd > MAX_BUY_USD) {
1851
1999
  return fail(`buy_token spends at most $${MAX_BUY_USD} per buy (HOOKIT_MAX_BUY_USD); ${amountText(value, pay)} is about $${usd.toFixed(2)}. Buy less, or in several buys.`);
@@ -1858,6 +2006,15 @@ server.registerTool(
1858
2006
  const planned = await planTrade(c, coin, true, leg.address, legAmount);
1859
2007
  if (planned.error) return fail(planned.error);
1860
2008
  let { route } = planned;
2009
+ if (!relay && route.kind === "router" && coin.markets.length > 1) {
2010
+ const cheaper = await cheaperMarket(c, coin, pay, value, route.out);
2011
+ if (cheaper) {
2012
+ const via = relayable(c, zeroAddress, cheaper.asset.address) ? `, or with ETH or USDG (swapped through Relay into that market)` : "";
2013
+ return fail(
2014
+ `This coin costs about ${cheaper.ratio}x more in its ${pay.symbol} market than in its ${cheaper.asset.symbol} market: ${amountText(value, pay)} buys ${amountText(route.out, coin.meta)} here, and the same dollars buy about ${amountText(cheaper.out, coin.meta)} there. Pay with ${cheaper.asset.symbol}${via}. Nothing was sent.`,
2015
+ );
2016
+ }
2017
+ }
1861
2018
  let minOut = (route.out * (10_000n - bps)) / 10_000n || 1n;
1862
2019
  const routeWords = await routeText(c, coin, route, true);
1863
2020
  const quote = {
@@ -1945,11 +2102,11 @@ server.registerTool(
1945
2102
  "sell_token",
1946
2103
  {
1947
2104
  description:
1948
- "Sells a hookit.fun coin from this wallet, exact-input: you set how many coins to sell, \"max\" or a percentage. By default it sells for the coin's own pair (ETH, a stock, kBTC; USDG for a multi-pair coin); receive \"usdg\" sells v2 coins priced in a tokenized stock for USDG in one transaction. To receive anything else the coin does not trade in (USDG from a coin priced in ETH, ETH from a coin priced in a stock…), it sells for the coin's pair, then swaps the proceeds through Relay: two transactions. It approves exactly the coins sold first. If the coin's Backed Floor pays more than the market, it says so: redeem_floor then pays more. Use dryRun to quote without sending.",
2105
+ "Sells a hookit.fun coin from this wallet, exact-input: you set how many coins to sell, \"max\" or a percentage. By default it sells for the coin's own pair (ETH, a stock, kBTC); a multi-pair coin sells in the market that pays the most, for that market's asset (USDG through the aggregator on v2 coins). receive \"usdg\" sells v2 coins priced in a tokenized stock for USDG in one transaction. To receive anything else the coin does not trade in (USDG from a coin priced in ETH, ETH from a coin priced in a stock…), or when the market of the asset asked for cannot pay for the sale, it sells in the market that pays the most, then swaps the proceeds through Relay: two transactions. It approves exactly the coins sold first. If the coin's Backed Floor pays more than the market, it says so: redeem_floor then pays more. Use dryRun to quote without sending.",
1949
2106
  inputSchema: {
1950
2107
  token: tokenArg,
1951
2108
  amount: z.union([z.string(), z.number()]).describe('Coins to sell: "1000000", "max" or "50%". "max" keeps 1 wei of the coin.'),
1952
- receive: z.string().optional().describe('"eth", "usdg", a pair ticker such as "wNVDAx" or "kBTC", or a token address. Default: the coin\'s own pair.'),
2109
+ receive: z.string().optional().describe('"eth", "usdg", a pair ticker such as "wNVDAx" or "kBTC", or a token address. Default: the coin\'s own pair, or on a multi-pair coin the market that pays the most.'),
1953
2110
  slippageBps: slippageArg,
1954
2111
  dryRun: dryRunArg,
1955
2112
  },
@@ -1965,7 +2122,7 @@ server.registerTool(
1965
2122
  if (!asset || same(asset, token)) {
1966
2123
  return fail(`Cannot sell for "${receive}". Receive ETH, USDG, a pair ticker from list_pairs or a token address; this coin trades in ${(await tradeAssets(c, coin)).join(", ")}.`);
1967
2124
  }
1968
- const out = await assetMeta(asset);
2125
+ let out = await assetMeta(asset);
1969
2126
  const [balance, ethBalance, reserve] = await Promise.all([
1970
2127
  balanceOf(token, account.address),
1971
2128
  publicClient.getBalance({ address: account.address }),
@@ -1983,30 +2140,52 @@ server.registerTool(
1983
2140
  let leg = out;
1984
2141
  let route = null;
1985
2142
  let relay = null;
1986
- if ((await directAssets(c, coin)).some((a) => same(a.address, asset))) {
2143
+ let thin = null;
2144
+ let picked = null;
2145
+ const direct = await directAssets(c, coin);
2146
+ if (!receive && coin.markets.length > 1 && !(await aggregatorFor(c, coin))) {
2147
+ // A multi-pair coin without the v2 aggregator: sell in the market that pays the most, for its own quote, in one
2148
+ // transaction (USDG when it pays within 2% of the best). Most of its markets only pay what their buyers paid in.
2149
+ const { ranked, firstError, thinOnly } = await rankSales(c, coin, value, direct);
2150
+ if (!ranked.length) return fail(thinOnly ? saleTooBig(coin, value, await largestSale(c, coin, direct, value)) : firstError);
2151
+ const usdg = ranked.find((r) => same(r.asset.address, at(c, "usdg")));
2152
+ const pick = usdg && ranked[0].usd !== null && usdg.usd >= ranked[0].usd * 0.98 ? usdg : ranked[0];
2153
+ out = leg = pick.asset;
2154
+ route = pick.route;
2155
+ if (direct.length > 1) picked = `Sold in the coin's ${pick.asset.symbol} market, the one that pays the most for it now. Pass receive to pick another asset.`;
2156
+ } else if (direct.some((a) => same(a.address, asset))) {
1987
2157
  const planned = await planTrade(c, coin, false, asset, value);
1988
- if (planned.error) return fail(planned.error);
1989
- route = planned.route;
1990
- } else {
1991
- let firstError = null;
1992
- for (const landing of (await landingAssets(c, coin, asset)).slice(0, 3)) {
1993
- const planned = await planTrade(c, coin, false, landing.address, value);
1994
- if (planned.error) {
1995
- firstError ??= planned.error;
1996
- continue;
1997
- }
1998
- const q = await relayQuote(landing.address, asset, planned.route.out, relayBps);
2158
+ if (!planned.error) route = planned.route;
2159
+ else if (planned.thin) thin = planned.error;
2160
+ else return fail(planned.error);
2161
+ }
2162
+ if (!route) {
2163
+ // Sell where it pays the most among the markets Relay can swap from into `asset`, then swap the proceeds.
2164
+ const landings = (await landingAssets(c, coin, asset)).filter((a) => relayable(c, a.address, asset));
2165
+ const { ranked, firstError, thinOnly } = await rankSales(c, coin, value, landings);
2166
+ if (!ranked.length && thinOnly && (thin || landings.length)) {
2167
+ return fail(saleTooBig(coin, value, await largestSale(c, coin, thin ? [out, ...landings] : landings, value)));
2168
+ }
2169
+ let relayError = null;
2170
+ for (const r of ranked.slice(0, 3)) {
2171
+ const q = await relayQuote(r.asset.address, asset, r.route.out, relayBps);
1999
2172
  if (q.quote) {
2000
- leg = landing;
2001
- route = planned.route;
2173
+ leg = r.asset;
2174
+ route = r.route;
2002
2175
  relay = q.quote;
2003
2176
  break;
2004
2177
  }
2005
- if (!q.unsupported || !firstError) firstError = q.error;
2178
+ if (!q.unsupported || !relayError) relayError = q.error;
2006
2179
  }
2007
2180
  if (!relay) {
2181
+ if (thin) {
2182
+ return fail(
2183
+ `${thin} ${ranked.length ? `Selling in its ${ranked[0].asset.symbol} market and swapping to ${out.symbol} through Relay did not work either: ${relayError}` : `No other market of this coin can pay for it either: ${firstError}`} Sell less, or receive another asset.`,
2184
+ );
2185
+ }
2008
2186
  const assets = await tradeAssets(c, coin);
2009
- return fail(`${firstError ?? "Relay cannot swap what this coin trades in."} This coin trades in ${assets.join(", ")}: receive ${assets.length > 1 ? "one of those" : "that"} instead.`);
2187
+ const none = landings.length ? "Relay cannot swap what this coin trades in." : `Relay does not swap what this coin trades in into ${out.symbol} through hookit.fun (one side must be ETH or USDG).`;
2188
+ return fail(`${relayError ?? firstError ?? none} This coin trades in ${assets.join(", ")}: receive ${assets.length > 1 ? "one of those" : "that"} instead.`);
2010
2189
  }
2011
2190
  const capped = await relayCapRefusal(c, leg, route.out);
2012
2191
  if (capped) return fail(`The sale would pay ${capped}, more than one Relay swap converts. Receive ${leg.symbol} (receive "${leg.symbol}"), or sell less at a time.`);
@@ -2024,6 +2203,7 @@ server.registerTool(
2024
2203
  expected: amountText(relay ? relay.out : route.out, out),
2025
2204
  minimum: `${amountText(finalMin, out)}${relay ? " (estimated: Relay quotes again for what the sale returns)" : ""}`,
2026
2205
  route: relay ? `${routeWords}, then ${relayText(relay)} (two transactions)` : routeWords,
2206
+ ...(picked ? { market: picked } : {}),
2027
2207
  slippageBps: Number(bps),
2028
2208
  ...(atFloor !== null && atFloor > route.out
2029
2209
  ? { betterAtFloor: `The Backed Floor pays more right now: redeem_floor would pay ${amountText(atFloor, leg)} for these coins.` }
@@ -2066,6 +2246,7 @@ server.registerTool(
2066
2246
  received: proceeds !== null ? amountText(proceeds, out) : `about ${amountText(sent.result ?? route.out, out)}`,
2067
2247
  minimum: quote.minimum,
2068
2248
  route: quote.route,
2249
+ ...(picked ? { market: picked } : {}),
2069
2250
  tx: links(),
2070
2251
  });
2071
2252
  }
@@ -2471,7 +2652,7 @@ server.registerTool(
2471
2652
  }
2472
2653
  if (!coin) return reply({ ...info, tradable: false, note: "Classic bonding-curve coin: trade it on the site." });
2473
2654
  info.tradeIn = await tradeAssets(c, coin);
2474
- info.alsoTradeVia = "buy_token and sell_token also take ETH, USDG, kBTC, kHYPE or a tokenized stock: Relay swaps it into one of tradeIn first (two transactions).";
2655
+ info.alsoTradeVia = "buy_token and sell_token also take ETH, USDG, kBTC or a tokenized stock: Relay swaps it into one of tradeIn first (two transactions).";
2475
2656
  const floor = await floorOf(coin).catch(() => null);
2476
2657
  info.backedFloor = floor
2477
2658
  ? { floorPrice: `${floor.price} per ${coin.meta.symbol}`, reserve: amountText(floor.V, floor.quoteMeta), vault: floor.vault, redeem: "redeem_floor" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookit-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Let an AI agent launch a coin on hookit.fun (Ink) with its own hook modules, find, buy and sell coins with ETH, USDG or tokenized stocks (AI-agents-only ones too, with a Hookit Agent Pass), redeem or borrow at their Backed Floor, and collect its creator fees.",
5
5
  "type": "module",
6
6
  "bin": { "hookit-mcp": "index.js" },