@blockrun/franklin 3.12.2 → 3.13.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 +54 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/jupiter.js +85 -6
- package/dist/tools/zerox-base.d.ts +26 -0
- package/dist/tools/zerox-base.js +472 -0
- package/package.json +1 -1
package/dist/agent/context.js
CHANGED
|
@@ -213,6 +213,12 @@ 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). Mirrors Jupiter's local-call posture: 0x's API is hit directly, the user signs the Permit2 EIP-712 with their Base keypair, the tx settles on Base RPC. 0x's official affiliate program embeds 20 bps in the sell-token automatically (BlockRun affiliate, on-chain).
|
|
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
|
+
- 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).
|
|
221
|
+
|
|
216
222
|
**Sandbox (POST, x402-paid)**
|
|
217
223
|
- \`/v1/modal/{...path}\` — Modal GPU sandbox passthrough (create/exec/etc.).
|
|
218
224
|
- \`/v1/pm/{...path}\` — prediction-market data passthrough.
|
|
@@ -232,6 +238,53 @@ A bare \`402\` on a POST means the endpoint is healthy and the payment flow is w
|
|
|
232
238
|
|
|
233
239
|
**Verifying gateway health**: GET \`/v1/health/overview\` (free) is the right probe. Listing endpoints? Fetch \`/openapi.json\` and read the \`paths\` object — that is the source of truth, not your training memory.`;
|
|
234
240
|
}
|
|
241
|
+
function getTradingPlaybookSection() {
|
|
242
|
+
return `# Trading playbook (built-in tools)
|
|
243
|
+
|
|
244
|
+
Franklin has built-in tools for live Solana DEX swaps (\`JupiterSwap\`, \`JupiterQuote\`) and DeFi-data lookups (\`DeFiLlamaProtocols\`, \`DeFiLlamaProtocol\`, \`DeFiLlamaChains\`, \`DeFiLlamaYields\`, \`DeFiLlamaPrice\`). When the user asks for live trades or DeFi data, route through these tools — NOT WebSearch, NOT Bash + curl, NOT WebFetch (those won't sign x402 payments and will hit 402 walls).
|
|
245
|
+
|
|
246
|
+
## Before any live swap
|
|
247
|
+
|
|
248
|
+
1. **Quote first if the user hasn't already seen the numbers.** Run \`JupiterQuote\` to surface input amount, output amount, rate, price impact, and route. Users make informed decisions when they see numbers, not vibes.
|
|
249
|
+
2. **Reject \`priceImpactPct\` > 5 %** unless the user has explicitly asked to proceed despite impact. Memecoins on illiquid days routinely have 10–30 % impact — that is a money-losing trade. Tell them, ask them, then maybe proceed.
|
|
250
|
+
3. **Large-swap warning above ~\$20 USD equivalent.** Estimate via stablecoin reference if available (USDC, USDT inputs are 1:1). If you can't reliably estimate, say so in the AskUser prompt: "I cannot easily price-check this output token in USD before the swap; please confirm only if you know what you are buying."
|
|
251
|
+
|
|
252
|
+
## During a swap
|
|
253
|
+
|
|
254
|
+
- **Default \`auto_approve: false\`.** Only set true if the user has just authorized this specific call ("yes, swap 0.01 SOL for USDC"). NEVER set auto-approve session-wide. NEVER set it to "just do all three swaps I asked about" — each swap gets its own AskUser confirmation.
|
|
255
|
+
- **Be transparent about the 20 bps BlockRun referral fee.** It is shown by \`JupiterQuote\` automatically; if the user asks why, explain: it's BlockRun's integrator cut via Jupiter's official Referral Program — same mechanism Phantom and other Solana wallets use. The user is paying for the convenience layer.
|
|
256
|
+
- **Surface the Solscan link prominently after execution.** Trust is built on receipts. "Done" without a signature link is a red flag for the user.
|
|
257
|
+
|
|
258
|
+
## Failure handling
|
|
259
|
+
|
|
260
|
+
- \`No Solana wallet found\` → run \`franklin setup solana\`. The harness usually auto-creates on first run; this error means the file is corrupt or unreadable.
|
|
261
|
+
- \`/execute\` returns \`InsufficientFundsForRent\` / \`insufficient lamports\` / \`TokenAccountNotFound\` → user's Solana wallet is empty for the input mint. Show them the wallet pubkey (it was in the AskUser prompt) and tell them to send the input token to that address.
|
|
262
|
+
- \`/order\` returns no transaction or 30 %+ price impact → no liquidity for the pair. Suggest a smaller amount or a different output token.
|
|
263
|
+
- Live-swap session cap reached → user has done many live swaps in this session. Hard-stop is intentional; suggest \`/retry\` or set \`FRANKLIN_LIVE_SWAP_CAP\` to raise.
|
|
264
|
+
|
|
265
|
+
## Never
|
|
266
|
+
|
|
267
|
+
- Chain multiple live swaps without showing the running USD spent so far this turn.
|
|
268
|
+
- Tell the user "I executed your trade" without the Solscan link or signature.
|
|
269
|
+
- Compute USD value or P&L by guessing prices. Use \`TradingMarket\`, \`DeFiLlamaPrice\`, or \`JupiterQuote\` (with stablecoin reference) for ground truth.
|
|
270
|
+
- Mix paper and live state in your reply. Paper trading lives in \`TradingPortfolio\` (\`~/.blockrun/portfolio.json\`); live swaps are recorded in \`~/.blockrun/trades.jsonl\` with \`kind: 'live'\`. Be explicit about which one you're acting in.
|
|
271
|
+
|
|
272
|
+
## DeFi data (DeFiLlama tools)
|
|
273
|
+
|
|
274
|
+
- Match the tool to the question.
|
|
275
|
+
- "What's pumping on Solana?" → \`DeFiLlamaProtocols(chain='Solana', top_n=10)\`
|
|
276
|
+
- "Top yield for USDC" → \`DeFiLlamaYields(symbol='USDC', stablecoin_only=true)\`
|
|
277
|
+
- "Aave's TVL" → \`DeFiLlamaProtocol(slug='aave-v3')\`
|
|
278
|
+
- "BTC price" → \`DeFiLlamaPrice(coins=['coingecko:bitcoin'])\` or \`TradingMarket\`
|
|
279
|
+
- **Filter aggressively.** Default \`top_n=10\` unless the user asked for more. Raw DefiLlama payloads are 5–10 MB and will blow your context window.
|
|
280
|
+
- **Never call the same DeFiLlama endpoint twice in one turn.** Each call is paid. If you find yourself doing it, your plan is wrong.
|
|
281
|
+
|
|
282
|
+
## Paper vs. live
|
|
283
|
+
|
|
284
|
+
- Paper trading (TradingPortfolio etc.) is for plan-grade simulation: positions, risk caps, P&L tracking, no on-chain. Use it when the user wants to "test" or "simulate" a strategy.
|
|
285
|
+
- Live trading is JupiterSwap. It costs real USDC, signs an on-chain tx, and shows up on Solscan. NEVER conflate — if the user says "swap" they usually mean live; if they say "simulate" or "paper" they mean paper.
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
235
288
|
function getToolPatternsSection() {
|
|
236
289
|
return `# Tool Selection Patterns
|
|
237
290
|
- **Finding files**: Glob first (by name/pattern), then Grep (by content), then Read (specific file). Don't start with Read unless you know the exact path.
|
|
@@ -300,6 +353,7 @@ export function assembleInstructions(workingDir, model) {
|
|
|
300
353
|
getMissingAccessSection(),
|
|
301
354
|
getWalletKnowledgeSection(),
|
|
302
355
|
getBlockRunApiSection(),
|
|
356
|
+
getTradingPlaybookSection(),
|
|
303
357
|
getToolPatternsSection(),
|
|
304
358
|
getTokenEfficiencySection(),
|
|
305
359
|
getVerificationSection(),
|
package/dist/tools/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { moaCapability } from './moa.js';
|
|
|
26
26
|
import { webhookPostCapability } from './webhook.js';
|
|
27
27
|
import { walletCapability } from './wallet.js';
|
|
28
28
|
import { jupiterQuoteCapability, jupiterSwapCapability } from './jupiter.js';
|
|
29
|
+
import { base0xQuoteCapability, base0xSwapCapability } from './zerox-base.js';
|
|
29
30
|
import { defiLlamaProtocolsCapability, defiLlamaProtocolCapability, defiLlamaChainsCapability, defiLlamaYieldsCapability, defiLlamaPriceCapability, } from './defillama.js';
|
|
30
31
|
import { createTradingCapabilities } from './trading-execute.js';
|
|
31
32
|
import { Portfolio } from '../trading/portfolio.js';
|
|
@@ -148,6 +149,8 @@ export const allCapabilities = [
|
|
|
148
149
|
walletCapability,
|
|
149
150
|
jupiterQuoteCapability,
|
|
150
151
|
jupiterSwapCapability,
|
|
152
|
+
base0xQuoteCapability,
|
|
153
|
+
base0xSwapCapability,
|
|
151
154
|
defiLlamaProtocolsCapability,
|
|
152
155
|
defiLlamaProtocolCapability,
|
|
153
156
|
defiLlamaChainsCapability,
|
package/dist/tools/jupiter.js
CHANGED
|
@@ -27,6 +27,47 @@ const BLOCKRUN_REFERRAL_FEE_BPS = 20; // 0.2% — Jupiter docs default; well bel
|
|
|
27
27
|
const ULTRA_BASE = 'https://lite-api.jup.ag/ultra/v1';
|
|
28
28
|
const ORDER_TIMEOUT_MS = 15_000;
|
|
29
29
|
const EXECUTE_TIMEOUT_MS = 30_000;
|
|
30
|
+
// ─── Session safety: cumulative live-swap counter ─────────────────────────
|
|
31
|
+
// We removed the per-turn $-cap in v3.11.0 because it kept firing on legit
|
|
32
|
+
// LLM workloads — but a live on-chain swap is irreversible, so a cap here is
|
|
33
|
+
// different in kind. Default 10 swaps per Franklin process; user can override
|
|
34
|
+
// via FRANKLIN_LIVE_SWAP_CAP env (set to 0 to disable). Resets on restart.
|
|
35
|
+
const DEFAULT_LIVE_SWAP_CAP = 10;
|
|
36
|
+
const liveSwapCap = (() => {
|
|
37
|
+
const raw = process.env.FRANKLIN_LIVE_SWAP_CAP;
|
|
38
|
+
if (!raw)
|
|
39
|
+
return DEFAULT_LIVE_SWAP_CAP;
|
|
40
|
+
const n = Number(raw);
|
|
41
|
+
if (!Number.isFinite(n))
|
|
42
|
+
return DEFAULT_LIVE_SWAP_CAP;
|
|
43
|
+
if (n <= 0)
|
|
44
|
+
return Infinity;
|
|
45
|
+
return Math.floor(n);
|
|
46
|
+
})();
|
|
47
|
+
let liveSwapCount = 0;
|
|
48
|
+
// ─── Large-swap warning threshold ────────────────────────────────────────
|
|
49
|
+
// USD value above which we surface a "Large swap" line in the AskUser
|
|
50
|
+
// confirm — only computable when input is a known stablecoin. Override via
|
|
51
|
+
// FRANKLIN_LIVE_SWAP_WARN_USD env (default $20).
|
|
52
|
+
const DEFAULT_LARGE_SWAP_USD = 20;
|
|
53
|
+
const largeSwapThresholdUsd = (() => {
|
|
54
|
+
const raw = process.env.FRANKLIN_LIVE_SWAP_WARN_USD;
|
|
55
|
+
if (!raw)
|
|
56
|
+
return DEFAULT_LARGE_SWAP_USD;
|
|
57
|
+
const n = Number(raw);
|
|
58
|
+
if (!Number.isFinite(n) || n < 0)
|
|
59
|
+
return DEFAULT_LARGE_SWAP_USD;
|
|
60
|
+
return n;
|
|
61
|
+
})();
|
|
62
|
+
const STABLECOIN_MINTS = new Set([
|
|
63
|
+
'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC
|
|
64
|
+
'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', // USDT
|
|
65
|
+
]);
|
|
66
|
+
function estimateUsdValue(inputMint, humanAmount) {
|
|
67
|
+
if (STABLECOIN_MINTS.has(inputMint))
|
|
68
|
+
return humanAmount;
|
|
69
|
+
return null; // unknown — caller will surface a "couldn't price" warning instead
|
|
70
|
+
}
|
|
30
71
|
// ─── Symbol → mint shortcuts ──────────────────────────────────────────────
|
|
31
72
|
// Agents prefer "USDC" / "SOL" over 44-char base58 mint addresses. Anything
|
|
32
73
|
// not in this map is passed through verbatim — power users can drop in any
|
|
@@ -180,22 +221,37 @@ async function executeJupiterQuote(input) {
|
|
|
180
221
|
}
|
|
181
222
|
}
|
|
182
223
|
async function executeJupiterSwap(input, ctx) {
|
|
224
|
+
// Session-cap pre-check (cheapest, fail fast).
|
|
225
|
+
if (liveSwapCount >= liveSwapCap) {
|
|
226
|
+
return {
|
|
227
|
+
output: `Live-swap session cap reached (${liveSwapCount}/${liveSwapCap}). ` +
|
|
228
|
+
`Stopping to protect your wallet — this is a deliberate guardrail, not an error in your prompt.\n\n` +
|
|
229
|
+
`To raise: \`FRANKLIN_LIVE_SWAP_CAP=20 franklin\` (or 0 to disable).\n` +
|
|
230
|
+
`To continue with a fresh count: restart Franklin (\`exit\` then re-launch).`,
|
|
231
|
+
isError: true,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
183
234
|
const inputMint = resolveMint(input.input_mint);
|
|
184
235
|
const outputMint = resolveMint(input.output_mint);
|
|
185
236
|
const inDec = decimalsFor(inputMint);
|
|
186
237
|
const amountAtomic = toAtomicUnits(input.amount, inDec);
|
|
187
|
-
// Load wallet
|
|
238
|
+
// Load wallet — `getOrCreateSolanaWallet` auto-creates on first run, so this
|
|
239
|
+
// path firing means the file is corrupt or the .blockrun dir is unreadable.
|
|
188
240
|
let keypair;
|
|
189
241
|
try {
|
|
190
242
|
keypair = await loadSolanaKeypair();
|
|
191
243
|
}
|
|
192
244
|
catch (err) {
|
|
245
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
193
246
|
return {
|
|
194
|
-
output: `
|
|
195
|
-
`
|
|
247
|
+
output: `Couldn't load your Solana wallet. ` +
|
|
248
|
+
`Run \`franklin setup solana\` to (re)generate one. ` +
|
|
249
|
+
`If that's already worked before, check ~/.blockrun/solana-wallet.json is readable.\n\n` +
|
|
250
|
+
`Underlying error: ${msg}`,
|
|
196
251
|
isError: true,
|
|
197
252
|
};
|
|
198
253
|
}
|
|
254
|
+
const walletAddress = keypair.publicKey.toBase58();
|
|
199
255
|
// Step 1 — fetch order with our referral identity attached.
|
|
200
256
|
let order;
|
|
201
257
|
try {
|
|
@@ -203,7 +259,7 @@ async function executeJupiterSwap(input, ctx) {
|
|
|
203
259
|
inputMint,
|
|
204
260
|
outputMint,
|
|
205
261
|
amount: amountAtomic,
|
|
206
|
-
taker:
|
|
262
|
+
taker: walletAddress,
|
|
207
263
|
});
|
|
208
264
|
}
|
|
209
265
|
catch (err) {
|
|
@@ -221,8 +277,16 @@ async function executeJupiterSwap(input, ctx) {
|
|
|
221
277
|
// Step 2 — confirm with user (unless explicit auto_approve override).
|
|
222
278
|
if (!input.auto_approve && ctx.onAskUser) {
|
|
223
279
|
const quoteText = formatQuote(order);
|
|
224
|
-
const
|
|
225
|
-
const
|
|
280
|
+
const usdEst = estimateUsdValue(inputMint, input.amount);
|
|
281
|
+
const sections = ['Execute this Jupiter swap?', '', quoteText];
|
|
282
|
+
if (usdEst != null && usdEst >= largeSwapThresholdUsd) {
|
|
283
|
+
sections.push('', `⚠ Large swap warning — input is ~$${usdEst.toFixed(2)} (above $${largeSwapThresholdUsd} threshold). Confirm only if this matches your intent.`);
|
|
284
|
+
}
|
|
285
|
+
else if (usdEst == null) {
|
|
286
|
+
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.`);
|
|
287
|
+
}
|
|
288
|
+
sections.push('', `Wallet: ${walletAddress}`, `Live-swap session count: ${liveSwapCount}/${liveSwapCap === Infinity ? '∞' : liveSwapCap}`);
|
|
289
|
+
const answer = await ctx.onAskUser(sections.join('\n'), ['Confirm', 'Cancel']);
|
|
226
290
|
if (answer.toLowerCase() !== 'confirm') {
|
|
227
291
|
return { output: 'Swap cancelled by user.' };
|
|
228
292
|
}
|
|
@@ -248,6 +312,19 @@ async function executeJupiterSwap(input, ctx) {
|
|
|
248
312
|
requestId: order.requestId,
|
|
249
313
|
});
|
|
250
314
|
if (exec.status !== 'Success') {
|
|
315
|
+
const errStr = (exec.error ?? '').toLowerCase();
|
|
316
|
+
const looksInsufficient = errStr.includes('insufficient') ||
|
|
317
|
+
errStr.includes('lamports') ||
|
|
318
|
+
errStr.includes('tokenaccountnotfound') ||
|
|
319
|
+
errStr.includes('not enough');
|
|
320
|
+
if (looksInsufficient) {
|
|
321
|
+
return {
|
|
322
|
+
output: `Swap failed: insufficient balance. Your Solana wallet (${walletAddress}) does not hold enough of the input token.\n\n` +
|
|
323
|
+
`Send ${symbolFor(inputMint)} to that address (or fund it via the Franklin UI), then retry.\n\n` +
|
|
324
|
+
`Underlying error: ${exec.error ?? exec.code ?? 'unknown'}`,
|
|
325
|
+
isError: true,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
251
328
|
return {
|
|
252
329
|
output: `Jupiter Ultra /execute reported ${exec.status}` +
|
|
253
330
|
(exec.error ? `: ${exec.error}` : '') +
|
|
@@ -255,6 +332,7 @@ async function executeJupiterSwap(input, ctx) {
|
|
|
255
332
|
isError: true,
|
|
256
333
|
};
|
|
257
334
|
}
|
|
335
|
+
liveSwapCount += 1;
|
|
258
336
|
const sig = exec.signature ?? '<unknown>';
|
|
259
337
|
const explorer = `https://solscan.io/tx/${sig}`;
|
|
260
338
|
return {
|
|
@@ -263,6 +341,7 @@ async function executeJupiterSwap(input, ctx) {
|
|
|
263
341
|
formatQuote(order),
|
|
264
342
|
`Signature: ${sig}`,
|
|
265
343
|
explorer,
|
|
344
|
+
`(Session live-swap count: ${liveSwapCount}/${liveSwapCap === Infinity ? '∞' : liveSwapCap})`,
|
|
266
345
|
].join('\n'),
|
|
267
346
|
};
|
|
268
347
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0x Swap API V2 (Permit2) — Franklin built-in tools for Base trading.
|
|
3
|
+
*
|
|
4
|
+
* Same posture as the JupiterSwap tools, different chain + aggregator:
|
|
5
|
+
* - Calls 0x's API directly from this Franklin process (the user is the
|
|
6
|
+
* first-party caller; we are not a gateway proxy → no ToS violation).
|
|
7
|
+
* - Embeds BlockRun's affiliate identity in every quote (`swapFeeRecipient`
|
|
8
|
+
* + `swapFeeBps` + `swapFeeToken`). Fee flows on-chain to BlockRun at
|
|
9
|
+
* swap settlement — 0x's officially-supported integrator monetization.
|
|
10
|
+
* - User signs locally with their existing Base/EVM keypair (via @blockrun/
|
|
11
|
+
* llm's `getOrCreateWallet`); we never custody.
|
|
12
|
+
*
|
|
13
|
+
* Two tools exposed:
|
|
14
|
+
* - Base0xQuote — indicative price, free, no signing
|
|
15
|
+
* - Base0xSwap — full quote → sign Permit2 → submit raw tx → BaseScan link
|
|
16
|
+
*
|
|
17
|
+
* Reference (official 0x example):
|
|
18
|
+
* https://github.com/0xProject/0x-examples/tree/main/swap-v2-permit2-headless-example
|
|
19
|
+
*
|
|
20
|
+
* Required env:
|
|
21
|
+
* ZERO_EX_API_KEY — get one free at https://dashboard.0x.org
|
|
22
|
+
* BASE_RPC_URL — optional override; defaults to public Base RPC
|
|
23
|
+
*/
|
|
24
|
+
import type { CapabilityHandler } from '../agent/types.js';
|
|
25
|
+
export declare const base0xQuoteCapability: CapabilityHandler;
|
|
26
|
+
export declare const base0xSwapCapability: CapabilityHandler;
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0x Swap API V2 (Permit2) — Franklin built-in tools for Base trading.
|
|
3
|
+
*
|
|
4
|
+
* Same posture as the JupiterSwap tools, different chain + aggregator:
|
|
5
|
+
* - Calls 0x's API directly from this Franklin process (the user is the
|
|
6
|
+
* first-party caller; we are not a gateway proxy → no ToS violation).
|
|
7
|
+
* - Embeds BlockRun's affiliate identity in every quote (`swapFeeRecipient`
|
|
8
|
+
* + `swapFeeBps` + `swapFeeToken`). Fee flows on-chain to BlockRun at
|
|
9
|
+
* swap settlement — 0x's officially-supported integrator monetization.
|
|
10
|
+
* - User signs locally with their existing Base/EVM keypair (via @blockrun/
|
|
11
|
+
* llm's `getOrCreateWallet`); we never custody.
|
|
12
|
+
*
|
|
13
|
+
* Two tools exposed:
|
|
14
|
+
* - Base0xQuote — indicative price, free, no signing
|
|
15
|
+
* - Base0xSwap — full quote → sign Permit2 → submit raw tx → BaseScan link
|
|
16
|
+
*
|
|
17
|
+
* Reference (official 0x example):
|
|
18
|
+
* https://github.com/0xProject/0x-examples/tree/main/swap-v2-permit2-headless-example
|
|
19
|
+
*
|
|
20
|
+
* Required env:
|
|
21
|
+
* ZERO_EX_API_KEY — get one free at https://dashboard.0x.org
|
|
22
|
+
* BASE_RPC_URL — optional override; defaults to public Base RPC
|
|
23
|
+
*/
|
|
24
|
+
import { createWalletClient, http, publicActions, concat, numberToHex, size, parseUnits, formatUnits, maxUint256, erc20Abi, getContract, } from 'viem';
|
|
25
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
26
|
+
import { base } from 'viem/chains';
|
|
27
|
+
import { getOrCreateWallet } from '@blockrun/llm';
|
|
28
|
+
// ─── BlockRun affiliate identity on Base ─────────────────────────────────
|
|
29
|
+
// Reuses the existing BlockRun ops wallet that already receives x402
|
|
30
|
+
// settlements on Base. Every swap routed through these tools deposits 20
|
|
31
|
+
// bps of the sell-token amount into this address at settlement.
|
|
32
|
+
const BLOCKRUN_BASE_AFFILIATE = '0xe9030014F5DAe217d0A152f02A043567b16c1aBf';
|
|
33
|
+
const BLOCKRUN_AFFILIATE_FEE_BPS = 20; // 0.2% — matches Jupiter Ultra path.
|
|
34
|
+
// ─── 0x API endpoints ─────────────────────────────────────────────────────
|
|
35
|
+
const ZEROX_BASE = 'https://api.0x.org/swap/permit2';
|
|
36
|
+
const ZEROX_VERSION = 'v2';
|
|
37
|
+
const ZEROX_TIMEOUT_MS = 20_000;
|
|
38
|
+
// ─── Default Base RPC ────────────────────────────────────────────────────
|
|
39
|
+
// Public Base mainnet endpoint. Override via BASE_RPC_URL env (Alchemy,
|
|
40
|
+
// QuickNode public, etc.). The user-facing call is the swap submission;
|
|
41
|
+
// quote fetches are off-chain.
|
|
42
|
+
const DEFAULT_BASE_RPC = 'https://mainnet.base.org';
|
|
43
|
+
// ─── Session safety: cumulative live-swap counter ─────────────────────────
|
|
44
|
+
const DEFAULT_LIVE_SWAP_CAP = 10;
|
|
45
|
+
const liveSwapCap = (() => {
|
|
46
|
+
const raw = process.env.FRANKLIN_LIVE_SWAP_CAP;
|
|
47
|
+
if (!raw)
|
|
48
|
+
return DEFAULT_LIVE_SWAP_CAP;
|
|
49
|
+
const n = Number(raw);
|
|
50
|
+
if (!Number.isFinite(n))
|
|
51
|
+
return DEFAULT_LIVE_SWAP_CAP;
|
|
52
|
+
if (n <= 0)
|
|
53
|
+
return Infinity;
|
|
54
|
+
return Math.floor(n);
|
|
55
|
+
})();
|
|
56
|
+
let liveSwapCount = 0;
|
|
57
|
+
const DEFAULT_LARGE_SWAP_USD = 20;
|
|
58
|
+
const largeSwapThresholdUsd = (() => {
|
|
59
|
+
const raw = process.env.FRANKLIN_LIVE_SWAP_WARN_USD;
|
|
60
|
+
if (!raw)
|
|
61
|
+
return DEFAULT_LARGE_SWAP_USD;
|
|
62
|
+
const n = Number(raw);
|
|
63
|
+
if (!Number.isFinite(n) || n < 0)
|
|
64
|
+
return DEFAULT_LARGE_SWAP_USD;
|
|
65
|
+
return n;
|
|
66
|
+
})();
|
|
67
|
+
// ─── Base token map ──────────────────────────────────────────────────────
|
|
68
|
+
// EVM addresses are case-sensitive in some libraries — store as checksum.
|
|
69
|
+
const NATIVE_ETH = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE';
|
|
70
|
+
const SYMBOL_TO_ADDRESS = {
|
|
71
|
+
ETH: NATIVE_ETH,
|
|
72
|
+
WETH: '0x4200000000000000000000000000000000000006',
|
|
73
|
+
USDC: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
74
|
+
USDT: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2',
|
|
75
|
+
CBBTC: '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf',
|
|
76
|
+
CBETH: '0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22',
|
|
77
|
+
AERO: '0x940181a94A35A4569E4529A3CDfB74e38FD98631',
|
|
78
|
+
DAI: '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb',
|
|
79
|
+
};
|
|
80
|
+
const TOKEN_DECIMALS = {
|
|
81
|
+
[NATIVE_ETH.toLowerCase()]: 18,
|
|
82
|
+
'0x4200000000000000000000000000000000000006': 18, // WETH
|
|
83
|
+
'0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': 6, // USDC
|
|
84
|
+
'0xfde4c96c8593536e31f229ea8f37b2ada2699bb2': 6, // USDT
|
|
85
|
+
'0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf': 8, // CBBTC
|
|
86
|
+
'0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22': 18, // CBETH
|
|
87
|
+
'0x940181a94a35a4569e4529a3cdfb74e38fd98631': 18, // AERO
|
|
88
|
+
'0x50c5725949a6f0c72e6c4a641f24049a917db0cb': 18, // DAI
|
|
89
|
+
};
|
|
90
|
+
const STABLECOIN_ADDRESSES = new Set([
|
|
91
|
+
'0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', // USDC
|
|
92
|
+
'0xfde4c96c8593536e31f229ea8f37b2ada2699bb2', // USDT
|
|
93
|
+
'0x50c5725949a6f0c72e6c4a641f24049a917db0cb', // DAI
|
|
94
|
+
]);
|
|
95
|
+
function resolveTokenAddress(input) {
|
|
96
|
+
const upper = input.trim().toUpperCase();
|
|
97
|
+
if (SYMBOL_TO_ADDRESS[upper])
|
|
98
|
+
return SYMBOL_TO_ADDRESS[upper];
|
|
99
|
+
return input.trim();
|
|
100
|
+
}
|
|
101
|
+
function decimalsFor(address) {
|
|
102
|
+
const lower = address.toLowerCase();
|
|
103
|
+
return TOKEN_DECIMALS[lower] ?? 18;
|
|
104
|
+
}
|
|
105
|
+
function symbolFor(address) {
|
|
106
|
+
const lower = address.toLowerCase();
|
|
107
|
+
for (const [sym, addr] of Object.entries(SYMBOL_TO_ADDRESS)) {
|
|
108
|
+
if (addr.toLowerCase() === lower)
|
|
109
|
+
return sym;
|
|
110
|
+
}
|
|
111
|
+
return `${address.slice(0, 6)}…${address.slice(-4)}`;
|
|
112
|
+
}
|
|
113
|
+
function isStablecoin(address) {
|
|
114
|
+
return STABLECOIN_ADDRESSES.has(address.toLowerCase());
|
|
115
|
+
}
|
|
116
|
+
function estimateUsdValue(sellTokenAddr, humanAmount) {
|
|
117
|
+
if (isStablecoin(sellTokenAddr))
|
|
118
|
+
return humanAmount;
|
|
119
|
+
return null; // unknown — caller surfaces a "couldn't price" notice
|
|
120
|
+
}
|
|
121
|
+
function isNativeEth(addr) {
|
|
122
|
+
return addr.toLowerCase() === NATIVE_ETH.toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
// ─── Wallet + client setup ───────────────────────────────────────────────
|
|
125
|
+
async function loadEvmWallet() {
|
|
126
|
+
const raw = await getOrCreateWallet();
|
|
127
|
+
// @blockrun/llm returns { privateKey: '0x...', address: '0x...' } — use it
|
|
128
|
+
// verbatim for viem.
|
|
129
|
+
const account = privateKeyToAccount(raw.privateKey);
|
|
130
|
+
return { account, address: raw.address };
|
|
131
|
+
}
|
|
132
|
+
function makeClient(account) {
|
|
133
|
+
const rpcUrl = process.env.BASE_RPC_URL || DEFAULT_BASE_RPC;
|
|
134
|
+
return createWalletClient({
|
|
135
|
+
account,
|
|
136
|
+
chain: base,
|
|
137
|
+
transport: http(rpcUrl),
|
|
138
|
+
}).extend(publicActions);
|
|
139
|
+
}
|
|
140
|
+
function zeroxHeaders() {
|
|
141
|
+
const apiKey = process.env.ZERO_EX_API_KEY;
|
|
142
|
+
if (!apiKey) {
|
|
143
|
+
throw new Error("Base swaps need a 0x API key. Each Franklin user sets their own (free, no credit card): " +
|
|
144
|
+
"1) Sign up at https://dashboard.0x.org · " +
|
|
145
|
+
"2) Copy the API key from the Demo App or your own app · " +
|
|
146
|
+
"3) Run `ZERO_EX_API_KEY=zx_... franklin` (or add it to ~/.zshrc / ~/.bashrc). " +
|
|
147
|
+
"BlockRun does NOT need a 0x account — the affiliate fee routes to BlockRun's wallet regardless of whose key is making the call.");
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
'Content-Type': 'application/json',
|
|
151
|
+
'0x-api-key': apiKey,
|
|
152
|
+
'0x-version': ZEROX_VERSION,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// ─── 0x API calls ────────────────────────────────────────────────────────
|
|
156
|
+
async function zeroxFetch(path, params) {
|
|
157
|
+
const url = `${ZEROX_BASE}/${path}?${params.toString()}`;
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
const timer = setTimeout(() => controller.abort(), ZEROX_TIMEOUT_MS);
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(url, { headers: zeroxHeaders(), signal: controller.signal });
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
const text = await res.text();
|
|
164
|
+
throw new Error(`0x ${path} returned ${res.status}: ${text.slice(0, 300)}`);
|
|
165
|
+
}
|
|
166
|
+
return (await res.json());
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function buildSwapParams(args) {
|
|
173
|
+
const p = new URLSearchParams({
|
|
174
|
+
chainId: String(base.id),
|
|
175
|
+
sellToken: args.sellTokenAddr,
|
|
176
|
+
buyToken: args.buyTokenAddr,
|
|
177
|
+
sellAmount: args.sellAmountAtomic,
|
|
178
|
+
taker: args.taker,
|
|
179
|
+
});
|
|
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
|
+
}
|
|
187
|
+
// ─── Formatting ──────────────────────────────────────────────────────────
|
|
188
|
+
function formatQuoteText(q) {
|
|
189
|
+
const sellDec = decimalsFor(q.sellToken);
|
|
190
|
+
const buyDec = decimalsFor(q.buyToken);
|
|
191
|
+
const sellHuman = Number(formatUnits(BigInt(q.sellAmount), sellDec));
|
|
192
|
+
const buyHuman = Number(formatUnits(BigInt(q.buyAmount), buyDec));
|
|
193
|
+
const sellSym = symbolFor(q.sellToken);
|
|
194
|
+
const buySym = symbolFor(q.buyToken);
|
|
195
|
+
const rate = sellHuman > 0 ? buyHuman / sellHuman : 0;
|
|
196
|
+
const route = q.route?.fills?.map((f) => f.source).filter(Boolean).slice(0, 4).join(' → ') ||
|
|
197
|
+
'0x V2 router';
|
|
198
|
+
const minOut = q.minBuyAmount
|
|
199
|
+
? Number(formatUnits(BigInt(q.minBuyAmount), buyDec))
|
|
200
|
+
: null;
|
|
201
|
+
const lines = [
|
|
202
|
+
`${sellHuman.toFixed(Math.min(8, sellDec))} ${sellSym} → ${buyHuman.toFixed(Math.min(8, buyDec))} ${buySym}`,
|
|
203
|
+
`Rate: 1 ${sellSym} ≈ ${rate.toPrecision(6)} ${buySym}`,
|
|
204
|
+
];
|
|
205
|
+
if (minOut != null) {
|
|
206
|
+
lines.push(`Min received: ${minOut.toFixed(Math.min(8, buyDec))} ${buySym}`);
|
|
207
|
+
}
|
|
208
|
+
lines.push(`Route: ${route}`);
|
|
209
|
+
lines.push(`Affiliate fee: ${BLOCKRUN_AFFILIATE_FEE_BPS} bps (BlockRun affiliate, taken in ${sellSym})`);
|
|
210
|
+
return lines.join('\n');
|
|
211
|
+
}
|
|
212
|
+
// ─── Quote (read-only) ───────────────────────────────────────────────────
|
|
213
|
+
async function executeBase0xQuote(input) {
|
|
214
|
+
let walletAddress;
|
|
215
|
+
try {
|
|
216
|
+
const wallet = await loadEvmWallet();
|
|
217
|
+
walletAddress = wallet.address;
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
return {
|
|
221
|
+
output: `Couldn't load Base wallet: ${err instanceof Error ? err.message : String(err)}`,
|
|
222
|
+
isError: true,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const sellTokenAddr = resolveTokenAddress(input.sell_token);
|
|
226
|
+
const buyTokenAddr = resolveTokenAddress(input.buy_token);
|
|
227
|
+
const sellDec = decimalsFor(sellTokenAddr);
|
|
228
|
+
const sellAmount = parseUnits(input.sell_amount.toString(), sellDec).toString();
|
|
229
|
+
const params = buildSwapParams({
|
|
230
|
+
sellTokenAddr,
|
|
231
|
+
buyTokenAddr,
|
|
232
|
+
sellAmountAtomic: sellAmount,
|
|
233
|
+
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
|
+
});
|
|
240
|
+
try {
|
|
241
|
+
const price = await zeroxFetch('price', params);
|
|
242
|
+
if (!price.liquidityAvailable && price.liquidityAvailable !== undefined) {
|
|
243
|
+
return {
|
|
244
|
+
output: `0x reports no liquidity for ${symbolFor(sellTokenAddr)} → ${symbolFor(buyTokenAddr)} on Base.`,
|
|
245
|
+
isError: true,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
return { output: formatQuoteText(price) };
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
return { output: `0x /price failed: ${err instanceof Error ? err.message : String(err)}`, isError: true };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// ─── Swap (full execute) ─────────────────────────────────────────────────
|
|
255
|
+
async function executeBase0xSwap(input, ctx) {
|
|
256
|
+
if (liveSwapCount >= liveSwapCap) {
|
|
257
|
+
return {
|
|
258
|
+
output: `Live-swap session cap reached (${liveSwapCount}/${liveSwapCap}). Stopping to protect your wallet.\n` +
|
|
259
|
+
`Override with FRANKLIN_LIVE_SWAP_CAP=20 (or 0 to disable), or restart Franklin to reset.`,
|
|
260
|
+
isError: true,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
let wallet;
|
|
264
|
+
try {
|
|
265
|
+
wallet = await loadEvmWallet();
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
return {
|
|
269
|
+
output: `Couldn't load Base wallet. Run \`franklin setup\` to (re)generate a Base wallet, ` +
|
|
270
|
+
`or check ~/.blockrun/.session.\n\nUnderlying error: ${err instanceof Error ? err.message : String(err)}`,
|
|
271
|
+
isError: true,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const client = makeClient(wallet.account);
|
|
275
|
+
const sellTokenAddr = resolveTokenAddress(input.sell_token);
|
|
276
|
+
const buyTokenAddr = resolveTokenAddress(input.buy_token);
|
|
277
|
+
const sellDec = decimalsFor(sellTokenAddr);
|
|
278
|
+
const sellAmount = parseUnits(input.sell_amount.toString(), sellDec).toString();
|
|
279
|
+
const params = buildSwapParams({
|
|
280
|
+
sellTokenAddr,
|
|
281
|
+
buyTokenAddr,
|
|
282
|
+
sellAmountAtomic: sellAmount,
|
|
283
|
+
taker: wallet.address,
|
|
284
|
+
feeRecipient: BLOCKRUN_BASE_AFFILIATE,
|
|
285
|
+
feeBps: BLOCKRUN_AFFILIATE_FEE_BPS,
|
|
286
|
+
feeToken: sellTokenAddr,
|
|
287
|
+
});
|
|
288
|
+
// Step 1 — fetch the firm quote (returns tx + permit2.eip712 to sign).
|
|
289
|
+
let quote;
|
|
290
|
+
try {
|
|
291
|
+
quote = await zeroxFetch('quote', params);
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
return {
|
|
295
|
+
output: `0x /quote failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
296
|
+
isError: true,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (!quote.transaction) {
|
|
300
|
+
return {
|
|
301
|
+
output: `0x returned no transaction — likely no liquidity for this pair.`,
|
|
302
|
+
isError: true,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
// Step 2 — confirm with user (unless explicit auto_approve override).
|
|
306
|
+
if (!input.auto_approve && ctx.onAskUser) {
|
|
307
|
+
const quoteText = formatQuoteText(quote);
|
|
308
|
+
const usdEst = estimateUsdValue(sellTokenAddr, input.sell_amount);
|
|
309
|
+
const sections = ['Execute this 0x swap on Base?', '', quoteText];
|
|
310
|
+
if (usdEst != null && usdEst >= largeSwapThresholdUsd) {
|
|
311
|
+
sections.push('', `⚠ Large swap warning — input is ~$${usdEst.toFixed(2)} (above $${largeSwapThresholdUsd} threshold).`);
|
|
312
|
+
}
|
|
313
|
+
else if (usdEst == null) {
|
|
314
|
+
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.`);
|
|
315
|
+
}
|
|
316
|
+
sections.push('', `Wallet: ${wallet.address}`, `Live-swap session count: ${liveSwapCount}/${liveSwapCap === Infinity ? '∞' : liveSwapCap}`);
|
|
317
|
+
const answer = await ctx.onAskUser(sections.join('\n'), ['Confirm', 'Cancel']);
|
|
318
|
+
if (answer.toLowerCase() !== 'confirm') {
|
|
319
|
+
return { output: 'Swap cancelled by user.' };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Step 3 — for ERC-20 sell tokens, ensure Permit2 has an allowance.
|
|
323
|
+
// Native ETH skips this entirely.
|
|
324
|
+
if (!isNativeEth(sellTokenAddr)) {
|
|
325
|
+
const allowanceIssue = quote.issues?.allowance ?? null;
|
|
326
|
+
if (allowanceIssue) {
|
|
327
|
+
try {
|
|
328
|
+
const erc20 = getContract({ address: sellTokenAddr, abi: erc20Abi, client });
|
|
329
|
+
const { request } = await erc20.simulate.approve([allowanceIssue.spender, maxUint256]);
|
|
330
|
+
const approveHash = await erc20.write.approve(request.args);
|
|
331
|
+
await client.waitForTransactionReceipt({ hash: approveHash });
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
return {
|
|
335
|
+
output: `Permit2 approval failed for ${symbolFor(sellTokenAddr)}: ${err instanceof Error ? err.message : String(err)}\n` +
|
|
336
|
+
`This is a one-time setup step per token; retry the swap and it should succeed.`,
|
|
337
|
+
isError: true,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Step 4 — sign the Permit2 EIP-712 typed-data and append signature to
|
|
342
|
+
// transaction.data per the canonical 0x recipe.
|
|
343
|
+
if (!quote.permit2?.eip712) {
|
|
344
|
+
return {
|
|
345
|
+
output: '0x quote did not include permit2.eip712 — non-Permit2 path required, not yet supported.',
|
|
346
|
+
isError: true,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
let signature;
|
|
350
|
+
try {
|
|
351
|
+
signature = await client.signTypedData(quote.permit2.eip712);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
return {
|
|
355
|
+
output: `Permit2 signing failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
356
|
+
isError: true,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
const sigLengthHex = numberToHex(size(signature), { signed: false, size: 32 });
|
|
360
|
+
quote.transaction.data = concat([quote.transaction.data, sigLengthHex, signature]);
|
|
361
|
+
}
|
|
362
|
+
// Step 5 — submit. Native ETH path uses sendTransaction (with value);
|
|
363
|
+
// ERC-20 path uses signTransaction + sendRawTransaction (matches the
|
|
364
|
+
// official 0x example to avoid double-signing pitfalls).
|
|
365
|
+
let txHash;
|
|
366
|
+
try {
|
|
367
|
+
if (isNativeEth(sellTokenAddr)) {
|
|
368
|
+
txHash = await client.sendTransaction({
|
|
369
|
+
account: wallet.account,
|
|
370
|
+
chain: base,
|
|
371
|
+
to: quote.transaction.to,
|
|
372
|
+
data: quote.transaction.data,
|
|
373
|
+
value: BigInt(quote.transaction.value),
|
|
374
|
+
gas: quote.transaction.gas ? BigInt(quote.transaction.gas) : undefined,
|
|
375
|
+
gasPrice: quote.transaction.gasPrice ? BigInt(quote.transaction.gasPrice) : undefined,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
const nonce = await client.getTransactionCount({ address: wallet.address });
|
|
380
|
+
const signedTx = await client.signTransaction({
|
|
381
|
+
account: wallet.account,
|
|
382
|
+
chain: base,
|
|
383
|
+
to: quote.transaction.to,
|
|
384
|
+
data: quote.transaction.data,
|
|
385
|
+
gas: quote.transaction.gas ? BigInt(quote.transaction.gas) : undefined,
|
|
386
|
+
gasPrice: quote.transaction.gasPrice ? BigInt(quote.transaction.gasPrice) : undefined,
|
|
387
|
+
nonce,
|
|
388
|
+
});
|
|
389
|
+
txHash = await client.sendRawTransaction({ serializedTransaction: signedTx });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
394
|
+
const lower = msg.toLowerCase();
|
|
395
|
+
if (lower.includes('insufficient') ||
|
|
396
|
+
lower.includes('exceeds balance') ||
|
|
397
|
+
lower.includes('not enough')) {
|
|
398
|
+
return {
|
|
399
|
+
output: `Swap failed: insufficient balance. Your Base wallet (${wallet.address}) does not hold enough ${symbolFor(sellTokenAddr)}.\n\n` +
|
|
400
|
+
`Send ${symbolFor(sellTokenAddr)} to that address (or fund it via Coinbase / a bridge), then retry.\n\n` +
|
|
401
|
+
`Underlying error: ${msg}`,
|
|
402
|
+
isError: true,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
output: `Submit failed: ${msg}`,
|
|
407
|
+
isError: true,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
liveSwapCount += 1;
|
|
411
|
+
const explorer = `https://basescan.org/tx/${txHash}`;
|
|
412
|
+
return {
|
|
413
|
+
output: [
|
|
414
|
+
'✓ Swap executed on Base.',
|
|
415
|
+
formatQuoteText(quote),
|
|
416
|
+
`Tx hash: ${txHash}`,
|
|
417
|
+
explorer,
|
|
418
|
+
`(Session live-swap count: ${liveSwapCount}/${liveSwapCap === Infinity ? '∞' : liveSwapCap})`,
|
|
419
|
+
].join('\n'),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
// ─── Capability handlers ─────────────────────────────────────────────────
|
|
423
|
+
const COMMON_INPUT_PROPERTIES = {
|
|
424
|
+
sell_token: {
|
|
425
|
+
type: 'string',
|
|
426
|
+
description: "Sell-token address (Base EVM 0x... 42 chars), OR a symbol shortcut: ETH, WETH, USDC, USDT, CBBTC, CBETH, AERO, DAI.",
|
|
427
|
+
},
|
|
428
|
+
buy_token: {
|
|
429
|
+
type: 'string',
|
|
430
|
+
description: 'Buy-token address or symbol shortcut (same list as sell_token).',
|
|
431
|
+
},
|
|
432
|
+
sell_amount: {
|
|
433
|
+
type: 'number',
|
|
434
|
+
description: 'Amount of sell_token to swap, in human units (e.g. 0.01 ETH, 1.5 USDC). Decimals are looked up automatically for known tokens; defaults to 18 for unknown ERC-20s.',
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
export const base0xQuoteCapability = {
|
|
438
|
+
spec: {
|
|
439
|
+
name: 'Base0xQuote',
|
|
440
|
+
description: "Read-only price quote for a Base DEX swap via 0x V2. Returns sell/buy amounts, rate, minimum-received, route, and the BlockRun affiliate fee that would apply. Free — no on-chain transaction. Use before Base0xSwap to inspect the trade.",
|
|
441
|
+
input_schema: {
|
|
442
|
+
type: 'object',
|
|
443
|
+
required: ['sell_token', 'buy_token', 'sell_amount'],
|
|
444
|
+
properties: COMMON_INPUT_PROPERTIES,
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
execute: async (input) => {
|
|
448
|
+
return executeBase0xQuote(input);
|
|
449
|
+
},
|
|
450
|
+
concurrent: true,
|
|
451
|
+
};
|
|
452
|
+
export const base0xSwapCapability = {
|
|
453
|
+
spec: {
|
|
454
|
+
name: 'Base0xSwap',
|
|
455
|
+
description: "Execute a Base DEX swap via 0x V2 (Permit2). Quotes the order, 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. Requires ZERO_EX_API_KEY env var (free at dashboard.0x.org).",
|
|
456
|
+
input_schema: {
|
|
457
|
+
type: 'object',
|
|
458
|
+
required: ['sell_token', 'buy_token', 'sell_amount'],
|
|
459
|
+
properties: {
|
|
460
|
+
...COMMON_INPUT_PROPERTIES,
|
|
461
|
+
auto_approve: {
|
|
462
|
+
type: 'boolean',
|
|
463
|
+
description: 'If true, skip the AskUser confirm. Default false — agent should leave this false unless the user explicitly authorized this specific call.',
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
execute: async (input, ctx) => {
|
|
469
|
+
return executeBase0xSwap(input, ctx);
|
|
470
|
+
},
|
|
471
|
+
concurrent: false,
|
|
472
|
+
};
|
package/package.json
CHANGED