@blockrun/franklin 3.12.1 → 3.12.3

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.
@@ -205,9 +205,8 @@ You run on the BlockRun AI Gateway. When the user asks you to "test the BlockRun
205
205
  - \`GET /v1/models\` — full model catalog (id, owner, context window, pricing).
206
206
  - \`GET /v1/health/overview\` · \`/v1/health/regions\` · \`/v1/health/chain\` · \`/v1/health/models\` — gateway status.
207
207
 
208
- **Trading & DeFi (mixed methods, x402-paid; new in v3.12.0)**
209
- - \`GET /v1/defillama/protocols\` · \`/v1/defillama/protocol/{slug}\` · \`/v1/defillama/chains\` · \`/v1/defillama/yields\`TVL / yield-pool data, Apache-2.0 source. \$0.005/call.
210
- - \`GET /v1/defillama/prices/{coins}\` — token price lookup (coingecko:bitcoin, ethereum:0x..., solana:mint, comma-separated). \$0.001/call.
208
+ **Trading & DeFi (mixed methods, x402-paid)**
209
+ - For DefiLlama data, **use the built-in tools** \`DeFiLlamaProtocols\`, \`DeFiLlamaProtocol\`, \`DeFiLlamaChains\`, \`DeFiLlamaYields\`, \`DeFiLlamaPrice\`. They handle x402 payment automatically and filter responses (DefiLlama raw payloads are 5–10 MB; the tools return ranked summaries). Do NOT call \`/v1/defillama/*\` via Bash + curl the wallet won't sign payments through that path.
211
210
  - \`POST /v1/solana/rpc\` — JSON-RPC passthrough to public mainnet-beta (getAccountInfo, getTokenSupply, sendTransaction, etc.). \$0.0005 per call (per element of a batch). Use this instead of running your own RPC infra.
212
211
 
213
212
  **Solana DEX swap (Jupiter Ultra)**
@@ -233,6 +232,53 @@ A bare \`402\` on a POST means the endpoint is healthy and the payment flow is w
233
232
 
234
233
  **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.`;
235
234
  }
235
+ function getTradingPlaybookSection() {
236
+ return `# Trading playbook (built-in tools)
237
+
238
+ 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).
239
+
240
+ ## Before any live swap
241
+
242
+ 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.
243
+ 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.
244
+ 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."
245
+
246
+ ## During a swap
247
+
248
+ - **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.
249
+ - **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.
250
+ - **Surface the Solscan link prominently after execution.** Trust is built on receipts. "Done" without a signature link is a red flag for the user.
251
+
252
+ ## Failure handling
253
+
254
+ - \`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.
255
+ - \`/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.
256
+ - \`/order\` returns no transaction or 30 %+ price impact → no liquidity for the pair. Suggest a smaller amount or a different output token.
257
+ - 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.
258
+
259
+ ## Never
260
+
261
+ - Chain multiple live swaps without showing the running USD spent so far this turn.
262
+ - Tell the user "I executed your trade" without the Solscan link or signature.
263
+ - Compute USD value or P&L by guessing prices. Use \`TradingMarket\`, \`DeFiLlamaPrice\`, or \`JupiterQuote\` (with stablecoin reference) for ground truth.
264
+ - 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.
265
+
266
+ ## DeFi data (DeFiLlama tools)
267
+
268
+ - Match the tool to the question.
269
+ - "What's pumping on Solana?" → \`DeFiLlamaProtocols(chain='Solana', top_n=10)\`
270
+ - "Top yield for USDC" → \`DeFiLlamaYields(symbol='USDC', stablecoin_only=true)\`
271
+ - "Aave's TVL" → \`DeFiLlamaProtocol(slug='aave-v3')\`
272
+ - "BTC price" → \`DeFiLlamaPrice(coins=['coingecko:bitcoin'])\` or \`TradingMarket\`
273
+ - **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.
274
+ - **Never call the same DeFiLlama endpoint twice in one turn.** Each call is paid. If you find yourself doing it, your plan is wrong.
275
+
276
+ ## Paper vs. live
277
+
278
+ - 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.
279
+ - 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.
280
+ `;
281
+ }
236
282
  function getToolPatternsSection() {
237
283
  return `# Tool Selection Patterns
238
284
  - **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.
@@ -301,6 +347,7 @@ export function assembleInstructions(workingDir, model) {
301
347
  getMissingAccessSection(),
302
348
  getWalletKnowledgeSection(),
303
349
  getBlockRunApiSection(),
350
+ getTradingPlaybookSection(),
304
351
  getToolPatternsSection(),
305
352
  getTokenEfficiencySection(),
306
353
  getVerificationSection(),
@@ -0,0 +1,23 @@
1
+ /**
2
+ * DefiLlama capabilities — TVL, yield pools, protocol metadata, and token
3
+ * prices via the BlockRun `/v1/defillama/*` endpoints. Each tool handles
4
+ * x402 payment automatically against the user's USDC wallet.
5
+ *
6
+ * Five tools, each filtered + formatted on the way back so we don't dump
7
+ * 5–10 MB of raw DefiLlama JSON into agent context:
8
+ * - DeFiLlamaProtocols $0.005/call — top-N protocols by TVL
9
+ * - DeFiLlamaProtocol $0.005/call — single protocol detail
10
+ * - DeFiLlamaChains $0.005/call — TVL ranked by chain
11
+ * - DeFiLlamaYields $0.005/call — yield pools, filtered + ranked
12
+ * - DeFiLlamaPrice $0.001/call — token price lookup
13
+ *
14
+ * DefiLlama is Apache 2.0 / "free for public and commercial use" — the
15
+ * BlockRun gateway adds metering + (future) caching/reliability layers,
16
+ * which is what the per-call charge funds.
17
+ */
18
+ import type { CapabilityHandler } from '../agent/types.js';
19
+ export declare const defiLlamaProtocolsCapability: CapabilityHandler;
20
+ export declare const defiLlamaProtocolCapability: CapabilityHandler;
21
+ export declare const defiLlamaChainsCapability: CapabilityHandler;
22
+ export declare const defiLlamaYieldsCapability: CapabilityHandler;
23
+ export declare const defiLlamaPriceCapability: CapabilityHandler;
@@ -0,0 +1,408 @@
1
+ /**
2
+ * DefiLlama capabilities — TVL, yield pools, protocol metadata, and token
3
+ * prices via the BlockRun `/v1/defillama/*` endpoints. Each tool handles
4
+ * x402 payment automatically against the user's USDC wallet.
5
+ *
6
+ * Five tools, each filtered + formatted on the way back so we don't dump
7
+ * 5–10 MB of raw DefiLlama JSON into agent context:
8
+ * - DeFiLlamaProtocols $0.005/call — top-N protocols by TVL
9
+ * - DeFiLlamaProtocol $0.005/call — single protocol detail
10
+ * - DeFiLlamaChains $0.005/call — TVL ranked by chain
11
+ * - DeFiLlamaYields $0.005/call — yield pools, filtered + ranked
12
+ * - DeFiLlamaPrice $0.001/call — token price lookup
13
+ *
14
+ * DefiLlama is Apache 2.0 / "free for public and commercial use" — the
15
+ * BlockRun gateway adds metering + (future) caching/reliability layers,
16
+ * which is what the per-call charge funds.
17
+ */
18
+ import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
19
+ import { loadChain, API_URLS, VERSION } from '../config.js';
20
+ const TIMEOUT_MS = 30_000;
21
+ // ─── Shared GET-with-x402 flow ────────────────────────────────────────────
22
+ async function getWithPayment(path, ctx) {
23
+ const chain = loadChain();
24
+ const apiUrl = API_URLS[chain];
25
+ const endpoint = `${apiUrl}${path}`;
26
+ const headers = {
27
+ Accept: 'application/json',
28
+ 'User-Agent': `franklin/${VERSION}`,
29
+ };
30
+ const controller = new AbortController();
31
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
32
+ const onAbort = () => controller.abort();
33
+ ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
34
+ try {
35
+ let response = await fetch(endpoint, {
36
+ method: 'GET',
37
+ signal: controller.signal,
38
+ headers,
39
+ });
40
+ if (response.status === 402) {
41
+ const paymentHeaders = await signPayment(response, chain, endpoint);
42
+ if (!paymentHeaders) {
43
+ throw new Error('Payment signing failed — check wallet balance');
44
+ }
45
+ response = await fetch(endpoint, {
46
+ method: 'GET',
47
+ signal: controller.signal,
48
+ headers: { ...headers, ...paymentHeaders },
49
+ });
50
+ }
51
+ if (!response.ok) {
52
+ const errText = await response.text().catch(() => '');
53
+ throw new Error(`DefiLlama ${path} failed (${response.status}): ${errText.slice(0, 200)}`);
54
+ }
55
+ return (await response.json());
56
+ }
57
+ finally {
58
+ clearTimeout(timeout);
59
+ ctx.abortSignal.removeEventListener('abort', onAbort);
60
+ }
61
+ }
62
+ async function signPayment(response, chain, endpoint) {
63
+ try {
64
+ const paymentHeader = await extractPaymentReq(response);
65
+ if (!paymentHeader)
66
+ return null;
67
+ if (chain === 'solana') {
68
+ const wallet = await getOrCreateSolanaWallet();
69
+ const paymentRequired = parsePaymentRequired(paymentHeader);
70
+ const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
71
+ const secretBytes = await solanaKeyToBytes(wallet.privateKey);
72
+ const feePayer = details.extra?.feePayer || details.recipient;
73
+ const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
74
+ resourceUrl: details.resource?.url || endpoint,
75
+ resourceDescription: details.resource?.description || 'Franklin DefiLlama call',
76
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
77
+ extra: details.extra,
78
+ });
79
+ return { 'PAYMENT-SIGNATURE': payload };
80
+ }
81
+ const wallet = await getOrCreateWallet();
82
+ const paymentRequired = parsePaymentRequired(paymentHeader);
83
+ const details = extractPaymentDetails(paymentRequired);
84
+ const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
85
+ resourceUrl: details.resource?.url || endpoint,
86
+ resourceDescription: details.resource?.description || 'Franklin DefiLlama call',
87
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
88
+ extra: details.extra,
89
+ });
90
+ return { 'PAYMENT-SIGNATURE': payload };
91
+ }
92
+ catch (err) {
93
+ console.error(`[franklin] DefiLlama payment error: ${err.message}`);
94
+ return null;
95
+ }
96
+ }
97
+ async function extractPaymentReq(response) {
98
+ let header = response.headers.get('payment-required');
99
+ if (!header) {
100
+ try {
101
+ const body = (await response.json());
102
+ if (body.x402 || body.accepts)
103
+ header = btoa(JSON.stringify(body));
104
+ }
105
+ catch {
106
+ /* ignore */
107
+ }
108
+ }
109
+ return header;
110
+ }
111
+ // ─── Formatting helpers ──────────────────────────────────────────────────
112
+ function formatUsd(value) {
113
+ if (value == null || !Number.isFinite(value))
114
+ return 'n/a';
115
+ if (value >= 1e9)
116
+ return `$${(value / 1e9).toFixed(2)}B`;
117
+ if (value >= 1e6)
118
+ return `$${(value / 1e6).toFixed(2)}M`;
119
+ if (value >= 1e3)
120
+ return `$${(value / 1e3).toFixed(2)}K`;
121
+ return `$${value.toFixed(2)}`;
122
+ }
123
+ function formatPct(value, digits = 2) {
124
+ if (value == null || !Number.isFinite(value))
125
+ return 'n/a';
126
+ const sign = value >= 0 ? '+' : '';
127
+ return `${sign}${value.toFixed(digits)}%`;
128
+ }
129
+ export const defiLlamaProtocolsCapability = {
130
+ spec: {
131
+ name: 'DeFiLlamaProtocols',
132
+ description: 'Rank DeFi protocols by total value locked (TVL) across all chains, optionally filtered by category, chain, or minimum TVL. ' +
133
+ 'Returns the top-N protocols (default 20), each with TVL, 24h/7d change, chain breakdown, and slug. ' +
134
+ 'Uses BlockRun gateway → DefiLlama. $0.005 per call. ' +
135
+ 'Categories include: Lending, Liquid Staking, Bridge, Dexes, CDP, Yield, Yield Aggregator, Derivatives, Stablecoins, Insurance, etc.',
136
+ input_schema: {
137
+ type: 'object',
138
+ properties: {
139
+ top_n: { type: 'number', description: 'Max results (default 20, hard cap 100).' },
140
+ category: { type: 'string', description: 'Category filter, exact match (case-insensitive).' },
141
+ chain: { type: 'string', description: 'Chain name filter (e.g. "Ethereum", "Solana", "Base").' },
142
+ min_tvl_usd: { type: 'number', description: 'Drop protocols with TVL below this floor.' },
143
+ },
144
+ },
145
+ },
146
+ execute: async (input, ctx) => {
147
+ const params = input;
148
+ const topN = Math.min(Math.max(1, params.top_n ?? 20), 100);
149
+ try {
150
+ const res = await getWithPayment('/v1/defillama/protocols', ctx);
151
+ let list = res ?? [];
152
+ if (params.category) {
153
+ const want = params.category.toLowerCase();
154
+ list = list.filter((p) => (p.category ?? '').toLowerCase() === want);
155
+ }
156
+ if (params.chain) {
157
+ const want = params.chain.toLowerCase();
158
+ list = list.filter((p) => (p.chains ?? []).some((c) => c.toLowerCase() === want));
159
+ }
160
+ if (params.min_tvl_usd != null) {
161
+ list = list.filter((p) => Number(p.tvl) >= params.min_tvl_usd);
162
+ }
163
+ list.sort((a, b) => Number(b.tvl) - Number(a.tvl));
164
+ list = list.slice(0, topN);
165
+ if (list.length === 0)
166
+ return { output: 'No DefiLlama protocols matched the filters.' };
167
+ const lines = [
168
+ `## DefiLlama protocols — top ${list.length}` +
169
+ (params.category ? ` · category=${params.category}` : '') +
170
+ (params.chain ? ` · chain=${params.chain}` : ''),
171
+ ];
172
+ list.forEach((p, i) => {
173
+ const chains = (p.chains ?? []).slice(0, 4).join(', ');
174
+ const more = (p.chains ?? []).length > 4 ? ` +${(p.chains ?? []).length - 4}` : '';
175
+ const cat = p.category ? ` · ${p.category}` : '';
176
+ const change = p.change_1d != null ? ` · 24h ${formatPct(p.change_1d)}` : '';
177
+ lines.push(`${i + 1}. **${p.name}** (${p.slug}) — ${formatUsd(Number(p.tvl))}${cat}${change}\n chains: ${chains}${more}`);
178
+ });
179
+ return { output: lines.join('\n') };
180
+ }
181
+ catch (err) {
182
+ return { output: `Error: ${err.message}`, isError: true };
183
+ }
184
+ },
185
+ concurrent: true,
186
+ };
187
+ export const defiLlamaProtocolCapability = {
188
+ spec: {
189
+ name: 'DeFiLlamaProtocol',
190
+ description: 'Detailed TVL + chain breakdown for a single DeFi protocol identified by DefiLlama slug ' +
191
+ '(e.g. "aave", "uniswap", "lido", "jito", "marinade-finance"). ' +
192
+ 'Returns TVL across each chain it operates on, recent change, audits, social. ' +
193
+ '$0.005 per call. To find a slug, run DeFiLlamaProtocols first.',
194
+ input_schema: {
195
+ type: 'object',
196
+ properties: {
197
+ slug: { type: 'string', description: 'DefiLlama protocol slug, lowercase, dash-separated.' },
198
+ },
199
+ required: ['slug'],
200
+ },
201
+ },
202
+ execute: async (input, ctx) => {
203
+ const params = input;
204
+ if (!params.slug)
205
+ return { output: 'Error: slug is required', isError: true };
206
+ try {
207
+ const safeSlug = encodeURIComponent(params.slug.trim().toLowerCase());
208
+ const p = await getWithPayment(`/v1/defillama/protocol/${safeSlug}`, ctx);
209
+ const chainBreakdown = p.currentChainTvls
210
+ ? Object.entries(p.currentChainTvls)
211
+ .filter(([k]) => !k.endsWith('-staking') && !k.endsWith('-borrowed') && !k.endsWith('-pool2'))
212
+ .sort((a, b) => b[1] - a[1])
213
+ .slice(0, 8)
214
+ .map(([chain, tvl]) => `${chain} ${formatUsd(tvl)}`)
215
+ .join(', ')
216
+ : 'n/a';
217
+ const totalTvl = p.currentChainTvls
218
+ ? Object.values(p.currentChainTvls).reduce((a, b) => a + b, 0)
219
+ : Array.isArray(p.tvl)
220
+ ? p.tvl[p.tvl.length - 1]?.totalLiquidityUSD ?? 0
221
+ : p.tvl ?? 0;
222
+ const lines = [
223
+ `## ${p.name}${p.category ? ` (${p.category})` : ''}`,
224
+ '',
225
+ `TVL: ${formatUsd(totalTvl)}`,
226
+ ];
227
+ if (p.change_1d != null)
228
+ lines.push(`24h change: ${formatPct(p.change_1d)}`);
229
+ if (p.change_7d != null)
230
+ lines.push(`7d change: ${formatPct(p.change_7d)}`);
231
+ lines.push(`Chains: ${chainBreakdown}`);
232
+ if (p.url)
233
+ lines.push(`URL: ${p.url}`);
234
+ if (p.twitter)
235
+ lines.push(`Twitter: @${p.twitter}`);
236
+ if (p.audits)
237
+ lines.push(`Audits: ${p.audits}`);
238
+ if (p.description)
239
+ lines.push('', p.description);
240
+ return { output: lines.join('\n') };
241
+ }
242
+ catch (err) {
243
+ return { output: `Error: ${err.message}`, isError: true };
244
+ }
245
+ },
246
+ concurrent: true,
247
+ };
248
+ export const defiLlamaChainsCapability = {
249
+ spec: {
250
+ name: 'DeFiLlamaChains',
251
+ description: 'TVL ranking across every chain DefiLlama tracks. Default returns top 20 by TVL. $0.005 per call.',
252
+ input_schema: {
253
+ type: 'object',
254
+ properties: {
255
+ top_n: { type: 'number', description: 'Max results (default 20, hard cap 200).' },
256
+ },
257
+ },
258
+ },
259
+ execute: async (input, ctx) => {
260
+ const params = input;
261
+ const topN = Math.min(Math.max(1, params.top_n ?? 20), 200);
262
+ try {
263
+ const list = await getWithPayment('/v1/defillama/chains', ctx);
264
+ const sorted = (list ?? []).slice().sort((a, b) => Number(b.tvl) - Number(a.tvl)).slice(0, topN);
265
+ if (sorted.length === 0)
266
+ return { output: 'DefiLlama returned no chains.' };
267
+ const lines = [`## TVL by chain — top ${sorted.length}`];
268
+ sorted.forEach((c, i) => {
269
+ const sym = c.tokenSymbol ? ` (${c.tokenSymbol})` : '';
270
+ lines.push(`${i + 1}. **${c.name}**${sym} — ${formatUsd(Number(c.tvl))}`);
271
+ });
272
+ return { output: lines.join('\n') };
273
+ }
274
+ catch (err) {
275
+ return { output: `Error: ${err.message}`, isError: true };
276
+ }
277
+ },
278
+ concurrent: true,
279
+ };
280
+ export const defiLlamaYieldsCapability = {
281
+ spec: {
282
+ name: 'DeFiLlamaYields',
283
+ description: 'Search DeFi yield pools (lending, LPs, vaults, staking) by symbol/chain/project, ranked by APY. ' +
284
+ 'Returns top-N pools (default 10). $0.005 per call. ' +
285
+ 'Default filters: TVL > $1M (avoid microcaps), APY > 0. Override via params. ' +
286
+ 'Use stablecoin_only=true for "where can my USDC earn?" queries.',
287
+ input_schema: {
288
+ type: 'object',
289
+ properties: {
290
+ symbol: { type: 'string', description: 'Token symbol filter (e.g. "USDC", "ETH"). Matches tokens in the pool.' },
291
+ chain: { type: 'string', description: 'Chain filter (e.g. "Ethereum", "Solana", "Base").' },
292
+ project: { type: 'string', description: 'DefiLlama project slug filter (e.g. "aave-v3", "lido", "kamino").' },
293
+ min_tvl_usd: { type: 'number', description: 'Minimum pool TVL in USD (default 1_000_000).' },
294
+ min_apy_pct: { type: 'number', description: 'Minimum APY in percent (default 0).' },
295
+ stablecoin_only: { type: 'boolean', description: 'If true, only stablecoin pools.' },
296
+ top_n: { type: 'number', description: 'Max results (default 10, hard cap 50).' },
297
+ },
298
+ },
299
+ },
300
+ execute: async (input, ctx) => {
301
+ const params = input;
302
+ const topN = Math.min(Math.max(1, params.top_n ?? 10), 50);
303
+ const minTvl = params.min_tvl_usd ?? 1_000_000;
304
+ const minApy = params.min_apy_pct ?? 0;
305
+ try {
306
+ const res = await getWithPayment('/v1/defillama/yields', ctx);
307
+ const pools = res.data ?? [];
308
+ let filtered = pools.filter((p) => (p.apy ?? -1) >= minApy && (p.tvlUsd ?? 0) >= minTvl);
309
+ if (params.symbol) {
310
+ const want = params.symbol.toUpperCase();
311
+ filtered = filtered.filter((p) => p.symbol?.toUpperCase().includes(want));
312
+ }
313
+ if (params.chain) {
314
+ const want = params.chain.toLowerCase();
315
+ filtered = filtered.filter((p) => p.chain?.toLowerCase() === want);
316
+ }
317
+ if (params.project) {
318
+ const want = params.project.toLowerCase();
319
+ filtered = filtered.filter((p) => p.project?.toLowerCase() === want);
320
+ }
321
+ if (params.stablecoin_only) {
322
+ filtered = filtered.filter((p) => p.stablecoin === true);
323
+ }
324
+ filtered.sort((a, b) => (b.apy ?? 0) - (a.apy ?? 0));
325
+ const top = filtered.slice(0, topN);
326
+ if (top.length === 0)
327
+ return { output: 'No yield pools matched the filters.' };
328
+ const filterDesc = [
329
+ params.symbol && `symbol=${params.symbol}`,
330
+ params.chain && `chain=${params.chain}`,
331
+ params.project && `project=${params.project}`,
332
+ params.stablecoin_only && 'stablecoin_only',
333
+ `min_tvl=${formatUsd(minTvl)}`,
334
+ `min_apy=${minApy}%`,
335
+ ]
336
+ .filter(Boolean)
337
+ .join(' · ');
338
+ const lines = [`## Yield pools — top ${top.length} by APY · ${filterDesc}`];
339
+ top.forEach((p, i) => {
340
+ const breakdown = p.apyBase != null && p.apyReward != null
341
+ ? ` (base ${p.apyBase.toFixed(2)}% + reward ${p.apyReward.toFixed(2)}%)`
342
+ : '';
343
+ const il = p.ilRisk ? ` · IL: ${p.ilRisk}` : '';
344
+ lines.push(`${i + 1}. **${p.project}** / ${p.chain} / ${p.symbol} — ${(p.apy ?? 0).toFixed(2)}% APY${breakdown}\n TVL: ${formatUsd(p.tvlUsd)}${il} · pool: ${p.pool}`);
345
+ });
346
+ return { output: lines.join('\n') };
347
+ }
348
+ catch (err) {
349
+ return { output: `Error: ${err.message}`, isError: true };
350
+ }
351
+ },
352
+ concurrent: true,
353
+ };
354
+ export const defiLlamaPriceCapability = {
355
+ spec: {
356
+ name: 'DeFiLlamaPrice',
357
+ description: 'Token price lookup via DefiLlama (covers thousands of tokens — anything DEX-listed). $0.001 per call. ' +
358
+ 'Coin identifier syntax: "{platform}:{address}" or "coingecko:{slug}". ' +
359
+ 'Examples: "coingecko:bitcoin" (BTC USD), "ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" (WETH), ' +
360
+ '"solana:So11111111111111111111111111111111111111112" (SOL/wSOL), ' +
361
+ '"solana:DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" (BONK). ' +
362
+ 'Pass multiple identifiers for batch lookup. ' +
363
+ 'Use the existing /v1/crypto/price/{symbol} endpoint via TradingMarket for the major-asset shorthand instead — ' +
364
+ 'this tool is for arbitrary on-chain mints / addresses.',
365
+ input_schema: {
366
+ type: 'object',
367
+ properties: {
368
+ coins: {
369
+ type: 'array',
370
+ items: { type: 'string' },
371
+ description: 'Coin identifiers in DefiLlama syntax. Up to 50 per call.',
372
+ },
373
+ },
374
+ required: ['coins'],
375
+ },
376
+ },
377
+ execute: async (input, ctx) => {
378
+ const params = input;
379
+ if (!params.coins || params.coins.length === 0) {
380
+ return { output: 'Error: coins array is required', isError: true };
381
+ }
382
+ if (params.coins.length > 50) {
383
+ return { output: `Error: max 50 coins per call (got ${params.coins.length})`, isError: true };
384
+ }
385
+ try {
386
+ const safeList = params.coins.map((c) => encodeURIComponent(c.trim())).join(',');
387
+ const res = await getWithPayment(`/v1/defillama/prices/${safeList}`, ctx);
388
+ const coins = res.coins ?? {};
389
+ const lines = [`## Token prices — ${Object.keys(coins).length} match(es)`];
390
+ for (const id of params.coins) {
391
+ const entry = coins[id];
392
+ if (!entry || entry.price == null) {
393
+ lines.push(`- ${id}: no price returned`);
394
+ continue;
395
+ }
396
+ const sym = entry.symbol ? ` (${entry.symbol})` : '';
397
+ const conf = entry.confidence != null ? ` · conf ${(entry.confidence * 100).toFixed(0)}%` : '';
398
+ const ts = entry.timestamp ? ` · ${new Date(entry.timestamp * 1000).toISOString().slice(0, 19)}Z` : '';
399
+ lines.push(`- **${id}**${sym}: $${entry.price.toFixed(entry.price < 0.01 ? 8 : 4)}${conf}${ts}`);
400
+ }
401
+ return { output: lines.join('\n') };
402
+ }
403
+ catch (err) {
404
+ return { output: `Error: ${err.message}`, isError: true };
405
+ }
406
+ },
407
+ concurrent: true,
408
+ };
@@ -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 { defiLlamaProtocolsCapability, defiLlamaProtocolCapability, defiLlamaChainsCapability, defiLlamaYieldsCapability, defiLlamaPriceCapability, } from './defillama.js';
29
30
  import { createTradingCapabilities } from './trading-execute.js';
30
31
  import { Portfolio } from '../trading/portfolio.js';
31
32
  import { RiskEngine } from '../trading/risk.js';
@@ -147,6 +148,11 @@ export const allCapabilities = [
147
148
  walletCapability,
148
149
  jupiterQuoteCapability,
149
150
  jupiterSwapCapability,
151
+ defiLlamaProtocolsCapability,
152
+ defiLlamaProtocolCapability,
153
+ defiLlamaChainsCapability,
154
+ defiLlamaYieldsCapability,
155
+ defiLlamaPriceCapability,
150
156
  ];
151
157
  export { readCapability, writeCapability, editCapability, bashCapability, globCapability, grepCapability, webFetchCapability, webSearchCapability, taskCapability, detachCapability, };
152
158
  export { createSubAgentCapability } from './subagent.js';
@@ -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 first fail fast if Solana isn't set up.
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: `No Solana wallet found. Run \`franklin setup solana\` to generate one, or check ~/.blockrun/solana-wallet.json.\n` +
195
- `(${err instanceof Error ? err.message : 'unknown error'})`,
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: keypair.publicKey.toBase58(),
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 question = `Execute this Jupiter swap?\n\n${quoteText}\n\nWallet: ${keypair.publicKey.toBase58()}`;
225
- const answer = await ctx.onAskUser(question, ['Confirm', 'Cancel']);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.12.1",
3
+ "version": "3.12.3",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {