@blockrun/franklin 3.12.3 → 3.14.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,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 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
|
+
- Symbol shortcuts pre-mapped: ETH (native), WETH, USDC, USDT, CBBTC, CBETH, AERO, DAI. Raw \`0x...\` addresses pass through.
|
|
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
|
+
|
|
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.
|
|
@@ -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/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,
|
|
@@ -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,470 @@
|
|
|
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
|
+
import { loadConfig } from '../commands/config.js';
|
|
29
|
+
import { loadChain, API_URLS, VERSION } from '../config.js';
|
|
30
|
+
// ─── BlockRun affiliate identity on Base ─────────────────────────────────
|
|
31
|
+
// Reuses the existing BlockRun ops wallet that already receives x402
|
|
32
|
+
// settlements on Base. Every swap routed through these tools deposits 20
|
|
33
|
+
// bps of the sell-token amount into this address at settlement.
|
|
34
|
+
const BLOCKRUN_BASE_AFFILIATE = '0xe9030014F5DAe217d0A152f02A043567b16c1aBf';
|
|
35
|
+
const BLOCKRUN_AFFILIATE_FEE_BPS = 20; // 0.2% — matches Jupiter Ultra path.
|
|
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;
|
|
43
|
+
// ─── Default Base RPC ────────────────────────────────────────────────────
|
|
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.
|
|
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
|
+
}
|
|
53
|
+
// ─── Session safety: cumulative live-swap counter ─────────────────────────
|
|
54
|
+
const DEFAULT_LIVE_SWAP_CAP = 10;
|
|
55
|
+
const liveSwapCap = (() => {
|
|
56
|
+
const raw = process.env.FRANKLIN_LIVE_SWAP_CAP;
|
|
57
|
+
if (!raw)
|
|
58
|
+
return DEFAULT_LIVE_SWAP_CAP;
|
|
59
|
+
const n = Number(raw);
|
|
60
|
+
if (!Number.isFinite(n))
|
|
61
|
+
return DEFAULT_LIVE_SWAP_CAP;
|
|
62
|
+
if (n <= 0)
|
|
63
|
+
return Infinity;
|
|
64
|
+
return Math.floor(n);
|
|
65
|
+
})();
|
|
66
|
+
let liveSwapCount = 0;
|
|
67
|
+
const DEFAULT_LARGE_SWAP_USD = 20;
|
|
68
|
+
const largeSwapThresholdUsd = (() => {
|
|
69
|
+
const raw = process.env.FRANKLIN_LIVE_SWAP_WARN_USD;
|
|
70
|
+
if (!raw)
|
|
71
|
+
return DEFAULT_LARGE_SWAP_USD;
|
|
72
|
+
const n = Number(raw);
|
|
73
|
+
if (!Number.isFinite(n) || n < 0)
|
|
74
|
+
return DEFAULT_LARGE_SWAP_USD;
|
|
75
|
+
return n;
|
|
76
|
+
})();
|
|
77
|
+
// ─── Base token map ──────────────────────────────────────────────────────
|
|
78
|
+
// EVM addresses are case-sensitive in some libraries — store as checksum.
|
|
79
|
+
const NATIVE_ETH = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE';
|
|
80
|
+
const SYMBOL_TO_ADDRESS = {
|
|
81
|
+
ETH: NATIVE_ETH,
|
|
82
|
+
WETH: '0x4200000000000000000000000000000000000006',
|
|
83
|
+
USDC: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
|
|
84
|
+
USDT: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2',
|
|
85
|
+
CBBTC: '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf',
|
|
86
|
+
CBETH: '0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22',
|
|
87
|
+
AERO: '0x940181a94A35A4569E4529A3CDfB74e38FD98631',
|
|
88
|
+
DAI: '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb',
|
|
89
|
+
};
|
|
90
|
+
const TOKEN_DECIMALS = {
|
|
91
|
+
[NATIVE_ETH.toLowerCase()]: 18,
|
|
92
|
+
'0x4200000000000000000000000000000000000006': 18, // WETH
|
|
93
|
+
'0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': 6, // USDC
|
|
94
|
+
'0xfde4c96c8593536e31f229ea8f37b2ada2699bb2': 6, // USDT
|
|
95
|
+
'0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf': 8, // CBBTC
|
|
96
|
+
'0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22': 18, // CBETH
|
|
97
|
+
'0x940181a94a35a4569e4529a3cdfb74e38fd98631': 18, // AERO
|
|
98
|
+
'0x50c5725949a6f0c72e6c4a641f24049a917db0cb': 18, // DAI
|
|
99
|
+
};
|
|
100
|
+
const STABLECOIN_ADDRESSES = new Set([
|
|
101
|
+
'0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', // USDC
|
|
102
|
+
'0xfde4c96c8593536e31f229ea8f37b2ada2699bb2', // USDT
|
|
103
|
+
'0x50c5725949a6f0c72e6c4a641f24049a917db0cb', // DAI
|
|
104
|
+
]);
|
|
105
|
+
function resolveTokenAddress(input) {
|
|
106
|
+
const upper = input.trim().toUpperCase();
|
|
107
|
+
if (SYMBOL_TO_ADDRESS[upper])
|
|
108
|
+
return SYMBOL_TO_ADDRESS[upper];
|
|
109
|
+
return input.trim();
|
|
110
|
+
}
|
|
111
|
+
function decimalsFor(address) {
|
|
112
|
+
const lower = address.toLowerCase();
|
|
113
|
+
return TOKEN_DECIMALS[lower] ?? 18;
|
|
114
|
+
}
|
|
115
|
+
function symbolFor(address) {
|
|
116
|
+
const lower = address.toLowerCase();
|
|
117
|
+
for (const [sym, addr] of Object.entries(SYMBOL_TO_ADDRESS)) {
|
|
118
|
+
if (addr.toLowerCase() === lower)
|
|
119
|
+
return sym;
|
|
120
|
+
}
|
|
121
|
+
return `${address.slice(0, 6)}…${address.slice(-4)}`;
|
|
122
|
+
}
|
|
123
|
+
function isStablecoin(address) {
|
|
124
|
+
return STABLECOIN_ADDRESSES.has(address.toLowerCase());
|
|
125
|
+
}
|
|
126
|
+
function estimateUsdValue(sellTokenAddr, humanAmount) {
|
|
127
|
+
if (isStablecoin(sellTokenAddr))
|
|
128
|
+
return humanAmount;
|
|
129
|
+
return null; // unknown — caller surfaces a "couldn't price" notice
|
|
130
|
+
}
|
|
131
|
+
function isNativeEth(addr) {
|
|
132
|
+
return addr.toLowerCase() === NATIVE_ETH.toLowerCase();
|
|
133
|
+
}
|
|
134
|
+
// ─── Wallet + client setup ───────────────────────────────────────────────
|
|
135
|
+
async function loadEvmWallet() {
|
|
136
|
+
const raw = await getOrCreateWallet();
|
|
137
|
+
// @blockrun/llm returns { privateKey: '0x...', address: '0x...' } — use it
|
|
138
|
+
// verbatim for viem.
|
|
139
|
+
const account = privateKeyToAccount(raw.privateKey);
|
|
140
|
+
return { account, address: raw.address };
|
|
141
|
+
}
|
|
142
|
+
function makeClient(account) {
|
|
143
|
+
return createWalletClient({
|
|
144
|
+
account,
|
|
145
|
+
chain: base,
|
|
146
|
+
transport: http(resolveBaseRpcUrl()),
|
|
147
|
+
}).extend(publicActions);
|
|
148
|
+
}
|
|
149
|
+
// ─── 0x calls via BlockRun gateway (free public passthrough) ─────────────
|
|
150
|
+
async function gatewayGet(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}`,
|
|
157
|
+
};
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
const timer = setTimeout(() => controller.abort(), ZEROX_TIMEOUT_MS);
|
|
160
|
+
const onAbort = () => controller.abort();
|
|
161
|
+
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
162
|
+
try {
|
|
163
|
+
const response = await fetch(endpoint, {
|
|
164
|
+
method: 'GET',
|
|
165
|
+
headers,
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
});
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const text = await response.text().catch(() => '');
|
|
170
|
+
throw new Error(`BlockRun gateway /v1/zerox/${path} returned ${response.status}: ${text.slice(0, 300)}`);
|
|
171
|
+
}
|
|
172
|
+
return (await response.json());
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
ctx.abortSignal.removeEventListener('abort', onAbort);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function buildSwapParams(args) {
|
|
180
|
+
// Affiliate params (swapFeeRecipient/Bps/Token) are NOT set here —
|
|
181
|
+
// the BlockRun gateway forces them server-side, ensuring every
|
|
182
|
+
// gateway-routed swap pays affiliate to BlockRun treasury regardless
|
|
183
|
+
// of what the agent passes. See blockrun/src/lib/zerox.ts:proxyToZerox.
|
|
184
|
+
return new URLSearchParams({
|
|
185
|
+
chainId: String(base.id),
|
|
186
|
+
sellToken: args.sellTokenAddr,
|
|
187
|
+
buyToken: args.buyTokenAddr,
|
|
188
|
+
sellAmount: args.sellAmountAtomic,
|
|
189
|
+
taker: args.taker,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
// ─── Formatting ──────────────────────────────────────────────────────────
|
|
193
|
+
function formatQuoteText(q) {
|
|
194
|
+
const sellDec = decimalsFor(q.sellToken);
|
|
195
|
+
const buyDec = decimalsFor(q.buyToken);
|
|
196
|
+
const sellHuman = Number(formatUnits(BigInt(q.sellAmount), sellDec));
|
|
197
|
+
const buyHuman = Number(formatUnits(BigInt(q.buyAmount), buyDec));
|
|
198
|
+
const sellSym = symbolFor(q.sellToken);
|
|
199
|
+
const buySym = symbolFor(q.buyToken);
|
|
200
|
+
const rate = sellHuman > 0 ? buyHuman / sellHuman : 0;
|
|
201
|
+
const route = q.route?.fills?.map((f) => f.source).filter(Boolean).slice(0, 4).join(' → ') ||
|
|
202
|
+
'0x V2 router';
|
|
203
|
+
const minOut = q.minBuyAmount
|
|
204
|
+
? Number(formatUnits(BigInt(q.minBuyAmount), buyDec))
|
|
205
|
+
: null;
|
|
206
|
+
const lines = [
|
|
207
|
+
`${sellHuman.toFixed(Math.min(8, sellDec))} ${sellSym} → ${buyHuman.toFixed(Math.min(8, buyDec))} ${buySym}`,
|
|
208
|
+
`Rate: 1 ${sellSym} ≈ ${rate.toPrecision(6)} ${buySym}`,
|
|
209
|
+
];
|
|
210
|
+
if (minOut != null) {
|
|
211
|
+
lines.push(`Min received: ${minOut.toFixed(Math.min(8, buyDec))} ${buySym}`);
|
|
212
|
+
}
|
|
213
|
+
lines.push(`Route: ${route}`);
|
|
214
|
+
lines.push(`Affiliate fee: ${BLOCKRUN_AFFILIATE_FEE_BPS} bps (BlockRun affiliate, taken in ${sellSym})`);
|
|
215
|
+
return lines.join('\n');
|
|
216
|
+
}
|
|
217
|
+
// ─── Quote (read-only) ───────────────────────────────────────────────────
|
|
218
|
+
async function executeBase0xQuote(input, ctx) {
|
|
219
|
+
let walletAddress;
|
|
220
|
+
try {
|
|
221
|
+
const wallet = await loadEvmWallet();
|
|
222
|
+
walletAddress = wallet.address;
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
return {
|
|
226
|
+
output: `Couldn't load Base wallet: ${err instanceof Error ? err.message : String(err)}`,
|
|
227
|
+
isError: true,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const sellTokenAddr = resolveTokenAddress(input.sell_token);
|
|
231
|
+
const buyTokenAddr = resolveTokenAddress(input.buy_token);
|
|
232
|
+
const sellDec = decimalsFor(sellTokenAddr);
|
|
233
|
+
const sellAmount = parseUnits(input.sell_amount.toString(), sellDec).toString();
|
|
234
|
+
const params = buildSwapParams({
|
|
235
|
+
sellTokenAddr,
|
|
236
|
+
buyTokenAddr,
|
|
237
|
+
sellAmountAtomic: sellAmount,
|
|
238
|
+
taker: walletAddress,
|
|
239
|
+
});
|
|
240
|
+
try {
|
|
241
|
+
const price = await gatewayGet('price', params, ctx);
|
|
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: `BlockRun gateway 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
|
+
});
|
|
285
|
+
// Step 1 — fetch the firm quote via BlockRun gateway (x402-paid).
|
|
286
|
+
// Gateway forces affiliate params server-side; user pays $0.001 USDC.
|
|
287
|
+
let quote;
|
|
288
|
+
try {
|
|
289
|
+
quote = await gatewayGet('quote', params, ctx);
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
return {
|
|
293
|
+
output: `BlockRun gateway 0x /quote failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
294
|
+
isError: true,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (!quote.transaction) {
|
|
298
|
+
return {
|
|
299
|
+
output: `0x returned no transaction — likely no liquidity for this pair.`,
|
|
300
|
+
isError: true,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
// Step 2 — confirm with user (unless explicit auto_approve override).
|
|
304
|
+
if (!input.auto_approve && ctx.onAskUser) {
|
|
305
|
+
const quoteText = formatQuoteText(quote);
|
|
306
|
+
const usdEst = estimateUsdValue(sellTokenAddr, input.sell_amount);
|
|
307
|
+
const sections = ['Execute this 0x swap on Base?', '', quoteText];
|
|
308
|
+
if (usdEst != null && usdEst >= largeSwapThresholdUsd) {
|
|
309
|
+
sections.push('', `⚠ Large swap warning — input is ~$${usdEst.toFixed(2)} (above $${largeSwapThresholdUsd} threshold).`);
|
|
310
|
+
}
|
|
311
|
+
else if (usdEst == null) {
|
|
312
|
+
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.`);
|
|
313
|
+
}
|
|
314
|
+
sections.push('', `Wallet: ${wallet.address}`, `Live-swap session count: ${liveSwapCount}/${liveSwapCap === Infinity ? '∞' : liveSwapCap}`);
|
|
315
|
+
const answer = await ctx.onAskUser(sections.join('\n'), ['Confirm', 'Cancel']);
|
|
316
|
+
if (answer.toLowerCase() !== 'confirm') {
|
|
317
|
+
return { output: 'Swap cancelled by user.' };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Step 3 — for ERC-20 sell tokens, ensure Permit2 has an allowance.
|
|
321
|
+
// Native ETH skips this entirely.
|
|
322
|
+
if (!isNativeEth(sellTokenAddr)) {
|
|
323
|
+
const allowanceIssue = quote.issues?.allowance ?? null;
|
|
324
|
+
if (allowanceIssue) {
|
|
325
|
+
try {
|
|
326
|
+
const erc20 = getContract({ address: sellTokenAddr, abi: erc20Abi, client });
|
|
327
|
+
const { request } = await erc20.simulate.approve([allowanceIssue.spender, maxUint256]);
|
|
328
|
+
const approveHash = await erc20.write.approve(request.args);
|
|
329
|
+
await client.waitForTransactionReceipt({ hash: approveHash });
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
return {
|
|
333
|
+
output: `Permit2 approval failed for ${symbolFor(sellTokenAddr)}: ${err instanceof Error ? err.message : String(err)}\n` +
|
|
334
|
+
`This is a one-time setup step per token; retry the swap and it should succeed.`,
|
|
335
|
+
isError: true,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Step 4 — sign the Permit2 EIP-712 typed-data and append signature to
|
|
340
|
+
// transaction.data per the canonical 0x recipe.
|
|
341
|
+
if (!quote.permit2?.eip712) {
|
|
342
|
+
return {
|
|
343
|
+
output: '0x quote did not include permit2.eip712 — non-Permit2 path required, not yet supported.',
|
|
344
|
+
isError: true,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
let signature;
|
|
348
|
+
try {
|
|
349
|
+
signature = await client.signTypedData(quote.permit2.eip712);
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
return {
|
|
353
|
+
output: `Permit2 signing failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
354
|
+
isError: true,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const sigLengthHex = numberToHex(size(signature), { signed: false, size: 32 });
|
|
358
|
+
quote.transaction.data = concat([quote.transaction.data, sigLengthHex, signature]);
|
|
359
|
+
}
|
|
360
|
+
// Step 5 — submit. Native ETH path uses sendTransaction (with value);
|
|
361
|
+
// ERC-20 path uses signTransaction + sendRawTransaction (matches the
|
|
362
|
+
// official 0x example to avoid double-signing pitfalls).
|
|
363
|
+
let txHash;
|
|
364
|
+
try {
|
|
365
|
+
if (isNativeEth(sellTokenAddr)) {
|
|
366
|
+
txHash = await client.sendTransaction({
|
|
367
|
+
account: wallet.account,
|
|
368
|
+
chain: base,
|
|
369
|
+
to: quote.transaction.to,
|
|
370
|
+
data: quote.transaction.data,
|
|
371
|
+
value: BigInt(quote.transaction.value),
|
|
372
|
+
gas: quote.transaction.gas ? BigInt(quote.transaction.gas) : undefined,
|
|
373
|
+
gasPrice: quote.transaction.gasPrice ? BigInt(quote.transaction.gasPrice) : undefined,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
const nonce = await client.getTransactionCount({ address: wallet.address });
|
|
378
|
+
const signedTx = await client.signTransaction({
|
|
379
|
+
account: wallet.account,
|
|
380
|
+
chain: base,
|
|
381
|
+
to: quote.transaction.to,
|
|
382
|
+
data: quote.transaction.data,
|
|
383
|
+
gas: quote.transaction.gas ? BigInt(quote.transaction.gas) : undefined,
|
|
384
|
+
gasPrice: quote.transaction.gasPrice ? BigInt(quote.transaction.gasPrice) : undefined,
|
|
385
|
+
nonce,
|
|
386
|
+
});
|
|
387
|
+
txHash = await client.sendRawTransaction({ serializedTransaction: signedTx });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
392
|
+
const lower = msg.toLowerCase();
|
|
393
|
+
if (lower.includes('insufficient') ||
|
|
394
|
+
lower.includes('exceeds balance') ||
|
|
395
|
+
lower.includes('not enough')) {
|
|
396
|
+
return {
|
|
397
|
+
output: `Swap failed: insufficient balance. Your Base wallet (${wallet.address}) does not hold enough ${symbolFor(sellTokenAddr)}.\n\n` +
|
|
398
|
+
`Send ${symbolFor(sellTokenAddr)} to that address (or fund it via Coinbase / a bridge), then retry.\n\n` +
|
|
399
|
+
`Underlying error: ${msg}`,
|
|
400
|
+
isError: true,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
output: `Submit failed: ${msg}`,
|
|
405
|
+
isError: true,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
liveSwapCount += 1;
|
|
409
|
+
const explorer = `https://basescan.org/tx/${txHash}`;
|
|
410
|
+
return {
|
|
411
|
+
output: [
|
|
412
|
+
'✓ Swap executed on Base.',
|
|
413
|
+
formatQuoteText(quote),
|
|
414
|
+
`Tx hash: ${txHash}`,
|
|
415
|
+
explorer,
|
|
416
|
+
`(Session live-swap count: ${liveSwapCount}/${liveSwapCap === Infinity ? '∞' : liveSwapCap})`,
|
|
417
|
+
].join('\n'),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// ─── Capability handlers ─────────────────────────────────────────────────
|
|
421
|
+
const COMMON_INPUT_PROPERTIES = {
|
|
422
|
+
sell_token: {
|
|
423
|
+
type: 'string',
|
|
424
|
+
description: "Sell-token address (Base EVM 0x... 42 chars), OR a symbol shortcut: ETH, WETH, USDC, USDT, CBBTC, CBETH, AERO, DAI.",
|
|
425
|
+
},
|
|
426
|
+
buy_token: {
|
|
427
|
+
type: 'string',
|
|
428
|
+
description: 'Buy-token address or symbol shortcut (same list as sell_token).',
|
|
429
|
+
},
|
|
430
|
+
sell_amount: {
|
|
431
|
+
type: 'number',
|
|
432
|
+
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.',
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
export const base0xQuoteCapability = {
|
|
436
|
+
spec: {
|
|
437
|
+
name: 'Base0xQuote',
|
|
438
|
+
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.",
|
|
439
|
+
input_schema: {
|
|
440
|
+
type: 'object',
|
|
441
|
+
required: ['sell_token', 'buy_token', 'sell_amount'],
|
|
442
|
+
properties: COMMON_INPUT_PROPERTIES,
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
execute: async (input, ctx) => {
|
|
446
|
+
return executeBase0xQuote(input, ctx);
|
|
447
|
+
},
|
|
448
|
+
concurrent: true,
|
|
449
|
+
};
|
|
450
|
+
export const base0xSwapCapability = {
|
|
451
|
+
spec: {
|
|
452
|
+
name: 'Base0xSwap',
|
|
453
|
+
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.",
|
|
454
|
+
input_schema: {
|
|
455
|
+
type: 'object',
|
|
456
|
+
required: ['sell_token', 'buy_token', 'sell_amount'],
|
|
457
|
+
properties: {
|
|
458
|
+
...COMMON_INPUT_PROPERTIES,
|
|
459
|
+
auto_approve: {
|
|
460
|
+
type: 'boolean',
|
|
461
|
+
description: 'If true, skip the AskUser confirm. Default false — agent should leave this false unless the user explicitly authorized this specific call.',
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
execute: async (input, ctx) => {
|
|
467
|
+
return executeBase0xSwap(input, ctx);
|
|
468
|
+
},
|
|
469
|
+
concurrent: false,
|
|
470
|
+
};
|
package/package.json
CHANGED