forgemesh-imagegen 2.0.3 → 2.1.0

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 +14 -9
  2. package/index.js +108 -151
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -17,9 +17,11 @@ Part of the [ForgeMesh](https://github.com/forgemeshlabs/forgemesh) ecosystem
17
17
  | `generate_clean` | Generate + background removal | $0.35 USDC |
18
18
  | `generate_hd` | Premium generate + 4x upscale (HD) | $0.50 USDC |
19
19
  | `generate_pro` | Top-tier generate + bg removal + 4x upscale | $0.75 USDC |
20
+ | `list_presets` | List the 58 identity-preserving transform presets (person or pet) | $0.005 USDC |
21
+ | `transform_image` | Transform a photo with a preset while keeping the subject's identity (FLUX Kontext Max) | $0.75 USDC |
20
22
 
21
- All tools accept `prompt` (required) and `aspect` (optional: `1:1`, `16:9`, `9:16`, `4:3`).
22
- `generate_image` also accepts `affiliate_id` for Pyrimid attribution, or uses `PYRIMID_AFFILIATE_ID` from the environment.
23
+ The four `generate_*` tools accept `prompt` (required) and `aspect` (optional: `1:1`, `16:9`, `9:16`, `4:3`).
24
+ `transform_image` takes a public `image_url`, a `preset` id from `list_presets`, an optional `aspect` (`match_input_image` default, or `1:1`, `16:9`, `9:16`, `4:3`, `3:4`) and `wait` (default true: polls the free status URL until the PNG is ready, usually 15-30s). Submit only images you have rights to; no minors; the provider safety filter applies.
23
25
 
24
26
  ---
25
27
 
@@ -36,8 +38,7 @@ Add to `claude_desktop_config.json`:
36
38
  "command": "npx",
37
39
  "args": ["-y", "forgemesh-imagegen"],
38
40
  "env": {
39
- "WALLET_PRIVATE_KEY": "0x...",
40
- "PYRIMID_AFFILIATE_ID": "af_your_id"
41
+ "WALLET_PRIVATE_KEY": "0x..."
41
42
  }
42
43
  }
43
44
  }
@@ -59,8 +60,8 @@ export WALLET_PRIVATE_KEY=0x...
59
60
 
60
61
  ## Requirements
61
62
 
62
- - A Base mainnet wallet private key with USDC
63
- - $1 USDC ≈ 4 base images, 2 clean, 2 HD, or 1 pro
63
+ - A **dedicated, low-balance** Base mainnet wallet private key with USDC (never your primary wallet: the server signs USDC transfers autonomously)
64
+ - $1 USDC ≈ 4 base images, 2 clean, 2 HD, or 1 pro, or 1 transform
64
65
  - No other API keys needed
65
66
 
66
67
  Get USDC on Base: [Coinbase](https://coinbase.com) → Bridge to Base, or buy directly on Base.
@@ -73,9 +74,6 @@ Get USDC on Base: [Coinbase](https://coinbase.com) → Bridge to Base, or buy di
73
74
  generate_image(prompt="a red panda in a spacesuit", aspect="1:1")
74
75
  → { image_url: "https://...", prompt: "...", aspect: "1:1", tier: "image" }
75
76
 
76
- generate_image(prompt="a red panda in a spacesuit", affiliate_id="af_your_id")
77
- → routes through Pyrimid product 3 with no extra cost to the caller
78
-
79
77
  generate_clean(prompt="a product photo of a ceramic mug")
80
78
  → { image_url: "https://...", tier: "clean" } // transparent PNG
81
79
 
@@ -84,6 +82,12 @@ generate_hd(prompt="a futuristic city at night, cyberpunk style")
84
82
 
85
83
  generate_pro(prompt="a logo mark, geometric eagle")
86
84
  → { image_url: "https://...", tier: "pro" } // transparent + HD
85
+
86
+ list_presets()
87
+ → { count: 58, presets: [{ id: "pet-royal", title: "Pet Royalty", subject: "pet", ... }, ...] }
88
+
89
+ transform_image(image_url="https://example.com/my-dog.jpg", preset="pet-royal")
90
+ → { job_id: "...", status: "succeeded", preset: "pet-royal", image_url: "https://..." }
87
91
  ```
88
92
 
89
93
  ---
@@ -95,6 +99,7 @@ Each tool call makes an HTTP request to the ForgeMesh imagegen service gated by
95
99
  - Network: Base mainnet (eip155:8453)
96
100
  - Token: USDC
97
101
  - Protocol: [x402](https://x402.org)
102
+ - The server only ever connects to `https://imagegen.coinopai.com`; every request has a 60s timeout and a 1 MB response cap; no third-party payment routers or affiliate hops.
98
103
 
99
104
  ---
100
105
 
package/index.js CHANGED
@@ -8,113 +8,87 @@ const { x402Client, x402HTTPClient } = require("@x402/core/client");
8
8
  const { ExactEvmScheme } = require("@x402/evm/exact/client");
9
9
  const { toClientEvmSigner } = require("@x402/evm");
10
10
  const { privateKeyToAccount } = require("viem/accounts");
11
- const { createPublicClient, createWalletClient, http, parseAbi } = require("viem");
12
- const { base } = require("viem/chains");
13
11
 
14
12
  const BASE_URL = "https://imagegen.coinopai.com";
15
13
  const VALID_ASPECTS = ["1:1", "16:9", "9:16", "4:3"];
16
- const PYRIMID_ROUTER = "0xc949AEa380D7b7984806143ddbfE519B03ABd68B";
17
- const PYRIMID_VENDOR_ID = "0x034604e25078e293d7b181fa23b3f2f6";
18
- const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
19
- const ROUTER_ABI = parseAbi([
20
- "function routePayment(bytes16 vendorId, uint256 productId, bytes16 affiliateId, address buyer, uint256 maxPrice) external",
21
- ]);
22
- const USDC_ABI = parseAbi([
23
- "function approve(address spender, uint256 amount) external returns (bool)",
24
- ]);
25
- const PYRIMID_PRODUCTS = {
26
- "/generate": { productId: 3n, priceUsdc: 250000n },
27
- };
14
+ const TRANSFORM_ASPECTS = ["match_input_image", "1:1", "16:9", "9:16", "4:3", "3:4"];
15
+ const REQUEST_TIMEOUT_MS = 60_000;
16
+ const MAX_RESPONSE_BYTES = 1_000_000;
17
+ const MAX_PROMPT_CHARS = 2_000;
18
+
19
+ const GENERATE_ANNOTATIONS = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
20
+ const promptSchema = (subject) => ({
21
+ type: "object",
22
+ properties: {
23
+ prompt: { type: "string", maxLength: MAX_PROMPT_CHARS, description: subject },
24
+ aspect: { type: "string", enum: VALID_ASPECTS, description: "Aspect ratio — 1:1 (default), 16:9, 9:16, 4:3" },
25
+ },
26
+ required: ["prompt"],
27
+ });
28
28
 
29
29
  const TOOLS = [
30
30
  {
31
31
  name: "generate_image",
32
- annotations: {
33
- readOnlyHint: false,
34
- destructiveHint: false,
35
- idempotentHint: false,
36
- openWorldHint: true,
37
- },
32
+ annotations: GENERATE_ANNOTATIONS,
38
33
  description: "Generate a standard AI image from a text prompt. Returns a PNG image URL. Costs $0.25 USDC on Base mainnet — paid automatically.",
39
- inputSchema: {
40
- type: "object",
41
- properties: {
42
- prompt: { type: "string", description: "Text description of the image to generate" },
43
- aspect: { type: "string", enum: VALID_ASPECTS, description: "Aspect ratio — 1:1 (default), 16:9, 9:16, 4:3" },
44
- affiliate_id: { type: "string", description: "Optional Pyrimid affiliate ID. Affiliate earns a commission from within the listed price — no extra cost to you." },
45
- },
46
- required: ["prompt"],
47
- },
34
+ inputSchema: promptSchema("Text description of the image to generate"),
48
35
  },
49
36
  {
50
37
  name: "generate_clean",
51
- annotations: {
52
- readOnlyHint: false,
53
- destructiveHint: false,
54
- idempotentHint: false,
55
- openWorldHint: true,
56
- },
38
+ annotations: GENERATE_ANNOTATIONS,
57
39
  description: "Generate an AI image with background removed. Returns a transparent PNG URL. Costs $0.35 USDC on Base mainnet.",
58
- inputSchema: {
59
- type: "object",
60
- properties: {
61
- prompt: { type: "string", description: "Text description of the subject (background will be removed)" },
62
- aspect: { type: "string", enum: VALID_ASPECTS, description: "Aspect ratio — 1:1 (default), 16:9, 9:16, 4:3" },
63
- },
64
- required: ["prompt"],
65
- },
40
+ inputSchema: promptSchema("Text description of the subject (background will be removed)"),
66
41
  },
67
42
  {
68
43
  name: "generate_hd",
69
- annotations: {
70
- readOnlyHint: false,
71
- destructiveHint: false,
72
- idempotentHint: false,
73
- openWorldHint: true,
74
- },
44
+ annotations: GENERATE_ANNOTATIONS,
75
45
  description: "Generate a premium AI image upscaled 4x HD. Returns a high-resolution image URL. Costs $0.50 USDC on Base mainnet.",
76
- inputSchema: {
77
- type: "object",
78
- properties: {
79
- prompt: { type: "string", description: "Text description of the image to generate" },
80
- aspect: { type: "string", enum: VALID_ASPECTS, description: "Aspect ratio — 1:1 (default), 16:9, 9:16, 4:3" },
81
- },
82
- required: ["prompt"],
83
- },
46
+ inputSchema: promptSchema("Text description of the image to generate"),
84
47
  },
85
48
  {
86
49
  name: "generate_pro",
87
- annotations: {
88
- readOnlyHint: false,
89
- destructiveHint: false,
90
- idempotentHint: false,
91
- openWorldHint: true,
92
- },
50
+ annotations: GENERATE_ANNOTATIONS,
93
51
  description: "Generate a top-tier AI image with background removal and 4x HD upscale. Costs $0.75 USDC on Base mainnet.",
52
+ inputSchema: promptSchema("Text description of the subject (background removed, then upscaled)"),
53
+ },
54
+ {
55
+ name: "list_presets",
56
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
57
+ description: "List the 58 identity-preserving transform presets for transform_image (action figure, pet royalty, CEO portrait, cartoon villain, fantasy poster, pet astronaut, ...). Returns id, title, tagline and whether each expects a person or a pet photo. Costs $0.005 USDC on Base mainnet.",
58
+ inputSchema: { type: "object", properties: {}, required: [] },
59
+ },
60
+ {
61
+ name: "transform_image",
62
+ annotations: GENERATE_ANNOTATIONS,
63
+ description: "Transform a photo with a named preset while keeping the subject's identity (FLUX Kontext Max image-to-image). Pass a public https image URL and a preset id from list_presets. Returns a job with a free status_url to poll until the PNG is ready (usually 15-30s). Costs $0.75 USDC on Base mainnet. Submit only images you have rights to; no minors; provider safety filter applies.",
94
64
  inputSchema: {
95
65
  type: "object",
96
66
  properties: {
97
- prompt: { type: "string", description: "Text description of the subject (background removed, then upscaled)" },
98
- aspect: { type: "string", enum: VALID_ASPECTS, description: "Aspect ratio — 1:1 (default), 16:9, 9:16, 4:3" },
67
+ image_url: { type: "string", format: "uri", maxLength: 2048, description: "Public https URL of the source photo (JPEG, PNG or WebP)" },
68
+ preset: { type: "string", pattern: "^[a-z0-9-]{1,64}$", description: "Preset id from list_presets, e.g. action-figure, pet-royal, ceo-portrait" },
69
+ aspect: { type: "string", enum: TRANSFORM_ASPECTS, description: "Output aspect (default match_input_image)" },
70
+ wait: { type: "boolean", description: "Poll the job until it finishes and return the final image_url (default true, up to ~90s)" },
99
71
  },
100
- required: ["prompt"],
72
+ required: ["image_url", "preset"],
101
73
  },
102
74
  },
103
75
  ];
104
76
 
105
77
  const TOOL_ENDPOINTS = {
106
- generate_image: { path: "/generate", price: "$0.25" },
107
- generate_clean: { path: "/generate/clean", price: "$0.35" },
108
- generate_hd: { path: "/generate/hd", price: "$0.50" },
109
- generate_pro: { path: "/generate/pro", price: "$0.75" },
78
+ generate_image: { path: "/generate", price: "$0.25" },
79
+ generate_clean: { path: "/generate/clean", price: "$0.35" },
80
+ generate_hd: { path: "/generate/hd", price: "$0.50" },
81
+ generate_pro: { path: "/generate/pro", price: "$0.75" },
82
+ list_presets: { path: "/presets", price: "$0.005" },
83
+ transform_image: { path: "/transform", price: "$0.75" },
110
84
  };
111
85
 
112
86
  function buildHttpClient() {
113
87
  const key = process.env.WALLET_PRIVATE_KEY;
114
88
  if (!key) {
115
89
  throw new Error(
116
- "WALLET_PRIVATE_KEY required — set a Base wallet private key funded with USDC.\n" +
117
- " generate_image = $0.25 | generate_clean = $0.35 | generate_hd = $0.50 | generate_pro = $0.75"
90
+ "WALLET_PRIVATE_KEY required — set a dedicated, low-balance Base wallet private key funded with USDC.\n" +
91
+ " generate_image = $0.25 | generate_clean = $0.35 | generate_hd = $0.50 | generate_pro = $0.75 | transform_image = $0.75"
118
92
  );
119
93
  }
120
94
  const pk = key.startsWith("0x") ? key : "0x" + key;
@@ -124,84 +98,55 @@ function buildHttpClient() {
124
98
  return { httpClient: new x402HTTPClient(coreClient), account };
125
99
  }
126
100
 
127
- const sleep = (ms) => new Promise(r => setTimeout(r, ms));
128
-
129
- async function callPyrimid(account, url, product, affiliateId) {
130
- const transport = http();
131
- const publicClient = createPublicClient({ chain: base, transport });
132
- const walletClient = createWalletClient({ account, chain: base, transport });
101
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
133
102
 
134
- const approveHash = await walletClient.writeContract({
135
- address: USDC_ADDRESS,
136
- abi: USDC_ABI,
137
- functionName: "approve",
138
- args: [PYRIMID_ROUTER, product.priceUsdc],
139
- });
140
- await publicClient.waitForTransactionReceipt({ hash: approveHash });
141
- await sleep(3000);
142
-
143
- const routeHash = await walletClient.writeContract({
144
- address: PYRIMID_ROUTER,
145
- abi: ROUTER_ABI,
146
- functionName: "routePayment",
147
- args: [PYRIMID_VENDOR_ID, product.productId, "0x00000000000000000000000000000000", account.address, product.priceUsdc],
148
- });
149
- await publicClient.waitForTransactionReceipt({ hash: routeHash });
150
- await sleep(3000);
151
-
152
- const paidRes = await fetch(url.toString(), {
153
- headers: { "X-Affiliate-ID": affiliateId, "X-Payment": routeHash },
154
- });
155
- if (!paidRes.ok) {
156
- const errBody = await paidRes.text().catch(() => paidRes.statusText);
157
- throw new Error(`Pyrimid payment failed — HTTP ${paidRes.status}: ${errBody.slice(0, 200)}`);
103
+ // Every outbound request: bounded time, bounded body, same-origin only.
104
+ async function fetchBounded(url, init = {}) {
105
+ const target = new URL(String(url));
106
+ if (target.origin !== BASE_URL) throw new Error(`Refusing to call ${target.origin}; this server only talks to ${BASE_URL}`);
107
+ const ctrl = new AbortController();
108
+ const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
109
+ try {
110
+ const res = await fetch(target.toString(), { ...init, signal: ctrl.signal, redirect: "error" });
111
+ const declared = Number(res.headers.get("content-length") || 0);
112
+ if (declared > MAX_RESPONSE_BYTES) throw new Error(`Response too large (${declared} bytes)`);
113
+ const text = await res.text();
114
+ if (text.length > MAX_RESPONSE_BYTES) throw new Error("Response too large");
115
+ return { status: res.status, ok: res.ok, headers: res.headers, text };
116
+ } finally {
117
+ clearTimeout(timer);
158
118
  }
159
- return paidRes.json();
160
119
  }
161
120
 
162
- async function callPaid(ctx, path, params, affiliateId) {
163
- const { httpClient, account } = ctx;
121
+ function parseJson(text, status) {
122
+ try { return JSON.parse(text); } catch { throw new Error(`HTTP ${status}: non-JSON response`); }
123
+ }
124
+
125
+ async function callPaid(ctx, path, params) {
126
+ const { httpClient } = ctx;
164
127
  const url = new URL(BASE_URL + path);
165
128
  if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
166
129
 
167
- const product = PYRIMID_PRODUCTS[path];
168
- if (!product) affiliateId = null;
169
- const extraHeaders = affiliateId ? { "X-Affiliate-ID": affiliateId } : {};
170
-
171
- if (affiliateId && product) {
172
- try {
173
- return await callPyrimid(account, url, product, affiliateId);
174
- } catch (_) {
175
- affiliateId = null;
176
- }
177
- }
178
-
179
- const directHeaders = affiliateId ? extraHeaders : {};
180
- const res = await fetch(url.toString(), { headers: directHeaders });
181
-
130
+ const res = await fetchBounded(url);
182
131
  if (res.status === 402) {
183
132
  let body;
184
- try { body = await res.clone().json(); } catch (_) {}
185
- const paymentRequired = httpClient.getPaymentRequiredResponse(
186
- (name) => res.headers.get(name),
187
- body
188
- );
133
+ try { body = JSON.parse(res.text); } catch { body = undefined; }
134
+ const paymentRequired = httpClient.getPaymentRequiredResponse((name) => res.headers.get(name), body);
189
135
  const paymentPayload = await httpClient.createPaymentPayload(paymentRequired);
190
- const paidRes = await fetch(url.toString(), {
191
- headers: { ...httpClient.encodePaymentSignatureHeader(paymentPayload), ...directHeaders },
192
- });
193
- if (!paidRes.ok) {
194
- const errBody = await paidRes.text().catch(() => paidRes.statusText);
195
- throw new Error(`Payment failed — HTTP ${paidRes.status}: ${errBody.slice(0, 200)}`);
196
- }
197
- return paidRes.json();
136
+ const paid = await fetchBounded(url, { headers: httpClient.encodePaymentSignatureHeader(paymentPayload) });
137
+ if (!paid.ok) throw new Error(`Payment failed — HTTP ${paid.status}: ${paid.text.slice(0, 200)}`);
138
+ return parseJson(paid.text, paid.status);
198
139
  }
140
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.text.slice(0, 200)}`);
141
+ return parseJson(res.text, res.status);
142
+ }
199
143
 
200
- if (!res.ok) {
201
- const errBody = await res.text().catch(() => res.statusText);
202
- throw new Error(`HTTP ${res.status}: ${errBody.slice(0, 200)}`);
203
- }
204
- return res.json();
144
+ function requirePrompt(args) {
145
+ if (!args || typeof args.prompt !== "string" || !args.prompt.trim()) throw new Error("prompt is required and must be a non-empty string");
146
+ if (args.prompt.length > MAX_PROMPT_CHARS) throw new Error(`prompt must be at most ${MAX_PROMPT_CHARS} characters`);
147
+ const aspect = args.aspect || "1:1";
148
+ if (!VALID_ASPECTS.includes(aspect)) throw new Error(`Invalid aspect ratio '${aspect}'. Valid: ${VALID_ASPECTS.join(", ")}`);
149
+ return { prompt: args.prompt.trim(), aspect };
205
150
  }
206
151
 
207
152
  async function main() {
@@ -214,33 +159,45 @@ async function main() {
214
159
  }
215
160
 
216
161
  const server = new Server(
217
- { name: "forgemesh-imagegen", version: "2.0.3" },
162
+ { name: "forgemesh-imagegen", version: require("./package.json").version },
218
163
  { capabilities: { tools: {} } }
219
164
  );
220
165
 
221
166
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
222
167
 
223
168
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
224
- const { name, arguments: args } = req.params;
169
+ const { name, arguments: args = {} } = req.params;
225
170
  try {
226
171
  const endpoint = TOOL_ENDPOINTS[name];
227
172
  if (!endpoint) throw new Error("Unknown tool: " + name);
228
173
 
229
- if (!args.prompt || typeof args.prompt !== "string" || !args.prompt.trim()) {
230
- throw new Error("prompt is required and must be a non-empty string");
174
+ if (name === "list_presets") {
175
+ const data = await callPaid(ctx, endpoint.path, null);
176
+ return { content: [{ type: "text", text: JSON.stringify({ count: data.count, presets: data.presets, usage: data.usage, terms: data.terms }, null, 2) }] };
231
177
  }
232
178
 
233
- const aspect = args.aspect || "1:1";
234
- if (!VALID_ASPECTS.includes(aspect)) {
235
- throw new Error(`Invalid aspect ratio '${aspect}'. Valid: ${VALID_ASPECTS.join(", ")}`);
179
+ if (name === "transform_image") {
180
+ if (typeof args.image_url !== "string" || !/^https:\/\/[^\s]{1,2040}$/.test(args.image_url)) throw new Error("image_url must be a public https URL");
181
+ if (typeof args.preset !== "string" || !/^[a-z0-9-]{1,64}$/.test(args.preset)) throw new Error("preset must be a preset id from list_presets");
182
+ const taspect = args.aspect || "match_input_image";
183
+ if (!TRANSFORM_ASPECTS.includes(taspect)) throw new Error(`Invalid aspect '${taspect}'. Valid: ${TRANSFORM_ASPECTS.join(", ")}`);
184
+ const job = await callPaid(ctx, endpoint.path, { image_url: args.image_url, preset: args.preset, aspect: taspect });
185
+ let result = job;
186
+ if (args.wait !== false && typeof job.status_url === "string") {
187
+ for (let i = 0; i < 30 && !["succeeded", "failed"].includes(result.status); i++) {
188
+ await sleep(3000);
189
+ const poll = await fetchBounded(job.status_url);
190
+ if (poll.ok) result = { ...job, ...parseJson(poll.text, poll.status) };
191
+ }
192
+ }
193
+ return {
194
+ content: [{ type: "text", text: JSON.stringify({ job_id: result.job_id, status: result.status, preset: job.preset, image_url: result.image_url || null, error: result.error || null, status_url: job.status_url }, null, 2) }],
195
+ isError: result.status === "failed",
196
+ };
236
197
  }
237
198
 
238
- const affiliateId = args.affiliate_id || process.env.PYRIMID_AFFILIATE_ID || null;
239
- const data = await callPaid(ctx, endpoint.path, {
240
- prompt: args.prompt.trim(),
241
- aspect,
242
- }, affiliateId);
243
-
199
+ const { prompt, aspect } = requirePrompt(args);
200
+ const data = await callPaid(ctx, endpoint.path, { prompt, aspect });
244
201
  return {
245
202
  content: [{
246
203
  type: "text",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgemesh-imagegen",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "mcpName": "io.github.forgemeshlabs/imagegen-mcp",
5
5
  "description": "MCP server for AI image generation — generate, clean (bg removal), HD (4x upscale), Pro. Pay per image in USDC via x402 on Base. No API key needed.",
6
6
  "main": "index.js",
@@ -48,7 +48,7 @@
48
48
  "url": "https://github.com/forgemeshlabs/imagegen-mcp/issues"
49
49
  },
50
50
  "dependencies": {
51
- "@modelcontextprotocol/sdk": "^1.30.0",
51
+ "@modelcontextprotocol/sdk": "^1.32.1",
52
52
  "@x402/core": "^2.11.0",
53
53
  "@x402/evm": "^2.11.0",
54
54
  "viem": "^2.0.0"