hookit-mcp 0.2.0 → 0.2.2

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 +4 -4
  2. package/index.js +224 -32
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -30,13 +30,13 @@ Optional env: `HOOKIT_API_URL` (default `https://www.hookit.fun`), `HOOKIT_RPC_U
30
30
  | `wallet_status` | The wallet's ETH and USDG, the hookit.fun coins it holds with their value, its open floor loans, and whether it can launch. |
31
31
  | `launch_token` | Launches in one call, with an optional `devBuyPct`. `dryRun: true` checks and simulates without signing. |
32
32
  | `claim_fees` | Collects the creator fees the wallet earned on coins it launched. |
33
- | `send_eth` | Sends ETH from the wallet, for example back to a Kraken deposit address on Ink. `dryRun: true` checks only. |
33
+ | `send_eth` | Sends ETH from the wallet, for example back to a Kraken deposit address on Ink. Refuses less than 0.001 ETH, Kraken's minimum deposit (`belowKrakenMinimum: true` for an address that is not Kraken). `dryRun: true` checks only. |
34
34
  | `agent_pass_challenge` | Step 1 for AI-agents-only coins: five short tasks for the Hookit Agent Pass check (or "already a holder"). `{ agentId? }` for an ERC-8004 validation (below). |
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.2";
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",
@@ -92,6 +95,8 @@ const SWAP_MIN_GAS = 650_000n;
92
95
  /** FloorLoanPlugin durations (days) and their upfront fee (bps of the loan). */
93
96
  const LOAN_FEES_BPS = { 7: 100, 14: 175, 30: 300, 60: 500, 90: 700 };
94
97
  const ETH = { address: zeroAddress, symbol: "ETH", decimals: 18 };
98
+ /** Kraken's minimum ETH deposit on Ink: smaller deposits are marked failed and lost. */
99
+ const KRAKEN_MIN_DEPOSIT_WEI = parseEther("0.001");
95
100
 
96
101
  const ink = defineChain({
97
102
  id: 57073,
@@ -139,7 +144,11 @@ const swapRouterAbi = parseAbi([
139
144
  /** Uniswap's V4Quoter: what a one-pool exact-input swap returns, hook included (eth_call only). */
140
145
  const quoterAbi = parseAbi([
141
146
  "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)",
147
+ "error UnexpectedRevertBytes(bytes revertData)",
148
+ "error NotEnoughLiquidity(bytes32 poolId)",
142
149
  ]);
150
+ /** NotEnoughLiquidity(bytes32): the pool could not fill the whole amount (V4Quoter wraps it in UnexpectedRevertBytes). */
151
+ const NOT_ENOUGH_LIQUIDITY = "7a5ed734";
143
152
  /** BalancedAggregatorC: USDG in or out on a v2 coin, bridged to the market's quote when it is a stock or ETH. */
144
153
  const aggregatorAbi = parseAbi([
145
154
  "struct RouteLeg { uint8 marketIndex; uint8 venue; uint256 amountIn; uint256 minAmountOut; }",
@@ -707,13 +716,61 @@ async function launches() {
707
716
  return body;
708
717
  }
709
718
 
710
- /** The USD price of one unit of `asset` (a stock or kBTC), from a hookit.fun coin priced in it; null if unknown. */
719
+ /**
720
+ * The USD price of one unit of `asset` (ETH, a stock, kBTC): hookit.fun's price for a coin priced in it, else its
721
+ * USDG pool on Ink (a stock that only backs secondary markets); null if unknown.
722
+ */
711
723
  async function usdPrice(c, asset) {
712
724
  if (same(asset, at(c, "usdg"))) return 1;
713
725
  const body = await launches().catch(() => null);
714
726
  if (asset === zeroAddress) return body?.ethUsd > 0 ? body.ethUsd : null;
715
727
  const pool = body?.pools.find((p) => same(p.quoteAddress, asset) && p.quoteUsd > 0);
716
- return pool ? pool.quoteUsd : null;
728
+ if (pool) return pool.quoteUsd;
729
+ const key = asset.toLowerCase();
730
+ const hit = poolPriceCache.get(key);
731
+ if (hit && Date.now() - hit.at < 30_000) return hit.price;
732
+ const price = await usdgPoolPrice(c, asset).catch(() => null);
733
+ poolPriceCache.set(key, { at: Date.now(), price });
734
+ return price;
735
+ }
736
+ const poolPriceCache = new Map();
737
+
738
+ /**
739
+ * One unit of `asset` in USDG from its Uniswap v4 USDG pool (the bridge RouteConfigC names for it), or null: a
740
+ * thousandth of a unit sold for USDG, else one USDG spent on it when the USDG side is too thin.
741
+ */
742
+ async function usdgPoolPrice(c, asset) {
743
+ const bridge = await read(at(c, "routeConfig"), routeConfigAbi, "bridgeOf", [asset]);
744
+ if (!bridge.enabled) return null;
745
+ const usdg = at(c, "usdg");
746
+ const usdgIs0 = BigInt(usdg) < BigInt(asset);
747
+ const poolKey = { currency0: usdgIs0 ? usdg : asset, currency1: usdgIs0 ? asset : usdg, fee: bridge.fee, tickSpacing: bridge.tickSpacing, hooks: bridge.hooks };
748
+ const quote = (zeroForOne, exactAmount) =>
749
+ publicClient
750
+ .simulateContract({ address: at(c, "v4Quoter"), abi: quoterAbi, functionName: "quoteExactInputSingle", args: [{ poolKey, zeroForOne, exactAmount, hookData: "0x" }] })
751
+ .then(({ result }) => result[0])
752
+ .catch(() => 0n);
753
+ const unit = 10n ** BigInt((await assetMeta(asset)).decimals);
754
+ const sold = await quote(!usdgIs0, unit / 1000n);
755
+ if (sold > 0n) return Number(formatUnits(sold * 1000n, 6));
756
+ const bought = await quote(usdgIs0, 1_000_000n);
757
+ return bought > 0n ? Number(unit) / Number(bought) : null;
758
+ }
759
+
760
+ /** `amount` of `from` in units of `to`, through their US dollar prices; null when either has no price. */
761
+ async function convertAtUsd(c, from, to, amount) {
762
+ if (same(from.address, to.address)) return amount;
763
+ const [pf, pt] = await Promise.all([usdPrice(c, from.address), usdPrice(c, to.address)]);
764
+ if (!pf || !pt) return null;
765
+ const v = (Number(formatUnits(amount, from.decimals)) * pf) / pt;
766
+ return Number.isFinite(v) && v > 0 ? parseUnits(v.toFixed(to.decimals), to.decimals) : null;
767
+ }
768
+
769
+ /** Whether hookit.fun's Relay proxy swaps `from` into `to` on Ink: one side must be ETH or USDG. */
770
+ function relayable(c, from, to) {
771
+ if (same(from, to)) return false;
772
+ const funding = (a) => same(a, zeroAddress) || same(a, at(c, "usdg"));
773
+ return funding(from) || funding(to);
717
774
  }
718
775
 
719
776
  /** The v2 aggregator that trades `coin` against USDG, or null (v1 coins, or a hookit.fun without one). */
@@ -791,10 +848,97 @@ async function planTrade(c, coin, isBuy, asset, amountIn) {
791
848
  if (result[0] <= 0n) return { error: `The pool would return nothing for this ${what}.` };
792
849
  return { route: { kind: "router", spender: at(c, "swapRouter"), market, zeroForOne, out: result[0] } };
793
850
  } catch (err) {
851
+ if (errorName(err) === "NotEnoughLiquidity" || errorText(err).includes(NOT_ENOUGH_LIQUIDITY)) {
852
+ const q = await assetMeta(market.quote);
853
+ return {
854
+ thin: true,
855
+ error: isBuy
856
+ ? `The coin's ${q.symbol} market cannot fill a buy this big. Buy less.`
857
+ : `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).`,
858
+ };
859
+ }
794
860
  return { error: swapFailure(err, what) };
795
861
  }
796
862
  }
797
863
 
864
+ /**
865
+ * The sale of `value` coins through each of `assets` (assets the coin trades in), best first by US dollar value,
866
+ * unpriced ones last: { ranked: [{ asset, route, usd }], firstError, thinOnly }. Markets that cannot pay are left out;
867
+ * `thinOnly` says every one that failed lacked liquidity.
868
+ */
869
+ async function rankSales(c, coin, value, assets) {
870
+ let firstError = null;
871
+ const scored = await Promise.all(
872
+ assets.map(async (asset, i) => {
873
+ const planned = await planTrade(c, coin, false, asset.address, value);
874
+ if (planned.error) return { i, error: planned.error, thin: planned.thin };
875
+ const price = await usdPrice(c, asset.address);
876
+ return { i, asset, route: planned.route, usd: price ? Number(formatUnits(planned.route.out, asset.decimals)) * price : null };
877
+ }),
878
+ );
879
+ for (const s of scored) if (s.error) firstError ??= s.error;
880
+ const ranked = scored
881
+ .filter((s) => s.route)
882
+ .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));
883
+ return { ranked, firstError, thinOnly: scored.every((s) => s.route || s.thin) };
884
+ }
885
+
886
+ /**
887
+ * The most coins one market of `assets` can pay for now, up to `value` (a binary search on quotes): { asset, amount }
888
+ * for the market that takes the most, or null when none takes any.
889
+ */
890
+ async function largestSale(c, coin, assets, value) {
891
+ const fits = await Promise.all(
892
+ assets.map(async (asset) => {
893
+ let lo = 0n;
894
+ let hi = value;
895
+ for (let i = 0; i < 14 && hi - lo > value / 1000n; i++) {
896
+ const mid = (lo + hi) / 2n;
897
+ if ((await planTrade(c, coin, false, asset.address, mid)).route) lo = mid;
898
+ else hi = mid;
899
+ }
900
+ return { asset, amount: (lo * 98n) / 100n };
901
+ }),
902
+ );
903
+ const best = fits.reduce((a, b) => (b.amount > a.amount ? b : a), { amount: 0n });
904
+ return best.amount > 0n ? best : null;
905
+ }
906
+
907
+ /** Why no single market takes a sale of `value` coins, and what to sell instead (`most` from largestSale). */
908
+ function saleTooBig(coin, value, most) {
909
+ 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.";
910
+ 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.`;
911
+ }
912
+
913
+ /** `assets` (where a Relay swap of `amount` of `pay` could land) sorted by how many coins each would buy, best first. */
914
+ async function rankBuyLandings(c, coin, pay, amount, assets) {
915
+ const scored = await Promise.all(
916
+ assets.map(async (asset, i) => {
917
+ const legAmount = await convertAtUsd(c, pay, asset, amount).catch(() => null);
918
+ const planned = legAmount ? await planTrade(c, coin, true, asset.address, legAmount) : null;
919
+ return { i, asset, out: planned?.route ? planned.route.out : -1n };
920
+ }),
921
+ );
922
+ return scored.sort((a, b) => (b.out > a.out ? 1 : b.out < a.out ? -1 : a.i - b.i)).map((s) => s.asset);
923
+ }
924
+
925
+ /**
926
+ * Another market of `coin` where the same dollars buy at least a quarter more coins than `out` (what `amount` of
927
+ * `pay` buys in its own market), or null: { asset, out, ratio }. The markets of one coin can drift far apart.
928
+ */
929
+ async function cheaperMarket(c, coin, pay, amount, out) {
930
+ let best = null;
931
+ for (const m of coin.markets) {
932
+ if (same(m.quote, pay.address)) continue;
933
+ const asset = await assetMeta(m.quote);
934
+ const legAmount = await convertAtUsd(c, pay, asset, amount).catch(() => null);
935
+ if (!legAmount) continue;
936
+ const planned = await planTrade(c, coin, true, asset.address, legAmount);
937
+ if (planned.route && planned.route.out * 4n >= out * 5n && (!best || planned.route.out > best.out)) best = { asset, out: planned.route.out };
938
+ }
939
+ return best ? { ...best, ratio: Number((best.out * 100n) / out) / 100 } : null;
940
+ }
941
+
798
942
  /** The call that executes `route` for `amountIn` with at least `minOut` back, to this wallet. */
799
943
  async function tradeCall(coin, route, isBuy, asset, amountIn, minOut) {
800
944
  if (route.kind === "router") {
@@ -1384,7 +1528,7 @@ server.registerTool(
1384
1528
  server.registerTool(
1385
1529
  "list_pairs",
1386
1530
  {
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.",
1531
+ 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
1532
  inputSchema: {},
1389
1533
  annotations: { readOnlyHint: true },
1390
1534
  },
@@ -1577,14 +1721,18 @@ server.registerTool(
1577
1721
  "send_eth",
1578
1722
  {
1579
1723
  description:
1580
- "Sends ETH from this wallet on Ink, for example to cash out to the user's Kraken ETH deposit address on the Ink network (get it with the Kraken MCP deposit addresses tool for the \"ETH - Ink\" method only). Kraken's minimum ETH deposit on Ink is 0.001 ETH; smaller deposits fail and the funds are lost, so never send less. Run it with dryRun first and repeat the address and amount to the user before sending.",
1724
+ "Sends ETH from this wallet on Ink, for example to cash out to the user's Kraken ETH deposit address on the Ink network (get it with the Kraken MCP deposit addresses tool for the \"ETH - Ink\" method only). Kraken's minimum ETH deposit on Ink is 0.001 ETH; smaller deposits fail and the funds are lost, so it refuses to send less unless belowKrakenMinimum is set for an address that is not Kraken. Run it with dryRun first and repeat the address and amount to the user before sending.",
1581
1725
  inputSchema: {
1582
1726
  to: z.string().describe("Destination address on Ink."),
1583
- amountEth: z.string().describe('Amount in ETH, or "max" to send everything except gas.'),
1727
+ amountEth: z.string().describe('Amount in ETH, or "max" to send everything except gas. At least 0.001 ETH.'),
1728
+ belowKrakenMinimum: z
1729
+ .boolean()
1730
+ .optional()
1731
+ .describe("Allows less than 0.001 ETH. Only for an address that is not a Kraken deposit address: Kraken loses smaller deposits."),
1584
1732
  dryRun: z.boolean().optional().describe("Check only. Signs nothing."),
1585
1733
  },
1586
1734
  },
1587
- serialized(async ({ to, amountEth, dryRun }) => {
1735
+ serialized(async ({ to, amountEth, belowKrakenMinimum, dryRun }) => {
1588
1736
  if (!account) return needKey();
1589
1737
  if (!isAddress(to)) return fail(`Not an address: ${to}`);
1590
1738
  const [balance, gasPrice] = await Promise.all([publicClient.getBalance({ address: account.address }), publicClient.getGasPrice()]);
@@ -1601,6 +1749,11 @@ server.registerTool(
1601
1749
  }
1602
1750
  }
1603
1751
  if (value <= 0n) return fail(`Nothing to send: the wallet holds ${formatEther(balance)} ETH.`);
1752
+ if (value < KRAKEN_MIN_DEPOSIT_WEI && !belowKrakenMinimum) {
1753
+ return fail(
1754
+ `${formatEther(value)} ETH is under Kraken's 0.001 ETH minimum deposit on Ink: Kraken marks smaller deposits as failed and the ETH is lost. Nothing was sent. Wait until the wallet holds more, or, only if ${to} is not a Kraken deposit address, pass belowKrakenMinimum: true.`,
1755
+ );
1756
+ }
1604
1757
  if (value + gasReserve > balance) {
1605
1758
  return fail(`Not enough ETH: the wallet holds ${formatEther(balance)} ETH and needs some left for gas.`);
1606
1759
  }
@@ -1745,7 +1898,10 @@ server.registerTool(
1745
1898
  }),
1746
1899
  );
1747
1900
 
1748
- /** The asset a coin trades against by default: its only market's quote, USDG for a multi-pair coin. */
1901
+ /**
1902
+ * The asset a coin trades against by default: its only market's quote, USDG for a multi-pair coin (a sale of a
1903
+ * multi-pair coin without the v2 aggregator goes to the market that pays the most instead: see sell_token).
1904
+ */
1749
1905
  function defaultAsset(c, coin) {
1750
1906
  return coin.markets.length === 1 ? coin.markets[0].quote : at(c, "usdg");
1751
1907
  }
@@ -1770,7 +1926,7 @@ server.registerTool(
1770
1926
  "buy_token",
1771
1927
  {
1772
1928
  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.",
1929
+ "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
1930
  inputSchema: {
1775
1931
  token: tokenArg,
1776
1932
  amount: z
@@ -1821,7 +1977,10 @@ server.registerTool(
1821
1977
  let relay = null;
1822
1978
  if (!(await directAssets(c, coin)).some((a) => same(a.address, asset))) {
1823
1979
  let firstError = null;
1824
- for (const landing of (await landingAssets(c, coin, asset)).slice(0, 3)) {
1980
+ // Land where the coin is cheapest: the markets of one coin can be priced far apart.
1981
+ const landings = (await landingAssets(c, coin, asset)).filter((a) => relayable(c, asset, a.address));
1982
+ 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).`;
1983
+ for (const landing of (await rankBuyLandings(c, coin, pay, value, landings)).slice(0, 3)) {
1825
1984
  const q = await relayQuote(asset, landing.address, value, relayBps);
1826
1985
  const checked = q.quote ? await relayCheck(asset, landing.address, value, q.quote) : q;
1827
1986
  if (q.quote && !checked.error) {
@@ -1845,7 +2004,7 @@ server.registerTool(
1845
2004
  usd = price === null ? null : Number(formatUnits(legAmount, leg.decimals)) * price;
1846
2005
  }
1847
2006
  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.`);
2007
+ 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
2008
  }
1850
2009
  if (usd > MAX_BUY_USD) {
1851
2010
  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 +2017,15 @@ server.registerTool(
1858
2017
  const planned = await planTrade(c, coin, true, leg.address, legAmount);
1859
2018
  if (planned.error) return fail(planned.error);
1860
2019
  let { route } = planned;
2020
+ if (!relay && route.kind === "router" && coin.markets.length > 1) {
2021
+ const cheaper = await cheaperMarket(c, coin, pay, value, route.out);
2022
+ if (cheaper) {
2023
+ const via = relayable(c, zeroAddress, cheaper.asset.address) ? `, or with ETH or USDG (swapped through Relay into that market)` : "";
2024
+ return fail(
2025
+ `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.`,
2026
+ );
2027
+ }
2028
+ }
1861
2029
  let minOut = (route.out * (10_000n - bps)) / 10_000n || 1n;
1862
2030
  const routeWords = await routeText(c, coin, route, true);
1863
2031
  const quote = {
@@ -1945,11 +2113,11 @@ server.registerTool(
1945
2113
  "sell_token",
1946
2114
  {
1947
2115
  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.",
2116
+ "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
2117
  inputSchema: {
1950
2118
  token: tokenArg,
1951
2119
  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.'),
2120
+ 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
2121
  slippageBps: slippageArg,
1954
2122
  dryRun: dryRunArg,
1955
2123
  },
@@ -1965,7 +2133,7 @@ server.registerTool(
1965
2133
  if (!asset || same(asset, token)) {
1966
2134
  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
2135
  }
1968
- const out = await assetMeta(asset);
2136
+ let out = await assetMeta(asset);
1969
2137
  const [balance, ethBalance, reserve] = await Promise.all([
1970
2138
  balanceOf(token, account.address),
1971
2139
  publicClient.getBalance({ address: account.address }),
@@ -1983,30 +2151,52 @@ server.registerTool(
1983
2151
  let leg = out;
1984
2152
  let route = null;
1985
2153
  let relay = null;
1986
- if ((await directAssets(c, coin)).some((a) => same(a.address, asset))) {
2154
+ let thin = null;
2155
+ let picked = null;
2156
+ const direct = await directAssets(c, coin);
2157
+ if (!receive && coin.markets.length > 1 && !(await aggregatorFor(c, coin))) {
2158
+ // A multi-pair coin without the v2 aggregator: sell in the market that pays the most, for its own quote, in one
2159
+ // transaction (USDG when it pays within 2% of the best). Most of its markets only pay what their buyers paid in.
2160
+ const { ranked, firstError, thinOnly } = await rankSales(c, coin, value, direct);
2161
+ if (!ranked.length) return fail(thinOnly ? saleTooBig(coin, value, await largestSale(c, coin, direct, value)) : firstError);
2162
+ const usdg = ranked.find((r) => same(r.asset.address, at(c, "usdg")));
2163
+ const pick = usdg && ranked[0].usd !== null && usdg.usd >= ranked[0].usd * 0.98 ? usdg : ranked[0];
2164
+ out = leg = pick.asset;
2165
+ route = pick.route;
2166
+ 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.`;
2167
+ } else if (direct.some((a) => same(a.address, asset))) {
1987
2168
  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);
2169
+ if (!planned.error) route = planned.route;
2170
+ else if (planned.thin) thin = planned.error;
2171
+ else return fail(planned.error);
2172
+ }
2173
+ if (!route) {
2174
+ // Sell where it pays the most among the markets Relay can swap from into `asset`, then swap the proceeds.
2175
+ const landings = (await landingAssets(c, coin, asset)).filter((a) => relayable(c, a.address, asset));
2176
+ const { ranked, firstError, thinOnly } = await rankSales(c, coin, value, landings);
2177
+ if (!ranked.length && thinOnly && (thin || landings.length)) {
2178
+ return fail(saleTooBig(coin, value, await largestSale(c, coin, thin ? [out, ...landings] : landings, value)));
2179
+ }
2180
+ let relayError = null;
2181
+ for (const r of ranked.slice(0, 3)) {
2182
+ const q = await relayQuote(r.asset.address, asset, r.route.out, relayBps);
1999
2183
  if (q.quote) {
2000
- leg = landing;
2001
- route = planned.route;
2184
+ leg = r.asset;
2185
+ route = r.route;
2002
2186
  relay = q.quote;
2003
2187
  break;
2004
2188
  }
2005
- if (!q.unsupported || !firstError) firstError = q.error;
2189
+ if (!q.unsupported || !relayError) relayError = q.error;
2006
2190
  }
2007
2191
  if (!relay) {
2192
+ if (thin) {
2193
+ return fail(
2194
+ `${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.`,
2195
+ );
2196
+ }
2008
2197
  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.`);
2198
+ 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).`;
2199
+ return fail(`${relayError ?? firstError ?? none} This coin trades in ${assets.join(", ")}: receive ${assets.length > 1 ? "one of those" : "that"} instead.`);
2010
2200
  }
2011
2201
  const capped = await relayCapRefusal(c, leg, route.out);
2012
2202
  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 +2214,7 @@ server.registerTool(
2024
2214
  expected: amountText(relay ? relay.out : route.out, out),
2025
2215
  minimum: `${amountText(finalMin, out)}${relay ? " (estimated: Relay quotes again for what the sale returns)" : ""}`,
2026
2216
  route: relay ? `${routeWords}, then ${relayText(relay)} (two transactions)` : routeWords,
2217
+ ...(picked ? { market: picked } : {}),
2027
2218
  slippageBps: Number(bps),
2028
2219
  ...(atFloor !== null && atFloor > route.out
2029
2220
  ? { betterAtFloor: `The Backed Floor pays more right now: redeem_floor would pay ${amountText(atFloor, leg)} for these coins.` }
@@ -2066,6 +2257,7 @@ server.registerTool(
2066
2257
  received: proceeds !== null ? amountText(proceeds, out) : `about ${amountText(sent.result ?? route.out, out)}`,
2067
2258
  minimum: quote.minimum,
2068
2259
  route: quote.route,
2260
+ ...(picked ? { market: picked } : {}),
2069
2261
  tx: links(),
2070
2262
  });
2071
2263
  }
@@ -2471,7 +2663,7 @@ server.registerTool(
2471
2663
  }
2472
2664
  if (!coin) return reply({ ...info, tradable: false, note: "Classic bonding-curve coin: trade it on the site." });
2473
2665
  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).";
2666
+ 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
2667
  const floor = await floorOf(coin).catch(() => null);
2476
2668
  info.backedFloor = floor
2477
2669
  ? { 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.2",
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" },