@blockrun/franklin 3.13.0 → 3.14.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.
package/dist/agent/context.js
CHANGED
|
@@ -213,11 +213,11 @@ You run on the BlockRun AI Gateway. When the user asks you to "test the BlockRun
|
|
|
213
213
|
- Use the **\`JupiterQuote\` and \`JupiterSwap\` built-in tools** — they call Jupiter's Ultra API directly from this process. The user is the first-party caller of Jupiter; we are not a gateway proxy here. A 20 bps platform fee is collected on-chain as part of the swap (Jupiter Referral Program — official integrator mechanism, not a hidden cost).
|
|
214
214
|
- Do NOT try to call \`/v1/jupiter/...\` on the BlockRun gateway — there is no such endpoint (Jupiter ToU forbids the gateway-proxy model).
|
|
215
215
|
|
|
216
|
-
**Base DEX swap (0x V2 Permit2)**
|
|
217
|
-
- Use the **\`Base0xQuote\` and \`Base0xSwap\` built-in tools** for swaps on Base (chain id 8453).
|
|
216
|
+
**Base DEX swap (0x V2 Permit2 via BlockRun gateway)**
|
|
217
|
+
- Use the **\`Base0xQuote\` and \`Base0xSwap\` built-in tools** for swaps on Base (chain id 8453). Tools route through BlockRun gateway \`/v1/zerox/{price,quote}\` (server-side 0x key, x402-paid). User pays $0.001 USDC per quote/swap call to the gateway; on-chain affiliate (20 bps in the sell-token) flows automatically to BlockRun treasury at swap settlement. **No 0x signup needed** — BlockRun manages the upstream key.
|
|
218
218
|
- Symbol shortcuts pre-mapped: ETH (native), WETH, USDC, USDT, CBBTC, CBETH, AERO, DAI. Raw \`0x...\` addresses pass through.
|
|
219
|
-
- **Each Franklin user supplies their OWN \`ZERO_EX_API_KEY\`** (free, no credit card, 10 req/s — sign up at https://dashboard.0x.org). The affiliate cut routes to BlockRun via the swap query params regardless of whose API key is making the call. If the swap tool errors with the env-var message, repeat the URL to the user — do not try to set the env yourself or invent a key.
|
|
220
219
|
- For native ETH → token: no Permit2 approval needed (native value path). For ERC-20 → token: first-time-per-token Permit2 approval auto-runs before the swap (one-time gas cost; future swaps of the same sell-token reuse it).
|
|
220
|
+
- The user signs Permit2 typed data locally with their Base keypair; the signed transaction is submitted to a Base RPC (default public mainnet-beta) — BlockRun never custodies keys.
|
|
221
221
|
|
|
222
222
|
**Sandbox (POST, x402-paid)**
|
|
223
223
|
- \`/v1/modal/{...path}\` — Modal GPU sandbox passthrough (create/exec/etc.).
|
|
@@ -9,6 +9,10 @@ export interface AppConfig {
|
|
|
9
9
|
'auto-compact'?: string;
|
|
10
10
|
'session-save'?: string;
|
|
11
11
|
'debug'?: string;
|
|
12
|
+
/** 0x V2 Swap API key for Base swaps. Free at https://dashboard.0x.org. Each user supplies their own; the on-chain affiliate fee routes to BlockRun regardless. */
|
|
13
|
+
'zerox-api-key'?: string;
|
|
14
|
+
/** Optional Base RPC URL override (Alchemy, QuickNode public, etc.). Defaults to https://mainnet.base.org. */
|
|
15
|
+
'base-rpc-url'?: string;
|
|
12
16
|
}
|
|
13
17
|
export declare function loadConfig(): AppConfig;
|
|
14
18
|
export declare function configCommand(action: string, keyOrUndefined?: string, value?: string): void;
|
package/dist/commands/config.js
CHANGED
package/dist/tools/zerox-base.js
CHANGED
|
@@ -24,22 +24,32 @@
|
|
|
24
24
|
import { createWalletClient, http, publicActions, concat, numberToHex, size, parseUnits, formatUnits, maxUint256, erc20Abi, getContract, } from 'viem';
|
|
25
25
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
26
26
|
import { base } from 'viem/chains';
|
|
27
|
-
import { getOrCreateWallet } from '@blockrun/llm';
|
|
27
|
+
import { getOrCreateWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, getOrCreateSolanaWallet, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
28
|
+
import { loadConfig } from '../commands/config.js';
|
|
29
|
+
import { loadChain, API_URLS, VERSION } from '../config.js';
|
|
28
30
|
// ─── BlockRun affiliate identity on Base ─────────────────────────────────
|
|
29
31
|
// Reuses the existing BlockRun ops wallet that already receives x402
|
|
30
32
|
// settlements on Base. Every swap routed through these tools deposits 20
|
|
31
33
|
// bps of the sell-token amount into this address at settlement.
|
|
32
34
|
const BLOCKRUN_BASE_AFFILIATE = '0xe9030014F5DAe217d0A152f02A043567b16c1aBf';
|
|
33
35
|
const BLOCKRUN_AFFILIATE_FEE_BPS = 20; // 0.2% — matches Jupiter Ultra path.
|
|
34
|
-
// ───
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
// ─── BlockRun gateway path for 0x ────────────────────────────────────────
|
|
37
|
+
// As of v3.14.0 we route through the BlockRun gateway (server-side 0x key),
|
|
38
|
+
// not directly to api.0x.org. User pays $0.001 via x402 per gateway call;
|
|
39
|
+
// affiliate 20 bps is force-set server-side and lands on-chain in the same
|
|
40
|
+
// BlockRun treasury that already collects x402 settlements.
|
|
41
|
+
const ZEROX_GATEWAY_PATH = '/v1/zerox';
|
|
42
|
+
const ZEROX_TIMEOUT_MS = 30_000;
|
|
38
43
|
// ─── Default Base RPC ────────────────────────────────────────────────────
|
|
39
|
-
// Public Base mainnet endpoint. Override via BASE_RPC_URL env
|
|
40
|
-
// QuickNode public, etc.).
|
|
41
|
-
// quote fetches are off-chain.
|
|
44
|
+
// Public Base mainnet endpoint. Override via BASE_RPC_URL env or
|
|
45
|
+
// `franklin config set base-rpc-url <url>` (Alchemy, QuickNode public, etc.).
|
|
46
|
+
// The user-facing call is the swap submission; quote fetches are off-chain.
|
|
42
47
|
const DEFAULT_BASE_RPC = 'https://mainnet.base.org';
|
|
48
|
+
function resolveBaseRpcUrl() {
|
|
49
|
+
return (process.env.BASE_RPC_URL ||
|
|
50
|
+
loadConfig()['base-rpc-url'] ||
|
|
51
|
+
DEFAULT_BASE_RPC);
|
|
52
|
+
}
|
|
43
53
|
// ─── Session safety: cumulative live-swap counter ─────────────────────────
|
|
44
54
|
const DEFAULT_LIVE_SWAP_CAP = 10;
|
|
45
55
|
const liveSwapCap = (() => {
|
|
@@ -130,59 +140,110 @@ async function loadEvmWallet() {
|
|
|
130
140
|
return { account, address: raw.address };
|
|
131
141
|
}
|
|
132
142
|
function makeClient(account) {
|
|
133
|
-
const rpcUrl = process.env.BASE_RPC_URL || DEFAULT_BASE_RPC;
|
|
134
143
|
return createWalletClient({
|
|
135
144
|
account,
|
|
136
145
|
chain: base,
|
|
137
|
-
transport: http(
|
|
146
|
+
transport: http(resolveBaseRpcUrl()),
|
|
138
147
|
}).extend(publicActions);
|
|
139
148
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
return {
|
|
150
|
-
'Content-Type': 'application/json',
|
|
151
|
-
'0x-api-key': apiKey,
|
|
152
|
-
'0x-version': ZEROX_VERSION,
|
|
149
|
+
// ─── 0x calls via BlockRun gateway (x402-paid) ───────────────────────────
|
|
150
|
+
async function gatewayGetWithPayment(path, params, ctx) {
|
|
151
|
+
const chain = loadChain();
|
|
152
|
+
const apiUrl = API_URLS[chain];
|
|
153
|
+
const endpoint = `${apiUrl}${ZEROX_GATEWAY_PATH}/${path}?${params.toString()}`;
|
|
154
|
+
const headers = {
|
|
155
|
+
Accept: 'application/json',
|
|
156
|
+
'User-Agent': `franklin/${VERSION}`,
|
|
153
157
|
};
|
|
154
|
-
}
|
|
155
|
-
// ─── 0x API calls ────────────────────────────────────────────────────────
|
|
156
|
-
async function zeroxFetch(path, params) {
|
|
157
|
-
const url = `${ZEROX_BASE}/${path}?${params.toString()}`;
|
|
158
158
|
const controller = new AbortController();
|
|
159
159
|
const timer = setTimeout(() => controller.abort(), ZEROX_TIMEOUT_MS);
|
|
160
|
+
const onAbort = () => controller.abort();
|
|
161
|
+
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
160
162
|
try {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
let response = await fetch(endpoint, {
|
|
164
|
+
method: 'GET',
|
|
165
|
+
headers,
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
});
|
|
168
|
+
if (response.status === 402) {
|
|
169
|
+
const paymentHeaders = await signGatewayPayment(response, chain, endpoint);
|
|
170
|
+
if (!paymentHeaders) {
|
|
171
|
+
throw new Error('Payment signing failed — check wallet balance');
|
|
172
|
+
}
|
|
173
|
+
response = await fetch(endpoint, {
|
|
174
|
+
method: 'GET',
|
|
175
|
+
headers: { ...headers, ...paymentHeaders },
|
|
176
|
+
signal: controller.signal,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (!response.ok) {
|
|
180
|
+
const text = await response.text().catch(() => '');
|
|
181
|
+
throw new Error(`BlockRun gateway /v1/zerox/${path} returned ${response.status}: ${text.slice(0, 300)}`);
|
|
165
182
|
}
|
|
166
|
-
return (await
|
|
183
|
+
return (await response.json());
|
|
167
184
|
}
|
|
168
185
|
finally {
|
|
169
186
|
clearTimeout(timer);
|
|
187
|
+
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function signGatewayPayment(response, chain, endpoint) {
|
|
191
|
+
try {
|
|
192
|
+
let header = response.headers.get('payment-required');
|
|
193
|
+
if (!header) {
|
|
194
|
+
try {
|
|
195
|
+
const body = (await response.json());
|
|
196
|
+
if (body.x402 || body.accepts)
|
|
197
|
+
header = btoa(JSON.stringify(body));
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* ignore */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!header)
|
|
204
|
+
return null;
|
|
205
|
+
if (chain === 'solana') {
|
|
206
|
+
const wallet = await getOrCreateSolanaWallet();
|
|
207
|
+
const paymentRequired = parsePaymentRequired(header);
|
|
208
|
+
const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
|
|
209
|
+
const secretBytes = await solanaKeyToBytes(wallet.privateKey);
|
|
210
|
+
const feePayer = details.extra?.feePayer || details.recipient;
|
|
211
|
+
const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
|
|
212
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
213
|
+
resourceDescription: details.resource?.description || 'Franklin 0x swap call',
|
|
214
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
|
|
215
|
+
extra: details.extra,
|
|
216
|
+
});
|
|
217
|
+
return { 'PAYMENT-SIGNATURE': payload };
|
|
218
|
+
}
|
|
219
|
+
const wallet = await getOrCreateWallet();
|
|
220
|
+
const paymentRequired = parsePaymentRequired(header);
|
|
221
|
+
const details = extractPaymentDetails(paymentRequired);
|
|
222
|
+
const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
|
|
223
|
+
resourceUrl: details.resource?.url || endpoint,
|
|
224
|
+
resourceDescription: details.resource?.description || 'Franklin 0x swap call',
|
|
225
|
+
maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
|
|
226
|
+
extra: details.extra,
|
|
227
|
+
});
|
|
228
|
+
return { 'PAYMENT-SIGNATURE': payload };
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
console.error(`[franklin] 0x gateway payment error: ${err.message}`);
|
|
232
|
+
return null;
|
|
170
233
|
}
|
|
171
234
|
}
|
|
172
235
|
function buildSwapParams(args) {
|
|
173
|
-
|
|
236
|
+
// Affiliate params (swapFeeRecipient/Bps/Token) are NOT set here —
|
|
237
|
+
// the BlockRun gateway forces them server-side, ensuring every
|
|
238
|
+
// gateway-routed swap pays affiliate to BlockRun treasury regardless
|
|
239
|
+
// of what the agent passes. See blockrun/src/lib/zerox.ts:proxyToZerox.
|
|
240
|
+
return new URLSearchParams({
|
|
174
241
|
chainId: String(base.id),
|
|
175
242
|
sellToken: args.sellTokenAddr,
|
|
176
243
|
buyToken: args.buyTokenAddr,
|
|
177
244
|
sellAmount: args.sellAmountAtomic,
|
|
178
245
|
taker: args.taker,
|
|
179
246
|
});
|
|
180
|
-
if (args.feeRecipient && args.feeBps && args.feeBps > 0 && args.feeToken) {
|
|
181
|
-
p.set('swapFeeRecipient', args.feeRecipient);
|
|
182
|
-
p.set('swapFeeBps', String(args.feeBps));
|
|
183
|
-
p.set('swapFeeToken', args.feeToken);
|
|
184
|
-
}
|
|
185
|
-
return p;
|
|
186
247
|
}
|
|
187
248
|
// ─── Formatting ──────────────────────────────────────────────────────────
|
|
188
249
|
function formatQuoteText(q) {
|
|
@@ -210,7 +271,7 @@ function formatQuoteText(q) {
|
|
|
210
271
|
return lines.join('\n');
|
|
211
272
|
}
|
|
212
273
|
// ─── Quote (read-only) ───────────────────────────────────────────────────
|
|
213
|
-
async function executeBase0xQuote(input) {
|
|
274
|
+
async function executeBase0xQuote(input, ctx) {
|
|
214
275
|
let walletAddress;
|
|
215
276
|
try {
|
|
216
277
|
const wallet = await loadEvmWallet();
|
|
@@ -231,14 +292,9 @@ async function executeBase0xQuote(input) {
|
|
|
231
292
|
buyTokenAddr,
|
|
232
293
|
sellAmountAtomic: sellAmount,
|
|
233
294
|
taker: walletAddress,
|
|
234
|
-
feeRecipient: BLOCKRUN_BASE_AFFILIATE,
|
|
235
|
-
feeBps: BLOCKRUN_AFFILIATE_FEE_BPS,
|
|
236
|
-
// Take the fee in the sell token so it's deterministic and doesn't
|
|
237
|
-
// depend on the output token having an existing recipient ATA / balance.
|
|
238
|
-
feeToken: sellTokenAddr,
|
|
239
295
|
});
|
|
240
296
|
try {
|
|
241
|
-
const price = await
|
|
297
|
+
const price = await gatewayGetWithPayment('price', params, ctx);
|
|
242
298
|
if (!price.liquidityAvailable && price.liquidityAvailable !== undefined) {
|
|
243
299
|
return {
|
|
244
300
|
output: `0x reports no liquidity for ${symbolFor(sellTokenAddr)} → ${symbolFor(buyTokenAddr)} on Base.`,
|
|
@@ -248,7 +304,7 @@ async function executeBase0xQuote(input) {
|
|
|
248
304
|
return { output: formatQuoteText(price) };
|
|
249
305
|
}
|
|
250
306
|
catch (err) {
|
|
251
|
-
return { output: `0x /price failed: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
|
307
|
+
return { output: `BlockRun gateway 0x /price failed: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
|
252
308
|
}
|
|
253
309
|
}
|
|
254
310
|
// ─── Swap (full execute) ─────────────────────────────────────────────────
|
|
@@ -281,18 +337,16 @@ async function executeBase0xSwap(input, ctx) {
|
|
|
281
337
|
buyTokenAddr,
|
|
282
338
|
sellAmountAtomic: sellAmount,
|
|
283
339
|
taker: wallet.address,
|
|
284
|
-
feeRecipient: BLOCKRUN_BASE_AFFILIATE,
|
|
285
|
-
feeBps: BLOCKRUN_AFFILIATE_FEE_BPS,
|
|
286
|
-
feeToken: sellTokenAddr,
|
|
287
340
|
});
|
|
288
|
-
// Step 1 — fetch the firm quote
|
|
341
|
+
// Step 1 — fetch the firm quote via BlockRun gateway (x402-paid).
|
|
342
|
+
// Gateway forces affiliate params server-side; user pays $0.001 USDC.
|
|
289
343
|
let quote;
|
|
290
344
|
try {
|
|
291
|
-
quote = await
|
|
345
|
+
quote = await gatewayGetWithPayment('quote', params, ctx);
|
|
292
346
|
}
|
|
293
347
|
catch (err) {
|
|
294
348
|
return {
|
|
295
|
-
output: `0x /quote failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
349
|
+
output: `BlockRun gateway 0x /quote failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
296
350
|
isError: true,
|
|
297
351
|
};
|
|
298
352
|
}
|
|
@@ -444,15 +498,15 @@ export const base0xQuoteCapability = {
|
|
|
444
498
|
properties: COMMON_INPUT_PROPERTIES,
|
|
445
499
|
},
|
|
446
500
|
},
|
|
447
|
-
execute: async (input) => {
|
|
448
|
-
return executeBase0xQuote(input);
|
|
501
|
+
execute: async (input, ctx) => {
|
|
502
|
+
return executeBase0xQuote(input, ctx);
|
|
449
503
|
},
|
|
450
504
|
concurrent: true,
|
|
451
505
|
};
|
|
452
506
|
export const base0xSwapCapability = {
|
|
453
507
|
spec: {
|
|
454
508
|
name: 'Base0xSwap',
|
|
455
|
-
description: "Execute a Base DEX swap via 0x V2 (Permit2). Quotes
|
|
509
|
+
description: "Execute a Base DEX swap via 0x V2 (Permit2). Quotes through BlockRun gateway (x402-paid, server-side 0x key — no user setup needed), asks the user to confirm, signs locally with the Franklin Base wallet, and submits via Base RPC. A 20 bps affiliate fee in the sell-token is collected on-chain by 0x as part of the swap (BlockRun affiliate program — official 0x integrator mechanism). Returns the BaseScan transaction link.",
|
|
456
510
|
input_schema: {
|
|
457
511
|
type: 'object',
|
|
458
512
|
required: ['sell_token', 'buy_token', 'sell_amount'],
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0x Gasless API V2 — Franklin built-in tool for Base swaps WITHOUT user gas.
|
|
3
|
+
*
|
|
4
|
+
* UX win over Base0xSwap (Permit2): the user signs only EIP-712 typed-data
|
|
5
|
+
* (no on-chain approval, no on-chain swap submission). 0x's relayer
|
|
6
|
+
* broadcasts the trade and pays gas; the user pays nothing in ETH and only
|
|
7
|
+
* needs to hold the input token (USDC, DAI, or other Permit-supporting
|
|
8
|
+
* ERC-20).
|
|
9
|
+
*
|
|
10
|
+
* For tokens that don't support Permit (USDT etc.) we error out and tell
|
|
11
|
+
* the user to either use Base0xSwap (which can do a one-time approve+swap
|
|
12
|
+
* with ETH gas) or swap from a Permit-supporting token instead.
|
|
13
|
+
*
|
|
14
|
+
* Routes through BlockRun gateway (/v1/zerox/gasless/{price,quote,submit,status})
|
|
15
|
+
* — server holds the 0x API key, no user setup needed. On-chain affiliate
|
|
16
|
+
* (20 bps) is force-set at quote time on the gateway side.
|
|
17
|
+
*
|
|
18
|
+
* Reference: https://github.com/0xProject/0x-examples/tree/main/gasless-v2-headless-example
|
|
19
|
+
*/
|
|
20
|
+
import type { CapabilityHandler } from '../agent/types.js';
|
|
21
|
+
export declare const base0xGaslessSwapCapability: CapabilityHandler;
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0x Gasless API V2 — Franklin built-in tool for Base swaps WITHOUT user gas.
|
|
3
|
+
*
|
|
4
|
+
* UX win over Base0xSwap (Permit2): the user signs only EIP-712 typed-data
|
|
5
|
+
* (no on-chain approval, no on-chain swap submission). 0x's relayer
|
|
6
|
+
* broadcasts the trade and pays gas; the user pays nothing in ETH and only
|
|
7
|
+
* needs to hold the input token (USDC, DAI, or other Permit-supporting
|
|
8
|
+
* ERC-20).
|
|
9
|
+
*
|
|
10
|
+
* For tokens that don't support Permit (USDT etc.) we error out and tell
|
|
11
|
+
* the user to either use Base0xSwap (which can do a one-time approve+swap
|
|
12
|
+
* with ETH gas) or swap from a Permit-supporting token instead.
|
|
13
|
+
*
|
|
14
|
+
* Routes through BlockRun gateway (/v1/zerox/gasless/{price,quote,submit,status})
|
|
15
|
+
* — server holds the 0x API key, no user setup needed. On-chain affiliate
|
|
16
|
+
* (20 bps) is force-set at quote time on the gateway side.
|
|
17
|
+
*
|
|
18
|
+
* Reference: https://github.com/0xProject/0x-examples/tree/main/gasless-v2-headless-example
|
|
19
|
+
*/
|
|
20
|
+
import { parseUnits, formatUnits, } from 'viem';
|
|
21
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
22
|
+
import { base } from 'viem/chains';
|
|
23
|
+
import { createWalletClient, http, publicActions } from 'viem';
|
|
24
|
+
import { getOrCreateWallet } from '@blockrun/llm';
|
|
25
|
+
import { loadConfig } from '../commands/config.js';
|
|
26
|
+
import { loadChain, API_URLS, VERSION } from '../config.js';
|
|
27
|
+
// ─── Constants ────────────────────────────────────────────────────────────
|
|
28
|
+
const ZEROX_GATEWAY_PATH = '/v1/zerox/gasless';
|
|
29
|
+
const QUOTE_TIMEOUT_MS = 30_000;
|
|
30
|
+
const SUBMIT_TIMEOUT_MS = 30_000;
|
|
31
|
+
const STATUS_TIMEOUT_MS = 10_000;
|
|
32
|
+
const MAX_STATUS_POLL_MS = 60_000; // hard ceiling on confirmation wait
|
|
33
|
+
const STATUS_POLL_INTERVAL_MS = 3_000;
|
|
34
|
+
// 0x SignatureType.EIP712 = 2 (per @0x/utils/signature.ts)
|
|
35
|
+
const SIGNATURE_TYPE_EIP712 = 2;
|
|
36
|
+
// Session safety guards — same pattern as zerox-base.ts
|
|
37
|
+
const DEFAULT_LIVE_SWAP_CAP = 10;
|
|
38
|
+
const liveSwapCap = (() => {
|
|
39
|
+
const raw = process.env.FRANKLIN_LIVE_SWAP_CAP;
|
|
40
|
+
if (!raw)
|
|
41
|
+
return DEFAULT_LIVE_SWAP_CAP;
|
|
42
|
+
const n = Number(raw);
|
|
43
|
+
if (!Number.isFinite(n))
|
|
44
|
+
return DEFAULT_LIVE_SWAP_CAP;
|
|
45
|
+
if (n <= 0)
|
|
46
|
+
return Infinity;
|
|
47
|
+
return Math.floor(n);
|
|
48
|
+
})();
|
|
49
|
+
let liveSwapCount = 0;
|
|
50
|
+
const DEFAULT_LARGE_SWAP_USD = 20;
|
|
51
|
+
const largeSwapThresholdUsd = (() => {
|
|
52
|
+
const raw = process.env.FRANKLIN_LIVE_SWAP_WARN_USD;
|
|
53
|
+
if (!raw)
|
|
54
|
+
return DEFAULT_LARGE_SWAP_USD;
|
|
55
|
+
const n = Number(raw);
|
|
56
|
+
if (!Number.isFinite(n) || n < 0)
|
|
57
|
+
return DEFAULT_LARGE_SWAP_USD;
|
|
58
|
+
return n;
|
|
59
|
+
})();
|
|
60
|
+
// ─── Base token map (mirror zerox-base.ts) ────────────────────────────────
|
|
61
|
+
const SYMBOL_TO_ADDRESS = {
|
|
62
|
+
WETH: '0x4200000000000000000000000000000000000006',
|
|
63
|
+
USDC: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
64
|
+
USDT: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2',
|
|
65
|
+
CBBTC: '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf',
|
|
66
|
+
CBETH: '0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22',
|
|
67
|
+
AERO: '0x940181a94A35A4569E4529A3CDfB74e38FD98631',
|
|
68
|
+
DAI: '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb',
|
|
69
|
+
};
|
|
70
|
+
const TOKEN_DECIMALS = {
|
|
71
|
+
'0x4200000000000000000000000000000000000006': 18,
|
|
72
|
+
'0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': 6,
|
|
73
|
+
'0xfde4c96c8593536e31f229ea8f37b2ada2699bb2': 6,
|
|
74
|
+
'0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf': 8,
|
|
75
|
+
'0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22': 18,
|
|
76
|
+
'0x940181a94a35a4569e4529a3cdfb74e38fd98631': 18,
|
|
77
|
+
'0x50c5725949a6f0c72e6c4a641f24049a917db0cb': 18,
|
|
78
|
+
};
|
|
79
|
+
const STABLECOIN_ADDRESSES = new Set([
|
|
80
|
+
'0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
|
|
81
|
+
'0xfde4c96c8593536e31f229ea8f37b2ada2699bb2',
|
|
82
|
+
'0x50c5725949a6f0c72e6c4a641f24049a917db0cb',
|
|
83
|
+
]);
|
|
84
|
+
function resolveTokenAddress(input) {
|
|
85
|
+
const upper = input.trim().toUpperCase();
|
|
86
|
+
if (SYMBOL_TO_ADDRESS[upper])
|
|
87
|
+
return SYMBOL_TO_ADDRESS[upper];
|
|
88
|
+
return input.trim();
|
|
89
|
+
}
|
|
90
|
+
function decimalsFor(address) {
|
|
91
|
+
return TOKEN_DECIMALS[address.toLowerCase()] ?? 18;
|
|
92
|
+
}
|
|
93
|
+
function symbolFor(address) {
|
|
94
|
+
const lower = address.toLowerCase();
|
|
95
|
+
for (const [sym, addr] of Object.entries(SYMBOL_TO_ADDRESS)) {
|
|
96
|
+
if (addr.toLowerCase() === lower)
|
|
97
|
+
return sym;
|
|
98
|
+
}
|
|
99
|
+
return `${address.slice(0, 6)}…${address.slice(-4)}`;
|
|
100
|
+
}
|
|
101
|
+
function isStablecoin(address) {
|
|
102
|
+
return STABLECOIN_ADDRESSES.has(address.toLowerCase());
|
|
103
|
+
}
|
|
104
|
+
function estimateUsdValue(addr, humanAmount) {
|
|
105
|
+
if (isStablecoin(addr))
|
|
106
|
+
return humanAmount;
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
// ─── Signature helpers ────────────────────────────────────────────────────
|
|
110
|
+
// Split a 65-byte 0x-prefixed signature into the {r, s, v, signatureType}
|
|
111
|
+
// shape that 0x's /gasless/submit expects.
|
|
112
|
+
function splitEip712Signature(sig) {
|
|
113
|
+
if (!sig.startsWith('0x'))
|
|
114
|
+
throw new Error('signature must be 0x-prefixed');
|
|
115
|
+
const noPrefix = sig.slice(2);
|
|
116
|
+
if (noPrefix.length !== 130) {
|
|
117
|
+
throw new Error(`expected 65-byte signature, got ${noPrefix.length / 2} bytes`);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
r: `0x${noPrefix.slice(0, 64)}`,
|
|
121
|
+
s: `0x${noPrefix.slice(64, 128)}`,
|
|
122
|
+
v: parseInt(noPrefix.slice(128, 130), 16),
|
|
123
|
+
signatureType: SIGNATURE_TYPE_EIP712,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// ─── Wallet + RPC ─────────────────────────────────────────────────────────
|
|
127
|
+
const DEFAULT_BASE_RPC = 'https://mainnet.base.org';
|
|
128
|
+
function resolveBaseRpcUrl() {
|
|
129
|
+
return (process.env.BASE_RPC_URL ||
|
|
130
|
+
loadConfig()['base-rpc-url'] ||
|
|
131
|
+
DEFAULT_BASE_RPC);
|
|
132
|
+
}
|
|
133
|
+
async function loadEvmWallet() {
|
|
134
|
+
const raw = await getOrCreateWallet();
|
|
135
|
+
const account = privateKeyToAccount(raw.privateKey);
|
|
136
|
+
return { account, address: raw.address };
|
|
137
|
+
}
|
|
138
|
+
function makeClient(account) {
|
|
139
|
+
return createWalletClient({
|
|
140
|
+
account,
|
|
141
|
+
chain: base,
|
|
142
|
+
transport: http(resolveBaseRpcUrl()),
|
|
143
|
+
}).extend(publicActions);
|
|
144
|
+
}
|
|
145
|
+
async function gatewayGet(pathSuffix, query, timeoutMs, ctx) {
|
|
146
|
+
const apiUrl = API_URLS[loadChain()];
|
|
147
|
+
const url = `${apiUrl}${ZEROX_GATEWAY_PATH}/${pathSuffix}?${query.toString()}`;
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
150
|
+
const onAbort = () => controller.abort();
|
|
151
|
+
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(url, {
|
|
154
|
+
method: 'GET',
|
|
155
|
+
headers: { Accept: 'application/json', 'User-Agent': `franklin/${VERSION}` },
|
|
156
|
+
signal: controller.signal,
|
|
157
|
+
});
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
const text = await res.text().catch(() => '');
|
|
160
|
+
throw new Error(`gateway ${pathSuffix} returned ${res.status}: ${text.slice(0, 300)}`);
|
|
161
|
+
}
|
|
162
|
+
return (await res.json());
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function gatewayPost(pathSuffix, body, timeoutMs, ctx) {
|
|
170
|
+
const apiUrl = API_URLS[loadChain()];
|
|
171
|
+
const url = `${apiUrl}${ZEROX_GATEWAY_PATH}/${pathSuffix}`;
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
174
|
+
const onAbort = () => controller.abort();
|
|
175
|
+
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
176
|
+
try {
|
|
177
|
+
const res = await fetch(url, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: {
|
|
180
|
+
Accept: 'application/json',
|
|
181
|
+
'Content-Type': 'application/json',
|
|
182
|
+
'User-Agent': `franklin/${VERSION}`,
|
|
183
|
+
},
|
|
184
|
+
body: JSON.stringify(body),
|
|
185
|
+
signal: controller.signal,
|
|
186
|
+
});
|
|
187
|
+
if (!res.ok) {
|
|
188
|
+
const text = await res.text().catch(() => '');
|
|
189
|
+
throw new Error(`gateway ${pathSuffix} returned ${res.status}: ${text.slice(0, 300)}`);
|
|
190
|
+
}
|
|
191
|
+
return (await res.json());
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ─── Formatting ──────────────────────────────────────────────────────────
|
|
199
|
+
function formatGaslessQuoteText(q) {
|
|
200
|
+
const sellDec = decimalsFor(q.sellToken);
|
|
201
|
+
const buyDec = decimalsFor(q.buyToken);
|
|
202
|
+
const sellHuman = Number(formatUnits(BigInt(q.sellAmount), sellDec));
|
|
203
|
+
const buyHuman = Number(formatUnits(BigInt(q.buyAmount), buyDec));
|
|
204
|
+
const sellSym = symbolFor(q.sellToken);
|
|
205
|
+
const buySym = symbolFor(q.buyToken);
|
|
206
|
+
const rate = sellHuman > 0 ? buyHuman / sellHuman : 0;
|
|
207
|
+
const route = q.route?.fills?.map((f) => f.source).filter(Boolean).slice(0, 4).join(' → ') ||
|
|
208
|
+
'0x V2 router';
|
|
209
|
+
const minOut = q.minBuyAmount
|
|
210
|
+
? Number(formatUnits(BigInt(q.minBuyAmount), buyDec))
|
|
211
|
+
: null;
|
|
212
|
+
const lines = [
|
|
213
|
+
`${sellHuman.toFixed(Math.min(8, sellDec))} ${sellSym} → ${buyHuman.toFixed(Math.min(8, buyDec))} ${buySym}`,
|
|
214
|
+
`Rate: 1 ${sellSym} ≈ ${rate.toPrecision(6)} ${buySym}`,
|
|
215
|
+
];
|
|
216
|
+
if (minOut != null) {
|
|
217
|
+
lines.push(`Min received: ${minOut.toFixed(Math.min(8, buyDec))} ${buySym}`);
|
|
218
|
+
}
|
|
219
|
+
lines.push(`Route: ${route}`);
|
|
220
|
+
lines.push(`Gas: paid by 0x relayer (you pay nothing in ETH)`);
|
|
221
|
+
lines.push(`Affiliate fee: 20 bps in ${sellSym} (BlockRun affiliate)`);
|
|
222
|
+
return lines.join('\n');
|
|
223
|
+
}
|
|
224
|
+
// ─── Status polling ──────────────────────────────────────────────────────
|
|
225
|
+
async function pollUntilDone(tradeHash, ctx) {
|
|
226
|
+
const deadline = Date.now() + MAX_STATUS_POLL_MS;
|
|
227
|
+
let last = { status: 'pending' };
|
|
228
|
+
while (Date.now() < deadline) {
|
|
229
|
+
if (ctx.abortSignal.aborted) {
|
|
230
|
+
throw new Error('aborted while polling status');
|
|
231
|
+
}
|
|
232
|
+
const params = new URLSearchParams({ chainId: String(base.id) });
|
|
233
|
+
try {
|
|
234
|
+
last = await gatewayGet(`status/${tradeHash}`, params, STATUS_TIMEOUT_MS, ctx);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
// Surface a transient failure but keep polling — relayer might be backlogged.
|
|
238
|
+
console.error(`[franklin] gasless status poll error: ${err.message}`);
|
|
239
|
+
}
|
|
240
|
+
if (last.status === 'confirmed' ||
|
|
241
|
+
last.status === 'succeeded' ||
|
|
242
|
+
last.status === 'failed') {
|
|
243
|
+
return last;
|
|
244
|
+
}
|
|
245
|
+
await new Promise((resolve) => setTimeout(resolve, STATUS_POLL_INTERVAL_MS));
|
|
246
|
+
}
|
|
247
|
+
return last;
|
|
248
|
+
}
|
|
249
|
+
async function executeBase0xGaslessSwap(input, ctx) {
|
|
250
|
+
if (liveSwapCount >= liveSwapCap) {
|
|
251
|
+
return {
|
|
252
|
+
output: `Live-swap session cap reached (${liveSwapCount}/${liveSwapCap}). Stopping to protect your wallet.\n` +
|
|
253
|
+
`Override with FRANKLIN_LIVE_SWAP_CAP=20 (or 0 to disable), or restart Franklin to reset.`,
|
|
254
|
+
isError: true,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
let wallet;
|
|
258
|
+
try {
|
|
259
|
+
wallet = await loadEvmWallet();
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
return {
|
|
263
|
+
output: `Couldn't load Base wallet: ${err instanceof Error ? err.message : String(err)}`,
|
|
264
|
+
isError: true,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const sellTokenAddr = resolveTokenAddress(input.sell_token);
|
|
268
|
+
const buyTokenAddr = resolveTokenAddress(input.buy_token);
|
|
269
|
+
const sellDec = decimalsFor(sellTokenAddr);
|
|
270
|
+
const sellAmount = parseUnits(input.sell_amount.toString(), sellDec).toString();
|
|
271
|
+
// Step 1 — fetch the gasless firm quote.
|
|
272
|
+
const quoteParams = new URLSearchParams({
|
|
273
|
+
chainId: String(base.id),
|
|
274
|
+
sellToken: sellTokenAddr,
|
|
275
|
+
buyToken: buyTokenAddr,
|
|
276
|
+
sellAmount,
|
|
277
|
+
taker: wallet.address,
|
|
278
|
+
});
|
|
279
|
+
let quote;
|
|
280
|
+
try {
|
|
281
|
+
quote = await gatewayGet('quote', quoteParams, QUOTE_TIMEOUT_MS, ctx);
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
return {
|
|
285
|
+
output: `Gasless /quote failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
286
|
+
isError: true,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (!quote.trade?.eip712) {
|
|
290
|
+
return {
|
|
291
|
+
output: `0x didn't return a tradable gasless quote — likely the pair has insufficient liquidity for gasless. ` +
|
|
292
|
+
`Try Base0xSwap (Permit2) instead, or a different output token.`,
|
|
293
|
+
isError: true,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
// Determine if approval is needed and whether gasless approval is available.
|
|
297
|
+
const approvalRequired = quote.issues?.allowance != null;
|
|
298
|
+
const gaslessApprovalAvailable = quote.approval != null;
|
|
299
|
+
if (approvalRequired && !gaslessApprovalAvailable) {
|
|
300
|
+
return {
|
|
301
|
+
output: `${symbolFor(sellTokenAddr)} doesn't support gasless approval (Permit) on Base — first-time use needs a one-time on-chain approve, which requires ETH.\n\n` +
|
|
302
|
+
`Two ways forward:\n` +
|
|
303
|
+
`1. Use Base0xSwap instead — it can do approve+swap with ETH gas.\n` +
|
|
304
|
+
`2. Swap from a Permit-supporting token (USDC, DAI) to ${symbolFor(buyTokenAddr)} instead.`,
|
|
305
|
+
isError: true,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
// Step 2 — confirm with user.
|
|
309
|
+
if (!input.auto_approve && ctx.onAskUser) {
|
|
310
|
+
const quoteText = formatGaslessQuoteText(quote);
|
|
311
|
+
const usdEst = estimateUsdValue(sellTokenAddr, input.sell_amount);
|
|
312
|
+
const sections = [
|
|
313
|
+
'Execute this gasless 0x swap on Base (no ETH needed)?',
|
|
314
|
+
'',
|
|
315
|
+
quoteText,
|
|
316
|
+
];
|
|
317
|
+
if (usdEst != null && usdEst >= largeSwapThresholdUsd) {
|
|
318
|
+
sections.push('', `⚠ Large swap warning — input is ~$${usdEst.toFixed(2)} (above $${largeSwapThresholdUsd} threshold).`);
|
|
319
|
+
}
|
|
320
|
+
else if (usdEst == null) {
|
|
321
|
+
sections.push('', `Note: input is not a stablecoin, so I cannot price-check this in USD before signing. Verify the output amount matches your intent.`);
|
|
322
|
+
}
|
|
323
|
+
sections.push('', `Wallet: ${wallet.address}`, `Live-swap session count: ${liveSwapCount}/${liveSwapCap === Infinity ? '∞' : liveSwapCap}`);
|
|
324
|
+
const answer = await ctx.onAskUser(sections.join('\n'), ['Confirm', 'Cancel']);
|
|
325
|
+
if (answer.toLowerCase() !== 'confirm') {
|
|
326
|
+
return { output: 'Swap cancelled by user.' };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const client = makeClient(wallet.account);
|
|
330
|
+
// Step 3 — sign trade typed-data.
|
|
331
|
+
let tradeSigHex;
|
|
332
|
+
try {
|
|
333
|
+
tradeSigHex = (await client.signTypedData({
|
|
334
|
+
account: wallet.account,
|
|
335
|
+
types: quote.trade.eip712.types,
|
|
336
|
+
domain: quote.trade.eip712.domain,
|
|
337
|
+
message: quote.trade.eip712.message,
|
|
338
|
+
primaryType: quote.trade.eip712.primaryType,
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
return {
|
|
343
|
+
output: `Trade signature failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
344
|
+
isError: true,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const tradeSplitSig = splitEip712Signature(tradeSigHex);
|
|
348
|
+
const tradeSubmitObject = {
|
|
349
|
+
type: quote.trade.type,
|
|
350
|
+
eip712: quote.trade.eip712,
|
|
351
|
+
signature: tradeSplitSig,
|
|
352
|
+
};
|
|
353
|
+
// Step 4 — sign approval typed-data if required and available.
|
|
354
|
+
let approvalSubmitObject;
|
|
355
|
+
if (approvalRequired && gaslessApprovalAvailable && quote.approval) {
|
|
356
|
+
try {
|
|
357
|
+
const approvalSigHex = (await client.signTypedData({
|
|
358
|
+
account: wallet.account,
|
|
359
|
+
types: quote.approval.eip712.types,
|
|
360
|
+
domain: quote.approval.eip712.domain,
|
|
361
|
+
message: quote.approval.eip712.message,
|
|
362
|
+
primaryType: quote.approval.eip712.primaryType,
|
|
363
|
+
}));
|
|
364
|
+
const approvalSplitSig = splitEip712Signature(approvalSigHex);
|
|
365
|
+
approvalSubmitObject = {
|
|
366
|
+
type: quote.approval.type,
|
|
367
|
+
eip712: quote.approval.eip712,
|
|
368
|
+
signature: approvalSplitSig,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
return {
|
|
373
|
+
output: `Approval signature failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
374
|
+
isError: true,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Step 5 — submit to 0x relayer via gateway.
|
|
379
|
+
const submitBody = {
|
|
380
|
+
trade: tradeSubmitObject,
|
|
381
|
+
chainId: base.id,
|
|
382
|
+
};
|
|
383
|
+
if (approvalSubmitObject)
|
|
384
|
+
submitBody.approval = approvalSubmitObject;
|
|
385
|
+
let submitRes;
|
|
386
|
+
try {
|
|
387
|
+
submitRes = await gatewayPost('submit', submitBody, SUBMIT_TIMEOUT_MS, ctx);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
return {
|
|
391
|
+
output: `Gasless /submit failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
392
|
+
isError: true,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
if (!submitRes.tradeHash) {
|
|
396
|
+
return {
|
|
397
|
+
output: `0x relayer didn't return a tradeHash — submission may have been rejected.`,
|
|
398
|
+
isError: true,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
// Step 6 — poll status until confirmed / failed / timeout.
|
|
402
|
+
const final = await pollUntilDone(submitRes.tradeHash, ctx);
|
|
403
|
+
liveSwapCount += 1;
|
|
404
|
+
const onChainHash = final.transactions?.[0]?.hash;
|
|
405
|
+
const explorer = onChainHash ? `https://basescan.org/tx/${onChainHash}` : null;
|
|
406
|
+
const statusLine = final.status === 'confirmed' || final.status === 'succeeded'
|
|
407
|
+
? '✓ Swap confirmed.'
|
|
408
|
+
: final.status === 'failed'
|
|
409
|
+
? `✗ Swap failed: ${final.reason ?? 'unknown reason'}`
|
|
410
|
+
: `⏳ Still pending after ${MAX_STATUS_POLL_MS / 1000}s — relayer is backlogged. Check status later via /v1/zerox/gasless/status/${submitRes.tradeHash}.`;
|
|
411
|
+
const lines = [
|
|
412
|
+
statusLine,
|
|
413
|
+
formatGaslessQuoteText(quote),
|
|
414
|
+
`Trade hash: ${submitRes.tradeHash}`,
|
|
415
|
+
];
|
|
416
|
+
if (onChainHash)
|
|
417
|
+
lines.push(`On-chain tx: ${onChainHash}`);
|
|
418
|
+
if (explorer)
|
|
419
|
+
lines.push(explorer);
|
|
420
|
+
lines.push(`(Session live-swap count: ${liveSwapCount}/${liveSwapCap === Infinity ? '∞' : liveSwapCap})`);
|
|
421
|
+
return {
|
|
422
|
+
output: lines.join('\n'),
|
|
423
|
+
isError: final.status === 'failed',
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
// ─── Capability handler ──────────────────────────────────────────────────
|
|
427
|
+
export const base0xGaslessSwapCapability = {
|
|
428
|
+
spec: {
|
|
429
|
+
name: 'Base0xGaslessSwap',
|
|
430
|
+
description: "Execute a Base DEX swap via 0x Gasless V2. The user signs only EIP-712 typed-data (offline, no on-chain action) — 0x's relayer broadcasts the trade and pays gas. **The user does NOT need any ETH for gas.** Only input token (USDC, DAI, etc. — Permit-supporting ERC-20) is required. Returns the BaseScan link once the relayer confirms. " +
|
|
431
|
+
"Routes through BlockRun gateway /v1/zerox/gasless/* — no 0x signup needed. Affiliate 20 bps in sell-token to BlockRun treasury (server-side enforced). " +
|
|
432
|
+
"Use this instead of Base0xSwap when the user has 0 ETH but holds USDC/DAI. For tokens that don't support Permit (USDT etc.), the tool errors with a clear instruction to use Base0xSwap instead.",
|
|
433
|
+
input_schema: {
|
|
434
|
+
type: 'object',
|
|
435
|
+
required: ['sell_token', 'buy_token', 'sell_amount'],
|
|
436
|
+
properties: {
|
|
437
|
+
sell_token: {
|
|
438
|
+
type: 'string',
|
|
439
|
+
description: 'Sell-token address or symbol. ONLY Permit-supporting tokens work for fully-gasless flow on Base: USDC, DAI. ETH is native — use Base0xSwap for ETH input. USDT does NOT support Permit on Base.',
|
|
440
|
+
},
|
|
441
|
+
buy_token: {
|
|
442
|
+
type: 'string',
|
|
443
|
+
description: 'Buy-token address or symbol (any token).',
|
|
444
|
+
},
|
|
445
|
+
sell_amount: {
|
|
446
|
+
type: 'number',
|
|
447
|
+
description: 'Amount of sell_token in human units (e.g. 0.1 USDC).',
|
|
448
|
+
},
|
|
449
|
+
auto_approve: {
|
|
450
|
+
type: 'boolean',
|
|
451
|
+
description: 'If true, skip the AskUser confirm. Default false. Only use when the user just authorized this specific call.',
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
execute: async (input, ctx) => {
|
|
457
|
+
return executeBase0xGaslessSwap(input, ctx);
|
|
458
|
+
},
|
|
459
|
+
concurrent: false,
|
|
460
|
+
};
|
package/package.json
CHANGED