@blockrun/franklin 3.14.1 → 3.15.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/dist/agent/context.js
CHANGED
|
@@ -213,11 +213,20 @@ 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
|
|
217
|
-
|
|
218
|
-
-
|
|
219
|
-
-
|
|
220
|
-
-
|
|
216
|
+
**Base DEX swap (0x V2 via BlockRun gateway)** — three modes, pick by user's wallet state:
|
|
217
|
+
|
|
218
|
+
- **\`Base0xQuote\`** (read-only): inspect price + impact + route. Free.
|
|
219
|
+
- **\`Base0xSwap\`** (Permit2): user signs Permit2 typed-data + submits the tx themselves to Base RPC. **User needs ETH for gas.** Routes through BlockRun gateway \`/v1/zerox/{price,quote}\` — no 0x signup needed.
|
|
220
|
+
- **\`Base0xGaslessSwap\`** (Gasless V2): user signs ONLY EIP-712 typed-data (offline, no on-chain action). 0x's relayer broadcasts the trade and pays gas. **User does NOT need any ETH.** Only works for Permit-supporting input tokens (USDC, DAI). USDT etc. do not support Permit on Base — the tool errors with that instruction. Routes through \`/v1/zerox/gasless/*\`.
|
|
221
|
+
|
|
222
|
+
**Pick the right tool:**
|
|
223
|
+
- User holds ETH on Base → use \`Base0xSwap\` (more flexibility, supports any input token).
|
|
224
|
+
- User holds USDC/DAI but no ETH → use \`Base0xGaslessSwap\` (zero gas needed).
|
|
225
|
+
- User asks for a quote without committing → use \`Base0xQuote\`.
|
|
226
|
+
|
|
227
|
+
Symbol shortcuts pre-mapped on all three: ETH (native, Base0xSwap only), WETH, USDC, USDT, CBBTC, CBETH, AERO, DAI. Raw \`0x...\` addresses pass through.
|
|
228
|
+
|
|
229
|
+
On-chain affiliate (20 bps in sell-token, force-set server-side) flows to BlockRun treasury at settlement on all three paths. BlockRun never custodies user keys; signing is always local.
|
|
221
230
|
|
|
222
231
|
**Sandbox (POST, x402-paid)**
|
|
223
232
|
- \`/v1/modal/{...path}\` — Modal GPU sandbox passthrough (create/exec/etc.).
|
|
@@ -3,6 +3,7 @@ export declare function normalizeSearchQuery(query: string): {
|
|
|
3
3
|
normalized: string;
|
|
4
4
|
tokens: string[];
|
|
5
5
|
};
|
|
6
|
+
export declare function isToolClassFailure(name: string, result: CapabilityResult): boolean;
|
|
6
7
|
export declare class SessionToolGuard {
|
|
7
8
|
private turn;
|
|
8
9
|
private webSearchesThisTurn;
|
package/dist/agent/tool-guard.js
CHANGED
|
@@ -128,6 +128,35 @@ function readKey(resolved, offset, limit) {
|
|
|
128
128
|
function fetchKey(url, maxLength) {
|
|
129
129
|
return `${url}::${maxLength ?? 12288}`;
|
|
130
130
|
}
|
|
131
|
+
// Circuit-breaker classifier for the per-tool kill switch.
|
|
132
|
+
//
|
|
133
|
+
// `isError: true` covers everything from "tool itself broke" (network, parse,
|
|
134
|
+
// timeout) to "agent fed me a bad input" (404 on a guessed URL, malformed URL).
|
|
135
|
+
// Only the first category should count toward disabling the tool — otherwise
|
|
136
|
+
// three hallucinated URLs in one prompt permanently kill WebFetch for the
|
|
137
|
+
// session, even though the tool worked correctly each time.
|
|
138
|
+
export function isToolClassFailure(name, result) {
|
|
139
|
+
if (!result.isError)
|
|
140
|
+
return false;
|
|
141
|
+
const out = String(result.output ?? '');
|
|
142
|
+
if (name === 'WebFetch') {
|
|
143
|
+
// HTTP 4xx/5xx — the URL was real-but-wrong or the upstream had issues.
|
|
144
|
+
// Either way, the tool worked; the agent should pick a different URL.
|
|
145
|
+
if (/^HTTP \d{3}\b/.test(out))
|
|
146
|
+
return false;
|
|
147
|
+
// Bad URL syntax / unsupported protocol / missing arg — agent input error.
|
|
148
|
+
if (out.startsWith('Error: invalid URL'))
|
|
149
|
+
return false;
|
|
150
|
+
if (out.startsWith('Error: only http'))
|
|
151
|
+
return false;
|
|
152
|
+
if (out.startsWith('Error: url is required'))
|
|
153
|
+
return false;
|
|
154
|
+
// User interrupt — not a tool failure.
|
|
155
|
+
if (out.startsWith('Error: request aborted'))
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
131
160
|
export class SessionToolGuard {
|
|
132
161
|
turn = 0;
|
|
133
162
|
webSearchesThisTurn = 0;
|
|
@@ -227,10 +256,15 @@ export class SessionToolGuard {
|
|
|
227
256
|
return null;
|
|
228
257
|
}
|
|
229
258
|
afterExecute(invocation, result) {
|
|
230
|
-
//
|
|
231
|
-
|
|
259
|
+
// Per-tool circuit breaker: count consecutive tool-class failures, reset on
|
|
260
|
+
// any success. Agent-input errors (e.g. WebFetch 404 on a guessed URL) are
|
|
261
|
+
// not tool failures and must not trip the breaker.
|
|
262
|
+
if (isToolClassFailure(invocation.name, result)) {
|
|
232
263
|
this.toolErrorCounts.set(invocation.name, (this.toolErrorCounts.get(invocation.name) ?? 0) + 1);
|
|
233
264
|
}
|
|
265
|
+
else if (!result.isError) {
|
|
266
|
+
this.toolErrorCounts.delete(invocation.name);
|
|
267
|
+
}
|
|
234
268
|
switch (invocation.name) {
|
|
235
269
|
case 'WebSearch':
|
|
236
270
|
case 'SearchX':
|
package/dist/tools/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import { webhookPostCapability } from './webhook.js';
|
|
|
27
27
|
import { walletCapability } from './wallet.js';
|
|
28
28
|
import { jupiterQuoteCapability, jupiterSwapCapability } from './jupiter.js';
|
|
29
29
|
import { base0xQuoteCapability, base0xSwapCapability } from './zerox-base.js';
|
|
30
|
+
import { base0xGaslessSwapCapability } from './zerox-gasless.js';
|
|
30
31
|
import { defiLlamaProtocolsCapability, defiLlamaProtocolCapability, defiLlamaChainsCapability, defiLlamaYieldsCapability, defiLlamaPriceCapability, } from './defillama.js';
|
|
31
32
|
import { createTradingCapabilities } from './trading-execute.js';
|
|
32
33
|
import { Portfolio } from '../trading/portfolio.js';
|
|
@@ -151,6 +152,7 @@ export const allCapabilities = [
|
|
|
151
152
|
jupiterSwapCapability,
|
|
152
153
|
base0xQuoteCapability,
|
|
153
154
|
base0xSwapCapability,
|
|
155
|
+
base0xGaslessSwapCapability,
|
|
154
156
|
defiLlamaProtocolsCapability,
|
|
155
157
|
defiLlamaProtocolCapability,
|
|
156
158
|
defiLlamaChainsCapability,
|
|
@@ -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