hookit-mcp 0.7.0 → 0.7.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.
- package/README.md +12 -11
- package/index.js +345 -82
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,9 +33,9 @@ Coin names, tickers and descriptions are written by whoever launched the coin. T
|
|
|
33
33
|
| `deploy_hook` | Deploys a hook written with hookit's Plugin SDK from this wallet on Ink, with the live ModuleRegistry as its constructor argument. Takes the Foundry artifact. `dryRun` is on unless `false`: it checks the code and prices the deployment. Workshop, below. |
|
|
34
34
|
| `submit_hook` | Submits that hook to the Workshop for Hookit's review (manifest, public repo, name, tagline, description), signed by this wallet: a message, no transaction, no fee. `dryRun` is on unless `false`. |
|
|
35
35
|
| `hook_status` | Where a submitted hook stands in the review, Hookit's note and checks, its module id once live, and what to do next. Needs no key. |
|
|
36
|
-
| `wallet_status` | The wallet's ETH and USDG, the hookit.fun coins it holds with their value, its open floor loans, and whether it can launch. |
|
|
36
|
+
| `wallet_status` | The wallet's ETH and USDG, the hookit.fun coins it holds with their value (tickers filtered like every launcher's text: anyone can send the wallet a coin), its open floor loans, and whether it can launch. |
|
|
37
37
|
| `launch_token` | Launches in one call, with an optional `devBuyPct`, `payout`, `evolve` (`{ feePct?, maxFeePct?, potPct? }`: this wallet becomes the coin's Evolve decider, below) `velocity` (`{ preset?, malusPct }`: Velocity Fee, below) and `builderHooks` (`[{ hook, pct?, initData? }]`: builder hooks, below). `dryRun: true` checks and simulates without signing. |
|
|
38
|
-
| `claim_fees` | Collects the creator fees the
|
|
38
|
+
| `claim_fees` | Collects what the wallet earned: the creator fees of coins it launched and the builder cut a Workshop hook it built paid it (ERC-6909 claims on Uniswap's PoolManager, redeemed through Hookit's V4ClaimsRedeemer for exactly what is owed). `dryRun: true` shows what is owed. |
|
|
39
39
|
| `send_eth` | Sends ETH from the wallet, for example back to a Kraken deposit address on Ink. Refuses less than 0.001 ETH, Kraken's minimum deposit (`belowKrakenMinimum: true` for an address that is not Kraken). `dryRun: true` checks only. With `HOOKIT_SEND_ETH_TO` set, it refuses any other address. |
|
|
40
40
|
| `agent_pass_challenge` | Step 1 for AI-agents-only coins: five short tasks for the Hookit Agent Pass check (or "already a holder"). `{ agentId? }` for an ERC-8004 validation (below). |
|
|
41
41
|
| `claim_agent_pass` | Step 2: `{ id, answers, agentId?, requestHash? }` within 30 seconds; signs the answers with the wallet and claims the pass on chain. With `agentId`, gets Hookit's ERC-8004 validation instead (below). |
|
|
@@ -67,7 +67,7 @@ Paying or receiving a token approves exactly that amount to the contract that tr
|
|
|
67
67
|
|
|
68
68
|
`coin_info` lists what a coin trades in (`tradeIn`). When `payWith` or `receive` is not one of them, the server swaps through [Relay](https://relay.link) on Ink, the same route hookit.fun's own swap box uses: it asks `https://www.hookit.fun/api/relay/quote` for the swap (Relay's API key stays on that server), then signs Relay's transactions itself. A buy swaps first and buys with what Relay returned; a sale sells for the coin's pair first, then swaps the proceeds. `dryRun` shows both legs.
|
|
69
69
|
|
|
70
|
-
Before signing, Relay's transactions are checked twice. First their shape: Ink only, calls only to Relay's Ink contracts (router, approval proxy, receiver, multicaller, depository), an approval only of Relay's approval proxy (replaced by an exact approval of the amount), and no more ETH than the amount. Then their effect: the approval and every call run together in one `eth_simulateV1` from the wallet, and the swap is refused unless the wallet would give at most the amount, get at least
|
|
70
|
+
Before signing, Relay's transactions are checked twice. First their shape: Ink only, calls only to Relay's Ink contracts (router, approval proxy, receiver, multicaller, depository), an approval only of Relay's approval proxy (replaced by an exact approval of the amount), and no more ETH than the amount. Then their effect: the approval and every call run together in one `eth_simulateV1` from the wallet, and the swap is refused unless the wallet would give at most the amount, get at least the minimum, and lose nothing else. That minimum is never the quote's word alone: it is at least the market price (Chainlink's ETH/USD as Factory C reads it, a stock's USDG pool on Ink, else hookit.fun's price) less the slippage and 5% for Relay's fees and price impact, and a quote under that floor is refused. Anything else is refused and nothing is sent. No allowance to Relay is left behind, even when a swap fails. One Relay swap converts at most the per-buy cap (`HOOKIT_MAX_BUY_ETH` / `HOOKIT_MAX_BUY_USD`), with at most 3% slippage. Relay swaps ETH or USDG with each other, kBTC and the tokenized stocks. The RPC in `HOOKIT_RPC_URL` must support `eth_simulateV1` (Ink's public RPC does).
|
|
71
71
|
|
|
72
72
|
When the first leg went through and the second did not, the reply is `partial: true` with what the wallet now holds: do not retry the call, or the first leg runs twice.
|
|
73
73
|
|
|
@@ -184,10 +184,11 @@ Builder hooks are hooks written by outside builders in hookit.fun's Workshop. Ho
|
|
|
184
184
|
|
|
185
185
|
```
|
|
186
186
|
list_builder_hooks()
|
|
187
|
-
launch_token({ "name": "Burny", "symbol": "BRN", "hookTaxPct": 3, "
|
|
187
|
+
launch_token({ "name": "Burny", "symbol": "BRN", "hookTaxPct": 3, "modules": { "autoBurn": true },
|
|
188
|
+
"builderHooks": [{ "hook": 21, "pct": 20 }], "dryRun": true })
|
|
188
189
|
```
|
|
189
190
|
|
|
190
|
-
- `hook` is the moduleId from `list_builder_hooks`, its plugin address or its exact name. `pct` is the whole percent of the hook pot it takes, up to its `maxPct` (hooks that take a share only); left out, hookit.fun picks the launch wizard's default. The coin's built-in pot destinations are rebalanced to leave it.
|
|
191
|
+
- `hook` is the moduleId from `list_builder_hooks`, its plugin address or its exact name. `pct` is the whole percent of the hook pot it takes, up to its `maxPct` (hooks that take a share only); left out, hookit.fun picks the launch wizard's default (the whole pot when the coin has no other pot destination). The pot's shares total 100%: with a `pct` below that, the rest goes to the coin's built-in pot destinations (Auto-Burn above), which are rebalanced to leave it.
|
|
191
192
|
- A hook with a share needs something to fill the hook pot: a hook tax, Creator → Hook, or a builder hook that adds its own fee (`fillsHookPot`). `maxFeePct` is the most a hook adds on a trade, and it counts in the 10% cap with the 1% base and the hook tax.
|
|
192
193
|
- Names, taglines and descriptions are the builders' own words: the server filters them like a launcher's text and the agent treats them as data. The module, its plugin, its pot and fee caps and whether it is halted are read from ModuleRegistry, and a hook whose module holds another plugin or is deprecated is not listed.
|
|
193
194
|
- Up to 8 per launch. Refused with Evolve and with Velocity Fee for now.
|
|
@@ -200,17 +201,17 @@ launch_token({ "name": "Burny", "symbol": "BRN", "hookTaxPct": 3, "builderHooks"
|
|
|
200
201
|
An agent writes a hook with [hookit's Plugin SDK](https://github.com/SDKHookit/SDKHookit) (its AGENTS.md holds every rule Hookit's review checks), runs the SDK's tests, then deploys and submits it from this wallet. The wallet that deploys is the one that submits.
|
|
201
202
|
|
|
202
203
|
```
|
|
203
|
-
deploy_hook({ "artifact": "out/MyHook.sol/MyHook.json" }) // checks it, gas and cost
|
|
204
|
-
deploy_hook({ "artifact": "out/MyHook.sol/MyHook.json", "dryRun": false }) // deploys it
|
|
204
|
+
deploy_hook({ "artifact": "/abs/path/to/sdk/out/MyHook.sol/MyHook.json" }) // checks it, gas and cost
|
|
205
|
+
deploy_hook({ "artifact": "/abs/path/to/sdk/out/MyHook.sol/MyHook.json", "dryRun": false }) // deploys it
|
|
205
206
|
// then, in the SDK folder: forge verify-contract … and forge script script/Manifest.s.sol … (the answer gives both)
|
|
206
|
-
submit_hook({ "manifest": "manifests/my-hook.json", "repo": "https://github.com/you/my-hook/tree/<commit>",
|
|
207
|
+
submit_hook({ "manifest": "/abs/path/to/sdk/manifests/my-hook.json", "repo": "https://github.com/you/my-hook/tree/<commit>",
|
|
207
208
|
"name": "My Hook", "tagline": "…", "description": "…", "dryRun": false })
|
|
208
209
|
hook_status({ "plugin": "0x…" }) // the review, then the module id
|
|
209
|
-
launch_token({ "name": "…", "symbol": "…", "builderHooks": [{ "hook": 21, "initData": "0x…" }], "dryRun": true })
|
|
210
|
+
launch_token({ "name": "…", "symbol": "…", "hookTaxPct": 3, "builderHooks": [{ "hook": 21, "initData": "0x…" }], "dryRun": true })
|
|
210
211
|
```
|
|
211
212
|
|
|
212
|
-
- `deploy_hook` takes the Foundry artifact (`out/<File>.sol/<Contract>.json` after `forge build
|
|
213
|
-
- `submit_hook` sends the manifest as the SDK wrote it and cleans the card exactly as the Workshop's form does, then signs the Workshop's submit message.
|
|
213
|
+
- `deploy_hook` takes the Foundry artifact (`out/<File>.sol/<Contract>.json` after `forge build`, as an absolute path: the server runs in its own folder) and refuses anything that is not a deployable plugin: built on the SDK's `HookPluginBase`, one `address` constructor, no linked libraries, code within Ink's limits. It reads regular files only, up to 16 MiB, and never quotes a file it cannot use. The constructor argument is always the ModuleRegistry pinned here, never one the agent or a website names.
|
|
214
|
+
- `submit_hook` sends the manifest as the SDK wrote it and cleans the card exactly as the Workshop's form does, then signs the Workshop's submit message. Before signing, it checks that the manifest's codehash is the code deployed at its address and refuses a submission hookit.fun would refuse (a live plugin, or one that already has a submission in review or approved); it warns when the explorer names another deployer.
|
|
214
215
|
- Nothing here is an audit: Hookit's review may ask for changes (a change is a new deployment and a new submission), and it can halt a plugin at any time.
|
|
215
216
|
- Both signing tools are dry runs unless `dryRun` is `false`: deploy or submit only once the user said to.
|
|
216
217
|
|
package/index.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// AI-agents-only coins too, with a Hookit Agent Pass), redeem them at their Backed Floor or borrow against it, and
|
|
5
5
|
// collect its creator fees.
|
|
6
6
|
// The key in HOOKIT_PRIVATE_KEY signs on this machine and is never sent anywhere.
|
|
7
|
-
import {
|
|
7
|
+
import { closeSync, constants as fsConstants, fstatSync, openSync, readSync } from "node:fs";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
9
|
|
|
9
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
11
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
isAddress,
|
|
27
28
|
keccak256,
|
|
28
29
|
maxUint256,
|
|
30
|
+
MethodNotSupportedRpcError,
|
|
29
31
|
parseAbi,
|
|
30
32
|
parseAbiParameters,
|
|
31
33
|
parseEther,
|
|
@@ -38,9 +40,11 @@ import { privateKeyToAccount } from "viem/accounts";
|
|
|
38
40
|
import { z } from "zod";
|
|
39
41
|
|
|
40
42
|
const API = (process.env.HOOKIT_API_URL || "https://www.hookit.fun").replace(/\/$/, "");
|
|
41
|
-
|
|
43
|
+
/** Ink's public RPC: what the commands this server hands out use, since HOOKIT_RPC_URL may carry an API key. */
|
|
44
|
+
const INK_PUBLIC_RPC = "https://rpc-gel.inkonchain.com";
|
|
45
|
+
const RPC = process.env.HOOKIT_RPC_URL || INK_PUBLIC_RPC;
|
|
42
46
|
const KEY = process.env.HOOKIT_PRIVATE_KEY?.trim();
|
|
43
|
-
const VERSION = "0.7.
|
|
47
|
+
const VERSION = "0.7.1";
|
|
44
48
|
const EXPLORER = "https://explorer.inkonchain.com";
|
|
45
49
|
/**
|
|
46
50
|
* The Ink deployments this server signs against. Pinned here, never taken from hookit.fun's catalog: a compromised
|
|
@@ -84,6 +88,8 @@ const PINNED = {
|
|
|
84
88
|
// off unless code is there and the contracts point at each other (the plugin at this ModuleRegistry, Velocity at
|
|
85
89
|
// Evolve's EvolveExtensions, each companion at Velocity).
|
|
86
90
|
moduleRegistry: "0x91f1622D49a09Ce2904E8f98650393eB3dAA2470", // ModuleRegistry (v2 modules; Velocity's tier ids are found by scanning it, never hard-coded)
|
|
91
|
+
poolManager: "0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32", // Uniswap v4 PoolManager on Ink (holds builder cuts as ERC-6909 claims)
|
|
92
|
+
claimsRedeemer: "0xB88be89e04B6bCB864B2AdA4F3294c69BAaA1A44", // V4ClaimsRedeemer: turns the caller's own ERC-6909 claims into ETH / tokens
|
|
87
93
|
velocityFeePlugin: "0x99772E2199da48e0d1388A3776F96b4257A49E8E", // VelocityFeePlugin (stand-alone, Factory C launches)
|
|
88
94
|
velocityEvolve: "0xA76B2081A521AD03d773B7A4EB38088DAcaDA628", // VelocityEvolve (Evolve extension; catalog id found by scanning EvolveExtensions)
|
|
89
95
|
velocityEvolveFee1: "0xebA39A6E554580f8F4c0aEB9B58Ead323D34eC95", // VelocityEvolveFee rank 1 (asks the 2nd percent of the malus)
|
|
@@ -153,10 +159,36 @@ const ink = defineChain({
|
|
|
153
159
|
blockExplorers: { default: { name: "Blockscout", url: "https://explorer.inkonchain.com" } },
|
|
154
160
|
});
|
|
155
161
|
|
|
156
|
-
|
|
162
|
+
/**
|
|
163
|
+
* HOOKIT_RPC_URL without eth_fillTransaction: viem takes the chain id, nonce and fees an RPC's fill answers over its
|
|
164
|
+
* own, so a wrong or hostile RPC could have the wallet sign for another chain. Refused, viem fills from the pinned Ink
|
|
165
|
+
* chain and the usual one-value calls.
|
|
166
|
+
*/
|
|
167
|
+
const rpc = (config) => {
|
|
168
|
+
const transport = http(RPC)(config);
|
|
169
|
+
return {
|
|
170
|
+
...transport,
|
|
171
|
+
request: (args, options) =>
|
|
172
|
+
args?.method === "eth_fillTransaction"
|
|
173
|
+
? Promise.reject(new MethodNotSupportedRpcError(new Error("not used by hookit-mcp"), { method: "eth_fillTransaction" }))
|
|
174
|
+
: transport.request(args, options),
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
/** The wallet. It signs transactions for Ink only, whatever an RPC answered. */
|
|
178
|
+
const account = (() => {
|
|
179
|
+
if (!KEY) return null;
|
|
180
|
+
const key = privateKeyToAccount(KEY.startsWith("0x") ? KEY : `0x${KEY}`);
|
|
181
|
+
return {
|
|
182
|
+
...key,
|
|
183
|
+
signTransaction: (tx, options) =>
|
|
184
|
+
tx?.chainId === ink.id
|
|
185
|
+
? key.signTransaction(tx, options)
|
|
186
|
+
: Promise.reject(new Error(`Refused to sign a transaction for chain ${tx?.chainId}: this server signs on Ink (${ink.id}) only. Check HOOKIT_RPC_URL.`)),
|
|
187
|
+
};
|
|
188
|
+
})();
|
|
157
189
|
// Ink makes a block a second: poll receipts at that pace rather than viem's 4 s default.
|
|
158
|
-
const publicClient = createPublicClient({ chain: ink, transport:
|
|
159
|
-
const walletClient = account ? createWalletClient({ account, chain: ink, transport:
|
|
190
|
+
const publicClient = createPublicClient({ chain: ink, transport: rpc, pollingInterval: 1_000 });
|
|
191
|
+
const walletClient = account ? createWalletClient({ account, chain: ink, transport: rpc }) : null;
|
|
160
192
|
|
|
161
193
|
const factoryAbi = parseAbi([
|
|
162
194
|
"event TokenLaunched(uint256 indexed launchId, address indexed token, address indexed creator, bytes32 poolId, address hooks, bool customHook, int24 tickLower, int24 tickUpper, uint128 liquidity)",
|
|
@@ -171,6 +203,12 @@ const escrowAbi = parseAbi([
|
|
|
171
203
|
"function balanceOf(address account, address currency) view returns (uint256)",
|
|
172
204
|
"function claimAll(address[] currencies)",
|
|
173
205
|
]);
|
|
206
|
+
/** The PoolManager's ERC-6909 side: a plugin's PAY intents (a Workshop builder's cut) land there as claims. */
|
|
207
|
+
const erc6909Abi = parseAbi([
|
|
208
|
+
"function balanceOf(address owner, uint256 id) view returns (uint256)",
|
|
209
|
+
"function approve(address spender, uint256 id, uint256 amount) returns (bool)",
|
|
210
|
+
]);
|
|
211
|
+
const claimsRedeemerAbi = parseAbi(["function claim(address currency, uint256 amount) returns (uint256)"]);
|
|
174
212
|
const erc20Abi = parseAbi([
|
|
175
213
|
"function symbol() view returns (string)",
|
|
176
214
|
"function decimals() view returns (uint8)",
|
|
@@ -364,6 +402,15 @@ function errorName(err) {
|
|
|
364
402
|
return null;
|
|
365
403
|
}
|
|
366
404
|
|
|
405
|
+
/** hookit.fun's simulation error, with hook C's PluginLaunchFailed(slot) put in words: that plugin's onLaunch reverted. */
|
|
406
|
+
function launchSimulationError(text) {
|
|
407
|
+
const t = String(text ?? "");
|
|
408
|
+
const m = /0x73bafd90([0-9a-f]{64})?/i.exec(t);
|
|
409
|
+
if (!m) return t;
|
|
410
|
+
const slot = m[1] ? ` of plugin slot ${Number.parseInt(m[1].slice(-4), 16)}` : "";
|
|
411
|
+
return `${t} (PluginLaunchFailed: the onLaunch${slot} reverted, for a builder hook usually on its initData: check the launch settings its description documents)`;
|
|
412
|
+
}
|
|
413
|
+
|
|
367
414
|
/** The `reason` of a PluginBlocked(slot, reason) found in the revert text, else null. */
|
|
368
415
|
function pluginBlockedReason(text) {
|
|
369
416
|
const m = new RegExp(`${PLUGIN_BLOCKED}([0-9a-f]{64})([0-9a-f]{64})`).exec(text);
|
|
@@ -405,7 +452,11 @@ async function nextBlockRetry(fn) {
|
|
|
405
452
|
|
|
406
453
|
function shortError(err) {
|
|
407
454
|
const name = errorName(err);
|
|
408
|
-
|
|
455
|
+
// viem's messages quote the RPC's URL, and HOOKIT_RPC_URL may carry an API key.
|
|
456
|
+
const text = String(err?.shortMessage || err?.message || err)
|
|
457
|
+
.split(RPC)
|
|
458
|
+
.join("HOOKIT_RPC_URL")
|
|
459
|
+
.slice(0, 300);
|
|
409
460
|
return name && !text.includes(name) ? `${name}: ${text}` : text;
|
|
410
461
|
}
|
|
411
462
|
|
|
@@ -1071,6 +1122,41 @@ const RELAY_INK = new Set(
|
|
|
1071
1122
|
const RELAY_APPROVAL_PROXY = getAddress("0xccc88a9d1b4ed6b0eaba998850414b24f1c315be");
|
|
1072
1123
|
/** Slippage allowed on a Relay leg (ETH, USDG, stocks: liquid pairs), whatever the coin's own slippage is. */
|
|
1073
1124
|
const RELAY_MAX_SLIPPAGE_BPS = 300;
|
|
1125
|
+
/** How far under the market price (on top of the slippage) a Relay quote may be: Relay's fees and price impact. */
|
|
1126
|
+
const RELAY_PRICE_MARGIN_BPS = 500;
|
|
1127
|
+
/** Chainlink's ETH/USD feed on Ink, as Factory C names it. */
|
|
1128
|
+
const ethUsdFeedAbi = parseAbi([
|
|
1129
|
+
"function ethUsdFeed() view returns (address)",
|
|
1130
|
+
"function decimals() view returns (uint8)",
|
|
1131
|
+
"function latestRoundData() view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)",
|
|
1132
|
+
]);
|
|
1133
|
+
let chainEthUsdCache = null;
|
|
1134
|
+
/** ETH in US dollars from the Chainlink feed the pinned Factory C reads, cached for 60 s; null when stale or unread. */
|
|
1135
|
+
async function chainEthUsd() {
|
|
1136
|
+
if (chainEthUsdCache && Date.now() - chainEthUsdCache.at < 60_000) return chainEthUsdCache.price;
|
|
1137
|
+
const feed = await read(PINNED.factory, ethUsdFeedAbi, "ethUsdFeed");
|
|
1138
|
+
const [[, answer, , updatedAt], decimals] = await Promise.all([read(feed, ethUsdFeedAbi, "latestRoundData"), read(feed, ethUsdFeedAbi, "decimals")]);
|
|
1139
|
+
const fresh = Number(updatedAt) > Date.now() / 1000 - 25 * 3600;
|
|
1140
|
+
const price = answer > 0n && fresh ? Number(answer) / 10 ** Number(decimals) : null;
|
|
1141
|
+
chainEthUsdCache = { at: Date.now(), price };
|
|
1142
|
+
return price;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** One unit of `asset` in US dollars from Ink itself, hookit.fun not asked: USDG 1, ETH from Chainlink, a stock from its USDG pool; else null. */
|
|
1146
|
+
async function chainUsdPrice(asset) {
|
|
1147
|
+
if (same(asset, PINNED.usdg)) return 1;
|
|
1148
|
+
if (asset === zeroAddress) return chainEthUsd();
|
|
1149
|
+
return usdgPoolPrice(null, asset);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/** What `amount` of `inMeta` is worth in `outMeta` at Ink's own prices, else hookit.fun's (kBTC has no USDG pool); null if unknown. */
|
|
1153
|
+
async function relayFairOut(inMeta, outMeta, amount) {
|
|
1154
|
+
const price = async (asset) => (await chainUsdPrice(asset).catch(() => null)) ?? (await usdPrice(null, asset).catch(() => null));
|
|
1155
|
+
const [pf, pt] = await Promise.all([price(inMeta.address), price(outMeta.address)]);
|
|
1156
|
+
if (!(pf > 0) || !(pt > 0)) return null;
|
|
1157
|
+
const v = (Number(formatUnits(amount, inMeta.decimals)) * pf) / pt;
|
|
1158
|
+
return Number.isFinite(v) && v > 0 ? parseUnits(v.toFixed(outMeta.decimals), outMeta.decimals) : null;
|
|
1159
|
+
}
|
|
1074
1160
|
|
|
1075
1161
|
/**
|
|
1076
1162
|
* A Relay quote to swap `amount` of `from` into `to` on Ink for this wallet, through hookit.fun's /api/relay/quote
|
|
@@ -1109,11 +1195,24 @@ async function relayQuote(from, to, amount, slippageBps) {
|
|
|
1109
1195
|
if (out <= 0n) return { error: `Relay would return no ${outMeta.symbol}.` };
|
|
1110
1196
|
const checked = relayPlan(body.calls, from, amount, inMeta.symbol);
|
|
1111
1197
|
if (checked.error) return { error: checked.error };
|
|
1198
|
+
// The minimum the swap is simulated and checked against is never the quote's word alone: it is at least the market
|
|
1199
|
+
// price less the slippage and a margin, so a forged quote (a minimum of 0, calls that pay someone else) is refused.
|
|
1200
|
+
const fair = await relayFairOut(inMeta, outMeta, amount);
|
|
1201
|
+
if (fair === null) {
|
|
1202
|
+
return { error: `Could not price ${inMeta.symbol} against ${outMeta.symbol}, so Relay's quote cannot be checked against the market. Nothing was sent.` };
|
|
1203
|
+
}
|
|
1204
|
+
const floor = (fair * BigInt(10_000 - slippageBps - RELAY_PRICE_MARGIN_BPS)) / 10_000n;
|
|
1205
|
+
if (out < floor) {
|
|
1206
|
+
return {
|
|
1207
|
+
error: `Relay's quote returns ${amountText(out, outMeta)} for ${amountText(amount, inMeta)}, more than ${(slippageBps + RELAY_PRICE_MARGIN_BPS) / 100}% under the market price (about ${amountText(fair, outMeta)}). Nothing was sent.`,
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
const quotedMin = BigInt(body.minimumAmount);
|
|
1112
1211
|
return {
|
|
1113
1212
|
quote: {
|
|
1114
1213
|
requestId: body.requestId,
|
|
1115
1214
|
out,
|
|
1116
|
-
min:
|
|
1215
|
+
min: quotedMin > floor ? quotedMin : floor,
|
|
1117
1216
|
impact: body.impactPercent ?? null,
|
|
1118
1217
|
calls: body.calls,
|
|
1119
1218
|
inMeta,
|
|
@@ -1209,7 +1308,7 @@ async function relaySimulate(from, to, amount, min, plan) {
|
|
|
1209
1308
|
const spent = -(net.get(getAddress(from)) ?? 0n);
|
|
1210
1309
|
if (spent > amount) return `Relay's transactions would take more than ${amountText(amount, await assetMeta(from))}`;
|
|
1211
1310
|
const got = net.get(getAddress(to)) ?? 0n;
|
|
1212
|
-
if (got < min) return `Relay's transactions would return ${amountText(got, await assetMeta(to))}, below
|
|
1311
|
+
if (got < min) return `Relay's transactions would return ${amountText(got, await assetMeta(to))}, below the minimum of ${amountText(min, await assetMeta(to))}`;
|
|
1213
1312
|
return null;
|
|
1214
1313
|
}
|
|
1215
1314
|
|
|
@@ -1645,8 +1744,9 @@ server.registerTool(
|
|
|
1645
1744
|
if (live.error) return fail(live.error);
|
|
1646
1745
|
return reply({
|
|
1647
1746
|
note: BUILDER_TEXT_NOTE,
|
|
1747
|
+
cut: "builderCutMaxPct is the most the builder's wallet may get of what a hook spends (Hookit's review cap, at most 20%), and spends shows that cap: what a hook really pays its builder is in its source.",
|
|
1648
1748
|
hooks: live.hooks,
|
|
1649
|
-
howToLaunch: 'launch_token with builderHooks: [{ "hook": <moduleId>, "pct": <whole % of the hook pot, up to maxPct> }]. dryRun first.',
|
|
1749
|
+
howToLaunch: 'launch_token with builderHooks: [{ "hook": <moduleId>, "pct": <whole % of the hook pot, up to maxPct>, "initData": <0x launch settings, optional> }]. dryRun first.',
|
|
1650
1750
|
});
|
|
1651
1751
|
},
|
|
1652
1752
|
);
|
|
@@ -1656,29 +1756,39 @@ server.registerTool(
|
|
|
1656
1756
|
{
|
|
1657
1757
|
annotations: SIGNS,
|
|
1658
1758
|
description:
|
|
1659
|
-
"Deploys a hook you wrote with hookit's Plugin SDK from this wallet on Ink, with the live ModuleRegistry as its constructor argument, as the SDK requires. Give it the Foundry artifact (out/<File>.sol/<Contract>.json after forge build, with the SDK's tests green). Checks it first: one address constructor
|
|
1759
|
+
"Deploys a hook you wrote with hookit's Plugin SDK from this wallet on Ink, with the live ModuleRegistry as its constructor argument, as the SDK requires. Give it the absolute path of the Foundry artifact (<SDK folder>/out/<File>.sol/<Contract>.json after forge build, with the SDK's tests green): this server does not run in the SDK folder. Checks it first: built on HookPluginBase (hookitPluginApi, one address constructor), code within Ink's limits, the gas and what it costs. dryRun is the default and deploys nothing: deploy only once the user said to. The answer gives the source-verify and manifest commands to run next, then submit_hook.",
|
|
1660
1760
|
inputSchema: {
|
|
1661
|
-
artifact: z.string().describe("
|
|
1761
|
+
artifact: z.string().describe("Absolute path to the plugin's Foundry artifact, e.g. /home/you/my-hook/out/MyPlugin.sol/MyPlugin.json."),
|
|
1662
1762
|
dryRun: z.boolean().optional().describe("Default true: check and price the deployment only. false deploys it from this wallet."),
|
|
1663
1763
|
},
|
|
1664
1764
|
},
|
|
1665
1765
|
serialized(async ({ artifact, dryRun }) => {
|
|
1666
1766
|
if (!account) return needKey();
|
|
1767
|
+
const file = readLocalFile(artifact, MAX_ARTIFACT_BYTES);
|
|
1768
|
+
if (file.error) return fail(`Could not read the artifact: ${file.error}. Run forge build in the SDK folder and give the absolute path of out/<File>.sol/<Contract>.json.`);
|
|
1667
1769
|
let raw;
|
|
1668
1770
|
try {
|
|
1669
|
-
raw = JSON.parse(
|
|
1670
|
-
} catch
|
|
1671
|
-
|
|
1771
|
+
raw = JSON.parse(file.text);
|
|
1772
|
+
} catch {
|
|
1773
|
+
// Never quote the parser's message: it carries the file's first characters, whatever file the path named.
|
|
1774
|
+
return fail(`${plainText(file.path).slice(0, 300)} is not JSON. Give the Foundry artifact: out/<File>.sol/<Contract>.json in the SDK folder.`);
|
|
1672
1775
|
}
|
|
1673
1776
|
const a = pluginArtifact(raw);
|
|
1674
1777
|
if (a.error) return fail(a.error);
|
|
1675
1778
|
const args = encodeAbiParameters([{ type: "address" }], [PINNED.moduleRegistry]);
|
|
1676
1779
|
const data = `${a.bytecode}${args.slice(2)}`;
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1780
|
+
let chainId, nonce, balance, fees;
|
|
1781
|
+
try {
|
|
1782
|
+
[chainId, nonce, balance, fees] = await Promise.all([
|
|
1783
|
+
publicClient.getChainId(),
|
|
1784
|
+
publicClient.getTransactionCount({ address: account.address, blockTag: "pending" }),
|
|
1785
|
+
publicClient.getBalance({ address: account.address }),
|
|
1786
|
+
publicClient.estimateFeesPerGas(),
|
|
1787
|
+
]);
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
return fail(`Ink's RPC did not answer: ${shortError(err)}. Nothing was sent; retry later.`);
|
|
1790
|
+
}
|
|
1791
|
+
if (chainId !== ink.id) return fail(`HOOKIT_RPC_URL answers for chain ${chainId}, not Ink (${ink.id}): nothing was signed. Point it at an Ink RPC.`);
|
|
1682
1792
|
const unfunded = `Not enough ETH on Ink to deploy ${a.name}: send some to ${account.address} for the gas, then call deploy_hook again.`;
|
|
1683
1793
|
if (balance === 0n) return fail(unfunded);
|
|
1684
1794
|
let gas;
|
|
@@ -1690,7 +1800,8 @@ server.registerTool(
|
|
|
1690
1800
|
return fail(`Deploying ${a.name} would fail: ${shortError(err)}`);
|
|
1691
1801
|
}
|
|
1692
1802
|
gas += gas / 5n;
|
|
1693
|
-
|
|
1803
|
+
// What the transaction may cost at the fee it is sent with, plus room for Ink's L1 data fee.
|
|
1804
|
+
const cost = gas * fees.maxFeePerGas;
|
|
1694
1805
|
const view = {
|
|
1695
1806
|
contract: a.name,
|
|
1696
1807
|
source: a.target,
|
|
@@ -1701,32 +1812,47 @@ server.registerTool(
|
|
|
1701
1812
|
gas: String(gas),
|
|
1702
1813
|
costEth: formatEther(cost),
|
|
1703
1814
|
};
|
|
1704
|
-
if (balance < cost + parseEther("0.
|
|
1705
|
-
return fail(`Not enough ETH on Ink to deploy:
|
|
1815
|
+
if (balance < cost + parseEther("0.00005")) {
|
|
1816
|
+
return fail(`Not enough ETH on Ink to deploy: up to ${formatEther(cost)} ETH of gas. Send some to ${account.address}.`);
|
|
1706
1817
|
}
|
|
1707
1818
|
if (dryRun !== false) {
|
|
1708
1819
|
return reply({ dryRun: true, ...view, verdict: `Ready to deploy ${a.name}. Ask the user, then call deploy_hook again with dryRun false.` });
|
|
1709
1820
|
}
|
|
1710
|
-
|
|
1711
|
-
|
|
1821
|
+
let hash;
|
|
1822
|
+
try {
|
|
1823
|
+
hash = await walletClient.sendTransaction({ data, gas, nonce, maxFeePerGas: fees.maxFeePerGas, maxPriorityFeePerGas: fees.maxPriorityFeePerGas });
|
|
1824
|
+
} catch (err) {
|
|
1825
|
+
const details = err?.details ? ` (${plainText(err.details).split(RPC).join("HOOKIT_RPC_URL").slice(0, 200)})` : "";
|
|
1826
|
+
return fail(`The deployment was not sent: ${shortError(err)}${details}`);
|
|
1827
|
+
}
|
|
1828
|
+
const receipt = await publicClient.waitForTransactionReceipt({ hash }).catch(() => null);
|
|
1829
|
+
if (!receipt) return fail(`Sent, but no receipt yet: check ${EXPLORER}/tx/${hash} before deploying again.`);
|
|
1712
1830
|
if (receipt.status !== "success" || !receipt.contractAddress) return fail(`The deployment reverted: ${EXPLORER}/tx/${hash}`);
|
|
1713
1831
|
const plugin = getAddress(receipt.contractAddress);
|
|
1714
|
-
const api = await
|
|
1715
|
-
|
|
1832
|
+
const [api, code] = await Promise.all([
|
|
1833
|
+
read(plugin, pluginApiAbi, "hookitPluginApi").catch(() => null),
|
|
1834
|
+
publicClient.getCode({ address: plugin }).catch(() => null),
|
|
1835
|
+
]);
|
|
1836
|
+
const name = manifestName(file.path, a.name);
|
|
1837
|
+
const sdk = resolve(dirname(file.path), "..", "..");
|
|
1838
|
+
const manifestFile = name ? join(sdk, "manifests", `${name}.json`) : null;
|
|
1716
1839
|
return reply({
|
|
1717
1840
|
deployed: true,
|
|
1718
1841
|
...view,
|
|
1719
1842
|
address: plugin,
|
|
1843
|
+
...(code && code !== "0x" ? { runtimeBytes: (code.length - 2) / 2 } : {}),
|
|
1720
1844
|
tx: `${EXPLORER}/tx/${hash}`,
|
|
1721
1845
|
explorer: `${EXPLORER}/address/${plugin}`,
|
|
1722
1846
|
pluginApi: api === null ? null : Number(api),
|
|
1723
|
-
...(api
|
|
1724
|
-
? { warning: `hookitPluginApi() answers ${api}, not ${PLUGIN_API}: Hookit cannot register it. Build on HookPluginBase.` }
|
|
1847
|
+
...(api === null || Number(api) !== PLUGIN_API
|
|
1848
|
+
? { warning: `hookitPluginApi() ${api === null ? "does not answer" : `answers ${api}, not ${PLUGIN_API}`}: Hookit cannot register it. Build on HookPluginBase.` }
|
|
1725
1849
|
: {}),
|
|
1726
1850
|
next: [
|
|
1727
|
-
`
|
|
1728
|
-
|
|
1729
|
-
|
|
1851
|
+
`In the SDK folder (${plainText(sdk).slice(0, 200)}), verify the source (no key needed): forge verify-contract ${plugin} ${a.target} --chain 57073 --constructor-args ${args} --verifier blockscout --verifier-url ${EXPLORER}/api/`,
|
|
1852
|
+
name
|
|
1853
|
+
? `Then print the manifest (it never broadcasts): forge script script/Manifest.s.sol --sig "run(string,address)" ${name} ${plugin} --rpc-url ${INK_PUBLIC_RPC}`
|
|
1854
|
+
: `Then add ${a.name} to script/Manifest.s.sol (_deployLocal and _declaration) under a name of yours, and print the manifest (it never broadcasts): forge script script/Manifest.s.sol --sig "run(string,address)" <that name> ${plugin} --rpc-url ${INK_PUBLIC_RPC}`,
|
|
1855
|
+
`Push the code to a public repo and note the commit, then submit_hook with the manifest file (${manifestFile ? plainText(manifestFile).slice(0, 300) : "the absolute path of manifests/<that name>.json"}), that repo link, a name, a tagline and a description.`,
|
|
1730
1856
|
],
|
|
1731
1857
|
});
|
|
1732
1858
|
}),
|
|
@@ -1754,29 +1880,45 @@ server.registerTool(
|
|
|
1754
1880
|
},
|
|
1755
1881
|
serialized(async ({ manifest, repo, name, tagline, description, tags, logo, dryRun }) => {
|
|
1756
1882
|
if (!account) return needKey();
|
|
1757
|
-
const m = workshopManifest(manifest, (path) =>
|
|
1883
|
+
const m = workshopManifest(manifest, (path) => {
|
|
1884
|
+
const file = readLocalFile(path, MAX_MANIFEST_BYTES);
|
|
1885
|
+
if (file.error) throw new Error(file.error);
|
|
1886
|
+
return file.text;
|
|
1887
|
+
});
|
|
1758
1888
|
if (m.error) return fail(m.error);
|
|
1759
1889
|
const parsed = workshopFields({ plugin: m.plugin, name, tagline, description, repo, manifest: m.text, logo, tags });
|
|
1760
1890
|
if (parsed.error) return fail(`Not submitted: ${parsed.error}.`);
|
|
1761
1891
|
const f = parsed.fields;
|
|
1762
|
-
|
|
1892
|
+
let code;
|
|
1893
|
+
try {
|
|
1894
|
+
code = await publicClient.getCode({ address: f.plugin });
|
|
1895
|
+
} catch (err) {
|
|
1896
|
+
return fail(`Ink's RPC did not answer: ${shortError(err)}. Nothing was sent; retry later.`);
|
|
1897
|
+
}
|
|
1763
1898
|
if (!code || code === "0x") return fail(`No contract at ${f.plugin} on Ink: deploy it first (deploy_hook), then print its manifest.`);
|
|
1899
|
+
if (m.codehash && m.codehash !== keccak256(code)) {
|
|
1900
|
+
return fail(`The manifest's codehash is not the code at ${f.plugin}: it was edited or printed for another build. Print it again with the SDK's Manifest script for this address.`);
|
|
1901
|
+
}
|
|
1764
1902
|
const api = await read(f.plugin, pluginApiAbi, "hookitPluginApi").catch(() => null);
|
|
1765
1903
|
if (api === null || Number(api) !== PLUGIN_API) return fail(`${f.plugin} is not a plugin of API ${PLUGIN_API} (hookitPluginApi()): build it on the SDK's HookPluginBase.`);
|
|
1766
|
-
const [existing,
|
|
1904
|
+
const [existing, explorer] = await Promise.all([
|
|
1767
1905
|
fetch(`${API}/api/workshop/submissions?plugin=${f.plugin}`, { signal: AbortSignal.timeout(15_000) })
|
|
1768
1906
|
.then((r) => (r.ok ? r.json() : null))
|
|
1769
1907
|
.then((b) => b?.submission ?? null)
|
|
1770
1908
|
.catch(() => null),
|
|
1771
1909
|
fetch(`${EXPLORER}/api/v2/addresses/${f.plugin}`, { signal: AbortSignal.timeout(10_000) })
|
|
1772
1910
|
.then((r) => (r.ok ? r.json() : null))
|
|
1773
|
-
.then((b) => (isAddress(b?.creator_address_hash ?? "") ? getAddress(b.creator_address_hash) : null))
|
|
1774
1911
|
.catch(() => null),
|
|
1775
1912
|
]);
|
|
1913
|
+
const deployer = isAddress(explorer?.creator_address_hash ?? "") ? getAddress(explorer.creator_address_hash) : null;
|
|
1776
1914
|
const warnings = [];
|
|
1777
1915
|
if (deployer && !same(deployer, account.address)) {
|
|
1778
1916
|
warnings.push(`This wallet did not deploy ${f.plugin} (${deployer} did): Hookit's review expects the deployer's signature, and the deployer can take the submission over.`);
|
|
1779
1917
|
}
|
|
1918
|
+
if (explorer?.is_verified === false) {
|
|
1919
|
+
warnings.push(`The explorer has no verified source for ${f.plugin}: run the forge verify-contract command deploy_hook gave before submitting, Hookit's review reads that source.`);
|
|
1920
|
+
}
|
|
1921
|
+
const refusal = workshopConflict(existing, account.address, deployer);
|
|
1780
1922
|
const view = {
|
|
1781
1923
|
plugin: f.plugin,
|
|
1782
1924
|
submitter: account.address,
|
|
@@ -1784,6 +1926,7 @@ server.registerTool(
|
|
|
1784
1926
|
...(existing ? { existing: workshopView(existing) } : {}),
|
|
1785
1927
|
...(warnings.length ? { warnings } : {}),
|
|
1786
1928
|
};
|
|
1929
|
+
if (refusal) return fail(`hookit.fun would refuse it: ${refusal}. Nothing was signed.`);
|
|
1787
1930
|
if (dryRun !== false) {
|
|
1788
1931
|
return reply({ dryRun: true, ...view, verdict: "Ready to submit. Show the user the card, then call submit_hook again with dryRun false." });
|
|
1789
1932
|
}
|
|
@@ -1872,9 +2015,10 @@ server.registerTool(
|
|
|
1872
2015
|
.sort((a, b) => (b.valueUsd ?? 0) - (a.valueUsd ?? 0))
|
|
1873
2016
|
.slice(0, 25)
|
|
1874
2017
|
.map((p) => ({
|
|
1875
|
-
token: p.token,
|
|
1876
|
-
|
|
1877
|
-
|
|
2018
|
+
token: isAddress(p.token ?? "") ? getAddress(p.token) : "?",
|
|
2019
|
+
// A coin's ticker is its launcher's text (anyone can launch and send this wallet dust of it).
|
|
2020
|
+
symbol: launcherLabel(p.symbol, 16, "(ticker withheld)"),
|
|
2021
|
+
balance: Number.isFinite(p.balance) ? p.balance : 0,
|
|
1878
2022
|
valueUsd: usd(p.valueUsd),
|
|
1879
2023
|
...(p.unrealizedPct !== null && p.unrealizedPct !== undefined ? { unrealizedPct: usd(p.unrealizedPct) } : {}),
|
|
1880
2024
|
}));
|
|
@@ -1884,6 +2028,7 @@ server.registerTool(
|
|
|
1884
2028
|
balanceEth: formatEther(balance),
|
|
1885
2029
|
...(usdgBalance !== null ? { balanceUsdg: short(usdgBalance, usdg.decimals) } : {}),
|
|
1886
2030
|
holdings,
|
|
2031
|
+
...(Array.isArray(holdings) && holdings.length ? { launcherText: LAUNCHER_TEXT_NOTE } : {}),
|
|
1887
2032
|
...(loans.length ? { loans: await Promise.all(loans.map((l) => loanView(c, l))) } : {}),
|
|
1888
2033
|
launchFeeEth: fee === null ? "unknown" : formatEther(fee),
|
|
1889
2034
|
roughCostEth: formatEther(needed),
|
|
@@ -1908,8 +2053,8 @@ server.registerTool(
|
|
|
1908
2053
|
{
|
|
1909
2054
|
annotations: SIGNS,
|
|
1910
2055
|
description:
|
|
1911
|
-
"Launches a coin on hookit.fun with the chosen preset or hook modules, in one call. With builderHooks, it adds Workshop hooks from list_builder_hooks; the server checks each slot of the transaction against the hook's registry entry before signing. With evolve, this wallet becomes the coin's Evolve decider and moves its fee and split after launch (evolve_status, evolve_decide, evolve_plug). With velocity, sells pay a malus when the price is below its moving average (and dip buys get hook tax back when buys pay one); the server checks the malus tier, its settings and the 10% fee rule in the transaction itself before signing. With dryRun it only checks the launch and returns the unsigned transaction: use it first and show the user the settings.",
|
|
1912
|
-
inputSchema: { ...launchShape, dryRun: z.boolean().optional().describe("Check and simulate only. Signs nothing.") },
|
|
2056
|
+
"Launches a coin on hookit.fun with the chosen preset or hook modules, in one call. With builderHooks, it adds Workshop hooks from list_builder_hooks; the server checks each slot of the transaction against the hook's registry entry before signing. With evolve, this wallet becomes the coin's Evolve decider and moves its fee and split after launch (evolve_status, evolve_decide, evolve_plug). With velocity, sells pay a malus when the price is below its moving average (and dip buys get hook tax back when buys pay one); the server checks the malus tier, its settings and the 10% fee rule in the transaction itself before signing. With dryRun it only checks the launch and returns the unsigned transaction: use it first and show the user the settings. Without dryRun it signs and sends at once.",
|
|
2057
|
+
inputSchema: { ...launchShape, dryRun: z.boolean().optional().describe("Check and simulate only. Signs nothing. Left out, the launch is signed and sent.") },
|
|
1913
2058
|
},
|
|
1914
2059
|
serialized(async ({ dryRun, ...args }) => {
|
|
1915
2060
|
if (!dryRun && !account) return needKey();
|
|
@@ -1968,7 +2113,7 @@ server.registerTool(
|
|
|
1968
2113
|
verdict: prepared.simulation?.needsApproval
|
|
1969
2114
|
? "Valid. A real launch will first approve the dev buy token, then launch."
|
|
1970
2115
|
: prepared.simulation?.ok === false
|
|
1971
|
-
? `The launch would fail: ${prepared.simulation.error}`
|
|
2116
|
+
? `The launch would fail: ${launchSimulationError(prepared.simulation.error)}`
|
|
1972
2117
|
: "The launch is valid.",
|
|
1973
2118
|
plan: prepared.plan,
|
|
1974
2119
|
...(args.evolve ? { evolve: evolveLaunchView(prepared) } : {}),
|
|
@@ -1980,7 +2125,7 @@ server.registerTool(
|
|
|
1980
2125
|
});
|
|
1981
2126
|
}
|
|
1982
2127
|
if (prepared.simulation && !prepared.simulation.ok) {
|
|
1983
|
-
return fail(`The launch would fail: ${prepared.simulation.error}\nSettings: ${prepared.plan.settings.join("; ")}`);
|
|
2128
|
+
return fail(`The launch would fail: ${launchSimulationError(prepared.simulation.error)}\nSettings: ${prepared.plan.settings.join("; ")}`);
|
|
1984
2129
|
}
|
|
1985
2130
|
|
|
1986
2131
|
const hash = await walletClient.sendTransaction({
|
|
@@ -2032,39 +2177,65 @@ server.registerTool(
|
|
|
2032
2177
|
"claim_fees",
|
|
2033
2178
|
{
|
|
2034
2179
|
annotations: SIGNS,
|
|
2035
|
-
description:
|
|
2180
|
+
description:
|
|
2181
|
+
"Collects what this wallet earned: the creator fees of coins it launched itself (paid in ETH and the pair tokens) and the builder cut a Workshop hook it built paid it (ERC-6909 claims on Uniswap's PoolManager, redeemed through Hookit's V4ClaimsRedeemer for exactly what is owed). With dryRun it only shows what is owed.",
|
|
2036
2182
|
inputSchema: {
|
|
2037
2183
|
currencies: z.array(z.string()).optional().describe("Token addresses to collect. Default: ETH plus every pair token."),
|
|
2184
|
+
dryRun: z.boolean().optional().describe("Show what is owed only. Signs nothing."),
|
|
2038
2185
|
},
|
|
2039
2186
|
},
|
|
2040
|
-
serialized(async ({ currencies }) => {
|
|
2187
|
+
serialized(async ({ currencies, dryRun }) => {
|
|
2041
2188
|
if (!account) return needKey();
|
|
2189
|
+
const bad = (currencies ?? []).find((x) => !isAddress(x));
|
|
2190
|
+
if (bad !== undefined) return fail(`Not a token address: ${plainText(bad).slice(0, 64)}`);
|
|
2042
2191
|
const c = await catalog();
|
|
2043
2192
|
if (!c.factory) return fail("Launches are not configured on this hookit.");
|
|
2044
2193
|
const hook = await publicClient.readContract({ address: c.factory, abi: factoryAbi, functionName: "masterHook" });
|
|
2045
2194
|
const escrow = await publicClient.readContract({ address: hook, abi: hookAbi, functionName: "feeEscrow" });
|
|
2046
|
-
const candidates = currencies?.length ? currencies : [zeroAddress, ...(c.pairAddresses ?? [])];
|
|
2195
|
+
const candidates = [...new Set((currencies?.length ? currencies : [zeroAddress, ...(c.pairAddresses ?? [])]).map((x) => getAddress(x)))];
|
|
2047
2196
|
const owed = [];
|
|
2197
|
+
const cuts = [];
|
|
2048
2198
|
for (const currency of candidates) {
|
|
2049
|
-
const
|
|
2050
|
-
|
|
2199
|
+
const [fees, cut] = await Promise.all([
|
|
2200
|
+
publicClient.readContract({ address: escrow, abi: escrowAbi, functionName: "balanceOf", args: [account.address, currency] }),
|
|
2201
|
+
// A currency's ERC-6909 id is its address as a number.
|
|
2202
|
+
publicClient.readContract({ address: PINNED.poolManager, abi: erc6909Abi, functionName: "balanceOf", args: [account.address, BigInt(currency)] }),
|
|
2203
|
+
]);
|
|
2204
|
+
if (fees > 0n) owed.push({ currency, amount: fees });
|
|
2205
|
+
if (cut > 0n) cuts.push({ currency, amount: cut });
|
|
2051
2206
|
}
|
|
2052
|
-
if (owed.length === 0) return reply("Nothing to collect yet.");
|
|
2053
|
-
const
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2207
|
+
if (owed.length === 0 && cuts.length === 0) return reply("Nothing to collect yet.");
|
|
2208
|
+
const owedText = async ({ currency, amount }) => amountText(amount, await assetMeta(currency));
|
|
2209
|
+
const view = {
|
|
2210
|
+
...(owed.length ? { creatorFees: await Promise.all(owed.map(owedText)) } : {}),
|
|
2211
|
+
...(cuts.length ? { builderCut: await Promise.all(cuts.map(owedText)) } : {}),
|
|
2212
|
+
};
|
|
2213
|
+
if (dryRun) return reply({ dryRun: true, ...view, verdict: "Call claim_fees again without dryRun to collect it." });
|
|
2214
|
+
const txs = [];
|
|
2215
|
+
try {
|
|
2216
|
+
if (owed.length) {
|
|
2217
|
+
const hash = await walletClient.writeContract({ address: escrow, abi: escrowAbi, functionName: "claimAll", args: [owed.map((o) => o.currency)] });
|
|
2218
|
+
const receipt = await publicClient.waitForTransactionReceipt({ hash });
|
|
2219
|
+
if (receipt.status !== "success") return fail(`Collecting the creator fees reverted: ${EXPLORER}/tx/${hash}`);
|
|
2220
|
+
txs.push(`${EXPLORER}/tx/${hash}`);
|
|
2060
2221
|
}
|
|
2061
|
-
const
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2222
|
+
for (const { currency, amount } of cuts) {
|
|
2223
|
+
// The redeemer may move exactly what is owed in this currency, and none of the wallet's other claims.
|
|
2224
|
+
const approve = await walletClient.writeContract({ address: PINNED.poolManager, abi: erc6909Abi, functionName: "approve", args: [PINNED.claimsRedeemer, BigInt(currency), amount] });
|
|
2225
|
+
if ((await publicClient.waitForTransactionReceipt({ hash: approve })).status !== "success") {
|
|
2226
|
+
return fail(`Approving the redeemer reverted: ${EXPLORER}/tx/${approve}${txs.length ? `. Already sent: ${txs.join(", ")}` : ""}`);
|
|
2227
|
+
}
|
|
2228
|
+
const hash = await walletClient.writeContract({ address: PINNED.claimsRedeemer, abi: claimsRedeemerAbi, functionName: "claim", args: [currency, amount] });
|
|
2229
|
+
const receipt = await publicClient.waitForTransactionReceipt({ hash });
|
|
2230
|
+
if (receipt.status !== "success") {
|
|
2231
|
+
return fail(`Redeeming the builder cut reverted: ${EXPLORER}/tx/${hash}${txs.length ? `. Already sent: ${txs.join(", ")}` : ""}`);
|
|
2232
|
+
}
|
|
2233
|
+
txs.push(`${EXPLORER}/tx/${hash}`);
|
|
2234
|
+
}
|
|
2235
|
+
} catch (err) {
|
|
2236
|
+
return fail(`Collection stopped: ${shortError(err)}${txs.length ? `. Already sent: ${txs.join(", ")}` : ""}`);
|
|
2066
2237
|
}
|
|
2067
|
-
return reply({ collected:
|
|
2238
|
+
return reply({ collected: view, txs });
|
|
2068
2239
|
}),
|
|
2069
2240
|
);
|
|
2070
2241
|
|
|
@@ -2926,6 +3097,23 @@ function launcherDescription(raw) {
|
|
|
2926
3097
|
return { untrusted: true, text: [...t].slice(0, 500).join("") };
|
|
2927
3098
|
}
|
|
2928
3099
|
|
|
3100
|
+
/**
|
|
3101
|
+
* A Workshop hook's description for an agent, under launcherDescription's rules, except that Solidity's abi.encode(...)
|
|
3102
|
+
* (and bytes. / string. calls) is not read as a link: builders are asked to document their launch settings that way.
|
|
3103
|
+
*/
|
|
3104
|
+
function builderDescription(raw) {
|
|
3105
|
+
// Those calls' dots are masked with a private-use character (any the builder wrote is dropped first) while the
|
|
3106
|
+
// link rules run, then put back.
|
|
3107
|
+
const masked = String(raw ?? "")
|
|
3108
|
+
.replace(/\uE000/g, "")
|
|
3109
|
+
.replace(/\b(?:abi|bytes|string)\.[A-Za-z_]\w*(?=\s*\()/g, (call) => call.replace(/\./g, "\uE000"));
|
|
3110
|
+
const t = evolveReason(masked);
|
|
3111
|
+
if (t === null || INSTRUCTION_BAIT.test(t)) {
|
|
3112
|
+
return { withheld: "The builder's description contains a link, a handle, an address or a tool name, so it is not shown. Read it on the hook's Workshop page." };
|
|
3113
|
+
}
|
|
3114
|
+
return { untrusted: true, text: [...t.replace(/\uE000/g, ".")].slice(0, 500).join("") };
|
|
3115
|
+
}
|
|
3116
|
+
|
|
2929
3117
|
/** One coin of /api/launches for an agent. */
|
|
2930
3118
|
function coinRow(p, now) {
|
|
2931
3119
|
const priceUsd = p.priceEth > 0 && p.quoteUsd > 0 ? p.priceEth * p.quoteUsd : null;
|
|
@@ -6245,7 +6433,7 @@ function builderHookView(h, entry) {
|
|
|
6245
6433
|
moduleId: entry.id,
|
|
6246
6434
|
name,
|
|
6247
6435
|
tagline: launcherLabel(h.tagline, 140, ""),
|
|
6248
|
-
description:
|
|
6436
|
+
description: builderDescription(h.description),
|
|
6249
6437
|
categories: Array.isArray(h.categories) ? h.categories.filter((c) => ["protection", "fees", "tokenomics"].includes(c)) : [],
|
|
6250
6438
|
plugin: entry.plugin,
|
|
6251
6439
|
builder: isAddress(h.builder ?? "") ? getAddress(h.builder) : null,
|
|
@@ -6256,7 +6444,8 @@ function builderHookView(h, entry) {
|
|
|
6256
6444
|
spends: Array.isArray(h.spends)
|
|
6257
6445
|
? h.spends.slice(0, 8).map((x) => ({ to: launcherLabel(x?.to, 24, "?"), pct: Number(x?.pct) || 0 }))
|
|
6258
6446
|
: [],
|
|
6259
|
-
|
|
6447
|
+
// The review's cap on what the builder's wallet gets of what the hook spends, not what this hook pays (B7).
|
|
6448
|
+
builderCutMaxPct: Math.min(20, Math.max(0, Number(h.builderCutPct) || 0)),
|
|
6260
6449
|
...(source ? { source } : {}),
|
|
6261
6450
|
launchWith: { hook: entry.id, ...(entry.maxPotBps > 0 ? { pct: Math.min(Math.floor(entry.maxPotBps / 100), 20) } : {}) },
|
|
6262
6451
|
};
|
|
@@ -6293,6 +6482,45 @@ async function builderHooksLive() {
|
|
|
6293
6482
|
return { hooks, entries };
|
|
6294
6483
|
}
|
|
6295
6484
|
|
|
6485
|
+
/**
|
|
6486
|
+
* A local file the agent named (an artifact, a manifest), read only when it is a regular file of at most maxBytes: a
|
|
6487
|
+
* FIFO, a device or a huge file would hang or kill this server. { path, text } or { error }; the error names the
|
|
6488
|
+
* resolved path and why, never what the file holds.
|
|
6489
|
+
*/
|
|
6490
|
+
function readLocalFile(path, maxBytes) {
|
|
6491
|
+
const full = resolve(String(path ?? ""));
|
|
6492
|
+
const shown = plainText(full).slice(0, 300);
|
|
6493
|
+
let fd;
|
|
6494
|
+
try {
|
|
6495
|
+
// O_NONBLOCK: opening a FIFO nobody writes to must not block; regular files read as usual.
|
|
6496
|
+
fd = openSync(full, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0));
|
|
6497
|
+
const st = fstatSync(fd);
|
|
6498
|
+
if (!st.isFile()) return { error: `${shown} is not a regular file` };
|
|
6499
|
+
if (st.size > maxBytes) return { error: `${shown} is ${st.size} bytes, above the ${maxBytes} this server reads` };
|
|
6500
|
+
const buf = Buffer.alloc(st.size);
|
|
6501
|
+
let read = 0;
|
|
6502
|
+
while (read < st.size) {
|
|
6503
|
+
const n = readSync(fd, buf, read, st.size - read, read);
|
|
6504
|
+
if (n === 0) break;
|
|
6505
|
+
read += n;
|
|
6506
|
+
}
|
|
6507
|
+
return { path: full, text: buf.subarray(0, read).toString("utf8") };
|
|
6508
|
+
} catch (err) {
|
|
6509
|
+
const why = { ENOENT: "no such file", EACCES: "permission denied", EISDIR: "a directory", ENOTDIR: "not a path to a file" }[err?.code] ?? "unreadable";
|
|
6510
|
+
return { error: `${shown}: ${why}. This server runs in ${plainText(process.cwd()).slice(0, 200)}: give an absolute path` };
|
|
6511
|
+
} finally {
|
|
6512
|
+
if (fd !== undefined) closeSync(fd);
|
|
6513
|
+
}
|
|
6514
|
+
}
|
|
6515
|
+
|
|
6516
|
+
/** The name the SDK's script/Manifest.s.sol gives `contract`, read next to its artifact (<sdk>/out/<File>.sol/<Contract>.json), or null. */
|
|
6517
|
+
function manifestName(artifactPath, contract) {
|
|
6518
|
+
const script = readLocalFile(join(dirname(artifactPath), "..", "..", "script", "Manifest.s.sol"), MAX_MANIFEST_BYTES);
|
|
6519
|
+
if (script.error) return null;
|
|
6520
|
+
const m = script.text.match(new RegExp(`keccak256\\("([a-z0-9][a-z0-9-]{0,63})"\\)\\)\\s*return\\s+address\\(new\\s+${contract}\\(`));
|
|
6521
|
+
return m ? m[1] : null;
|
|
6522
|
+
}
|
|
6523
|
+
|
|
6296
6524
|
/** The listed hook an agent names: by module id, plugin address or exact name (case ignored). { hook } or { error }. */
|
|
6297
6525
|
function builderHookFind(hooks, ref) {
|
|
6298
6526
|
const key = String(ref ?? "").trim();
|
|
@@ -6395,6 +6623,9 @@ const PLUGIN_API = 3;
|
|
|
6395
6623
|
/** EIP-170 and EIP-3860: the most runtime code a contract may have and the most init code a creation may carry. */
|
|
6396
6624
|
const MAX_RUNTIME_BYTES = 24_576;
|
|
6397
6625
|
const MAX_INITCODE_BYTES = 49_152;
|
|
6626
|
+
/** The most a Foundry artifact, a manifest and the SDK's Manifest script may weigh before this server reads them. */
|
|
6627
|
+
const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
|
|
6628
|
+
const MAX_MANIFEST_BYTES = 256 * 1024;
|
|
6398
6629
|
const WORKSHOP_TAGS = ["agents", "special"];
|
|
6399
6630
|
/** CIDv0 or CIDv1: a logo is an image pinned on IPFS, never an arbitrary URL (the site's rule). */
|
|
6400
6631
|
const WORKSHOP_LOGO = /^ipfs:\/\/(Qm[1-9A-HJ-NP-Za-km-z]{44}|b[a-z2-7]{50,100})$/;
|
|
@@ -6480,7 +6711,7 @@ function workshopSubmitMessage(f, issuedAt) {
|
|
|
6480
6711
|
|
|
6481
6712
|
/**
|
|
6482
6713
|
* The manifest submit_hook sends: the text of the SDK's manifest (a file path, or the JSON itself), sent as written so
|
|
6483
|
-
* its big integers keep every digit. { text, plugin } or { error }.
|
|
6714
|
+
* its big integers keep every digit. { text, plugin, codehash } or { error } (codehash null when the manifest has none).
|
|
6484
6715
|
*/
|
|
6485
6716
|
function workshopManifest(input, readText) {
|
|
6486
6717
|
const raw = String(input ?? "").trim();
|
|
@@ -6489,7 +6720,7 @@ function workshopManifest(input, readText) {
|
|
|
6489
6720
|
try {
|
|
6490
6721
|
text = readText(raw);
|
|
6491
6722
|
} catch (err) {
|
|
6492
|
-
return { error: `Could not read the manifest file
|
|
6723
|
+
return { error: `Could not read the manifest file: ${plainText(err?.message).slice(0, 400)}. Give the absolute path of the file the SDK's Manifest script wrote (manifests/<name>.json) or the JSON itself.` };
|
|
6493
6724
|
}
|
|
6494
6725
|
}
|
|
6495
6726
|
let parsed;
|
|
@@ -6501,7 +6732,8 @@ function workshopManifest(input, readText) {
|
|
|
6501
6732
|
if (!parsed || typeof parsed !== "object" || typeof parsed.plugin !== "string" || !isAddress(parsed.plugin)) {
|
|
6502
6733
|
return { error: "The manifest has no plugin address. Print it with the SDK's Manifest script for the deployed address." };
|
|
6503
6734
|
}
|
|
6504
|
-
|
|
6735
|
+
const codehash = typeof parsed.codehash === "string" && /^0x[0-9a-fA-F]{64}$/.test(parsed.codehash) ? parsed.codehash.toLowerCase() : null;
|
|
6736
|
+
return { text, plugin: getAddress(parsed.plugin), codehash };
|
|
6505
6737
|
}
|
|
6506
6738
|
|
|
6507
6739
|
/**
|
|
@@ -6521,39 +6753,43 @@ function pluginArtifact(a) {
|
|
|
6521
6753
|
if (!/^0x(?:[0-9a-fA-F]{2})+$/.test(bytecode) || !/^0x(?:[0-9a-fA-F]{2})+$/.test(runtime)) {
|
|
6522
6754
|
return { error: "This artifact has no deployable code (an interface, an abstract contract or a library?). Give the plugin contract's artifact." };
|
|
6523
6755
|
}
|
|
6756
|
+
// The source path and the name go into the shell commands this server tells the agent to run: plain ones only.
|
|
6524
6757
|
const target = Object.entries(a?.metadata?.settings?.compilationTarget ?? {})[0];
|
|
6525
|
-
|
|
6526
|
-
const
|
|
6758
|
+
if (!target) return { error: "This artifact has no compilationTarget metadata: build it with the SDK's forge build (default settings) and give out/<File>.sol/<Contract>.json." };
|
|
6759
|
+
const [source, name] = target.map(String);
|
|
6760
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(name)) return { error: "The artifact's contract name is not a plain Solidity identifier." };
|
|
6761
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9_.\/-]{0,199}\.sol$/.test(source) || source.split("/").includes("..")) {
|
|
6762
|
+
return { error: `${name}'s source path in the artifact is not a plain relative .sol path (letters, digits, _ . / - only).` };
|
|
6763
|
+
}
|
|
6764
|
+
const abi = Array.isArray(a.abi) ? a.abi : [];
|
|
6765
|
+
const ctor = abi.find((x) => x?.type === "constructor");
|
|
6527
6766
|
if (!ctor || ctor.inputs?.length !== 1 || ctor.inputs[0]?.type !== "address") {
|
|
6528
6767
|
return { error: `${name} must have one constructor argument, the ModuleRegistry's address (constructor(address moduleRegistry_) HookPluginBase(address(0), moduleRegistry_)), like the SDK's starter. Is this the plugin's artifact and not its Declaration library?` };
|
|
6529
6768
|
}
|
|
6530
6769
|
if (ctor.stateMutability === "payable") return { error: `${name}'s constructor is payable: a plugin holds no ETH of its own.` };
|
|
6770
|
+
if (!abi.some((x) => x?.type === "function" && x.name === "hookitPluginApi" && (x.inputs?.length ?? 0) === 0)) {
|
|
6771
|
+
return { error: `${name} has no hookitPluginApi(): a Hookit plugin is built on the SDK's HookPluginBase. Is this the plugin's artifact?` };
|
|
6772
|
+
}
|
|
6531
6773
|
const runtimeBytes = (runtime.length - 2) / 2;
|
|
6532
6774
|
if (runtimeBytes > MAX_RUNTIME_BYTES) return { error: `${name} is ${runtimeBytes} bytes of code, above Ink's ${MAX_RUNTIME_BYTES}.` };
|
|
6533
6775
|
if ((bytecode.length - 2) / 2 + 32 > MAX_INITCODE_BYTES) return { error: `${name}'s creation code is above Ink's ${MAX_INITCODE_BYTES} bytes.` };
|
|
6534
|
-
return { name, target:
|
|
6776
|
+
return { name, target: `${source}:${name}`, bytecode, runtimeBytes };
|
|
6535
6777
|
}
|
|
6536
6778
|
|
|
6537
|
-
/** The Manifest script's name for a contract: MyPlugin -> my-plugin. */
|
|
6538
|
-
const manifestSlug = (name) =>
|
|
6539
|
-
String(name)
|
|
6540
|
-
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
6541
|
-
.replace(/[^A-Za-z0-9]+/g, "-")
|
|
6542
|
-
.replace(/^-|-$/g, "")
|
|
6543
|
-
.toLowerCase() || "my-plugin";
|
|
6544
|
-
|
|
6545
6779
|
/** What the agent does next, by the submission's status. */
|
|
6546
6780
|
function workshopNext(s) {
|
|
6547
6781
|
switch (s.status) {
|
|
6548
6782
|
case "pending":
|
|
6549
6783
|
case "in_review":
|
|
6550
|
-
return
|
|
6784
|
+
return s.checks?.sourceVerified === false
|
|
6785
|
+
? "Hookit is reviewing it, but the explorer had no verified source when it was submitted: run the forge verify-contract command deploy_hook gave (no key needed), the review reads that source. Then check again with hook_status later (reviews take a human)."
|
|
6786
|
+
: "Hookit is reviewing it: nothing to do but wait. Check again with hook_status later (reviews take a human).";
|
|
6551
6787
|
case "changes_requested":
|
|
6552
6788
|
return "Hookit asked for changes (reviewNote). Card text only: submit_hook again with the same manifest. Code: a plugin is immutable, so fix it and its tests, deploy_hook again, print a new manifest for the new address and submit_hook that one.";
|
|
6553
6789
|
case "approved":
|
|
6554
6790
|
return "Approved. Hookit is registering it on chain; once hook_status says live, launch with it.";
|
|
6555
6791
|
case "live":
|
|
6556
|
-
return `Live as module ${s.moduleId}. Launch a coin with it: launch_token with builderHooks: [{ "hook": ${s.moduleId} }] and dryRun first. list_builder_hooks shows it to every agent.`;
|
|
6792
|
+
return `Live as module ${s.moduleId}. Launch a coin with it: launch_token with builderHooks: [{ "hook": ${s.moduleId} }] and dryRun first. A hook that takes a share of the hook pot needs something to fill it (hookTaxPct, e.g. 3), and the pot's shares total 100%: without a pct it takes the whole pot; with one, send the rest to a built-in destination (modules: { autoBurn: true }). list_builder_hooks shows it to every agent.`;
|
|
6557
6793
|
case "rejected":
|
|
6558
6794
|
return "Rejected (reviewNote). Start over with a new plugin only if the note leaves a way.";
|
|
6559
6795
|
case "halted":
|
|
@@ -6576,7 +6812,8 @@ function workshopView(s) {
|
|
|
6576
6812
|
checks: {
|
|
6577
6813
|
sourceVerified: c.sourceVerified ?? null,
|
|
6578
6814
|
codehashMatches: c.codehashMatches ?? null,
|
|
6579
|
-
|
|
6815
|
+
// The site stores null once the registry's dry run accepted it, its revert reason otherwise; absent is unknown.
|
|
6816
|
+
registryPrecheck: c.registryPrecheck === null ? "accepted" : c.registryPrecheck ? plainText(c.registryPrecheck).slice(0, 300) : null,
|
|
6580
6817
|
...(c.proxy ? { proxy: plainText(c.proxy).slice(0, 200) } : {}),
|
|
6581
6818
|
...(c.hasDelegatecall ? { hasDelegatecall: true } : {}),
|
|
6582
6819
|
},
|
|
@@ -6585,10 +6822,36 @@ function workshopView(s) {
|
|
|
6585
6822
|
};
|
|
6586
6823
|
}
|
|
6587
6824
|
|
|
6825
|
+
/**
|
|
6826
|
+
* Why hookit.fun would refuse `me` submitting a plugin that already has a submission, or null: the site's 409 rules
|
|
6827
|
+
* (live; the same wallet's approved or in-review one; another wallet's, unless `me` deployed the plugin and they did
|
|
6828
|
+
* not, and it is not under review). Unknown when the site does not show the earlier submitter.
|
|
6829
|
+
*/
|
|
6830
|
+
function workshopConflict(existing, me, deployer) {
|
|
6831
|
+
if (!existing) return null;
|
|
6832
|
+
const status = String(existing.status ?? "");
|
|
6833
|
+
if (status === "live") return "this plugin is already live";
|
|
6834
|
+
if (!isAddress(existing.submitter ?? "")) return null;
|
|
6835
|
+
if (same(existing.submitter, me)) {
|
|
6836
|
+
return status === "approved" || status === "in_review" ? `your submission of it is already ${status.replace("_", " ")}` : null;
|
|
6837
|
+
}
|
|
6838
|
+
// A rejected one frees the plugin for anyone (the site's reclaimVerdict); otherwise only its deployer takes it back.
|
|
6839
|
+
if (status === "rejected") return null;
|
|
6840
|
+
if (!deployer || !same(deployer, me) || same(deployer, existing.submitter)) {
|
|
6841
|
+
return "another wallet already submitted this plugin: if this wallet deployed it, ask the Workshop reviewers to reject that submission, then submit again";
|
|
6842
|
+
}
|
|
6843
|
+
if (status === "approved" || status === "in_review") {
|
|
6844
|
+
return `another wallet's submission of it is ${status.replace("_", " ")}: ask the Workshop reviewers to reject it, then submit again`;
|
|
6845
|
+
}
|
|
6846
|
+
return null;
|
|
6847
|
+
}
|
|
6848
|
+
|
|
6588
6849
|
/** Why a launch asked without any plugin must not be signed, or null: hookit.fun added a plugin slot nobody asked for. */
|
|
6589
6850
|
function unaskedSlotsRefusal(prepared) {
|
|
6590
6851
|
const d = launchConfigDecoded(prepared);
|
|
6591
|
-
|
|
6852
|
+
// Fails closed: a call it cannot read (Factory C's launchWithNftStrategy, say) could carry any plugin slot.
|
|
6853
|
+
if (!d) return "hookit answered with a launch call this version cannot read (not Factory C's launch or launchMulti). Nothing was sent; update hookit-mcp.";
|
|
6854
|
+
return d.plugins.length > 0
|
|
6592
6855
|
? `hookit answered with ${d.plugins.length === 1 ? "a plugin slot" : `${d.plugins.length} plugin slots`} (module ${d.plugins.map((p) => p.moduleId).join(", ")}) that this launch did not ask for. Nothing was sent; update hookit-mcp.`
|
|
6593
6856
|
: null;
|
|
6594
6857
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hookit-mcp",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Let an AI agent launch a coin on hookit.fun (Ink) with its own hook modules and builders' Workshop hooks, find, buy and sell coins with ETH, USDG or tokenized stocks (AI-agents-only ones too, with a Hookit Agent Pass), redeem or borrow at their Backed Floor, collect its creator fees, steer the fee split, the fee and the hooks of the Evolve coins it launched, and deploy and submit its own hook to the Workshop.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": { "hookit-mcp": "index.js" },
|