hookit-mcp 0.1.1 → 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 +110 -58
  2. package/index.js +2693 -321
  3. package/package.json +16 -16
package/index.js CHANGED
@@ -1,321 +1,2693 @@
1
- #!/usr/bin/env node
2
- // hookit-mcp: lets an AI agent launch a coin on hookit.fun (Ink) with its own hook modules.
3
- // The key in HOOKIT_PRIVATE_KEY signs on this machine and is never sent anywhere.
4
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
- import { createPublicClient, createWalletClient, decodeEventLog, defineChain, formatEther, http, isAddress, parseAbi, parseEther, zeroAddress } from "viem";
7
- import { privateKeyToAccount } from "viem/accounts";
8
- import { z } from "zod";
9
-
10
- const API = (process.env.HOOKIT_API_URL || "https://www.hookit.fun").replace(/\/$/, "");
11
- const RPC = process.env.HOOKIT_RPC_URL || "https://rpc-gel.inkonchain.com";
12
- const KEY = process.env.HOOKIT_PRIVATE_KEY?.trim();
13
-
14
- const ink = defineChain({
15
- id: 57073,
16
- name: "Ink",
17
- nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
18
- rpcUrls: { default: { http: [RPC] } },
19
- blockExplorers: { default: { name: "Blockscout", url: "https://explorer.inkonchain.com" } },
20
- });
21
-
22
- const account = KEY ? privateKeyToAccount(KEY.startsWith("0x") ? KEY : `0x${KEY}`) : null;
23
- const publicClient = createPublicClient({ chain: ink, transport: http(RPC) });
24
- const walletClient = account ? createWalletClient({ account, chain: ink, transport: http(RPC) }) : null;
25
-
26
- const factoryAbi = parseAbi([
27
- "event TokenLaunched(uint256 indexed launchId, address indexed token, address indexed creator, bytes32 poolId, address hooks, bool customHook, int24 tickLower, int24 tickUpper, uint128 liquidity)",
28
- "function masterHook() view returns (address)",
29
- "function launchFee() view returns (uint256)",
30
- ]);
31
- const hookAbi = parseAbi(["function feeEscrow() view returns (address)"]);
32
- const routerAbi = parseAbi([
33
- "event PayoutLaunch(address indexed token, address indexed agent, address indexed controller, uint8 provider, uint256 accountId, address wallet)",
34
- ]);
35
- const escrowAbi = parseAbi([
36
- "function balanceOf(address account, address currency) view returns (uint256)",
37
- "function claimAll(address[] currencies)",
38
- ]);
39
- const erc20Abi = parseAbi(["function symbol() view returns (string)", "function decimals() view returns (uint8)"]);
40
-
41
- let catalogCache = null;
42
- async function catalog() {
43
- if (catalogCache) return catalogCache;
44
- const res = await fetch(`${API}/api/agents/catalog`);
45
- if (!res.ok) throw new Error(`hookit catalog unavailable (${res.status})`);
46
- catalogCache = await res.json();
47
- return catalogCache;
48
- }
49
-
50
- function reply(value) {
51
- return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] };
52
- }
53
- function fail(message) {
54
- return { content: [{ type: "text", text: message }], isError: true };
55
- }
56
- function needKey() {
57
- return fail("No wallet. Set HOOKIT_PRIVATE_KEY in this server's env to a wallet funded for launches and nothing else.");
58
- }
59
-
60
- const launchShape = {
61
- name: z.string().describe("Coin name, up to 32 characters."),
62
- symbol: z.string().describe("Ticker, 1 to 12 letters or digits."),
63
- description: z.string().optional(),
64
- image: z.string().optional().describe("Logo as an https:// or ipfs:// URL."),
65
- twitter: z.string().optional(),
66
- telegram: z.string().optional(),
67
- website: z.string().optional(),
68
- pair: z.string().optional().describe("What the coin is priced in. Default eth. See list_pairs."),
69
- pairs: z.array(z.string()).optional().describe("Several pairs for a multi-pair launch (USDG and stocks only)."),
70
- preset: z.string().optional().describe("A strategy from list_presets: clean, community, deflationary, floor, builder, dynamic."),
71
- hookTaxPct: z.number().optional().describe("Extra swap fee feeding the hook modules, 0 to 9."),
72
- modules: z.record(z.union([z.boolean(), z.number()])).optional().describe("Module settings from list_modules, e.g. {\"autoBurn\": true, \"autoBurnPct\": 100}. Without a preset they start from a clean slate."),
73
- devBuyPct: z.number().optional().describe("Creator's own first buy in the launch tx, % of supply (up to 2.5, less with Max Tx). No sniper tax on it."),
74
- payout: z
75
- .object({
76
- kind: z.enum(["wallet", "x", "github", "tiktok"]),
77
- address: z.string().optional(),
78
- handle: z.string().optional(),
79
- accountId: z.string().optional(),
80
- })
81
- .optional()
82
- .describe("Who receives the creator fees. Default: the launching wallet."),
83
- };
84
-
85
- async function prepare(args) {
86
- const res = await fetch(`${API}/api/agents/prepare-launch`, {
87
- method: "POST",
88
- headers: { "content-type": "application/json" },
89
- body: JSON.stringify({ ...args, from: account?.address }),
90
- });
91
- const body = await res.json().catch(() => ({ ok: false, error: `hookit answered ${res.status}` }));
92
- if (!res.ok && !body.error) body.error = `hookit answered ${res.status}`;
93
- return body;
94
- }
95
-
96
- const server = new McpServer({ name: "hookit", version: "0.1.1" });
97
-
98
- server.registerTool(
99
- "list_presets",
100
- {
101
- description: "Launch strategies by goal (clean, community, deflationary, floor, builder, dynamic) with the exact hook modules each one turns on. Needs no key.",
102
- inputSchema: {},
103
- annotations: { readOnlyHint: true },
104
- },
105
- async () => {
106
- const c = await catalog();
107
- return reply(c.presets.map(({ id, label, goal, settings, singlePairOnly }) => ({ id, label, goal, settings, singlePairOnly })));
108
- },
109
- );
110
-
111
- server.registerTool(
112
- "list_modules",
113
- {
114
- description: "Every hook module an agent can combine, what it does, its settings and the rules a combination must follow. Needs no key.",
115
- inputSchema: {},
116
- annotations: { readOnlyHint: true },
117
- },
118
- async () => {
119
- const c = await catalog();
120
- return reply({ modules: c.modules, rules: c.rules });
121
- },
122
- );
123
-
124
- server.registerTool(
125
- "list_pairs",
126
- {
127
- 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.",
128
- inputSchema: {},
129
- annotations: { readOnlyHint: true },
130
- },
131
- async () => reply((await catalog()).pairs),
132
- );
133
-
134
- server.registerTool(
135
- "wallet_status",
136
- {
137
- description: "Says in words whether the wallet can launch: its address, ETH balance and the launch fee.",
138
- inputSchema: {},
139
- annotations: { readOnlyHint: true },
140
- },
141
- async () => {
142
- if (!account) return needKey();
143
- const c = await catalog();
144
- const [balance, fee] = await Promise.all([
145
- publicClient.getBalance({ address: account.address }),
146
- c.factory ? publicClient.readContract({ address: c.factory, abi: factoryAbi, functionName: "launchFee" }).catch(() => null) : null,
147
- ]);
148
- const gasPrice = await publicClient.getGasPrice();
149
- const needed = (fee ?? 0n) + gasPrice * 4_000_000n;
150
- return reply({
151
- address: account.address,
152
- chain: "Ink (57073)",
153
- balanceEth: formatEther(balance),
154
- launchFeeEth: fee === null ? "unknown" : formatEther(fee),
155
- roughCostEth: formatEther(needed),
156
- ready: balance >= needed,
157
- verdict:
158
- balance >= needed
159
- ? "Ready to launch."
160
- : `Not enough ETH on Ink. Send about ${formatEther(needed - balance)} ETH more to ${account.address}.`,
161
- ...(balance >= needed
162
- ? {}
163
- : {
164
- howToFund:
165
- "Withdraw ETH from Kraken straight to Ink (choose the Ink network), or bridge from another chain. With the Kraken CLI MCP loaded, an agent can do the withdrawal itself; the user must first whitelist this address in Kraken.",
166
- }),
167
- });
168
- },
169
- );
170
-
171
- server.registerTool(
172
- "launch_token",
173
- {
174
- description:
175
- "Launches a coin on hookit.fun with the chosen preset or hook modules, in one call. With dryRun it only checks the launch and returns the unsigned transaction: use it first and show the user the settings.",
176
- inputSchema: { ...launchShape, dryRun: z.boolean().optional().describe("Check and simulate only. Signs nothing.") },
177
- },
178
- async ({ dryRun, ...args }) => {
179
- if (!dryRun && !account) return needKey();
180
- let prepared = await prepare(args);
181
- if (prepared.error) return fail(prepared.error);
182
- if (!dryRun && prepared.approve) {
183
- // ERC-20 dev buy: approve the pair token, then prepare again so the launch is simulated.
184
- const approveHash = await walletClient.sendTransaction({
185
- to: prepared.approve.tx.to,
186
- data: prepared.approve.tx.data,
187
- });
188
- await publicClient.waitForTransactionReceipt({ hash: approveHash });
189
- prepared = await prepare(args);
190
- if (prepared.error) return fail(prepared.error);
191
- }
192
- if (dryRun) {
193
- return reply({
194
- dryRun: true,
195
- verdict: prepared.simulation?.needsApproval
196
- ? "Valid. A real launch will first approve the dev buy token, then launch."
197
- : prepared.simulation?.ok === false
198
- ? `The launch would fail: ${prepared.simulation.error}`
199
- : "The launch is valid.",
200
- plan: prepared.plan,
201
- approveFirst: prepared.approve ?? null,
202
- tx: prepared.tx,
203
- warnings: prepared.warnings,
204
- });
205
- }
206
- if (prepared.simulation && !prepared.simulation.ok) {
207
- return fail(`The launch would fail: ${prepared.simulation.error}\nSettings: ${prepared.plan.settings.join("; ")}`);
208
- }
209
-
210
- const hash = await walletClient.sendTransaction({
211
- to: prepared.tx.to,
212
- data: prepared.tx.data,
213
- value: BigInt(prepared.tx.value),
214
- gas: BigInt(prepared.tx.gas),
215
- });
216
- const receipt = await publicClient.waitForTransactionReceipt({ hash });
217
- if (receipt.status !== "success") return fail(`Launch transaction reverted: ${hash}`);
218
- let token = null;
219
- let agent = null;
220
- for (const log of receipt.logs) {
221
- for (const abi of [factoryAbi, routerAbi]) {
222
- try {
223
- const ev = decodeEventLog({ abi, data: log.data, topics: log.topics });
224
- if (ev.eventName === "TokenLaunched") token = ev.args.token;
225
- if (ev.eventName === "PayoutLaunch") agent = ev.args.agent;
226
- } catch {}
227
- }
228
- }
229
- return reply({
230
- launched: true,
231
- token,
232
- page: token ? `${API}/token/${token}` : null,
233
- tx: `https://explorer.inkonchain.com/tx/${hash}`,
234
- settings: prepared.plan.settings,
235
- payout: prepared.plan.payout,
236
- payoutAgent: agent,
237
- note: agent
238
- ? "Creator fees go to the payout target through this launch's CreatorPayoutAgent. This wallet is its controller."
239
- : "Creator fees build up in FeeEscrow for this wallet. Call claim_fees to collect them.",
240
- });
241
- },
242
- );
243
-
244
- server.registerTool(
245
- "claim_fees",
246
- {
247
- description: "Collects the creator fees this wallet has earned on coins it launched itself (paid in ETH and the pair tokens).",
248
- inputSchema: {
249
- currencies: z.array(z.string()).optional().describe("Token addresses to collect. Default: ETH plus every pair token."),
250
- },
251
- },
252
- async ({ currencies }) => {
253
- if (!account) return needKey();
254
- const c = await catalog();
255
- if (!c.factory) return fail("Launches are not configured on this hookit.");
256
- const hook = await publicClient.readContract({ address: c.factory, abi: factoryAbi, functionName: "masterHook" });
257
- const escrow = await publicClient.readContract({ address: hook, abi: hookAbi, functionName: "feeEscrow" });
258
- const candidates = currencies?.length ? currencies : [zeroAddress, ...(c.pairAddresses ?? [])];
259
- const owed = [];
260
- for (const currency of candidates) {
261
- const amount = await publicClient.readContract({ address: escrow, abi: escrowAbi, functionName: "balanceOf", args: [account.address, currency] });
262
- if (amount > 0n) owed.push({ currency, amount });
263
- }
264
- if (owed.length === 0) return reply("Nothing to collect yet.");
265
- const hash = await walletClient.writeContract({ address: escrow, abi: escrowAbi, functionName: "claimAll", args: [owed.map((o) => o.currency)] });
266
- await publicClient.waitForTransactionReceipt({ hash });
267
- const lines = [];
268
- for (const { currency, amount } of owed) {
269
- if (currency === zeroAddress) {
270
- lines.push(`${formatEther(amount)} ETH`);
271
- continue;
272
- }
273
- const [symbol, decimals] = await Promise.all([
274
- publicClient.readContract({ address: currency, abi: erc20Abi, functionName: "symbol" }).catch(() => currency),
275
- publicClient.readContract({ address: currency, abi: erc20Abi, functionName: "decimals" }).catch(() => 18),
276
- ]);
277
- lines.push(`${Number(amount) / 10 ** Number(decimals)} ${symbol}`);
278
- }
279
- return reply({ collected: lines, tx: `https://explorer.inkonchain.com/tx/${hash}` });
280
- },
281
- );
282
-
283
- server.registerTool(
284
- "send_eth",
285
- {
286
- description:
287
- "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.",
288
- inputSchema: {
289
- to: z.string().describe("Destination address on Ink."),
290
- amountEth: z.string().describe('Amount in ETH, or "max" to send everything except gas.'),
291
- dryRun: z.boolean().optional().describe("Check only. Signs nothing."),
292
- },
293
- },
294
- async ({ to, amountEth, dryRun }) => {
295
- if (!account) return needKey();
296
- if (!isAddress(to)) return fail(`Not an address: ${to}`);
297
- const [balance, gasPrice] = await Promise.all([publicClient.getBalance({ address: account.address }), publicClient.getGasPrice()]);
298
- // A plain transfer is 21k gas; keep extra for the L1 data fee.
299
- const gasReserve = gasPrice * 60_000n + 20_000_000_000_000n;
300
- let value;
301
- if (amountEth.trim().toLowerCase() === "max") {
302
- value = balance > gasReserve ? balance - gasReserve : 0n;
303
- } else {
304
- try {
305
- value = parseEther(amountEth.trim());
306
- } catch {
307
- return fail(`Not an amount: ${amountEth}`);
308
- }
309
- }
310
- if (value <= 0n) return fail(`Nothing to send: the wallet holds ${formatEther(balance)} ETH.`);
311
- if (value + gasReserve > balance) {
312
- return fail(`Not enough ETH: the wallet holds ${formatEther(balance)} ETH and needs some left for gas.`);
313
- }
314
- if (dryRun) return reply({ dryRun: true, from: account.address, to, amountEth: formatEther(value), verdict: "Ready to send." });
315
- const hash = await walletClient.sendTransaction({ to, value });
316
- await publicClient.waitForTransactionReceipt({ hash });
317
- return reply({ sentEth: formatEther(value), to, tx: `https://explorer.inkonchain.com/tx/${hash}` });
318
- },
319
- );
320
-
321
- await server.connect(new StdioServerTransport());
1
+ #!/usr/bin/env node
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 or tokenized stocks (swapped through Relay when the coin trades in something else;
4
+ // AI-agents-only coins too, with a Hookit Agent Pass), redeem them at their Backed Floor or borrow against it, and
5
+ // collect its creator fees.
6
+ // The key in HOOKIT_PRIVATE_KEY signs on this machine and is never sent anywhere.
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import {
10
+ createPublicClient,
11
+ createWalletClient,
12
+ decodeEventLog,
13
+ decodeFunctionData,
14
+ defineChain,
15
+ encodeAbiParameters,
16
+ encodeFunctionData,
17
+ formatEther,
18
+ formatUnits,
19
+ getAddress,
20
+ http,
21
+ isAddress,
22
+ keccak256,
23
+ maxUint256,
24
+ parseAbi,
25
+ parseEther,
26
+ parseUnits,
27
+ toHex,
28
+ zeroAddress,
29
+ zeroHash,
30
+ } from "viem";
31
+ import { privateKeyToAccount } from "viem/accounts";
32
+ import { z } from "zod";
33
+
34
+ const API = (process.env.HOOKIT_API_URL || "https://www.hookit.fun").replace(/\/$/, "");
35
+ const RPC = process.env.HOOKIT_RPC_URL || "https://rpc-gel.inkonchain.com";
36
+ const KEY = process.env.HOOKIT_PRIVATE_KEY?.trim();
37
+ const VERSION = "0.2.1";
38
+ const EXPLORER = "https://explorer.inkonchain.com";
39
+ /**
40
+ * The Ink deployments this server signs against. Pinned here, never taken from hookit.fun's catalog: a compromised
41
+ * site must not be able to point an approval, a value transfer or a quote at another contract. A Hookit redeploy
42
+ * needs a new hookit-mcp release.
43
+ */
44
+ const PINNED = {
45
+ factory: "0x145c3F3560c23fDA93eA79f2EfeB7f33D8A9Ff3e", // LaunchFactoryC: new launches
46
+ factories: [
47
+ "0x145c3F3560c23fDA93eA79f2EfeB7f33D8A9Ff3e", // LaunchFactoryC
48
+ "0x99C6f8921858660881186296c39a07d1B30Dc039", // Master LaunchFactory gen 3
49
+ "0x2851Cb70a6784ae69B45E6b7065908A00176322F", // Master LaunchFactory gen 2
50
+ "0xAB6eaE3092AE574BEF0A16505FDf137072DCafdd", // Master LaunchFactory gen 1
51
+ ],
52
+ payoutRouter: "0x9d1C8384fdAf2597C41FA2af0c2f16049CaF550C", // CreatorPayoutRouterC
53
+ swapRouter: "0xb099176151Ecde2E194CCa23E491413f789D1Cb7", // HookitSwapRouter
54
+ usdg: "0xe343167631d89B6Ffc58B88d6b7fB0228795491D", // Paxos USDG, 6 decimals
55
+ v4Quoter: "0x3972C00f7ed4885e145823eb7C655375d275A1C5", // Uniswap V4Quoter
56
+ routeConfig: "0x34D6C456A31d50829Ca572D019E1E3410A5D05E3", // RouteConfigC: names the v2 aggregator
57
+ aggregator: "0x6518b4b2D77F8351F8cE7C9BA1ca7E6D8631D210", // BalancedAggregatorC (USDG in / out on v2 coins)
58
+ loanPlugin: "0xA9e9347175B1d128478D0DdA475dE5a11C33DBB4", // FloorLoanPlugin
59
+ gatedBuyPlugin: "0x4af18ddf7Ccbb0d30DE397B8ebdc873f98BFA9f3", // GatedBuyPlugin
60
+ agentPass: "0xe847CCD9EcD9889c39B381C83B33e0251759Cf67", // HookitAgentPass
61
+ agentValidator: "0x7A9365784b438D327D468b717dd644fd0fB09fa7", // HookitAgentValidator (ERC-8004)
62
+ };
63
+ /** Tickers payWith / receive accept, besides "eth" and "usdg" (the catalog's list is only a fallback). */
64
+ const KNOWN_ASSETS = {
65
+ kbtc: "0x73E0C0d45E048D25Fc26Fa3159b0aA04BfA4Db98",
66
+ khype: "0xAd09Cd20e513E4d8cB78036F77Ab9AfdE8555929",
67
+ waaplx: "0x943BF64D566c32A2Bcd41AC92FB63C111cC9De8f",
68
+ wamznx: "0x910cabdE3EBa7Fc1Ce64fD14bD680b9f60fA0F90",
69
+ wgooglx: "0xf8c5308F80E459bb53d9EbE689854d9cBb2Caa6f",
70
+ wmcdx: "0xc6639026a3a862cd4fcbae3f67cB2D25A2959d37",
71
+ wmstrx: "0x30987adF0B11dc698438a99BA04ec3a1AB2c7EaB",
72
+ wnflxx: "0x7d87fD6A379714194a797c0bBB8B40c30D250856",
73
+ wnvdax: "0xa8ddb5Cd96b5222AFe198316E9A57CAA642850D5",
74
+ wspyx: "0xE7E553Cd128F0011777323A0b44a7b96EA1CB540",
75
+ wtslax: "0xc3FdBe3A68EE5dE461D30415a8165cf9Aefe1171",
76
+ };
77
+ /** Largest single buy paid in ETH (HOOKIT_MAX_BUY_ETH overrides it): a typo must not empty the wallet. */
78
+ const MAX_BUY_WEI = (() => {
79
+ try {
80
+ const v = parseEther((process.env.HOOKIT_MAX_BUY_ETH || "0.5").trim());
81
+ return v > 0n ? v : parseEther("0.5");
82
+ } catch {
83
+ return parseEther("0.5");
84
+ }
85
+ })();
86
+ /** Largest single buy paid in a token (USDG, a stock, kBTC), in US dollars (HOOKIT_MAX_BUY_USD overrides it). */
87
+ const MAX_BUY_USD = (() => {
88
+ const v = Number((process.env.HOOKIT_MAX_BUY_USD || "1000").trim());
89
+ return Number.isFinite(v) && v > 0 ? v : 1000;
90
+ })();
91
+ /** Ink's per-transaction gas cap (2^24). */
92
+ const MAX_TX_GAS = 16_777_216n;
93
+ /** Gas floor for a swap through a hooked pool (the hook's modules can do a lot of work). */
94
+ const SWAP_MIN_GAS = 650_000n;
95
+ /** FloorLoanPlugin durations (days) and their upfront fee (bps of the loan). */
96
+ const LOAN_FEES_BPS = { 7: 100, 14: 175, 30: 300, 60: 500, 90: 700 };
97
+ const ETH = { address: zeroAddress, symbol: "ETH", decimals: 18 };
98
+
99
+ const ink = defineChain({
100
+ id: 57073,
101
+ name: "Ink",
102
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
103
+ rpcUrls: { default: { http: [RPC] } },
104
+ blockExplorers: { default: { name: "Blockscout", url: "https://explorer.inkonchain.com" } },
105
+ });
106
+
107
+ const account = KEY ? privateKeyToAccount(KEY.startsWith("0x") ? KEY : `0x${KEY}`) : null;
108
+ // Ink makes a block a second: poll receipts at that pace rather than viem's 4 s default.
109
+ const publicClient = createPublicClient({ chain: ink, transport: http(RPC), pollingInterval: 1_000 });
110
+ const walletClient = account ? createWalletClient({ account, chain: ink, transport: http(RPC) }) : null;
111
+
112
+ const factoryAbi = parseAbi([
113
+ "event TokenLaunched(uint256 indexed launchId, address indexed token, address indexed creator, bytes32 poolId, address hooks, bool customHook, int24 tickLower, int24 tickUpper, uint128 liquidity)",
114
+ "function masterHook() view returns (address)",
115
+ "function launchFee() view returns (uint256)",
116
+ ]);
117
+ const hookAbi = parseAbi(["function feeEscrow() view returns (address)"]);
118
+ const routerAbi = parseAbi([
119
+ "event PayoutLaunch(address indexed token, address indexed agent, address indexed controller, uint8 provider, uint256 accountId, address wallet)",
120
+ ]);
121
+ const escrowAbi = parseAbi([
122
+ "function balanceOf(address account, address currency) view returns (uint256)",
123
+ "function claimAll(address[] currencies)",
124
+ ]);
125
+ const erc20Abi = parseAbi([
126
+ "function symbol() view returns (string)",
127
+ "function decimals() view returns (uint8)",
128
+ "function balanceOf(address) view returns (uint256)",
129
+ "function totalSupply() view returns (uint256)",
130
+ "function allowance(address owner, address spender) view returns (uint256)",
131
+ "function approve(address spender, uint256 amount) returns (bool)",
132
+ ]);
133
+ const launchLookupAbi = parseAbi([
134
+ "function tokenLaunchId(address token) view returns (uint256)",
135
+ "function poolKeyOf(uint256 launchId) view returns ((address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks))",
136
+ "function launchMarketCount(uint256 launchId) view returns (uint8)",
137
+ "function poolKeyOfMarket(uint256 launchId, uint256 marketIndex) view returns ((address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks))",
138
+ ]);
139
+ const swapRouterAbi = parseAbi([
140
+ "function swapExactIn((address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks) key, bool zeroForOne, uint256 amountIn, uint256 minAmountOut, uint160 sqrtPriceLimitX96) payable returns (uint256 amountOut)",
141
+ ]);
142
+ /** Uniswap's V4Quoter: what a one-pool exact-input swap returns, hook included (eth_call only). */
143
+ const quoterAbi = parseAbi([
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)",
147
+ ]);
148
+ /** NotEnoughLiquidity(bytes32): the pool could not fill the whole amount (V4Quoter wraps it in UnexpectedRevertBytes). */
149
+ const NOT_ENOUGH_LIQUIDITY = "7a5ed734";
150
+ /** BalancedAggregatorC: USDG in or out on a v2 coin, bridged to the market's quote when it is a stock or ETH. */
151
+ const aggregatorAbi = parseAbi([
152
+ "struct RouteLeg { uint8 marketIndex; uint8 venue; uint256 amountIn; uint256 minAmountOut; }",
153
+ "function factory() view returns (address)",
154
+ "function buyExactInput(uint256 launchId, address token, uint256 amountIn, uint256 minTotalOut, RouteLeg[] legs, address recipient, uint256 deadline) returns (uint256 totalOut)",
155
+ "function sellExactInput(uint256 launchId, address token, uint256 amountIn, uint256 minTotalOut, RouteLeg[] legs, address recipient, uint256 deadline) returns (uint256 totalOut)",
156
+ "function quoteExactInput(bool isBuy, uint256 launchId, address token, uint256 amountIn, RouteLeg[] legs, address recipient) returns (uint256[] legOuts, uint256 totalOut)",
157
+ "error InsufficientOutput()",
158
+ "error UnsupportedQuote()",
159
+ "error UnsupportedVenue()",
160
+ "error Expired()",
161
+ ]);
162
+ const routeConfigAbi = parseAbi([
163
+ "function aggregator() view returns (address)",
164
+ "function bridgeOf(address quote) view returns ((uint24 fee, int24 tickSpacing, address hooks, bool enabled))",
165
+ "function bridgeV3Of(address quote) view returns ((address pool, uint24 fee, bool enabled))",
166
+ ]);
167
+ /** FloorVault (v1) and FloorVaultC (v2): the Backed Floor reserve of a coin; `floorState` is v2 only. */
168
+ const floorVaultAbi = parseAbi([
169
+ "function floorVault() view returns (address)",
170
+ "function redeemFloor(address token, uint256 tokenAmount) returns (uint256 quoteOut)",
171
+ "function reserve(address token) view returns (uint256)",
172
+ "function quoteOf(address token) view returns (address)",
173
+ "function floorState(address token) view returns (uint256 V, uint256 S, uint256 E)",
174
+ "event Redeemed(address indexed token, address indexed account, uint256 tokenAmount, uint256 quoteOut)",
175
+ "error ZeroAmount()",
176
+ "error QuoteNotSet()",
177
+ "error InsufficientReserve()",
178
+ "error FloorWouldDecrease()",
179
+ "error FloorSupplyExhausted()",
180
+ ]);
181
+ /** FloorLoanPlugin (v2 module 1): borrow the floor value of a coin, repay before expiry to get it back. */
182
+ const loanAbi = parseAbi([
183
+ "struct Loan { address borrower; uint40 start; uint40 expiry; uint8 status; address token; uint128 e; uint128 gross; uint128 fee; uint128 net; }",
184
+ "function loansEnabled(address token) view returns (bool)",
185
+ "function previewBorrow(address token, uint256 e, uint256 duration) view returns (uint256 gross, uint256 fee, uint256 net)",
186
+ "function maxCollateral(address token) view returns (uint256)",
187
+ "function repayAmount(uint256 loanId) view returns (uint256)",
188
+ "function loansOf(address borrower) view returns (uint256[])",
189
+ "function getLoan(uint256 loanId) view returns (Loan)",
190
+ "function borrow(address token, uint256 e, uint256 duration, uint256 minNet) returns (uint256 loanId)",
191
+ "function repay(uint256 loanId, uint256 maxRepay) payable returns (uint256 repaid)",
192
+ "event Borrowed(uint256 indexed loanId, address indexed borrower, address indexed token, uint256 e, uint256 gross, uint256 fee, uint256 net, uint256 expiry)",
193
+ "error LoansNotEnabled(address token)",
194
+ "error InvalidDuration(uint256 duration)",
195
+ "error CollateralCapExceeded(uint256 maxCollateral)",
196
+ "error ZeroLoan()",
197
+ "error BelowMinimum(uint256 net, uint256 minNet)",
198
+ "error NotBorrower()",
199
+ "error LoanNotOpen()",
200
+ "error LoanExpired()",
201
+ "error RepayTooHigh(uint256 repay, uint256 maxRepay)",
202
+ "error FloorSupplyExhausted()",
203
+ "error ModuleHalted(uint32 moduleId)",
204
+ ]);
205
+ const gatedBuyAbi = parseAbi([
206
+ "function gateState(address token) view returns (address hook, address creator, uint64 endsAt, bool open)",
207
+ "function canBuy(address token, address wallet) view returns (bool ok, uint256 left)",
208
+ "function spentOf(address token, address wallet) view returns (uint256)",
209
+ "function params(address token) view returns ((uint32 duration, uint64 listId, uint128 walletCap, (address token, uint128 minBalance)[] communities))",
210
+ ]);
211
+ const passAbi = parseAbi([
212
+ "function claim(uint256 deadline, bytes signature)",
213
+ "function balanceOf(address wallet) view returns (uint256)",
214
+ "function agentOf(address wallet) view returns (bool isAgent, uint8 via)",
215
+ "error Expired()",
216
+ "error AlreadyHolder()",
217
+ "error Revoked()",
218
+ "error InvalidSignature()",
219
+ ]);
220
+ /** HookitAgentValidator: the ERC-8004 adapter the pass reads (claim_agent_pass with agentId). */
221
+ const validatorAbi = parseAbi([
222
+ "function validate(uint256 agentId, uint256 deadline, bytes signature, bytes32 requestHash)",
223
+ "function balanceOf(address wallet) view returns (uint256)",
224
+ "function agentIdOf(address wallet) view returns (bool linked, uint256 agentId)",
225
+ "function identityRegistry() view returns (address)",
226
+ "function validationRegistry() view returns (address)",
227
+ "error InvalidSignature()",
228
+ "error Expired()",
229
+ "error NotAgentWallet()",
230
+ "error Revoked()",
231
+ "error WrongRequest()",
232
+ ]);
233
+ /** The ERC-8004 ValidationRegistry: the request the identity's owner addresses to Hookit's validator. */
234
+ const validationRegistryAbi = parseAbi([
235
+ "function validationRequest(address validatorAddress, uint256 agentId, string requestURI, bytes32 requestHash)",
236
+ "function getValidationStatus(bytes32 requestHash) view returns (address validatorAddress, uint256 agentId, uint8 response, bytes32 responseHash, string tag, uint256 lastUpdate)",
237
+ "function getSummary(uint256 agentId, address[] validatorAddresses, string tag) view returns (uint64 count, uint8 avgResponse)",
238
+ ]);
239
+ /** The ERC-8004 IdentityRegistry: who may send an identity's validation request (its owner or an approved operator). */
240
+ const identityAbi = parseAbi([
241
+ "function ownerOf(uint256 agentId) view returns (address)",
242
+ "function getApproved(uint256 agentId) view returns (address)",
243
+ "function isApprovedForAll(address owner, address operator) view returns (bool)",
244
+ ]);
245
+ /** What hookit.fun's validation requests carry (web/src/lib/agent-pass.ts), and Hookit's response tag. */
246
+ const VALIDATION_REQUEST_URI = "https://hookit.fun/agents";
247
+ const VALIDATION_TAG = "hookit-agent";
248
+ /** Hook C reverts (wrapped by the PoolManager): a guard plugin refused the buy, or a plugin's size cap. */
249
+ const PLUGIN_BLOCKED = keccak256(toHex("PluginBlocked(uint16,uint16)")).slice(2, 10);
250
+ const PLUGIN_SIZE_CAP = keccak256(toHex("PluginSizeCap(uint16)")).slice(2, 10);
251
+ /** Hook anti-MEV: one swap per wallet (tx.origin) per pool per block. */
252
+ const SANDWICH_BLOCKED = keccak256(toHex("SandwichBlocked()")).slice(2, 10);
253
+ /** GatedBuyPlugin.REASON_EXACT_INPUT_ONLY: with a wallet cap, exact-output buys are refused during the window. */
254
+ const REASON_EXACT_INPUT_ONLY = 2;
255
+ const PASS_STEPS =
256
+ "This coin is AI-agents-only for now. Get your Agent Pass first: call agent_pass_challenge, then claim_agent_pass, then retry buy_token.";
257
+
258
+ let catalogCache = null;
259
+ async function catalog() {
260
+ if (catalogCache) return catalogCache;
261
+ const res = await fetch(`${API}/api/agents/catalog`);
262
+ if (!res.ok) throw new Error(`hookit catalog unavailable (${res.status})`);
263
+ const body = await res.json();
264
+ // Presets, modules, pairs and rules come from hookit.fun; every contract address is this server's own.
265
+ const { factory, factories, payoutRouter, gatedBuyPlugin, agentPass, agentValidator } = PINNED;
266
+ catalogCache = { ...body, factory, factories, payoutRouter, gatedBuyPlugin, agentPass, agentValidator };
267
+ return catalogCache;
268
+ }
269
+
270
+ function reply(value) {
271
+ return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] };
272
+ }
273
+ function fail(message) {
274
+ return { content: [{ type: "text", text: message }], isError: true };
275
+ }
276
+ function needKey() {
277
+ return fail("No wallet. Set HOOKIT_PRIVATE_KEY in this server's env to a wallet funded for launches and nothing else.");
278
+ }
279
+
280
+ /**
281
+ * Whether `wallet` counts as an agent on the Hookit Agent Pass. `via` is "pass" for a pass holder; "erc8004" for the
282
+ * operating wallet of an ERC-8004 identity validated by Hookit (claim_agent_pass with agentId) or by a validator the
283
+ * owner trusts.
284
+ */
285
+ async function agentStatus(pass, wallet) {
286
+ const [isAgent, via] = await publicClient.readContract({ address: pass, abi: passAbi, functionName: "agentOf", args: [wallet] });
287
+ return { holder: isAgent, via: isAgent ? (via === 1 ? "pass" : via === 2 ? "erc8004" : null) : null };
288
+ }
289
+
290
+ /** The exact text the wallet signs for /api/agents/pass (answers as sent, joined with " | "). */
291
+ function passMessage(wallet, id, answers) {
292
+ return `Hookit Agent Pass\nWallet: ${getAddress(wallet)}\nChallenge: ${id}\nAnswers: ${answers.join(" | ")}`;
293
+ }
294
+
295
+ /** Every revert payload and message an error carries, lowercased, to look for a nested selector. */
296
+ function errorText(err) {
297
+ const parts = [];
298
+ let e = err;
299
+ for (let i = 0; e && i < 12; i++) {
300
+ for (const v of [e.raw, e.data, e.data?.data, e.details, e.shortMessage, e.message]) {
301
+ if (typeof v === "string") parts.push(v);
302
+ }
303
+ e = e.cause;
304
+ }
305
+ return parts.join(" ").toLowerCase();
306
+ }
307
+
308
+ function errorName(err) {
309
+ let e = err;
310
+ for (let i = 0; e && i < 12; i++) {
311
+ if (e.data?.errorName) return e.data.errorName;
312
+ e = e.cause;
313
+ }
314
+ return null;
315
+ }
316
+
317
+ /** The `reason` of a PluginBlocked(slot, reason) found in the revert text, else null. */
318
+ function pluginBlockedReason(text) {
319
+ const m = new RegExp(`${PLUGIN_BLOCKED}([0-9a-f]{64})([0-9a-f]{64})`).exec(text);
320
+ return m ? Number.parseInt(m[2].slice(-4), 16) : null;
321
+ }
322
+
323
+ function swapFailure(err, what = "buy") {
324
+ const text = errorText(err);
325
+ if (pluginBlockedReason(text) === REASON_EXACT_INPUT_ONLY) {
326
+ return "During the gated window this coin only accepts exact-input buys. buy_token always buys exact-input, so retry it rather than another route.";
327
+ }
328
+ if (text.includes(PLUGIN_BLOCKED)) return `The coin's launch gate refused this wallet (PluginBlocked). ${PASS_STEPS} If the coin is gated to an allowlist or holders instead, wait for the gate to open.`;
329
+ if (text.includes(PLUGIN_SIZE_CAP)) return "This buy is above what the wallet may still buy during the coin's gate window (wallet cap). Buy less, or wait for the gate to open.";
330
+ if (text.includes(SANDWICH_BLOCKED)) return "This coin allows one trade per wallet per block (anti-MEV) and this wallet already traded it in the latest block. Retry in a few seconds.";
331
+ if (/insufficient funds/.test(text)) return `The wallet does not hold enough ETH for this ${what} and its gas.`;
332
+ const name = errorName(err);
333
+ if (name === "InsufficientOutput") return `The ${what} would return less than the slippage floor. Retry, or allow more slippage.`;
334
+ if (name === "UnsupportedQuote" || name === "UnsupportedVenue") return `No USDG route reaches this coin's market for this ${what}.`;
335
+ return `The ${what} would fail: ${shortError(err)}`;
336
+ }
337
+
338
+ /**
339
+ * Runs an eth_call or a send; when the coin's anti-MEV rule refuses it because this wallet traded the same pool in
340
+ * the latest block (calls run against that block), waits for the next block (Ink makes one a second) and runs it once
341
+ * more.
342
+ */
343
+ async function nextBlockRetry(fn) {
344
+ try {
345
+ return await fn();
346
+ } catch (err) {
347
+ if (!errorText(err).includes(SANDWICH_BLOCKED)) throw err;
348
+ const start = await publicClient.getBlockNumber();
349
+ for (let i = 0; i < 20 && (await publicClient.getBlockNumber()) <= start; i++) {
350
+ await new Promise((resolve) => setTimeout(resolve, 500));
351
+ }
352
+ return fn();
353
+ }
354
+ }
355
+
356
+ function shortError(err) {
357
+ const name = errorName(err);
358
+ const text = String(err?.shortMessage || err?.message || err).slice(0, 300);
359
+ return name && !text.includes(name) ? `${name}: ${text}` : text;
360
+ }
361
+
362
+ /** Why a validator or ValidationRegistry call would fail, in words (claim_agent_pass with agentId). */
363
+ function validationFailure(err, agentId) {
364
+ const name = errorName(err);
365
+ const text = errorText(err);
366
+ if (name === "Expired") return "the voucher expired. Call agent_pass_challenge again.";
367
+ if (name === "Revoked") {
368
+ return "this wallet was revoked by Hookit's ERC-8004 validator; it cannot be validated again unless the owner lifts the revocation.";
369
+ }
370
+ if (name === "NotAgentWallet") return `this wallet is not the operating wallet (agentWallet) of ERC-8004 identity ${agentId}.`;
371
+ if (name === "InvalidSignature") return "the voucher's signature was refused. Call agent_pass_challenge again later.";
372
+ if (name === "WrongRequest") return `no validation request from the identity's owner was found for identity ${agentId}.`;
373
+ if (/exists/.test(text)) return "this validation request was already sent. Call agent_pass_challenge again for a new voucher.";
374
+ if (/not authorized/.test(text)) return `this wallet is not the owner (or an approved operator) of ERC-8004 identity ${agentId}.`;
375
+ if (/insufficient funds/.test(text)) return "the wallet has no ETH on Ink for the gas.";
376
+ return String(err?.shortMessage || err?.message || err).slice(0, 300);
377
+ }
378
+
379
+ /**
380
+ * Simulates then sends one call from this wallet and waits for it: { hash }, or { error } plus the hash when it was sent
381
+ * (nothing is sent when the simulation fails). `validate` ends in a nested ValidationRegistry write that the node's
382
+ * estimate can leave short of gas (seen on an Ink fork), so the call gets 30% on top; unused gas is not paid.
383
+ */
384
+ async function sendChecked(call, agentId) {
385
+ let request;
386
+ let gas;
387
+ try {
388
+ ({ request } = await publicClient.simulateContract({ account, ...call }));
389
+ gas = await publicClient.estimateContractGas({ account, ...call });
390
+ } catch (err) {
391
+ return { error: validationFailure(err, agentId) };
392
+ }
393
+ let hash;
394
+ try {
395
+ hash = await walletClient.writeContract({ ...request, gas: (gas * 13n) / 10n });
396
+ const receipt = await publicClient.waitForTransactionReceipt({ hash });
397
+ if (receipt.status !== "success") return { error: "the transaction reverted", hash };
398
+ } catch (err) {
399
+ return { error: String(err?.shortMessage || err?.message || err).slice(0, 300), ...(hash ? { hash } : {}) };
400
+ }
401
+ return { hash };
402
+ }
403
+
404
+ /**
405
+ * claim_agent_pass with agentId, once hookit answered with the validator's voucher. Nothing in the answer is trusted:
406
+ * the validator is the pinned one, the voucher must be for this wallet and identity with a sane deadline, `request` must
407
+ * be `validationRequest(validator, agentId, VALIDATION_REQUEST_URI, this voucher's hash)` to the validator's own registry,
408
+ * and who owns the identity is read on chain. A link-only simulation proves the voucher works before anything is sent.
409
+ * Then: with `requestHash` (a request the identity's owner already addressed to Hookit's validator) `validate` answers
410
+ * it; when this wallet owns the identity or is its approved operator and Hookit has not published yet, it sends the
411
+ * request then `validate`; else it only links the wallet (nothing when already linked). The verdict is read on chain.
412
+ */
413
+ async function claimValidation(body, agentId, requestHash) {
414
+ const id = BigInt(agentId);
415
+ const me = account.address;
416
+ const validator = at(null, "agentValidator");
417
+ if (!same(body.validator, validator)) return fail("hookit answered with an unexpected validator address. Nothing was sent.");
418
+ let registry;
419
+ let identity;
420
+ let now;
421
+ try {
422
+ [registry, identity, now] = await Promise.all([
423
+ publicClient.readContract({ address: validator, abi: validatorAbi, functionName: "validationRegistry" }),
424
+ publicClient.readContract({ address: validator, abi: validatorAbi, functionName: "identityRegistry" }),
425
+ publicClient.getBlock().then((b) => b.timestamp),
426
+ ]);
427
+ } catch {
428
+ return fail("Could not read Hookit's ERC-8004 validator on Ink. Nothing was sent; try again later.");
429
+ }
430
+ let deadline;
431
+ let signature;
432
+ let ownHash;
433
+ try {
434
+ if (!same(body.validate?.to, validator) || !same(body.validateLinkOnly?.to, validator) || !same(body.request?.to, registry)) {
435
+ throw new Error("to");
436
+ }
437
+ const validate = decodeFunctionData({ abi: validatorAbi, data: body.validate.data });
438
+ const linkOnly = decodeFunctionData({ abi: validatorAbi, data: body.validateLinkOnly.data });
439
+ const request = decodeFunctionData({ abi: validationRegistryAbi, data: body.request.data });
440
+ if (validate.functionName !== "validate" || linkOnly.functionName !== "validate") throw new Error("fn");
441
+ if (request.functionName !== "validationRequest") throw new Error("fn");
442
+ [, deadline, signature, ownHash] = validate.args;
443
+ if (validate.args[0] !== id || linkOnly.args[0] !== id || request.args[1] !== id) throw new Error("agentId");
444
+ if (linkOnly.args[1] !== deadline || linkOnly.args[2] !== signature || linkOnly.args[3] !== zeroHash) throw new Error("voucher");
445
+ if (!same(request.args[0], validator) || request.args[2] !== VALIDATION_REQUEST_URI || request.args[3] !== ownHash) throw new Error("request");
446
+ const expected = keccak256(
447
+ encodeAbiParameters(
448
+ [{ type: "string" }, { type: "address" }, { type: "uint256" }, { type: "address" }, { type: "uint256" }],
449
+ [VALIDATION_TAG, validator, id, me, deadline],
450
+ ),
451
+ );
452
+ if (ownHash !== expected) throw new Error("requestHash");
453
+ if (deadline <= now || deadline > now + 86_400n) throw new Error("deadline");
454
+ } catch {
455
+ return fail("hookit answered with unexpected validation transactions. Nothing was sent.");
456
+ }
457
+ const call = (hash) => ({ address: validator, abi: validatorAbi, functionName: "validate", args: [id, deadline, signature, hash] });
458
+ // The voucher itself (deadline, this wallet as the identity's agentWallet, signature, revocation), before any send.
459
+ try {
460
+ await publicClient.simulateContract({ account, ...call(zeroHash) });
461
+ } catch (err) {
462
+ return fail(`Nothing was sent: the validation would fail: ${validationFailure(err, agentId)}`);
463
+ }
464
+
465
+ const publishedCount = () =>
466
+ publicClient
467
+ .readContract({ address: registry, abi: validationRegistryAbi, functionName: "getSummary", args: [id, [validator], VALIDATION_TAG] })
468
+ .then(([count]) => count)
469
+ .catch(() => null);
470
+ const [[linked, linkedId], owner, approved, published] = await Promise.all([
471
+ publicClient.readContract({ address: validator, abi: validatorAbi, functionName: "agentIdOf", args: [me] }),
472
+ publicClient.readContract({ address: identity, abi: identityAbi, functionName: "ownerOf", args: [id] }).catch(() => null),
473
+ publicClient.readContract({ address: identity, abi: identityAbi, functionName: "getApproved", args: [id] }).catch(() => null),
474
+ publishedCount(),
475
+ ]);
476
+ const canRequest =
477
+ !!owner &&
478
+ (same(owner, me) ||
479
+ same(approved, me) ||
480
+ (await publicClient.readContract({ address: identity, abi: identityAbi, functionName: "isApprovedForAll", args: [owner, me] }).catch(() => false)));
481
+ const alreadyLinked = linked && linkedId === id;
482
+
483
+ const txs = [];
484
+ const notes = [];
485
+ const send = async (c) => {
486
+ const r = await sendChecked(c, agentId);
487
+ if (r.hash) txs.push(`${EXPLORER}/tx/${r.hash}`);
488
+ return r;
489
+ };
490
+ const sentSoFar = () => (txs.length ? ` Sent so far: ${txs.join(", ")}.` : " Nothing was sent.");
491
+ if (requestHash) {
492
+ let status;
493
+ try {
494
+ status = await publicClient.readContract({ address: registry, abi: validationRegistryAbi, functionName: "getValidationStatus", args: [requestHash] });
495
+ } catch {
496
+ return fail(`No validation request ${requestHash} was found on Ink's ValidationRegistry. Nothing was sent.`);
497
+ }
498
+ if (!same(status[0], validator) || status[1] !== id) {
499
+ return fail(`Request ${requestHash} is not addressed to Hookit's validator for identity ${agentId}. Nothing was sent.`);
500
+ }
501
+ const r = await send(call(requestHash));
502
+ if (r.error) return fail(`validate failed: ${r.error}.${sentSoFar()}`);
503
+ } else if (canRequest && published === 0n) {
504
+ // Owner or operator: request Hookit's validation, then validate (Hookit publishes its response).
505
+ const r1 = await send({ address: registry, abi: validationRegistryAbi, functionName: "validationRequest", args: [validator, id, VALIDATION_REQUEST_URI, ownHash] });
506
+ let linkedNow = false;
507
+ if (r1.error) {
508
+ if (r1.hash) return fail(`The validation request failed: ${r1.error}.${sentSoFar()}`);
509
+ notes.push(`Hookit's validation was not published: the request would fail (${r1.error}).`);
510
+ } else {
511
+ let r2 = await send(call(ownHash));
512
+ if (r2.error && !r2.hash) {
513
+ // The registry may not show the request yet on this RPC: one more try a moment later.
514
+ await sleep(2_000);
515
+ r2 = await send(call(ownHash));
516
+ }
517
+ if (r2.hash && r2.error) return fail(`validate failed: ${r2.error}.${sentSoFar()}`);
518
+ if (r2.error) notes.push(`Hookit's validation was not published: validate would fail (${r2.error}).`);
519
+ else linkedNow = true;
520
+ }
521
+ if (!linkedNow && !alreadyLinked) {
522
+ const r3 = await send(call(zeroHash));
523
+ if (r3.error) return fail(`The validation failed: ${r3.error}.${sentSoFar()}`);
524
+ }
525
+ } else if (!alreadyLinked) {
526
+ const r = await send(call(zeroHash));
527
+ if (r.error) return fail(`The validation failed: ${r.error}.${sentSoFar()}`);
528
+ }
529
+
530
+ const [status, publishedAfter] = await Promise.all([agentStatus(at(null, "agentPass"), me).catch(() => null), publishedCount()]);
531
+ if (!status?.holder) {
532
+ return fail(
533
+ `This wallet does not count as an agent on the Hookit Agent Pass after the validation (ERC-8004 agents may be switched off on Hookit right now): use claim_agent_pass without agentId for a plain pass.${sentSoFar()}`,
534
+ );
535
+ }
536
+ const isPublished = (publishedAfter ?? 0n) > 0n;
537
+ const howToPublish = `To publish Hookit's validation on ERC-8004 too: the identity's owner${owner ? ` (${getAddress(owner)})` : ""} sends validationRequest(${validator}, ${agentId}, "${VALIDATION_REQUEST_URI}", any new 32-byte hash) to the ValidationRegistry ${registry}, then this wallet runs agent_pass_challenge({ agentId: "${agentId}" }) and claim_agent_pass with the same agentId and requestHash set to that hash. Or the owner approves this wallet on the IdentityRegistry ${identity} and this wallet runs the two tools again with agentId.`;
538
+ return reply({
539
+ validated: true,
540
+ wallet: me,
541
+ agentId,
542
+ validator,
543
+ agentPass: status,
544
+ publishedOnErc8004: isPublished,
545
+ tx: txs,
546
+ ...(notes.length ? { note: notes.join(" ") } : {}),
547
+ next: `${txs.length ? "" : "Nothing was sent. "}This wallet is linked to ERC-8004 identity ${agentId} and counts as an agent: buy AI-agents-only coins with buy_token (step 3), from this same wallet.${isPublished ? ` Hookit's 100/100 "${VALIDATION_TAG}" validation of identity ${agentId} is public on ERC-8004.` : ` ${howToPublish}`}`,
548
+ });
549
+ }
550
+
551
+ function opensAt(endsAt) {
552
+ const sec = Number(endsAt);
553
+ const minutes = Math.max(0, Math.ceil((sec * 1000 - Date.now()) / 60_000));
554
+ return `${new Date(sec * 1000).toISOString()} (in about ${minutes} min)`;
555
+ }
556
+
557
+ /** A catalog address, or its Ink default when this hookit.fun does not list it yet. */
558
+ /** A pinned Ink address (see PINNED). `c` is unused: kept so call sites read the same. */
559
+ function at(c, name) {
560
+ return getAddress(PINNED[name]);
561
+ }
562
+
563
+ const read = (address, abi, functionName, args = []) => publicClient.readContract({ address, abi, functionName, args });
564
+
565
+ /** An amount for people: every digit before the point, at most 6 significant ones after leading zeros. */
566
+ function short(amount, decimals) {
567
+ const s = formatUnits(amount, decimals);
568
+ const [whole, frac = ""] = s.split(".");
569
+ if (!frac) return whole;
570
+ const lead = /^-?0$/.test(whole) ? Math.max(0, frac.search(/[1-9]/)) : 0;
571
+ const kept = frac.slice(0, lead + 6).replace(/0+$/, "");
572
+ return kept ? `${whole}.${kept}` : whole;
573
+ }
574
+ function amountText(amount, meta) {
575
+ return `${short(amount, meta.decimals)} ${meta.symbol}`;
576
+ }
577
+
578
+ const assetCache = new Map();
579
+ /** { address, symbol, decimals } of ETH (zeroAddress) or an ERC-20. */
580
+ async function assetMeta(address) {
581
+ if (address === zeroAddress) return ETH;
582
+ const key = address.toLowerCase();
583
+ if (!assetCache.has(key)) {
584
+ const meta = Promise.all([
585
+ read(address, erc20Abi, "symbol").catch(() => "tokens"),
586
+ read(address, erc20Abi, "decimals").catch(() => 18),
587
+ ]).then(([symbol, decimals]) => ({ address: getAddress(address), symbol, decimals: Number(decimals) }));
588
+ assetCache.set(key, meta);
589
+ }
590
+ return assetCache.get(key);
591
+ }
592
+
593
+ async function balanceOf(asset, wallet) {
594
+ return asset === zeroAddress ? publicClient.getBalance({ address: wallet }) : read(asset, erc20Abi, "balanceOf", [wallet]);
595
+ }
596
+
597
+ const same = (a, b) => typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase();
598
+
599
+ /** The Master pool of `token` on the first factory that knows it, or null. */
600
+ async function resolvePool(c, token) {
601
+ const factories = (c.factories?.length ? c.factories : [c.factory]).filter(Boolean);
602
+ for (const factory of factories) {
603
+ const launchId = await publicClient
604
+ .readContract({ address: factory, abi: launchLookupAbi, functionName: "tokenLaunchId", args: [token] })
605
+ .catch(() => 0n);
606
+ if (launchId > 0n) {
607
+ const key = await publicClient.readContract({ address: factory, abi: launchLookupAbi, functionName: "poolKeyOf", args: [launchId] });
608
+ return { factory, launchId, key };
609
+ }
610
+ }
611
+ return null;
612
+ }
613
+
614
+ /**
615
+ * A Master coin and every market it trades in: { token, meta, factory, launchId, key, markets[{ index, key, tokenIs0,
616
+ * quote }] }, or null when no Master factory knows it. A multi-pair launch has one market per quote (USDG, stocks).
617
+ */
618
+ async function loadCoin(c, token) {
619
+ const pool = await resolvePool(c, token);
620
+ if (!pool) return null;
621
+ const count = Number(await read(pool.factory, launchLookupAbi, "launchMarketCount", [pool.launchId]).catch(() => 0));
622
+ const keys =
623
+ count > 0
624
+ ? await Promise.all(Array.from({ length: count }, (_, i) => read(pool.factory, launchLookupAbi, "poolKeyOfMarket", [pool.launchId, BigInt(i)])))
625
+ : [pool.key];
626
+ if (!keys.every((key) => same(key.currency0, token) || same(key.currency1, token))) return null;
627
+ const markets = keys.map((key, index) => {
628
+ const tokenIs0 = same(key.currency0, token);
629
+ return { index, key, tokenIs0, quote: getAddress(tokenIs0 ? key.currency1 : key.currency0) };
630
+ });
631
+ return { ...pool, token, meta: await assetMeta(token), markets };
632
+ }
633
+
634
+ const NOT_A_COIN =
635
+ "hookit.fun does not know this coin on its Master factories (Classic bonding-curve coins are not supported here). Find coins with list_coins.";
636
+
637
+ /** What `input` names: "eth", "usdg", a pair id or ticker from list_pairs, a market's quote symbol, or an address. */
638
+ async function resolveAsset(c, input, coin) {
639
+ const s = String(input).trim();
640
+ const low = s.toLowerCase();
641
+ if (low === "eth" || low === "ether" || low === "native") return zeroAddress;
642
+ if (isAddress(s)) return getAddress(s);
643
+ if (low === "usdg") return at(c, "usdg");
644
+ if (KNOWN_ASSETS[low]) return getAddress(KNOWN_ASSETS[low]);
645
+ const pair = (c.pairs ?? []).find((p) => same(p.id, low) || same(p.ticker, low));
646
+ if (pair?.address && isAddress(pair.address)) return getAddress(pair.address);
647
+ for (const m of coin?.markets ?? []) {
648
+ if (same((await assetMeta(m.quote)).symbol, low)) return m.quote;
649
+ }
650
+ return null;
651
+ }
652
+
653
+ /**
654
+ * What the coin trades against on chain, as [{ address, symbol, decimals }]: each market's quote, plus USDG on a v2
655
+ * coin when the aggregator can bridge USDG to one of its markets (RouteConfigC enables a v4 or v3 USDG pool per quote).
656
+ */
657
+ async function directAssets(c, coin) {
658
+ const assets = [];
659
+ for (const m of coin.markets) {
660
+ if (!assets.some((a) => same(a.address, m.quote))) assets.push(await assetMeta(m.quote));
661
+ }
662
+ const usdg = at(c, "usdg");
663
+ if (!assets.some((a) => same(a.address, usdg)) && (await aggregatorFor(c, coin))) {
664
+ const rc = at(c, "routeConfig");
665
+ for (const m of coin.markets) {
666
+ const [v4, v3] = await Promise.all([
667
+ read(rc, routeConfigAbi, "bridgeOf", [m.quote]).catch(() => null),
668
+ read(rc, routeConfigAbi, "bridgeV3Of", [m.quote]).catch(() => null),
669
+ ]);
670
+ if (v4?.enabled || v3?.enabled) {
671
+ assets.push(await assetMeta(usdg));
672
+ break;
673
+ }
674
+ }
675
+ }
676
+ return assets;
677
+ }
678
+
679
+ /** The symbols of directAssets. */
680
+ async function tradeAssets(c, coin) {
681
+ return (await directAssets(c, coin)).map((a) => a.symbol);
682
+ }
683
+
684
+ /**
685
+ * An amount of `meta` given by the user: a decimal, "max" (the whole balance) or "N%" of it, in base units; null when
686
+ * it is not an amount. `keepDust` keeps 1 base unit when spending everything: emptying a holder-airdrop coin in one
687
+ * transaction can hit a bug of the older airdrop vaults, and 1 wei of a coin is worth nothing.
688
+ */
689
+ function parseAmount(input, meta, balance, keepDust = false) {
690
+ const s = String(input).trim().toLowerCase();
691
+ if (s === "max" || s === "all") return keepDust && balance > 1n ? balance - 1n : balance;
692
+ const pct = /^([0-9]*\.?[0-9]+)\s*%$/.exec(s);
693
+ if (pct) {
694
+ const bp = Math.round(Number(pct[1]) * 100);
695
+ if (!(bp > 0 && bp <= 10_000)) return null;
696
+ const v = (balance * BigInt(bp)) / 10_000n;
697
+ return keepDust && bp === 10_000 && v > 1n ? v - 1n : v;
698
+ }
699
+ try {
700
+ return parseUnits(s, meta.decimals);
701
+ } catch {
702
+ return null;
703
+ }
704
+ }
705
+
706
+ /** hookit.fun's coin list (/api/launches), cached for 30 s. */
707
+ let launchesCache = null;
708
+ async function launches() {
709
+ if (launchesCache && Date.now() - launchesCache.at < 30_000) return launchesCache.body;
710
+ const res = await fetch(`${API}/api/launches`);
711
+ const body = await res.json().catch(() => null);
712
+ if (!res.ok || !Array.isArray(body?.pools)) throw new Error(body?.error ?? `hookit answered ${res.status}`);
713
+ launchesCache = { at: Date.now(), body };
714
+ return body;
715
+ }
716
+
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
+ */
721
+ async function usdPrice(c, asset) {
722
+ if (same(asset, at(c, "usdg"))) return 1;
723
+ const body = await launches().catch(() => null);
724
+ if (asset === zeroAddress) return body?.ethUsd > 0 ? body.ethUsd : null;
725
+ const pool = body?.pools.find((p) => same(p.quoteAddress, asset) && p.quoteUsd > 0);
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);
772
+ }
773
+
774
+ /** The v2 aggregator that trades `coin` against USDG, or null (v1 coins, or a hookit.fun without one). */
775
+ async function aggregatorFor(c, coin) {
776
+ const configured = await read(at(c, "routeConfig"), routeConfigAbi, "aggregator").catch(() => null);
777
+ const aggregator = configured && configured !== zeroAddress ? getAddress(configured) : at(c, "aggregator");
778
+ const factory = await read(aggregator, aggregatorAbi, "factory").catch(() => null);
779
+ return same(factory, coin.factory) ? aggregator : null;
780
+ }
781
+
782
+ /** abi.encode(wallet): the hookData HookitSwapRouter and the aggregator pass, so hook plugins credit this wallet. */
783
+ const hookDataFor = (wallet) => encodeAbiParameters([{ type: "address" }], [wallet]);
784
+
785
+ /**
786
+ * The route and quote of an exact-input trade of `coin` against `asset` (`isBuy`: pay `asset`, get the coin; else the
787
+ * reverse): HookitSwapRouter on the market quoted in `asset`, else the aggregator for USDG on a v2 coin (the best
788
+ * single market and bridge). { route } or { error }. Quotes are eth_calls from this wallet, so gates see it.
789
+ */
790
+ async function planTrade(c, coin, isBuy, asset, amountIn) {
791
+ const what = isBuy ? "buy" : "sale";
792
+ const usdg = at(c, "usdg");
793
+ const aggregator = same(asset, usdg) ? await aggregatorFor(c, coin) : null;
794
+ if (aggregator) {
795
+ const options = [];
796
+ for (const m of coin.markets) {
797
+ for (const venue of same(m.quote, usdg) ? [0] : [0, 1]) options.push({ marketIndex: m.index, venue });
798
+ }
799
+ let firstError = null;
800
+ const quotes = await Promise.all(
801
+ options.map(async (o) => {
802
+ try {
803
+ const leg = { ...o, amountIn, minAmountOut: 0n };
804
+ const { result } = await nextBlockRetry(() =>
805
+ publicClient.simulateContract({
806
+ account,
807
+ address: aggregator,
808
+ abi: aggregatorAbi,
809
+ functionName: "quoteExactInput",
810
+ args: [isBuy, coin.launchId, coin.token, amountIn, [leg], account.address],
811
+ }),
812
+ );
813
+ return { ...o, out: result[1] };
814
+ } catch (err) {
815
+ firstError ??= err;
816
+ return { ...o, out: 0n };
817
+ }
818
+ }),
819
+ );
820
+ const best = quotes.reduce((a, b) => (b.out > a.out ? b : a));
821
+ if (best.out > 0n) return { route: { kind: "aggregator", spender: aggregator, leg: best, out: best.out } };
822
+ if (!coin.markets.some((m) => same(m.quote, asset))) {
823
+ if (["UnsupportedQuote", "UnsupportedVenue"].includes(errorName(firstError))) {
824
+ const assets = (await tradeAssets(c, coin)).filter((a) => a !== "USDG");
825
+ return { error: `No on-chain USDG route reaches this coin's market. Trade it in ${assets.join(", ")} instead.` };
826
+ }
827
+ return { error: firstError ? swapFailure(firstError, what) : `The pools would return nothing for this ${what}.` };
828
+ }
829
+ }
830
+ const market = coin.markets.find((m) => same(m.quote, asset));
831
+ if (!market) {
832
+ const assets = await tradeAssets(c, coin);
833
+ return { error: `This coin trades in ${assets.join(", ")} here, not in that token. Use one of those.` };
834
+ }
835
+ const zeroForOne = isBuy ? !market.tokenIs0 : market.tokenIs0;
836
+ try {
837
+ const { result } = await nextBlockRetry(() =>
838
+ publicClient.simulateContract({
839
+ account,
840
+ address: at(c, "v4Quoter"),
841
+ abi: quoterAbi,
842
+ functionName: "quoteExactInputSingle",
843
+ args: [{ poolKey: market.key, zeroForOne, exactAmount: amountIn, hookData: hookDataFor(account.address) }],
844
+ }),
845
+ );
846
+ if (result[0] <= 0n) return { error: `The pool would return nothing for this ${what}.` };
847
+ return { route: { kind: "router", spender: at(c, "swapRouter"), market, zeroForOne, out: result[0] } };
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
+ }
858
+ return { error: swapFailure(err, what) };
859
+ }
860
+ }
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
+
940
+ /** The call that executes `route` for `amountIn` with at least `minOut` back, to this wallet. */
941
+ async function tradeCall(coin, route, isBuy, asset, amountIn, minOut) {
942
+ if (route.kind === "router") {
943
+ return {
944
+ address: route.spender,
945
+ abi: swapRouterAbi,
946
+ functionName: "swapExactIn",
947
+ args: [route.market.key, route.zeroForOne, amountIn, minOut, 0n],
948
+ ...(isBuy && asset === zeroAddress ? { value: amountIn } : {}),
949
+ };
950
+ }
951
+ const { timestamp } = await publicClient.getBlock();
952
+ const leg = { marketIndex: route.leg.marketIndex, venue: route.leg.venue, amountIn, minAmountOut: 0n };
953
+ return {
954
+ address: route.spender,
955
+ abi: aggregatorAbi,
956
+ functionName: isBuy ? "buyExactInput" : "sellExactInput",
957
+ args: [coin.launchId, coin.token, amountIn, minOut, [leg], account.address, timestamp + 900n],
958
+ };
959
+ }
960
+
961
+ /**
962
+ * Simulates `call` from this wallet, sends it with a third more gas than estimated (at least `minGas`) and waits:
963
+ * { hash, result, receipt }, or { error } when the simulation fails (nothing sent) or the transaction reverts.
964
+ */
965
+ async function send(call, { minGas = 0n, explain = shortError } = {}) {
966
+ let result;
967
+ let gas;
968
+ try {
969
+ ({ result, gas } = await nextBlockRetry(async () => {
970
+ const { result } = await publicClient.simulateContract({ account, ...call });
971
+ const estimated = await publicClient.estimateContractGas({ account, ...call });
972
+ return { result, gas: estimated + estimated / 3n };
973
+ }));
974
+ } catch (err) {
975
+ return { error: explain(err) };
976
+ }
977
+ if (gas < minGas) gas = minGas;
978
+ if (gas > MAX_TX_GAS) gas = MAX_TX_GAS;
979
+ const hash = await walletClient.writeContract({ ...call, gas });
980
+ const receipt = await publicClient.waitForTransactionReceipt({ hash });
981
+ if (receipt.status !== "success") return { error: `the transaction reverted: ${EXPLORER}/tx/${hash}`, hash };
982
+ return { hash, result, receipt };
983
+ }
984
+
985
+ /** Approves `spender` for exactly `amount` of `asset` unless it already may spend that much. { hash } or { error }. */
986
+ async function approveIfNeeded(asset, spender, amount) {
987
+ if (asset === zeroAddress) return {};
988
+ const allowance = await read(asset, erc20Abi, "allowance", [account.address, spender]).catch(() => 0n);
989
+ if (allowance >= amount) return {};
990
+ const sent = await send({ address: asset, abi: erc20Abi, functionName: "approve", args: [spender, amount] });
991
+ return sent.error ? { error: `The approval would fail: ${sent.error}` } : { hash: sent.hash };
992
+ }
993
+
994
+ /** ETH the wallet keeps for gas (with Ink's L1 data fee) on a trade of `gasUnits`. */
995
+ async function gasReserve(gasUnits) {
996
+ const gasPrice = await publicClient.getGasPrice();
997
+ return gasPrice * gasUnits + 20_000_000_000_000n;
998
+ }
999
+
1000
+ /** What a mined transaction cost in ETH: execution gas plus Ink's L1 data fee. */
1001
+ function feeOf(receipt) {
1002
+ const l1 = receipt.l1Fee != null ? BigInt(receipt.l1Fee) : 0n;
1003
+ return receipt.gasUsed * receipt.effectiveGasPrice + l1;
1004
+ }
1005
+
1006
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1007
+
1008
+ /**
1009
+ * Relay's contracts on Ink (api.relay.link/chains): a conversion only ever calls one of these, or approves one of them
1010
+ * on the token it spends. A quote that asks for anything else is refused before signing.
1011
+ */
1012
+ const RELAY_INK = new Set(
1013
+ [
1014
+ "0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f", // erc20Router
1015
+ "0xccc88a9d1b4ed6b0eaba998850414b24f1c315be", // approvalProxy
1016
+ "0x7f4babd2c7d35221e72ab67ea72cba99573a0089", // relayReceiver
1017
+ "0xe2035a0d568dec7f37b3267823cd9c919e744594", // multicaller
1018
+ "0x4cd00e387622c35bddb9b4c962c136462338bc31", // depository
1019
+ ],
1020
+ );
1021
+ const RELAY_APPROVAL_PROXY = getAddress("0xccc88a9d1b4ed6b0eaba998850414b24f1c315be");
1022
+ /** Slippage allowed on a Relay leg (ETH, USDG, stocks: liquid pairs), whatever the coin's own slippage is. */
1023
+ const RELAY_MAX_SLIPPAGE_BPS = 300;
1024
+
1025
+ /**
1026
+ * A Relay quote to swap `amount` of `from` into `to` on Ink for this wallet, through hookit.fun's /api/relay/quote
1027
+ * (Relay's key stays on that server): { quote: { requestId, out, min, impact, calls } } or { error }.
1028
+ */
1029
+ async function relayQuote(from, to, amount, slippageBps) {
1030
+ const [inMeta, outMeta] = await Promise.all([assetMeta(from), assetMeta(to)]);
1031
+ let res;
1032
+ try {
1033
+ res = await fetch(`${API}/api/relay/quote`, {
1034
+ method: "POST",
1035
+ headers: { "content-type": "application/json" },
1036
+ body: JSON.stringify({
1037
+ user: account.address,
1038
+ originChainId: ink.id,
1039
+ destinationChainId: ink.id,
1040
+ originCurrency: from,
1041
+ destinationCurrency: to,
1042
+ amount: amount.toString(),
1043
+ slippageBps,
1044
+ }),
1045
+ });
1046
+ } catch {
1047
+ return { error: "hookit.fun's Relay quote did not answer. Retry later." };
1048
+ }
1049
+ const body = await res.json().catch(() => null);
1050
+ if (!res.ok) {
1051
+ const why = body?.error ?? `hookit.fun answered ${res.status}`;
1052
+ if (why === "Unsupported Relay pair") return { error: `Relay does not swap ${inMeta.symbol} into ${outMeta.symbol} through hookit.fun.`, unsupported: true };
1053
+ return { error: `Relay has no ${inMeta.symbol} to ${outMeta.symbol} route right now (${why}).` };
1054
+ }
1055
+ if (!/^\d+$/.test(body?.amountOut ?? "") || !/^\d+$/.test(body?.minimumAmount ?? "") || !Array.isArray(body?.calls)) {
1056
+ return { error: "hookit.fun's Relay quote came back malformed. Nothing was sent." };
1057
+ }
1058
+ const out = BigInt(body.amountOut);
1059
+ if (out <= 0n) return { error: `Relay would return no ${outMeta.symbol}.` };
1060
+ const checked = relayPlan(body.calls, from, amount, inMeta.symbol);
1061
+ if (checked.error) return { error: checked.error };
1062
+ return {
1063
+ quote: {
1064
+ requestId: body.requestId,
1065
+ out,
1066
+ min: BigInt(body.minimumAmount),
1067
+ impact: body.impactPercent ?? null,
1068
+ calls: body.calls,
1069
+ inMeta,
1070
+ outMeta,
1071
+ },
1072
+ };
1073
+ }
1074
+
1075
+ /**
1076
+ * Checks a Relay quote's calls before anything is signed: Ink only, at most one approval (of Relay's approval proxy,
1077
+ * on the token spent; replaced by an exact approval of `amount`: an allowance given to Relay's router could be spent
1078
+ * by anyone), every other call to a Relay contract, and no more ETH sent than `amount` (none when an ERC-20 is spent).
1079
+ * The calls' effect is checked separately, by relaySimulate. { spender, txs } or { error }.
1080
+ */
1081
+ function relayPlan(calls, from, amount, symbol) {
1082
+ const refused = (why) => ({ error: `Refused Relay's transactions (${why}). Nothing was sent.` });
1083
+ if (calls.length === 0 || calls.length > 4) return refused(`${calls.length} calls`);
1084
+ let spender = null;
1085
+ let total = 0n;
1086
+ const txs = [];
1087
+ for (const call of calls) {
1088
+ if (Number(call?.chainId) !== ink.id) return refused("a call outside Ink");
1089
+ if (!isAddress(call.to ?? "") || !/^0x([0-9a-fA-F]{2})*$/.test(call.data ?? "") || !/^\d+$/.test(String(call.value ?? "0"))) {
1090
+ return refused("a malformed call");
1091
+ }
1092
+ const to = getAddress(call.to);
1093
+ const value = BigInt(call.value ?? 0);
1094
+ if (from !== zeroAddress && same(to, from)) {
1095
+ let decoded;
1096
+ try {
1097
+ decoded = decodeFunctionData({ abi: erc20Abi, data: call.data });
1098
+ } catch {
1099
+ decoded = null;
1100
+ }
1101
+ if (decoded?.functionName !== "approve" || value !== 0n || !same(decoded.args[0], RELAY_APPROVAL_PROXY) || spender) {
1102
+ return refused(`a call on ${symbol} other than approving Relay's approval proxy`);
1103
+ }
1104
+ spender = getAddress(decoded.args[0]);
1105
+ continue;
1106
+ }
1107
+ if (!RELAY_INK.has(to.toLowerCase())) return refused(`a call to ${to}, which is not a Relay contract this version knows; update hookit-mcp`);
1108
+ if (from !== zeroAddress && value > 0n) return refused("ETH sent with a token swap");
1109
+ total += value;
1110
+ txs.push({ to, data: call.data, value });
1111
+ }
1112
+ if (txs.length === 0) return refused("no swap call");
1113
+ if (from === zeroAddress && total > amount) return refused("more ETH than the amount");
1114
+ if (from !== zeroAddress && !spender && txs.some((tx) => same(tx.to, RELAY_APPROVAL_PROXY))) spender = RELAY_APPROVAL_PROXY;
1115
+ return { spender, txs };
1116
+ }
1117
+
1118
+ const TRANSFER_TOPIC = keccak256(toHex("Transfer(address,address,uint256)"));
1119
+ /** eth_simulateV1 reports ETH moves as Transfer logs of this address (traceTransfers). */
1120
+ const ETH_TRACE = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
1121
+
1122
+ /**
1123
+ * Runs the approval and Relay's transactions together in one eth_simulateV1 from this wallet (nothing is sent) and
1124
+ * checks what the wallet would send and get: at most `amount` of `from` out, at least `min` of `to` in, nothing else
1125
+ * out. Relay contracts run arbitrary calls, so this is what stops a forged quote from paying someone else. null when
1126
+ * sound, else why not.
1127
+ */
1128
+ async function relaySimulate(from, to, amount, min, plan) {
1129
+ const wallet = account.address;
1130
+ const calls = [];
1131
+ if (from !== zeroAddress && plan.spender) {
1132
+ calls.push({ from: wallet, to: from, data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [plan.spender, amount] }) });
1133
+ }
1134
+ for (const tx of plan.txs) calls.push({ from: wallet, to: tx.to, data: tx.data, value: toHex(tx.value) });
1135
+ let result;
1136
+ try {
1137
+ result = await publicClient.request({
1138
+ method: "eth_simulateV1",
1139
+ params: [{ blockStateCalls: [{ calls }], traceTransfers: true, validation: false }, "latest"],
1140
+ });
1141
+ } catch (err) {
1142
+ return `this RPC cannot simulate Relay's transactions (eth_simulateV1: ${shortError(err)}). Set HOOKIT_RPC_URL to an Ink RPC that supports it, such as https://rpc-gel.inkonchain.com.`;
1143
+ }
1144
+ const net = new Map();
1145
+ const add = (asset, v) => net.set(asset, (net.get(asset) ?? 0n) + v);
1146
+ for (const call of result?.[0]?.calls ?? []) {
1147
+ if (call.status !== "0x1") return "Relay's swap fails when simulated";
1148
+ for (const log of call.logs ?? []) {
1149
+ if (log.topics?.[0] !== TRANSFER_TOPIC || log.topics.length !== 3) continue;
1150
+ const asset = same(log.address, ETH_TRACE) ? zeroAddress : getAddress(log.address);
1151
+ const v = /^0x[0-9a-fA-F]{1,64}$/.test(log.data ?? "") ? BigInt(log.data) : 0n;
1152
+ if (same(`0x${log.topics[1].slice(26)}`, wallet)) add(asset, -v);
1153
+ if (same(`0x${log.topics[2].slice(26)}`, wallet)) add(asset, v);
1154
+ }
1155
+ }
1156
+ for (const [asset, v] of net) {
1157
+ if (v < 0n && !same(asset, from)) return `Relay's transactions would also take ${asset === zeroAddress ? "ETH" : asset} from the wallet`;
1158
+ }
1159
+ const spent = -(net.get(getAddress(from)) ?? 0n);
1160
+ if (spent > amount) return `Relay's transactions would take more than ${amountText(amount, await assetMeta(from))}`;
1161
+ const got = net.get(getAddress(to)) ?? 0n;
1162
+ if (got < min) return `Relay's transactions would return ${amountText(got, await assetMeta(to))}, below Relay's own minimum of ${amountText(min, await assetMeta(to))}`;
1163
+ return null;
1164
+ }
1165
+
1166
+ /** The structural and the simulated check of a Relay quote: { plan } or { error } (refused, nothing sent). */
1167
+ async function relayCheck(from, to, amount, quote) {
1168
+ const plan = relayPlan(quote.calls, from, amount, quote.inMeta.symbol);
1169
+ if (plan.error) return plan;
1170
+ const why = await relaySimulate(from, to, amount, quote.min, plan);
1171
+ return why ? { error: `Refused Relay's transactions: ${why}. Nothing was sent.` } : { plan };
1172
+ }
1173
+
1174
+ /** Sets this wallet's allowance of `asset` to `spender` to exactly `amount` (0 revokes). { hash } or { error }. */
1175
+ async function setAllowance(asset, spender, amount) {
1176
+ const allowance = await read(asset, erc20Abi, "allowance", [account.address, spender]).catch(() => null);
1177
+ if (allowance === amount) return {};
1178
+ const sent = await send({ address: asset, abi: erc20Abi, functionName: "approve", args: [spender, amount] });
1179
+ return sent.error ? { error: `The approval would fail: ${sent.error}` } : { hash: sent.hash, receipt: sent.receipt };
1180
+ }
1181
+
1182
+ /** `asset`'s balance of `wallet` at `blockNumber`, retried while a lagging RPC does not have that block yet; null if never. */
1183
+ async function balanceAt(asset, wallet, blockNumber) {
1184
+ for (let i = 0; i < 10; i++) {
1185
+ try {
1186
+ return asset === zeroAddress
1187
+ ? await publicClient.getBalance({ address: wallet, blockNumber })
1188
+ : await publicClient.readContract({ address: asset, abi: erc20Abi, functionName: "balanceOf", args: [wallet], blockNumber });
1189
+ } catch {
1190
+ await sleep(500);
1191
+ }
1192
+ }
1193
+ return null;
1194
+ }
1195
+
1196
+ /** Relay's status of a request ("success", "pending", "failure", "refund"…), through hookit.fun; null if unknown. */
1197
+ async function relayStatus(requestId) {
1198
+ if (!/^0x[0-9a-fA-F]{64}$/.test(requestId ?? "")) return null;
1199
+ try {
1200
+ const res = await fetch(`${API}/api/relay/status?requestId=${requestId}`);
1201
+ const body = await res.json().catch(() => null);
1202
+ return res.ok && typeof body?.status === "string" ? body.status : null;
1203
+ } catch {
1204
+ return null;
1205
+ }
1206
+ }
1207
+
1208
+ /**
1209
+ * Swaps `amount` of `from` into `to` on Ink with a Relay quote, from this wallet: checks the quote again (simulated
1210
+ * from the current state), approves exactly `amount` to Relay's approval proxy when `from` is a token, sends Relay's
1211
+ * transactions, then waits until the wallet holds at least Relay's minimum. Whatever happens, no allowance to Relay is
1212
+ * left behind. { received, txs } or { error, txs }; `received` is measured at the swap's block, with the gas paid
1213
+ * counted back when `to` is ETH.
1214
+ */
1215
+ async function relayConvert(from, to, amount, quote) {
1216
+ const checked = await relayCheck(from, to, amount, quote);
1217
+ if (checked.error) return { error: checked.error, txs: [] };
1218
+ const { plan } = checked;
1219
+ const txs = [];
1220
+ const receipts = [];
1221
+ const approves = from !== zeroAddress && plan.spender;
1222
+ try {
1223
+ if (approves) {
1224
+ const approval = await setAllowance(from, plan.spender, amount);
1225
+ if (approval.error) return { error: approval.error, txs };
1226
+ if (approval.hash) {
1227
+ txs.push(approval.hash);
1228
+ receipts.push(approval.receipt);
1229
+ }
1230
+ }
1231
+ for (const tx of plan.txs) {
1232
+ let gas;
1233
+ try {
1234
+ await publicClient.call({ account, ...tx });
1235
+ const estimated = await publicClient.estimateGas({ account, ...tx });
1236
+ gas = estimated + estimated / 3n;
1237
+ } catch (err) {
1238
+ return { error: `Relay's swap would fail: ${shortError(err)}`, txs };
1239
+ }
1240
+ const hash = await walletClient.sendTransaction({ ...tx, gas: gas > MAX_TX_GAS ? MAX_TX_GAS : gas });
1241
+ const receipt = await publicClient.waitForTransactionReceipt({ hash });
1242
+ txs.push(hash);
1243
+ receipts.push(receipt);
1244
+ if (receipt.status !== "success") return { error: `Relay's swap reverted: ${EXPLORER}/tx/${hash}`, txs };
1245
+ }
1246
+ } finally {
1247
+ if (approves) {
1248
+ const left = await read(from, erc20Abi, "allowance", [account.address, plan.spender]).catch(() => 1n);
1249
+ if (left > 0n) {
1250
+ const revoked = await setAllowance(from, plan.spender, 0n);
1251
+ if (revoked.hash) txs.push(revoked.hash);
1252
+ }
1253
+ }
1254
+ }
1255
+ // A swap inside Ink settles in Relay's own transaction; otherwise Relay's solver fills it shortly after.
1256
+ const fees = to === zeroAddress ? receipts.reduce((sum, r) => sum + feeOf(r), 0n) : 0n;
1257
+ const before = await balanceAt(to, account.address, receipts[0].blockNumber - 1n);
1258
+ const last = receipts.at(-1).blockNumber;
1259
+ if (before === null) return { error: `Could not read the wallet's ${quote.outMeta.symbol} balance after Relay's swap.`, txs, swapped: true };
1260
+ let got = ((await balanceAt(to, account.address, last)) ?? before) - before + fees;
1261
+ for (let i = 0; got < quote.min && i < 40; i++) {
1262
+ await sleep(1_500);
1263
+ const status = await relayStatus(quote.requestId);
1264
+ if (status === "failure" || status === "refund") {
1265
+ return { error: `Relay reported "${status}" for this swap: ${quote.inMeta.symbol} comes back to the wallet if it was taken.`, txs, swapped: true };
1266
+ }
1267
+ got = (await balanceOf(to, account.address)) - before + fees;
1268
+ }
1269
+ if (got < quote.min) {
1270
+ return { error: `Relay delivered ${amountText(got > 0n ? got : 0n, quote.outMeta)}, below its minimum of ${amountText(quote.min, quote.outMeta)}. Check the wallet (wallet_status).`, txs, swapped: true };
1271
+ }
1272
+ // Positive slippage stays in the wallet: the next leg spends at most what was quoted.
1273
+ return { received: got < quote.out ? got : quote.out, txs };
1274
+ }
1275
+
1276
+ /**
1277
+ * Where a Relay conversion should land for `coin`, best first: its markets' quotes (USDG, then ETH, then the rest),
1278
+ * then USDG when the aggregator bridges it to a market. `except` (the asset swapped from) is left out.
1279
+ */
1280
+ async function landingAssets(c, coin, except) {
1281
+ const usdg = at(c, "usdg");
1282
+ const rank = (a) => (same(a.address, usdg) ? 0 : a.address === zeroAddress ? 1 : 2);
1283
+ const markets = [];
1284
+ for (const m of coin.markets) {
1285
+ if (!markets.some((a) => same(a.address, m.quote))) markets.push(await assetMeta(m.quote));
1286
+ }
1287
+ markets.sort((a, b) => rank(a) - rank(b));
1288
+ const direct = await directAssets(c, coin);
1289
+ const bridged = direct.filter((a) => !markets.some((m) => same(m.address, a.address)));
1290
+ return [...markets, ...bridged].filter((a) => !same(a.address, except));
1291
+ }
1292
+
1293
+ /** Why one Relay swap must not convert `amount` of `meta` (above the per-buy cap, in ETH or US dollars), or null. */
1294
+ async function relayCapRefusal(c, meta, amount) {
1295
+ if (meta.address === zeroAddress) return amount > MAX_BUY_WEI ? `${amountText(amount, meta)} (HOOKIT_MAX_BUY_ETH is ${formatEther(MAX_BUY_WEI)})` : null;
1296
+ const price = await usdPrice(c, meta.address);
1297
+ if (price === null) return `${amountText(amount, meta)}, which could not be priced in US dollars`;
1298
+ const usd = Number(formatUnits(amount, meta.decimals)) * price;
1299
+ return usd > MAX_BUY_USD ? `about $${usd.toFixed(2)} (HOOKIT_MAX_BUY_USD is $${MAX_BUY_USD})` : null;
1300
+ }
1301
+
1302
+ /** Relay's swap, in words. */
1303
+ function relayText(quote) {
1304
+ const impact = quote.impact !== null && Number.isFinite(Number(quote.impact)) ? `, price impact ${Number(quote.impact).toFixed(2)}%` : "";
1305
+ return `${quote.inMeta.symbol} to ${quote.outMeta.symbol} through Relay${impact}`;
1306
+ }
1307
+
1308
+ /**
1309
+ * The Backed Floor of a coin: { vault, quote, quoteMeta, V, F, perToken(amount) } where `perToken` is what
1310
+ * `redeemFloor` pays for `amount`, or null when the coin has no floor. v2 vaults count the floor over the free supply
1311
+ * F = S − E (coins locked as loan collateral are out of it); v1 vaults over the total supply.
1312
+ */
1313
+ async function floorOf(coin) {
1314
+ const vault = await read(coin.key.hooks, floorVaultAbi, "floorVault").catch(() => null);
1315
+ if (!vault || vault === zeroAddress) return null;
1316
+ const quote = await read(vault, floorVaultAbi, "quoteOf", [coin.token]).catch(() => null);
1317
+ let V;
1318
+ let F;
1319
+ let v2 = true;
1320
+ try {
1321
+ const [v, s, e] = await read(vault, floorVaultAbi, "floorState", [coin.token]);
1322
+ V = v;
1323
+ F = s - e;
1324
+ } catch {
1325
+ v2 = false;
1326
+ V = await read(vault, floorVaultAbi, "reserve", [coin.token]).catch(() => 0n);
1327
+ F = await read(coin.token, erc20Abi, "totalSupply");
1328
+ }
1329
+ if (V === 0n || F === 0n || quote === null) return null;
1330
+ const quoteMeta = await assetMeta(getAddress(quote));
1331
+ const perToken = (amount) => (amount * V) / F;
1332
+ // Human price: quote per whole coin.
1333
+ const price = short((V * 10n ** BigInt(coin.meta.decimals)) / F, quoteMeta.decimals);
1334
+ return { vault: getAddress(vault), v2, quote: quoteMeta.address, quoteMeta, V, F, perToken, price: `${price} ${quoteMeta.symbol}` };
1335
+ }
1336
+
1337
+ /**
1338
+ * Gated Buy before a buy: null when the coin has no closed gate (or the plugin is unknown), { refuse } when this wallet
1339
+ * cannot buy `value` now, else what the gate allows. `value` is in the market's quote (`unit`); null skips the cap
1340
+ * check (a bridged buy, where the hook then refuses an over-cap buy itself).
1341
+ */
1342
+ async function gateCheck(c, token, wallet, value, unit = ETH) {
1343
+ const plugin = c.gatedBuyPlugin;
1344
+ if (!plugin) return null;
1345
+ let state;
1346
+ try {
1347
+ state = await publicClient.readContract({ address: plugin, abi: gatedBuyAbi, functionName: "gateState", args: [token] });
1348
+ } catch {
1349
+ return null;
1350
+ }
1351
+ const [hook, , endsAt, open] = state;
1352
+ if (hook === zeroAddress || open) return null;
1353
+ const [[ok, left], params] = await Promise.all([
1354
+ publicClient.readContract({ address: plugin, abi: gatedBuyAbi, functionName: "canBuy", args: [token, wallet] }),
1355
+ publicClient.readContract({ address: plugin, abi: gatedBuyAbi, functionName: "params", args: [token] }),
1356
+ ]);
1357
+ const agentsOnly =
1358
+ !!c.agentPass &&
1359
+ params.listId === 0n &&
1360
+ params.communities.length === 1 &&
1361
+ params.communities[0].token.toLowerCase() === c.agentPass.toLowerCase();
1362
+ if (!ok) {
1363
+ if (agentsOnly) {
1364
+ const status = await agentStatus(c.agentPass, wallet).catch(() => null);
1365
+ if (!status?.holder) return { refuse: `${PASS_STEPS} After ${opensAt(endsAt)} anyone can buy.` };
1366
+ }
1367
+ if (params.walletCap > 0n) {
1368
+ const spent = await publicClient
1369
+ .readContract({ address: plugin, abi: gatedBuyAbi, functionName: "spentOf", args: [token, wallet] })
1370
+ .catch(() => 0n);
1371
+ if (spent >= params.walletCap) {
1372
+ return { refuse: `This wallet used its ${amountText(params.walletCap, unit)} cap for the gate window. It can buy more after ${opensAt(endsAt)}.` };
1373
+ }
1374
+ }
1375
+ return {
1376
+ refuse: `This coin is gated until ${opensAt(endsAt)}: until then only its allowlist and holders of its communities can buy, from their own wallet. Retry after that.`,
1377
+ };
1378
+ }
1379
+ if (value !== null && left !== maxUint256 && value > left) {
1380
+ return {
1381
+ refuse: `During the gate window this wallet can buy at most ${amountText(left, unit)} more of this coin (wallet cap). Retry with an amount at or below that, or after ${opensAt(endsAt)}.`,
1382
+ };
1383
+ }
1384
+ return { agentsOnly, opensAt: opensAt(endsAt), capLeft: left === maxUint256 ? null : amountText(left, unit) };
1385
+ }
1386
+
1387
+ /** The wallet's loans on the loan plugin that are still open, with what repaying each costs now. */
1388
+ async function openLoans(c, wallet) {
1389
+ const plugin = at(c, "loanPlugin");
1390
+ const ids = await read(plugin, loanAbi, "loansOf", [wallet]).catch(() => []);
1391
+ const now = BigInt(Math.floor(Date.now() / 1000));
1392
+ const loans = [];
1393
+ for (const id of ids) {
1394
+ const loan = await read(plugin, loanAbi, "getLoan", [id]);
1395
+ if (loan.status !== 1) continue;
1396
+ const meta = await assetMeta(loan.token);
1397
+ const expired = loan.expiry <= now;
1398
+ const owed = expired ? null : await read(plugin, loanAbi, "repayAmount", [id]).catch(() => null);
1399
+ loans.push({ id, loan, meta, expired, owed });
1400
+ }
1401
+ return loans;
1402
+ }
1403
+
1404
+ /** One open loan for people, with its quote for amounts (the coin's floor quote). */
1405
+ async function loanView(c, { id, loan, meta, expired, owed }) {
1406
+ const coin = await loadCoin(c, loan.token).catch(() => null);
1407
+ const floor = coin ? await floorOf(coin).catch(() => null) : null;
1408
+ const q = floor?.quoteMeta ?? ETH;
1409
+ return {
1410
+ loanId: id.toString(),
1411
+ token: loan.token,
1412
+ collateral: amountText(loan.e, meta),
1413
+ received: amountText(loan.net, q),
1414
+ expires: new Date(Number(loan.expiry) * 1000).toISOString(),
1415
+ ...(expired
1416
+ ? { status: "expired: the collateral is burned at settlement and the wallet keeps what it received; nothing is owed" }
1417
+ : { repayNow: owed === null ? "unknown" : amountText(owed, q) }),
1418
+ };
1419
+ }
1420
+
1421
+ const launchShape = {
1422
+ name: z.string().describe("Coin name, up to 32 characters."),
1423
+ symbol: z.string().describe("Ticker, 1 to 12 letters or digits."),
1424
+ description: z.string().optional(),
1425
+ image: z.string().optional().describe("Logo as an https:// or ipfs:// URL."),
1426
+ twitter: z.string().optional(),
1427
+ telegram: z.string().optional(),
1428
+ website: z.string().optional(),
1429
+ pair: z.string().optional().describe("What the coin is priced in. Default eth. See list_pairs."),
1430
+ pairs: z.array(z.string()).optional().describe("Several pairs for a multi-pair launch (USDG and stocks only)."),
1431
+ preset: z.string().optional().describe("A strategy from list_presets: clean, community, deflationary, floor, builder, dynamic."),
1432
+ hookTaxPct: z.number().optional().describe("Extra swap fee feeding the hook modules, 0 to 9."),
1433
+ modules: z.record(z.union([z.boolean(), z.number()])).optional().describe("Module settings from list_modules, e.g. {\"autoBurn\": true, \"autoBurnPct\": 100}. Without a preset they start from a clean slate."),
1434
+ devBuyPct: z.number().optional().describe("Creator's own first buy in the launch tx, % of supply (up to 2.5, less with Max Tx). No sniper tax on it."),
1435
+ payout: z
1436
+ .object({
1437
+ kind: z.enum(["wallet", "x", "github", "tiktok"]),
1438
+ address: z.string().optional(),
1439
+ handle: z.string().optional(),
1440
+ accountId: z.string().optional(),
1441
+ })
1442
+ .optional()
1443
+ .describe("Who receives the creator fees. Default: the launching wallet."),
1444
+ };
1445
+
1446
+ async function prepare(args) {
1447
+ const res = await fetch(`${API}/api/agents/prepare-launch`, {
1448
+ method: "POST",
1449
+ headers: { "content-type": "application/json" },
1450
+ body: JSON.stringify({ ...args, from: account?.address }),
1451
+ });
1452
+ const body = await res.json().catch(() => ({ ok: false, error: `hookit answered ${res.status}` }));
1453
+ if (!res.ok && !body.error) body.error = `hookit answered ${res.status}`;
1454
+ return body;
1455
+ }
1456
+
1457
+ /** Most ETH a launch may send: the launch fee plus an ETH dev buy within the per-buy cap. */
1458
+ const MAX_LAUNCH_VALUE = MAX_BUY_WEI + parseEther("0.01");
1459
+
1460
+ /**
1461
+ * Why the transactions hookit.fun prepared for a launch must not be signed, or null: the launch goes to the pinned
1462
+ * Factory C or CreatorPayoutRouterC with at most MAX_LAUNCH_VALUE, and a dev-buy approval only approves one of those.
1463
+ */
1464
+ function launchRefusal(prepared) {
1465
+ const targets = [PINNED.factory, PINNED.payoutRouter];
1466
+ const tx = prepared.tx;
1467
+ if (!tx || !isAddress(tx.to ?? "") || !targets.some((t) => same(t, tx.to))) {
1468
+ return "hookit answered with a launch to a contract this version does not know (not Factory C or its payout router). Nothing was sent; update hookit-mcp.";
1469
+ }
1470
+ if (!/^\d+$/.test(String(tx.value ?? "0")) || BigInt(tx.value ?? 0) > MAX_LAUNCH_VALUE) {
1471
+ return `hookit answered with a launch that sends more than ${formatEther(MAX_LAUNCH_VALUE)} ETH (launch fee plus a dev buy within HOOKIT_MAX_BUY_ETH). Nothing was sent.`;
1472
+ }
1473
+ if (prepared.approve) {
1474
+ let call = null;
1475
+ try {
1476
+ call = decodeFunctionData({ abi: erc20Abi, data: prepared.approve.tx?.data });
1477
+ } catch {}
1478
+ if (call?.functionName !== "approve" || !targets.some((t) => same(t, call.args[0])) || BigInt(prepared.approve.tx?.value ?? 0) !== 0n) {
1479
+ return "hookit answered with a dev-buy approval this version does not expect. Nothing was sent.";
1480
+ }
1481
+ }
1482
+ return null;
1483
+ }
1484
+
1485
+ /**
1486
+ * Runs the tools that sign one at a time: an agent may call several at once, and two of them sharing the wallet's
1487
+ * nonce or its balance deltas could spend what the other one just received.
1488
+ */
1489
+ let signing = Promise.resolve();
1490
+ function serialized(handler) {
1491
+ return (...args) => {
1492
+ const run = signing.then(() => handler(...args));
1493
+ signing = run.catch(() => {});
1494
+ return run;
1495
+ };
1496
+ }
1497
+
1498
+ const server = new McpServer({ name: "hookit", version: VERSION });
1499
+
1500
+ server.registerTool(
1501
+ "list_presets",
1502
+ {
1503
+ description: "Launch strategies by goal (clean, community, deflationary, floor, builder, dynamic) with the exact hook modules each one turns on. Needs no key.",
1504
+ inputSchema: {},
1505
+ annotations: { readOnlyHint: true },
1506
+ },
1507
+ async () => {
1508
+ const c = await catalog();
1509
+ return reply(c.presets.map(({ id, label, goal, settings, singlePairOnly }) => ({ id, label, goal, settings, singlePairOnly })));
1510
+ },
1511
+ );
1512
+
1513
+ server.registerTool(
1514
+ "list_modules",
1515
+ {
1516
+ description: "Every hook module an agent can combine, what it does, its settings and the rules a combination must follow. Needs no key.",
1517
+ inputSchema: {},
1518
+ annotations: { readOnlyHint: true },
1519
+ },
1520
+ async () => {
1521
+ const c = await catalog();
1522
+ return reply({ modules: c.modules, rules: c.rules });
1523
+ },
1524
+ );
1525
+
1526
+ server.registerTool(
1527
+ "list_pairs",
1528
+ {
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.",
1530
+ inputSchema: {},
1531
+ annotations: { readOnlyHint: true },
1532
+ },
1533
+ async () => reply((await catalog()).pairs),
1534
+ );
1535
+
1536
+ server.registerTool(
1537
+ "wallet_status",
1538
+ {
1539
+ description:
1540
+ "This wallet on Ink: its address, ETH and USDG balances, the hookit.fun coins it holds (with their USD value), its open Backed Floor loans, whether it holds a Hookit Agent Pass, and in words whether it can launch (launch fee and gas).",
1541
+ inputSchema: {},
1542
+ annotations: { readOnlyHint: true },
1543
+ },
1544
+ async () => {
1545
+ if (!account) return needKey();
1546
+ const c = await catalog();
1547
+ const usdg = await assetMeta(at(c, "usdg"));
1548
+ const [balance, fee, usdgBalance, positions, loans] = await Promise.all([
1549
+ publicClient.getBalance({ address: account.address }),
1550
+ c.factory ? publicClient.readContract({ address: c.factory, abi: factoryAbi, functionName: "launchFee" }).catch(() => null) : null,
1551
+ balanceOf(usdg.address, account.address).catch(() => null),
1552
+ fetch(`${API}/api/wallets/${account.address}`)
1553
+ .then((res) => (res.ok ? res.json() : null))
1554
+ .then((body) => (Array.isArray(body?.positions) ? body.positions : null))
1555
+ .catch(() => null),
1556
+ openLoans(c, account.address).catch(() => []),
1557
+ ]);
1558
+ const gasPrice = await publicClient.getGasPrice();
1559
+ const needed = (fee ?? 0n) + gasPrice * 4_000_000n;
1560
+ // AI-agents-only coins: whether this wallet holds a Hookit Agent Pass.
1561
+ const agentPass = c.agentPass ? await agentStatus(c.agentPass, account.address).catch(() => null) : null;
1562
+ const usd = (n) => Math.round((n ?? 0) * 100) / 100;
1563
+ const holdings =
1564
+ positions === null
1565
+ ? "unavailable (hookit.fun's wallet API did not answer)"
1566
+ : positions
1567
+ .filter((p) => p.balance > 0)
1568
+ .sort((a, b) => (b.valueUsd ?? 0) - (a.valueUsd ?? 0))
1569
+ .slice(0, 25)
1570
+ .map((p) => ({
1571
+ token: p.token,
1572
+ symbol: p.symbol,
1573
+ balance: p.balance,
1574
+ valueUsd: usd(p.valueUsd),
1575
+ ...(p.unrealizedPct !== null && p.unrealizedPct !== undefined ? { unrealizedPct: usd(p.unrealizedPct) } : {}),
1576
+ }));
1577
+ return reply({
1578
+ address: account.address,
1579
+ chain: "Ink (57073)",
1580
+ balanceEth: formatEther(balance),
1581
+ ...(usdgBalance !== null ? { balanceUsdg: short(usdgBalance, usdg.decimals) } : {}),
1582
+ holdings,
1583
+ ...(loans.length ? { loans: await Promise.all(loans.map((l) => loanView(c, l))) } : {}),
1584
+ launchFeeEth: fee === null ? "unknown" : formatEther(fee),
1585
+ roughCostEth: formatEther(needed),
1586
+ ...(agentPass ? { agentPass } : {}),
1587
+ ready: balance >= needed,
1588
+ verdict:
1589
+ balance >= needed
1590
+ ? "Ready to launch."
1591
+ : `Not enough ETH on Ink. Send about ${formatEther(needed - balance)} ETH more to ${account.address}.`,
1592
+ ...(balance >= needed
1593
+ ? {}
1594
+ : {
1595
+ howToFund:
1596
+ "Withdraw ETH from Kraken straight to Ink (choose the Ink network), or bridge from another chain. With the Kraken CLI MCP loaded, an agent can do the withdrawal itself; the user must first whitelist this address in Kraken.",
1597
+ }),
1598
+ });
1599
+ },
1600
+ );
1601
+
1602
+ server.registerTool(
1603
+ "launch_token",
1604
+ {
1605
+ description:
1606
+ "Launches a coin on hookit.fun with the chosen preset or hook modules, in one call. With dryRun it only checks the launch and returns the unsigned transaction: use it first and show the user the settings.",
1607
+ inputSchema: { ...launchShape, dryRun: z.boolean().optional().describe("Check and simulate only. Signs nothing.") },
1608
+ },
1609
+ serialized(async ({ dryRun, ...args }) => {
1610
+ if (!dryRun && !account) return needKey();
1611
+ let prepared = await prepare(args);
1612
+ if (prepared.error) return fail(prepared.error);
1613
+ let refused = launchRefusal(prepared);
1614
+ if (refused) return fail(refused);
1615
+ if (!dryRun && prepared.approve) {
1616
+ // ERC-20 dev buy: approve the pair token, then prepare again so the launch is simulated.
1617
+ const approveHash = await walletClient.sendTransaction({
1618
+ to: prepared.approve.tx.to,
1619
+ data: prepared.approve.tx.data,
1620
+ });
1621
+ await publicClient.waitForTransactionReceipt({ hash: approveHash });
1622
+ prepared = await prepare(args);
1623
+ if (prepared.error) return fail(prepared.error);
1624
+ refused = launchRefusal(prepared);
1625
+ if (refused) return fail(refused);
1626
+ }
1627
+ if (dryRun) {
1628
+ return reply({
1629
+ dryRun: true,
1630
+ verdict: prepared.simulation?.needsApproval
1631
+ ? "Valid. A real launch will first approve the dev buy token, then launch."
1632
+ : prepared.simulation?.ok === false
1633
+ ? `The launch would fail: ${prepared.simulation.error}`
1634
+ : "The launch is valid.",
1635
+ plan: prepared.plan,
1636
+ approveFirst: prepared.approve ?? null,
1637
+ tx: prepared.tx,
1638
+ warnings: prepared.warnings,
1639
+ });
1640
+ }
1641
+ if (prepared.simulation && !prepared.simulation.ok) {
1642
+ return fail(`The launch would fail: ${prepared.simulation.error}\nSettings: ${prepared.plan.settings.join("; ")}`);
1643
+ }
1644
+
1645
+ const hash = await walletClient.sendTransaction({
1646
+ to: prepared.tx.to,
1647
+ data: prepared.tx.data,
1648
+ value: BigInt(prepared.tx.value),
1649
+ gas: BigInt(prepared.tx.gas),
1650
+ });
1651
+ const receipt = await publicClient.waitForTransactionReceipt({ hash });
1652
+ if (receipt.status !== "success") return fail(`Launch transaction reverted: ${hash}`);
1653
+ let token = null;
1654
+ let agent = null;
1655
+ for (const log of receipt.logs) {
1656
+ for (const abi of [factoryAbi, routerAbi]) {
1657
+ try {
1658
+ const ev = decodeEventLog({ abi, data: log.data, topics: log.topics });
1659
+ if (ev.eventName === "TokenLaunched") token = ev.args.token;
1660
+ if (ev.eventName === "PayoutLaunch") agent = ev.args.agent;
1661
+ } catch {}
1662
+ }
1663
+ }
1664
+ return reply({
1665
+ launched: true,
1666
+ token,
1667
+ page: token ? `${API}/token/${token}` : null,
1668
+ tx: `https://explorer.inkonchain.com/tx/${hash}`,
1669
+ settings: prepared.plan.settings,
1670
+ payout: prepared.plan.payout,
1671
+ payoutAgent: agent,
1672
+ note: agent
1673
+ ? "Creator fees go to the payout target through this launch's CreatorPayoutAgent. This wallet is its controller."
1674
+ : "Creator fees build up in FeeEscrow for this wallet. Call claim_fees to collect them.",
1675
+ });
1676
+ }),
1677
+ );
1678
+
1679
+ server.registerTool(
1680
+ "claim_fees",
1681
+ {
1682
+ description: "Collects the creator fees this wallet has earned on coins it launched itself (paid in ETH and the pair tokens).",
1683
+ inputSchema: {
1684
+ currencies: z.array(z.string()).optional().describe("Token addresses to collect. Default: ETH plus every pair token."),
1685
+ },
1686
+ },
1687
+ serialized(async ({ currencies }) => {
1688
+ if (!account) return needKey();
1689
+ const c = await catalog();
1690
+ if (!c.factory) return fail("Launches are not configured on this hookit.");
1691
+ const hook = await publicClient.readContract({ address: c.factory, abi: factoryAbi, functionName: "masterHook" });
1692
+ const escrow = await publicClient.readContract({ address: hook, abi: hookAbi, functionName: "feeEscrow" });
1693
+ const candidates = currencies?.length ? currencies : [zeroAddress, ...(c.pairAddresses ?? [])];
1694
+ const owed = [];
1695
+ for (const currency of candidates) {
1696
+ const amount = await publicClient.readContract({ address: escrow, abi: escrowAbi, functionName: "balanceOf", args: [account.address, currency] });
1697
+ if (amount > 0n) owed.push({ currency, amount });
1698
+ }
1699
+ if (owed.length === 0) return reply("Nothing to collect yet.");
1700
+ const hash = await walletClient.writeContract({ address: escrow, abi: escrowAbi, functionName: "claimAll", args: [owed.map((o) => o.currency)] });
1701
+ await publicClient.waitForTransactionReceipt({ hash });
1702
+ const lines = [];
1703
+ for (const { currency, amount } of owed) {
1704
+ if (currency === zeroAddress) {
1705
+ lines.push(`${formatEther(amount)} ETH`);
1706
+ continue;
1707
+ }
1708
+ const [symbol, decimals] = await Promise.all([
1709
+ publicClient.readContract({ address: currency, abi: erc20Abi, functionName: "symbol" }).catch(() => currency),
1710
+ publicClient.readContract({ address: currency, abi: erc20Abi, functionName: "decimals" }).catch(() => 18),
1711
+ ]);
1712
+ lines.push(`${Number(amount) / 10 ** Number(decimals)} ${symbol}`);
1713
+ }
1714
+ return reply({ collected: lines, tx: `https://explorer.inkonchain.com/tx/${hash}` });
1715
+ }),
1716
+ );
1717
+
1718
+ server.registerTool(
1719
+ "send_eth",
1720
+ {
1721
+ description:
1722
+ "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.",
1723
+ inputSchema: {
1724
+ to: z.string().describe("Destination address on Ink."),
1725
+ amountEth: z.string().describe('Amount in ETH, or "max" to send everything except gas.'),
1726
+ dryRun: z.boolean().optional().describe("Check only. Signs nothing."),
1727
+ },
1728
+ },
1729
+ serialized(async ({ to, amountEth, dryRun }) => {
1730
+ if (!account) return needKey();
1731
+ if (!isAddress(to)) return fail(`Not an address: ${to}`);
1732
+ const [balance, gasPrice] = await Promise.all([publicClient.getBalance({ address: account.address }), publicClient.getGasPrice()]);
1733
+ // A plain transfer is 21k gas; keep extra for the L1 data fee.
1734
+ const gasReserve = gasPrice * 60_000n + 20_000_000_000_000n;
1735
+ let value;
1736
+ if (amountEth.trim().toLowerCase() === "max") {
1737
+ value = balance > gasReserve ? balance - gasReserve : 0n;
1738
+ } else {
1739
+ try {
1740
+ value = parseEther(amountEth.trim());
1741
+ } catch {
1742
+ return fail(`Not an amount: ${amountEth}`);
1743
+ }
1744
+ }
1745
+ if (value <= 0n) return fail(`Nothing to send: the wallet holds ${formatEther(balance)} ETH.`);
1746
+ if (value + gasReserve > balance) {
1747
+ return fail(`Not enough ETH: the wallet holds ${formatEther(balance)} ETH and needs some left for gas.`);
1748
+ }
1749
+ if (dryRun) return reply({ dryRun: true, from: account.address, to, amountEth: formatEther(value), verdict: "Ready to send." });
1750
+ const hash = await walletClient.sendTransaction({ to, value });
1751
+ await publicClient.waitForTransactionReceipt({ hash });
1752
+ return reply({ sentEth: formatEther(value), to, tx: `https://explorer.inkonchain.com/tx/${hash}` });
1753
+ }),
1754
+ );
1755
+
1756
+ server.registerTool(
1757
+ "agent_pass_challenge",
1758
+ {
1759
+ description:
1760
+ "Step 1 of 3 to buy an AI-agents-only coin (Gated Buy, AI agents only): starts the Hookit Agent Pass check for this wallet. Returns an id and five short tasks. Answer all of them yourself, in order, and call claim_agent_pass({ id, answers }) within 30 seconds (step 2), then buy_token (step 3). An ERC-8004 agent whose operating wallet is this wallet can pass its agentId here and to claim_agent_pass to get Hookit's ERC-8004 validation instead of a pass. Without agentId, if the wallet already holds a pass or counts as an agent it says so: go straight to buy_token.",
1761
+ inputSchema: {
1762
+ agentId: z
1763
+ .string()
1764
+ .regex(/^[0-9]{1,78}$/, "agentId is a string of digits")
1765
+ .optional()
1766
+ .describe("Optional: your ERC-8004 agent id on Ink, for an ERC-8004 validation (also when this wallet already counts, to publish it). Pass the same agentId to claim_agent_pass."),
1767
+ },
1768
+ },
1769
+ async ({ agentId }) => {
1770
+ if (!account) return needKey();
1771
+ const holder = async (pass) => {
1772
+ const status = await agentStatus(pass, account.address).catch(() => null);
1773
+ if (!status?.holder) return null;
1774
+ return reply({
1775
+ wallet: account.address,
1776
+ agentPass: status,
1777
+ verdict:
1778
+ status.via === "pass"
1779
+ ? "This wallet already holds a Hookit Agent Pass. Go straight to buy_token."
1780
+ : "This wallet already counts as an agent on the Hookit Agent Pass: no pass needed. Go straight to buy_token.",
1781
+ });
1782
+ };
1783
+ if (!agentId) {
1784
+ const already = await holder(at(null, "agentPass"));
1785
+ if (already) return already;
1786
+ }
1787
+ const res = await fetch(`${API}/api/agents/pass?wallet=${account.address}`);
1788
+ const body = await res.json().catch(() => null);
1789
+ if (!res.ok || !body?.ok) return fail(body?.error ?? `hookit answered ${res.status}`);
1790
+ const within = body.solveWithinSec ?? 30;
1791
+ return reply({
1792
+ id: body.id,
1793
+ wallet: body.wallet,
1794
+ tasks: body.tasks,
1795
+ answerWithinSec: within,
1796
+ next: `Answer all ${body.tasks.length} tasks yourself, in order, with short answers only (a number, a word or one character), and call claim_agent_pass with { "id": "${body.id}", "answers": [...]${agentId ? `, "agentId": "${agentId}"` : ""} } within ${within} seconds of this call. One try per challenge: if it fails, call agent_pass_challenge again.`,
1797
+ });
1798
+ },
1799
+ );
1800
+
1801
+ server.registerTool(
1802
+ "claim_agent_pass",
1803
+ {
1804
+ description:
1805
+ "Step 2 of 3 to buy an AI-agents-only coin: sends your answers to the agent_pass_challenge tasks, signed by this wallet's key, and claims the Hookit Agent Pass on Ink from this wallet (one small transaction). Call it within 30 seconds of agent_pass_challenge. Then buy with buy_token (step 3). With agentId (an ERC-8004 identity on Ink whose operating wallet, agentWallet, is this wallet), it gets Hookit's ERC-8004 validation instead of a pass: if this wallet owns the identity or is its approved operator, it sends the validation request then validate (two transactions, and Hookit publishes a 100/100 \"hookit-agent\" validation anyone can read); otherwise it only links the wallet (one transaction). Either way the wallet then counts as an agent. With requestHash too (a validation request the identity's owner already sent to Hookit's validator), validate answers that request.",
1806
+ inputSchema: {
1807
+ id: z.string().describe("The challenge id returned by agent_pass_challenge."),
1808
+ answers: z
1809
+ .array(z.union([z.string(), z.number()]))
1810
+ .describe("Your answers to the tasks, in the same order: one short answer per task."),
1811
+ agentId: z
1812
+ .string()
1813
+ .regex(/^[0-9]+$/, "agentId is a string of digits")
1814
+ .optional()
1815
+ .describe("Optional: your ERC-8004 agent id on Ink, as a string of digits, to get Hookit's ERC-8004 validation instead of a pass. This wallet must be the identity's agentWallet."),
1816
+ requestHash: z
1817
+ .string()
1818
+ .regex(/^0x[0-9a-fA-F]{64}$/, "requestHash is a 0x-prefixed 32-byte hex string")
1819
+ .optional()
1820
+ .describe("Optional, with agentId: the hash of a validationRequest the identity's owner already sent to Hookit's validator on Ink's ValidationRegistry; Hookit then publishes its response to it."),
1821
+ },
1822
+ },
1823
+ serialized(async ({ id, answers, agentId, requestHash }) => {
1824
+ if (!account) return needKey();
1825
+ if (requestHash && !agentId) return fail("requestHash goes with agentId. Nothing was sent.");
1826
+ if (agentId && BigInt(agentId) > maxUint256) return fail("agentId is too large for an ERC-8004 identity. Nothing was sent.");
1827
+ const sent = answers.map((a) => String(a).trim());
1828
+ const signature = await account.signMessage({ message: passMessage(account.address, id, sent) });
1829
+ const res = await fetch(`${API}/api/agents/pass`, {
1830
+ method: "POST",
1831
+ headers: { "content-type": "application/json" },
1832
+ body: JSON.stringify({ id, wallet: account.address, answers: sent, signature, ...(agentId ? { agentId } : {}) }),
1833
+ });
1834
+ const body = await res.json().catch(() => null);
1835
+ if (!res.ok || !body?.ok) {
1836
+ const error = body?.error ?? `hookit answered ${res.status}`;
1837
+ const used = [400, 401, 408, 410].includes(res.status) || (res.status === 403 && /operating wallet/.test(error));
1838
+ const retry = used ? " Call agent_pass_challenge for a new challenge and answer within 30 seconds." : "";
1839
+ return fail(`${error}${retry}`);
1840
+ }
1841
+ if (agentId) return claimValidation(body, agentId, requestHash?.toLowerCase());
1842
+ if (body.alreadyHolder) {
1843
+ return reply({ wallet: account.address, agentPass: { holder: true, via: "pass" }, verdict: "This wallet already holds a Hookit Agent Pass. Go straight to buy_token." });
1844
+ }
1845
+ // Only ever send claim(deadline, signature) to the pinned pass, with no value, whatever the answer says.
1846
+ const pass = at(null, "agentPass");
1847
+ if (!same(body.pass, pass)) return fail("hookit answered with an unexpected pass address. Nothing was sent.");
1848
+ let call;
1849
+ try {
1850
+ if (body.claim?.to?.toLowerCase() !== pass.toLowerCase()) throw new Error("to");
1851
+ call = decodeFunctionData({ abi: passAbi, data: body.claim.data });
1852
+ if (call.functionName !== "claim") throw new Error("fn");
1853
+ } catch {
1854
+ return fail("hookit answered with an unexpected claim transaction. Nothing was sent.");
1855
+ }
1856
+ let request;
1857
+ try {
1858
+ ({ request } = await publicClient.simulateContract({ account, address: pass, abi: passAbi, functionName: "claim", args: call.args }));
1859
+ } catch (err) {
1860
+ const name = errorName(err);
1861
+ const why =
1862
+ name === "Expired"
1863
+ ? "the voucher expired. Call agent_pass_challenge again."
1864
+ : name === "Revoked"
1865
+ ? "this wallet's pass was revoked; it cannot claim again unless the owner lifts the revocation."
1866
+ : name === "AlreadyHolder"
1867
+ ? "this wallet already holds a pass. Go straight to buy_token."
1868
+ : name === "InvalidSignature"
1869
+ ? "the voucher's signature was refused. Call agent_pass_challenge again later."
1870
+ : /insufficient funds/.test(errorText(err))
1871
+ ? "the wallet has no ETH on Ink for the gas."
1872
+ : String(err?.shortMessage || err?.message || err).slice(0, 300);
1873
+ return fail(`The claim would fail: ${why}`);
1874
+ }
1875
+ const hash = await walletClient.writeContract(request);
1876
+ const receipt = await publicClient.waitForTransactionReceipt({ hash });
1877
+ if (receipt.status !== "success") return fail(`Claim transaction reverted: ${EXPLORER}/tx/${hash}`);
1878
+ const balance = await publicClient.readContract({ address: pass, abi: passAbi, functionName: "balanceOf", args: [account.address] });
1879
+ return reply({
1880
+ claimed: true,
1881
+ wallet: account.address,
1882
+ pass,
1883
+ balanceOf: balance.toString(),
1884
+ tx: `${EXPLORER}/tx/${hash}`,
1885
+ next: "This wallet now holds a Hookit Agent Pass. Buy AI-agents-only coins with buy_token (step 3), from this same wallet.",
1886
+ });
1887
+ }),
1888
+ );
1889
+
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
+ */
1894
+ function defaultAsset(c, coin) {
1895
+ return coin.markets.length === 1 ? coin.markets[0].quote : at(c, "usdg");
1896
+ }
1897
+
1898
+ /** How a trade is routed, in words. */
1899
+ async function routeText(c, coin, route, isBuy) {
1900
+ if (route.kind === "router") return `direct, in the coin's ${(await assetMeta(route.market.quote)).symbol} market (HookitSwapRouter)`;
1901
+ const market = coin.markets[route.leg.marketIndex];
1902
+ const q = await assetMeta(market.quote);
1903
+ if (same(market.quote, at(c, "usdg"))) return "direct, in the coin's USDG market (Hookit aggregator)";
1904
+ const bridge = `the USDG/${q.symbol} Uniswap ${route.leg.venue === 1 ? "v3" : "v4"} pool`;
1905
+ return isBuy
1906
+ ? `USDG to ${q.symbol} through ${bridge}, then the coin's ${q.symbol} market (Hookit aggregator, one transaction)`
1907
+ : `the coin's ${q.symbol} market, then ${q.symbol} to USDG through ${bridge} (Hookit aggregator, one transaction)`;
1908
+ }
1909
+
1910
+ const tokenArg = z.string().describe("The coin's address on Ink.");
1911
+ const slippageArg = z.number().int().min(1).max(5000).optional().describe("Max slippage in basis points. Default 500 (5%).");
1912
+ const dryRunArg = z.boolean().optional().describe("Quote and check only. Signs nothing.");
1913
+
1914
+ server.registerTool(
1915
+ "buy_token",
1916
+ {
1917
+ description:
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.",
1919
+ inputSchema: {
1920
+ token: tokenArg,
1921
+ amount: z
1922
+ .union([z.string(), z.number()])
1923
+ .optional()
1924
+ .describe(`What to spend, in payWith units: "0.01" (ETH), "25" (USDG), or "50%" of the balance. At most ${formatEther(MAX_BUY_WEI)} ETH or $${MAX_BUY_USD} per buy.`),
1925
+ payWith: z.string().optional().describe('"eth", "usdg", a pair ticker from list_pairs such as "wNVDAx" or "kBTC", or a token address. Default: the coin\'s own pair.'),
1926
+ amountEth: z.union([z.string(), z.number()]).optional().describe("Older form of amount with payWith \"eth\"."),
1927
+ slippageBps: slippageArg,
1928
+ dryRun: dryRunArg,
1929
+ },
1930
+ },
1931
+ serialized(async ({ token, amount, payWith, amountEth, slippageBps, dryRun }) => {
1932
+ if (!account) return needKey();
1933
+ if (!isAddress(token)) return fail(`Not an address: ${token}`);
1934
+ if (amount === undefined && amountEth !== undefined) {
1935
+ amount = amountEth;
1936
+ payWith ??= "eth";
1937
+ }
1938
+ if (amount === undefined) return fail('amount is required, e.g. { "amount": "0.01", "payWith": "eth" }.');
1939
+ token = getAddress(token);
1940
+ const c = await catalog();
1941
+ const coin = await loadCoin(c, token);
1942
+ if (!coin) return fail(NOT_A_COIN);
1943
+ const asset = payWith ? await resolveAsset(c, payWith, coin) : defaultAsset(c, coin);
1944
+ if (!asset || same(asset, token)) {
1945
+ return fail(`Cannot pay with "${payWith}". Pay with ETH, USDG, a pair ticker from list_pairs or a token address; this coin trades in ${(await tradeAssets(c, coin)).join(", ")}.`);
1946
+ }
1947
+ const pay = await assetMeta(asset);
1948
+ const [ethBalance, reserve] = await Promise.all([publicClient.getBalance({ address: account.address }), gasReserve(2_000_000n)]);
1949
+ const balance = asset === zeroAddress ? (ethBalance > reserve ? ethBalance - reserve : 0n) : await balanceOf(asset, account.address);
1950
+ const value = parseAmount(amount, pay, balance);
1951
+ if (value === null) return fail(`Not an amount: ${amount}`);
1952
+ if (value <= 0n) return fail(`Nothing to spend: the wallet holds ${amountText(balance, pay)}${asset === zeroAddress ? " beyond gas" : ""}.`);
1953
+ if (asset === zeroAddress && value > MAX_BUY_WEI) {
1954
+ return fail(`buy_token spends at most ${formatEther(MAX_BUY_WEI)} ETH per buy (HOOKIT_MAX_BUY_ETH). Buy less, or in several buys.`);
1955
+ }
1956
+ if (value > balance) {
1957
+ return fail(`Not enough ${pay.symbol}: the wallet holds ${amountText(balance, pay)}${asset === zeroAddress ? " beyond gas" : ""}, and the buy spends ${amountText(value, pay)}.`);
1958
+ }
1959
+ if (ethBalance < reserve) return fail(`Not enough ETH for gas: the wallet holds ${formatEther(ethBalance)} ETH and needs about ${formatEther(reserve)}.`);
1960
+
1961
+ // The coin's trade runs in `leg` (an asset it trades in). When `asset` is not one, Relay swaps it into one first.
1962
+ const bps = BigInt(slippageBps ?? 500);
1963
+ const relayBps = Math.min(slippageBps ?? 100, RELAY_MAX_SLIPPAGE_BPS);
1964
+ let leg = pay;
1965
+ let legAmount = value;
1966
+ let relay = null;
1967
+ if (!(await directAssets(c, coin)).some((a) => same(a.address, asset))) {
1968
+ let firstError = null;
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)) {
1973
+ const q = await relayQuote(asset, landing.address, value, relayBps);
1974
+ const checked = q.quote ? await relayCheck(asset, landing.address, value, q.quote) : q;
1975
+ if (q.quote && !checked.error) {
1976
+ relay = q.quote;
1977
+ leg = landing;
1978
+ legAmount = q.quote.out;
1979
+ break;
1980
+ }
1981
+ if (!checked.unsupported || !firstError) firstError = checked.error;
1982
+ }
1983
+ if (!relay) {
1984
+ const assets = await tradeAssets(c, coin);
1985
+ return fail(`${firstError ?? "Relay cannot swap into what this coin trades in."} This coin trades in ${assets.join(", ")}: pay with ${assets.length > 1 ? "one of those" : "that"} instead.`);
1986
+ }
1987
+ }
1988
+ if (asset !== zeroAddress) {
1989
+ let price = await usdPrice(c, asset);
1990
+ let usd = price === null ? null : Number(formatUnits(value, pay.decimals)) * price;
1991
+ if (usd === null && relay) {
1992
+ price = await usdPrice(c, leg.address);
1993
+ usd = price === null ? null : Number(formatUnits(legAmount, leg.decimals)) * price;
1994
+ }
1995
+ if (usd === null) {
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.`);
1997
+ }
1998
+ if (usd > MAX_BUY_USD) {
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.`);
2000
+ }
2001
+ }
2002
+
2003
+ const inMarket = coin.markets.some((m) => same(m.quote, leg.address));
2004
+ const gate = await gateCheck(c, token, account.address, inMarket ? legAmount : null, leg);
2005
+ if (gate?.refuse) return fail(gate.refuse);
2006
+ const planned = await planTrade(c, coin, true, leg.address, legAmount);
2007
+ if (planned.error) return fail(planned.error);
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
+ }
2018
+ let minOut = (route.out * (10_000n - bps)) / 10_000n || 1n;
2019
+ const routeWords = await routeText(c, coin, route, true);
2020
+ const quote = {
2021
+ token,
2022
+ spend: amountText(value, pay),
2023
+ ...(relay ? { relay: `${amountText(relay.out, leg)} expected, at least ${amountText(relay.min, leg)}` } : {}),
2024
+ expected: amountText(route.out, coin.meta),
2025
+ minimum: amountText(minOut, coin.meta),
2026
+ route: relay ? `${relayText(relay)}, then ${routeWords} (two transactions)` : routeWords,
2027
+ slippageBps: Number(bps),
2028
+ ...(gate ? { gate: gate.agentsOnly ? `AI agents only until ${gate.opensAt}` : `gated until ${gate.opensAt}`, capLeft: gate.capLeft } : {}),
2029
+ };
2030
+ if (dryRun) {
2031
+ const approvals = [
2032
+ ...(relay && asset !== zeroAddress ? [`exactly ${amountText(value, pay)} to Relay's approval proxy`] : []),
2033
+ ...(leg.address !== zeroAddress ? [`exactly ${relay ? `the ${leg.symbol} Relay returns` : amountText(value, pay)} to ${route.spender}`] : []),
2034
+ ];
2035
+ return reply({
2036
+ dryRun: true,
2037
+ ...quote,
2038
+ ...(relay ? { relayCheck: "Relay's transactions were simulated from this wallet: they only spend what you pay and return at least Relay's minimum." } : {}),
2039
+ ...(approvals.length ? { approval: `A real buy first approves ${approvals.join(", then ")}.` } : {}),
2040
+ verdict: "The buy would go through at this price.",
2041
+ });
2042
+ }
2043
+
2044
+ const txs = [];
2045
+ const links = () => txs.map((h) => `${EXPLORER}/tx/${h}`);
2046
+ let partial = null;
2047
+ if (relay) {
2048
+ const converted = await relayConvert(asset, leg.address, value, relay);
2049
+ txs.push(...converted.txs);
2050
+ if (converted.error) {
2051
+ if (!converted.swapped) return fail(`${converted.error}${txs.length ? ` Sent: ${links().join(", ")}.` : ""}`);
2052
+ return reply({ partial: true, spent: quote.spend, problem: converted.error, tx: links(), doNotRetry: "Relay's swap went through. Check the wallet before buying again." });
2053
+ }
2054
+ // What Relay returned buys the coin, at no worse a price than quoted: the minimum scales with what arrived.
2055
+ const quotedMin = (((route.out * converted.received) / relay.out) * (10_000n - bps)) / 10_000n;
2056
+ legAmount = converted.received;
2057
+ partial = (problem) =>
2058
+ reply({
2059
+ partial: true,
2060
+ spent: quote.spend,
2061
+ relaySwap: `${amountText(value, pay)} into ${amountText(legAmount, leg)}`,
2062
+ problem,
2063
+ tx: links(),
2064
+ doNotRetry: `Relay's swap went through and the wallet keeps the ${amountText(legAmount, leg)}. Buy the coin with ${leg.symbol} (payWith "${leg.symbol}") instead of retrying this call.`,
2065
+ });
2066
+ const again = await planTrade(c, coin, true, leg.address, legAmount);
2067
+ if (again.error) return partial(`The buy would fail now: ${again.error}`);
2068
+ route = again.route;
2069
+ minOut = (route.out * (10_000n - bps)) / 10_000n || 1n;
2070
+ if (minOut < quotedMin) minOut = quotedMin;
2071
+ if (route.out < minOut) return partial(`The coin's price moved past the slippage floor since the quote (now ${amountText(route.out, coin.meta)}, floor ${amountText(minOut, coin.meta)}).`);
2072
+ }
2073
+ const approval = await approveIfNeeded(leg.address, route.spender, legAmount);
2074
+ if (approval.error) return partial ? partial(approval.error) : fail(approval.error);
2075
+ if (approval.hash) txs.push(approval.hash);
2076
+ const sent = await send(await tradeCall(coin, route, true, leg.address, legAmount, minOut), {
2077
+ minGas: SWAP_MIN_GAS,
2078
+ explain: (err) => swapFailure(err),
2079
+ });
2080
+ if (sent.hash) txs.push(sent.hash);
2081
+ if (sent.error) {
2082
+ const why = sent.hash ? `The buy reverted (price moved past the slippage floor?): ${EXPLORER}/tx/${sent.hash}` : sent.error;
2083
+ if (partial) return partial(why);
2084
+ const approved = approval.hash ? ` The approval went through (${EXPLORER}/tx/${approval.hash}).` : "";
2085
+ return fail(`${why}${approved}`);
2086
+ }
2087
+ const block = sent.receipt.blockNumber;
2088
+ const [before, after] = await Promise.all([balanceAt(token, account.address, block - 1n), balanceAt(token, account.address, block)]);
2089
+ return reply({
2090
+ bought: before !== null && after !== null ? amountText(after - before, coin.meta) : `about ${amountText(route.out, coin.meta)}`,
2091
+ spent: quote.spend,
2092
+ ...(relay ? { relaySwap: `${amountText(value, pay)} into ${amountText(legAmount, leg)}` } : {}),
2093
+ minimum: amountText(minOut, coin.meta),
2094
+ route: quote.route,
2095
+ page: `${API}/token/${token}`,
2096
+ tx: links(),
2097
+ });
2098
+ }),
2099
+ );
2100
+
2101
+ server.registerTool(
2102
+ "sell_token",
2103
+ {
2104
+ description:
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.",
2106
+ inputSchema: {
2107
+ token: tokenArg,
2108
+ amount: z.union([z.string(), z.number()]).describe('Coins to sell: "1000000", "max" or "50%". "max" keeps 1 wei of the coin.'),
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.'),
2110
+ slippageBps: slippageArg,
2111
+ dryRun: dryRunArg,
2112
+ },
2113
+ },
2114
+ serialized(async ({ token, amount, receive, slippageBps, dryRun }) => {
2115
+ if (!account) return needKey();
2116
+ if (!isAddress(token)) return fail(`Not an address: ${token}`);
2117
+ token = getAddress(token);
2118
+ const c = await catalog();
2119
+ const coin = await loadCoin(c, token);
2120
+ if (!coin) return fail(NOT_A_COIN);
2121
+ const asset = receive ? await resolveAsset(c, receive, coin) : defaultAsset(c, coin);
2122
+ if (!asset || same(asset, token)) {
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(", ")}.`);
2124
+ }
2125
+ let out = await assetMeta(asset);
2126
+ const [balance, ethBalance, reserve] = await Promise.all([
2127
+ balanceOf(token, account.address),
2128
+ publicClient.getBalance({ address: account.address }),
2129
+ gasReserve(2_000_000n),
2130
+ ]);
2131
+ const value = parseAmount(amount, coin.meta, balance, true);
2132
+ if (value === null) return fail(`Not an amount: ${amount}`);
2133
+ if (value <= 0n) return fail(`Nothing to sell: the wallet holds ${amountText(balance, coin.meta)}.`);
2134
+ if (value > balance) return fail(`The wallet holds only ${amountText(balance, coin.meta)}.`);
2135
+ if (ethBalance < reserve) return fail(`Not enough ETH for gas: the wallet holds ${formatEther(ethBalance)} ETH and needs about ${formatEther(reserve)}.`);
2136
+
2137
+ // The coin sells for `leg` (an asset it trades in). When `asset` is not one, Relay swaps the proceeds into it.
2138
+ const bps = BigInt(slippageBps ?? 500);
2139
+ const relayBps = Math.min(slippageBps ?? 100, RELAY_MAX_SLIPPAGE_BPS);
2140
+ let leg = out;
2141
+ let route = null;
2142
+ let relay = null;
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))) {
2157
+ const planned = await planTrade(c, coin, false, asset, value);
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);
2172
+ if (q.quote) {
2173
+ leg = r.asset;
2174
+ route = r.route;
2175
+ relay = q.quote;
2176
+ break;
2177
+ }
2178
+ if (!q.unsupported || !relayError) relayError = q.error;
2179
+ }
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
+ }
2186
+ const assets = await tradeAssets(c, coin);
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.`);
2189
+ }
2190
+ const capped = await relayCapRefusal(c, leg, route.out);
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.`);
2192
+ }
2193
+ const minOut = (route.out * (10_000n - bps)) / 10_000n || 1n;
2194
+ const floor = await floorOf(coin).catch(() => null);
2195
+ const atFloor = floor && same(floor.quote, leg.address) ? floor.perToken(value) : null;
2196
+ const routeWords = await routeText(c, coin, route, false);
2197
+ // Relay's minimum, scaled to the sale's own minimum.
2198
+ const finalMin = relay ? (relay.min * minOut) / route.out : minOut;
2199
+ const quote = {
2200
+ token,
2201
+ sell: amountText(value, coin.meta),
2202
+ ...(relay ? { sale: `${amountText(route.out, leg)} expected, at least ${amountText(minOut, leg)}` } : {}),
2203
+ expected: amountText(relay ? relay.out : route.out, out),
2204
+ minimum: `${amountText(finalMin, out)}${relay ? " (estimated: Relay quotes again for what the sale returns)" : ""}`,
2205
+ route: relay ? `${routeWords}, then ${relayText(relay)} (two transactions)` : routeWords,
2206
+ ...(picked ? { market: picked } : {}),
2207
+ slippageBps: Number(bps),
2208
+ ...(atFloor !== null && atFloor > route.out
2209
+ ? { betterAtFloor: `The Backed Floor pays more right now: redeem_floor would pay ${amountText(atFloor, leg)} for these coins.` }
2210
+ : {}),
2211
+ };
2212
+ if (dryRun) {
2213
+ const approvals = [
2214
+ `exactly ${amountText(value, coin.meta)} to ${route.spender}`,
2215
+ ...(relay && leg.address !== zeroAddress ? [`exactly the ${leg.symbol} it swaps to Relay's approval proxy`] : []),
2216
+ ];
2217
+ return reply({
2218
+ dryRun: true,
2219
+ ...quote,
2220
+ ...(relay ? { relayCheck: "Relay's transactions are simulated from this wallet once the sale has paid, and refused unless they only spend the proceeds and return at least Relay's minimum." } : {}),
2221
+ approval: `A real sale first approves ${approvals.join(", then ")}.`,
2222
+ verdict: "The sale would go through at this price.",
2223
+ });
2224
+ }
2225
+
2226
+ const txs = [];
2227
+ const links = () => txs.map((h) => `${EXPLORER}/tx/${h}`);
2228
+ const approval = await approveIfNeeded(token, route.spender, value);
2229
+ if (approval.error) return fail(approval.error);
2230
+ if (approval.hash) txs.push(approval.hash);
2231
+ const sent = await send(await tradeCall(coin, route, false, leg.address, value, minOut), {
2232
+ minGas: SWAP_MIN_GAS,
2233
+ explain: (err) => swapFailure(err, "sale"),
2234
+ });
2235
+ if (sent.error) {
2236
+ const approved = approval.hash ? ` The approval went through (${EXPLORER}/tx/${approval.hash}).` : "";
2237
+ return fail(`${sent.hash ? `The sale reverted (price moved past the slippage floor?): ${EXPLORER}/tx/${sent.hash}` : sent.error}${approved}`);
2238
+ }
2239
+ txs.push(sent.hash);
2240
+ const block = sent.receipt.blockNumber;
2241
+ const [before, after] = await Promise.all([balanceAt(leg.address, account.address, block - 1n), balanceAt(leg.address, account.address, block)]);
2242
+ const proceeds = before !== null && after !== null ? after - before + (leg.address === zeroAddress ? feeOf(sent.receipt) : 0n) : null;
2243
+ if (!relay) {
2244
+ return reply({
2245
+ sold: quote.sell,
2246
+ received: proceeds !== null ? amountText(proceeds, out) : `about ${amountText(sent.result ?? route.out, out)}`,
2247
+ minimum: quote.minimum,
2248
+ route: quote.route,
2249
+ ...(picked ? { market: picked } : {}),
2250
+ tx: links(),
2251
+ });
2252
+ }
2253
+
2254
+ const partial = (problem) =>
2255
+ reply({
2256
+ partial: true,
2257
+ sold: quote.sell,
2258
+ ...(proceeds !== null ? { sale: amountText(proceeds, leg) } : {}),
2259
+ problem,
2260
+ tx: links(),
2261
+ doNotRetry: `The sale went through: the wallet keeps the ${leg.symbol}. Do not sell again; swap the ${leg.symbol} later, or keep it.`,
2262
+ });
2263
+ if (proceeds === null || proceeds <= 0n) return partial(`Could not read what the sale paid, so the ${leg.symbol} was not swapped to ${out.symbol}.`);
2264
+ const again = await relayQuote(leg.address, asset, proceeds, relayBps);
2265
+ if (again.error) return partial(again.error);
2266
+ const floorOut = (relay.out * proceeds * (10_000n - bps)) / (route.out * 10_000n);
2267
+ if (again.quote.out < floorOut) {
2268
+ return partial(`Relay now offers only ${amountText(again.quote.out, out)} for it, past the slippage floor (${amountText(floorOut, out)}), so it was not swapped.`);
2269
+ }
2270
+ const converted = await relayConvert(leg.address, asset, proceeds, again.quote);
2271
+ txs.push(...converted.txs);
2272
+ if (converted.error) return partial(`Relay's swap to ${out.symbol} did not complete: ${converted.error}`);
2273
+ return reply({
2274
+ sold: quote.sell,
2275
+ received: amountText(converted.received, out),
2276
+ sale: amountText(proceeds, leg),
2277
+ route: quote.route,
2278
+ tx: links(),
2279
+ });
2280
+ }),
2281
+ );
2282
+
2283
+ /** Why a FloorVault or FloorLoanPlugin call would fail, in words. */
2284
+ function floorFailure(err) {
2285
+ const name = errorName(err);
2286
+ if (name === "LoansNotEnabled") return "loans are not enabled on this coin.";
2287
+ if (name === "CollateralCapExceeded") return "that is more collateral than the loan cap allows now (loans may lock at most half of the free supply).";
2288
+ if (name === "ZeroLoan" || name === "ZeroAmount") return "the amount is too small: it is worth 0 at the floor.";
2289
+ if (name === "BelowMinimum") return "the floor moved and the loan would pay less than quoted. Retry.";
2290
+ if (name === "LoanExpired") return "the loan has expired: it can no longer be repaid. The collateral is burned at settlement and the wallet keeps what it borrowed.";
2291
+ if (name === "LoanNotOpen") return "this loan is not open (already repaid or settled).";
2292
+ if (name === "NotBorrower") return "this wallet is not the borrower of this loan.";
2293
+ if (name === "RepayTooHigh") return "the floor rose past the repay bound. Retry.";
2294
+ if (name === "ModuleHalted") return "Hookit halted the loan module: new loans are paused (repaying still works).";
2295
+ if (name === "InsufficientReserve" || name === "FloorSupplyExhausted") return "the floor reserve cannot pay this amount.";
2296
+ if (/insufficient funds/.test(errorText(err))) return "the wallet does not hold enough ETH for this and its gas.";
2297
+ return shortError(err);
2298
+ }
2299
+
2300
+ server.registerTool(
2301
+ "redeem_floor",
2302
+ {
2303
+ description:
2304
+ "Redeems coins at their Backed Floor: the coins are burned and the floor reserve pays their floor value (floor price = reserve / circulating supply, a price that never goes down) in the coin's floor quote (ETH, USDG...). No slippage: the payout is exact. It compares with selling on the market and says which pays more. Only coins launched with Backed Floor. Use dryRun to see the payout first.",
2305
+ inputSchema: {
2306
+ token: tokenArg,
2307
+ amount: z.union([z.string(), z.number()]).describe('Coins to redeem: "1000000", "max" or "50%". "max" keeps 1 wei of the coin.'),
2308
+ dryRun: z.boolean().optional().describe("Check only. Signs nothing."),
2309
+ },
2310
+ },
2311
+ serialized(async ({ token, amount, dryRun }) => {
2312
+ if (!account) return needKey();
2313
+ if (!isAddress(token)) return fail(`Not an address: ${token}`);
2314
+ token = getAddress(token);
2315
+ const c = await catalog();
2316
+ const coin = await loadCoin(c, token);
2317
+ if (!coin) return fail(NOT_A_COIN);
2318
+ const floor = await floorOf(coin);
2319
+ if (!floor) return fail("This coin has no Backed Floor reserve to redeem from.");
2320
+ const balance = await balanceOf(token, account.address);
2321
+ const value = parseAmount(amount, coin.meta, balance, true);
2322
+ if (value === null) return fail(`Not an amount: ${amount}`);
2323
+ if (value <= 0n) return fail(`Nothing to redeem: the wallet holds ${amountText(balance, coin.meta)}.`);
2324
+ if (value > balance) return fail(`The wallet holds only ${amountText(balance, coin.meta)}.`);
2325
+ const payout = floor.perToken(value);
2326
+ if (payout === 0n) return fail("Too few coins: they are worth 0 at the floor.");
2327
+ const sale = await planTrade(c, coin, false, floor.quote, value);
2328
+ const saleOut = sale.route?.out ?? null;
2329
+ const view = {
2330
+ token,
2331
+ redeem: amountText(value, coin.meta),
2332
+ receive: amountText(payout, floor.quoteMeta),
2333
+ floorPrice: `${floor.price} per ${coin.meta.symbol}`,
2334
+ ...(saleOut !== null ? { marketWouldPay: amountText(saleOut, floor.quoteMeta) } : {}),
2335
+ verdict:
2336
+ saleOut !== null && saleOut > payout
2337
+ ? `Selling pays more right now (${amountText(saleOut, floor.quoteMeta)}): consider sell_token instead.`
2338
+ : "Redeeming pays at least as much as selling right now.",
2339
+ };
2340
+ if (dryRun) return reply({ dryRun: true, ...view, approval: `A real redeem first approves exactly ${view.redeem} to the floor vault ${floor.vault}.` });
2341
+
2342
+ const approval = await approveIfNeeded(token, floor.vault, value);
2343
+ if (approval.error) return fail(approval.error);
2344
+ const sent = await send(
2345
+ { address: floor.vault, abi: floorVaultAbi, functionName: "redeemFloor", args: [token, value] },
2346
+ { explain: (err) => `The redeem would fail: ${floorFailure(err)}` },
2347
+ );
2348
+ if (sent.error) return fail(sent.error);
2349
+ let received = sent.result;
2350
+ for (const log of sent.receipt.logs) {
2351
+ if (!same(log.address, floor.vault)) continue;
2352
+ try {
2353
+ const ev = decodeEventLog({ abi: floorVaultAbi, data: log.data, topics: log.topics });
2354
+ if (ev.eventName === "Redeemed") received = ev.args.quoteOut;
2355
+ } catch {}
2356
+ }
2357
+ return reply({
2358
+ redeemed: view.redeem,
2359
+ received: amountText(received, floor.quoteMeta),
2360
+ tx: [...(approval.hash ? [`${EXPLORER}/tx/${approval.hash}`] : []), `${EXPLORER}/tx/${sent.hash}`],
2361
+ });
2362
+ }),
2363
+ );
2364
+
2365
+ server.registerTool(
2366
+ "borrow_against_floor",
2367
+ {
2368
+ description:
2369
+ "Borrows the Backed Floor value of a coin without selling it (v2 coins launched with Backed Floor and loans): the coins are locked as collateral, the wallet receives their floor value minus an upfront fee (1% for 7 days, 1.75% for 14, 3% for 30, 5% for 60, 7% for 90), and repay_loan gets them back before expiry for their floor value then (it only rises with the floor). Without repayment the collateral is burned at expiry and the wallet keeps what it borrowed: nothing more is ever owed, no liquidation before expiry. Use dryRun to see the terms first.",
2370
+ inputSchema: {
2371
+ token: tokenArg,
2372
+ amount: z.union([z.string(), z.number()]).describe('Coins to lock: "1000000", "50%", or "max" (the most the wallet and the loan cap allow).'),
2373
+ days: z.union([z.literal(7), z.literal(14), z.literal(30), z.literal(60), z.literal(90)]).optional().describe("Loan length: 7, 14, 30, 60 or 90 days. Default 30."),
2374
+ dryRun: z.boolean().optional().describe("Check only. Signs nothing."),
2375
+ },
2376
+ },
2377
+ serialized(async ({ token, amount, days, dryRun }) => {
2378
+ if (!account) return needKey();
2379
+ if (!isAddress(token)) return fail(`Not an address: ${token}`);
2380
+ token = getAddress(token);
2381
+ const c = await catalog();
2382
+ const plugin = at(c, "loanPlugin");
2383
+ const coin = await loadCoin(c, token);
2384
+ if (!coin) return fail(NOT_A_COIN);
2385
+ const enabled = await read(plugin, loanAbi, "loansEnabled", [token]).catch(() => false);
2386
+ if (!enabled) return fail("Loans are not enabled on this coin. Only v2 coins launched with Backed Floor and loans (the \"Allow loans\" option) can be borrowed against.");
2387
+ const floor = await floorOf(coin);
2388
+ if (!floor) return fail("This coin's Backed Floor reserve is empty: there is nothing to borrow yet.");
2389
+ const length = days ?? 30;
2390
+ const duration = BigInt(length * 86_400);
2391
+ const [balance, cap] = await Promise.all([balanceOf(token, account.address), read(plugin, loanAbi, "maxCollateral", [token])]);
2392
+ const isMax = ["max", "all"].includes(String(amount).trim().toLowerCase());
2393
+ let value = parseAmount(amount, coin.meta, balance, true);
2394
+ if (value === null) return fail(`Not an amount: ${amount}`);
2395
+ if (isMax && value > cap) value = cap;
2396
+ if (value <= 0n) {
2397
+ return fail(cap === 0n ? "The loan cap is reached on this coin: no new collateral can be locked now." : `Nothing to lock: the wallet holds ${amountText(balance, coin.meta)}.`);
2398
+ }
2399
+ if (value > balance) return fail(`The wallet holds only ${amountText(balance, coin.meta)}.`);
2400
+ if (value > cap) return fail(`At most ${amountText(cap, coin.meta)} can be locked now (loans may lock at most half of the free supply). Use "max".`);
2401
+ let preview;
2402
+ try {
2403
+ preview = await read(plugin, loanAbi, "previewBorrow", [token, value, duration]);
2404
+ } catch (err) {
2405
+ return fail(`The loan would fail: ${floorFailure(err)}`);
2406
+ }
2407
+ const [gross, fee, net] = preview;
2408
+ const minNet = (net * 995n) / 1000n;
2409
+ const q = floor.quoteMeta;
2410
+ const terms = {
2411
+ token,
2412
+ collateral: amountText(value, coin.meta),
2413
+ receive: amountText(net, q),
2414
+ fee: `${amountText(fee, q)} (${LOAN_FEES_BPS[length] / 100}% upfront, kept by the floor reserve)`,
2415
+ days: length,
2416
+ expiresAround: new Date(Date.now() + length * 86_400_000).toISOString(),
2417
+ repayToday: amountText(gross, q),
2418
+ howItEnds: `Repay with repay_loan before expiry to get the coins back, for their floor value at that time (${amountText(gross, q)} today; more if the floor rose). Without repayment the coins are burned at expiry and the wallet keeps the ${q.symbol}: nothing more is owed.`,
2419
+ };
2420
+ if (dryRun) return reply({ dryRun: true, ...terms, approval: `A real loan first approves exactly ${terms.collateral} to the loan plugin ${plugin}.` });
2421
+
2422
+ const approval = await approveIfNeeded(token, plugin, value);
2423
+ if (approval.error) return fail(approval.error);
2424
+ const sent = await send(
2425
+ { address: plugin, abi: loanAbi, functionName: "borrow", args: [token, value, duration, minNet] },
2426
+ { explain: (err) => `The loan would fail: ${floorFailure(err)}` },
2427
+ );
2428
+ if (sent.error) return fail(sent.error);
2429
+ let loanId = sent.result;
2430
+ let got = net;
2431
+ let expiry = null;
2432
+ for (const log of sent.receipt.logs) {
2433
+ if (!same(log.address, plugin)) continue;
2434
+ try {
2435
+ const ev = decodeEventLog({ abi: loanAbi, data: log.data, topics: log.topics });
2436
+ if (ev.eventName === "Borrowed") ({ loanId, net: got, expiry } = ev.args);
2437
+ } catch {}
2438
+ }
2439
+ return reply({
2440
+ borrowed: true,
2441
+ loanId: loanId.toString(),
2442
+ collateral: terms.collateral,
2443
+ received: amountText(got, q),
2444
+ expires: expiry ? new Date(Number(expiry) * 1000).toISOString() : terms.expiresAround,
2445
+ next: `Repay with repay_loan({ "loanId": "${loanId}" }) before expiry to get the coins back.`,
2446
+ tx: [...(approval.hash ? [`${EXPLORER}/tx/${approval.hash}`] : []), `${EXPLORER}/tx/${sent.hash}`],
2447
+ });
2448
+ }),
2449
+ );
2450
+
2451
+ server.registerTool(
2452
+ "repay_loan",
2453
+ {
2454
+ description:
2455
+ "Repays a Backed Floor loan of this wallet before it expires and gets the collateral coins back. It pays the collateral's floor value now (at least what was borrowed: the floor never goes down), in the loan's quote (ETH, USDG...). Without loanId it repays the wallet's only open loan. Use dryRun to see the amount first.",
2456
+ inputSchema: {
2457
+ loanId: z.string().regex(/^[0-9]+$/, "loanId is a string of digits").optional().describe("The loan id from borrow_against_floor or wallet_status. Default: the only open loan."),
2458
+ dryRun: z.boolean().optional().describe("Check only. Signs nothing."),
2459
+ },
2460
+ },
2461
+ serialized(async ({ loanId, dryRun }) => {
2462
+ if (!account) return needKey();
2463
+ const c = await catalog();
2464
+ const plugin = at(c, "loanPlugin");
2465
+ let id;
2466
+ if (loanId) {
2467
+ id = BigInt(loanId);
2468
+ } else {
2469
+ const open = (await openLoans(c, account.address)).filter((l) => !l.expired);
2470
+ if (open.length === 0) return fail("This wallet has no open loan to repay.");
2471
+ if (open.length > 1) {
2472
+ return fail(`This wallet has ${open.length} open loans: ${open.map((l) => `${l.id} (${amountText(l.loan.e, l.meta)})`).join(", ")}. Pass the loanId to repay.`);
2473
+ }
2474
+ id = open[0].id;
2475
+ }
2476
+ const loan = await read(plugin, loanAbi, "getLoan", [id]).catch(() => null);
2477
+ if (!loan || loan.borrower === zeroAddress) return fail(`There is no loan ${id}.`);
2478
+ if (!same(loan.borrower, account.address)) return fail(`Loan ${id} belongs to another wallet.`);
2479
+ if (loan.status !== 1) return fail(`Loan ${id} is not open: it was already ${loan.status === 2 ? "repaid" : "settled at expiry"}.`);
2480
+ if (BigInt(Math.floor(Date.now() / 1000)) >= loan.expiry) {
2481
+ return fail(`Loan ${id} has expired: it can no longer be repaid. The collateral is burned at settlement and the wallet keeps what it borrowed; nothing is owed.`);
2482
+ }
2483
+ const coin = await loadCoin(c, loan.token);
2484
+ const floor = coin ? await floorOf(coin) : null;
2485
+ if (!coin || !floor) return fail("Could not read this loan's coin and floor on Ink. Nothing was sent; retry later.");
2486
+ const q = floor.quoteMeta;
2487
+ let owed;
2488
+ try {
2489
+ owed = await read(plugin, loanAbi, "repayAmount", [id]);
2490
+ } catch (err) {
2491
+ return fail(`The repay would fail: ${floorFailure(err)}`);
2492
+ }
2493
+ // The floor can rise before the transaction lands: allow 0.5% more; ETH above the actual amount is refunded.
2494
+ const maxRepay = owed + (owed * 5n) / 1000n + 1n;
2495
+ const native = floor.quote === zeroAddress;
2496
+ const [ethBalance, reserve] = await Promise.all([publicClient.getBalance({ address: account.address }), gasReserve(800_000n)]);
2497
+ const holds = native ? ethBalance : await balanceOf(floor.quote, account.address);
2498
+ if (holds < (native ? maxRepay + reserve : owed)) {
2499
+ return fail(`Not enough ${q.symbol}: repaying costs ${amountText(owed, q)}${native ? " plus gas" : ""}, and the wallet holds ${amountText(holds, q)}.`);
2500
+ }
2501
+ if (!native && ethBalance < reserve) return fail(`Not enough ETH for gas: the wallet holds ${formatEther(ethBalance)} ETH.`);
2502
+ const view = {
2503
+ loanId: id.toString(),
2504
+ repay: amountText(owed, q),
2505
+ getBack: amountText(loan.e, coin.meta),
2506
+ expires: new Date(Number(loan.expiry) * 1000).toISOString(),
2507
+ };
2508
+ if (dryRun) {
2509
+ return reply({
2510
+ dryRun: true,
2511
+ ...view,
2512
+ bound: `It sends at most ${amountText(maxRepay, q)}${native ? " (the rest is refunded)" : ` and first approves that much ${q.symbol} to ${plugin}`}.`,
2513
+ });
2514
+ }
2515
+ const approval = native ? {} : await approveIfNeeded(floor.quote, plugin, maxRepay);
2516
+ if (approval.error) return fail(approval.error);
2517
+ const sent = await send(
2518
+ { address: plugin, abi: loanAbi, functionName: "repay", args: [id, maxRepay], ...(native ? { value: maxRepay } : {}) },
2519
+ { explain: (err) => `The repay would fail: ${floorFailure(err)}` },
2520
+ );
2521
+ if (sent.error) return fail(sent.error);
2522
+ return reply({
2523
+ repaid: amountText(sent.result, q),
2524
+ gotBack: view.getBack,
2525
+ tx: [...(approval.hash ? [`${EXPLORER}/tx/${approval.hash}`] : []), `${EXPLORER}/tx/${sent.hash}`],
2526
+ });
2527
+ }),
2528
+ );
2529
+
2530
+ /** The hooks a coin runs, by name, from a /api/launches pool. */
2531
+ function hookNames(p) {
2532
+ const on = Object.entries(p.hooks ?? {})
2533
+ .filter(([name, v]) => v === true && name !== "customHook")
2534
+ .map(([name]) => name);
2535
+ if (p.bossRaid) on.push("bossRaid");
2536
+ if (p.gatedBuy) on.push("gatedBuy");
2537
+ return on;
2538
+ }
2539
+
2540
+ /** One coin of /api/launches for an agent. */
2541
+ function coinRow(p, now) {
2542
+ const priceUsd = p.priceEth > 0 && p.quoteUsd > 0 ? p.priceEth * p.quoteUsd : null;
2543
+ const gated = p.gatedBuy && p.gatedBuyEndsAt > now;
2544
+ const pair = p.markets?.length > 1 ? p.markets.map((m) => m.quoteAsset ?? "?").join(" + ") : (p.quoteAsset ?? "ETH");
2545
+ const round = (n) => (Number.isFinite(n) ? Math.round(n * 100) / 100 : null);
2546
+ return {
2547
+ token: p.contractAddress ?? p.id,
2548
+ name: p.name,
2549
+ ticker: p.ticker,
2550
+ pair,
2551
+ marketCapUsd: round(p.marketCap),
2552
+ ...(priceUsd !== null ? { priceUsd: Number(priceUsd.toPrecision(6)) } : {}),
2553
+ volume24hUsd: round(p.volume24h ?? 0),
2554
+ change24hPct: round(p.change24h ?? 0),
2555
+ launched: p.launchedAt ? new Date(p.launchedAt * 1000).toISOString() : null,
2556
+ hooks: hookNames(p),
2557
+ ...(p.launchedByAgent ? { launchedByAgent: true } : {}),
2558
+ ...(gated ? { gate: `${p.gatedBuyAgentsOnly ? "AI agents only" : "gated"} until ${new Date(p.gatedBuyEndsAt * 1000).toISOString()}` } : {}),
2559
+ };
2560
+ }
2561
+
2562
+ server.registerTool(
2563
+ "list_coins",
2564
+ {
2565
+ description:
2566
+ "Lists hookit.fun coins the trading tools can trade (Master rail), with market cap, price, 24h volume and change, pair, hooks and launch gate. Sort by newest, market cap, volume or 24h gainers; filter AI-agents-only coins, coins launched by agents, Backed Floor coins (redeem_floor), Boss Raid coins; or search a name or ticker. Needs no key.",
2567
+ inputSchema: {
2568
+ sort: z.enum(["new", "marketcap", "volume", "gainers"]).optional().describe("Default new."),
2569
+ filter: z
2570
+ .enum(["all", "ai_agents_only", "gated", "launched_by_agents", "backed_floor", "boss_raid"])
2571
+ .optional()
2572
+ .describe("ai_agents_only and gated: coins whose launch gate is still closed. Default all."),
2573
+ pair: z.string().optional().describe('Only coins priced in this pair: "eth", "usdg", "wNVDAx"...'),
2574
+ search: z.string().optional().describe("Part of a name or ticker."),
2575
+ limit: z.number().int().min(1).max(50).optional().describe("Default 20."),
2576
+ },
2577
+ annotations: { readOnlyHint: true },
2578
+ },
2579
+ async ({ sort, filter, pair, search, limit }) => {
2580
+ let body;
2581
+ try {
2582
+ body = await launches();
2583
+ } catch (err) {
2584
+ return fail(`hookit.fun's coin list did not answer: ${err.message}`);
2585
+ }
2586
+ const now = Math.floor(Date.now() / 1000);
2587
+ const want = pair?.trim().toLowerCase();
2588
+ const needle = search?.trim().toLowerCase();
2589
+ const pools = body.pools.filter((p) => {
2590
+ if (p.rail === "classic" || p.hookType === "Classic") return false;
2591
+ if (needle && !`${p.name} ${p.ticker}`.toLowerCase().includes(needle)) return false;
2592
+ if (want) {
2593
+ const quotes = [p.quoteAsset, ...(p.markets ?? []).map((m) => m.quoteAsset)].filter(Boolean).map((q) => q.toLowerCase());
2594
+ if (!quotes.includes(want) && !(want === "eth" && quotes.length === 0)) return false;
2595
+ }
2596
+ switch (filter ?? "all") {
2597
+ case "ai_agents_only":
2598
+ return p.gatedBuyAgentsOnly && p.gatedBuyEndsAt > now;
2599
+ case "gated":
2600
+ return p.gatedBuy && p.gatedBuyEndsAt > now;
2601
+ case "launched_by_agents":
2602
+ return !!p.launchedByAgent;
2603
+ case "backed_floor":
2604
+ return !!p.hooks?.backedFloor;
2605
+ case "boss_raid":
2606
+ return !!p.bossRaid;
2607
+ default:
2608
+ return true;
2609
+ }
2610
+ });
2611
+ const by = {
2612
+ new: (p) => p.launchedAt ?? 0,
2613
+ marketcap: (p) => p.marketCap ?? 0,
2614
+ volume: (p) => p.volume24h ?? 0,
2615
+ gainers: (p) => p.change24h ?? 0,
2616
+ }[sort ?? "new"];
2617
+ pools.sort((a, b) => by(b) - by(a));
2618
+ const rows = pools.slice(0, limit ?? 20).map((p) => coinRow(p, now));
2619
+ return reply({ count: pools.length, shown: rows.length, coins: rows, next: "coin_info({ token }) for one coin; buy_token to buy." });
2620
+ },
2621
+ );
2622
+
2623
+ server.registerTool(
2624
+ "coin_info",
2625
+ {
2626
+ description:
2627
+ "Everything about one hookit.fun coin: market data, what it trades in, its hooks, its Backed Floor (price, reserve) and loans, its launch gate, and this wallet's balance and loans on it. Needs no key (the wallet part needs one).",
2628
+ inputSchema: { token: tokenArg },
2629
+ annotations: { readOnlyHint: true },
2630
+ },
2631
+ async ({ token }) => {
2632
+ if (!isAddress(token)) return fail(`Not an address: ${token}`);
2633
+ token = getAddress(token);
2634
+ const c = await catalog();
2635
+ const [coin, pool] = await Promise.all([
2636
+ loadCoin(c, token),
2637
+ fetch(`${API}/api/launches/${token}`)
2638
+ .then((res) => (res.ok ? res.json() : null))
2639
+ .then((body) => body?.pool ?? null)
2640
+ .catch(() => null),
2641
+ ]);
2642
+ if (!coin && !pool) return fail(NOT_A_COIN);
2643
+ const now = Math.floor(Date.now() / 1000);
2644
+ const info = { token, page: `${API}/token/${token}` };
2645
+ if (pool) {
2646
+ Object.assign(info, coinRow(pool, now), {
2647
+ token,
2648
+ ...(pool.description ? { description: pool.description.slice(0, 500) } : {}),
2649
+ ...(pool.hookTaxBps ? { hookTaxPct: pool.hookTaxBps / 100 } : {}),
2650
+ ...(pool.creator ? { creator: pool.creator } : {}),
2651
+ });
2652
+ }
2653
+ if (!coin) return reply({ ...info, tradable: false, note: "Classic bonding-curve coin: trade it on the site." });
2654
+ info.tradeIn = await tradeAssets(c, coin);
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).";
2656
+ const floor = await floorOf(coin).catch(() => null);
2657
+ info.backedFloor = floor
2658
+ ? { floorPrice: `${floor.price} per ${coin.meta.symbol}`, reserve: amountText(floor.V, floor.quoteMeta), vault: floor.vault, redeem: "redeem_floor" }
2659
+ : null;
2660
+ const plugin = at(c, "loanPlugin");
2661
+ const loans = floor ? await read(plugin, loanAbi, "loansEnabled", [token]).catch(() => false) : false;
2662
+ if (loans) {
2663
+ const cap = await read(plugin, loanAbi, "maxCollateral", [token]).catch(() => null);
2664
+ info.loans = {
2665
+ enabled: true,
2666
+ ...(cap !== null ? { maxCollateralNow: amountText(cap, coin.meta) } : {}),
2667
+ feesPct: Object.fromEntries(Object.entries(LOAN_FEES_BPS).map(([d, bps]) => [`${d}d`, bps / 100])),
2668
+ borrow: "borrow_against_floor",
2669
+ };
2670
+ }
2671
+ if (c.gatedBuyPlugin) {
2672
+ const state = await read(c.gatedBuyPlugin, gatedBuyAbi, "gateState", [token]).catch(() => null);
2673
+ if (state && state[0] !== zeroAddress && !state[3]) {
2674
+ const gate = account ? await gateCheck(c, token, account.address, null, coin.meta).catch(() => null) : null;
2675
+ info.gate = {
2676
+ opensAt: opensAt(state[2]),
2677
+ ...(account ? { thisWalletCanBuy: !gate?.refuse, ...(gate?.refuse ? { why: gate.refuse } : {}) } : {}),
2678
+ };
2679
+ }
2680
+ }
2681
+ if (account) {
2682
+ const balance = await balanceOf(token, account.address).catch(() => 0n);
2683
+ const mine = loans ? (await openLoans(c, account.address)).filter((l) => same(l.loan.token, token)) : [];
2684
+ info.wallet = {
2685
+ balance: amountText(balance, coin.meta),
2686
+ ...(mine.length ? { loans: await Promise.all(mine.map((l) => loanView(c, l))) } : {}),
2687
+ };
2688
+ }
2689
+ return reply(info);
2690
+ },
2691
+ );
2692
+
2693
+ await server.connect(new StdioServerTransport());