From b25ea9a7c5c577a9c5163139c8715e482ddf9871 Mon Sep 17 00:00:00 2001 From: 1bcMax <195689928+1bcMax@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:59:24 -0700 Subject: [PATCH 1/2] fix(wallet,stats,trading): --max-spend counts tool+dropped spend; paid-tool accounting; trading guards (3.29.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 money audit of the paid-tool surface (beyond the diff-scoped rounds 1-3), 13 adversarially-verified findings + a follow-up on merged PR #89. Wallet spend ceiling (HIGH): - --max-spend was blind to ALL paid-tool USDC (ImageGen/VideoGen/MusicGen, Exa, Surf, RealFace, Voice, Phone, Modal, DeFiLlama, RPC, Prediction) — only LLM token cost counted. New getLiveSpendUsd accumulator in tracker is diffed around tool execution in the loop and folded into sessionCostUsd, ceiling re-checked. - A paid LLM call whose stream then failed (timeout/mid-stream error/Esc) debited USDC but was never counted; the loop catch now folds getLastPaidUsd() before the next attempt resets it (drift previously compounded across --resume). Paid-but-invisible accounting: - DeFiLlama (5), MultiChainRPC, PredictionMarket (now persisted), MusicGen, and panel phone Buy/Renew/List now call recordUsage with the settled amount (parity with surf.ts) — were charging real USDC invisible to franklin stats. Money-safety + correctness: - $5 BuyPhoneNumber/RenewPhoneNumber now confirm via onAskUser (skipped under FRANKLIN_MEDIA_AUTO_APPROVE_ALL) — were ungated while a $0.05 Modal create gates. - VoiceStatus records one telemetry row per invocation, not per 5s poll (was up to 420/call, evicting real spend history at the 1000-row cap). - RiskEngine per-position cap values the held position at cost basis, not the incoming order price (a buy could slip the cap after a price drop). - closePosition clamps an over-sized close to the held qty (flatten) instead of throwing a confusing 'only X held' error. PR #89 (thanks @samsamtrum) merged + refined: floor excess precision to the atomic unit and reject only true dust (instead of rejecting any excess precision), clearer message + unit test. New no-spend tests: live-spend ceiling basis, risk cost-basis cap, close clamp, Jupiter atomic-unit math. Suite 479/479. --- CHANGELOG.md | 14 ++++++++++ package-lock.json | 4 +-- package.json | 2 +- src/agent/loop.ts | 55 +++++++++++++++++++++++++++++++++++++- src/phone/client.ts | 14 ++++++++++ src/stats/tracker.ts | 22 +++++++++++++++ src/tools/defillama.ts | 19 ++++++++----- src/tools/jupiter.ts | 19 +++++++++---- src/tools/musicgen.ts | 7 +++++ src/tools/phone.ts | 24 +++++++++++++++++ src/tools/prediction.ts | 5 ++++ src/tools/rpc.ts | 19 ++++++++----- src/tools/voice.ts | 20 +++++++++++--- src/trading/engine.ts | 6 ++++- src/trading/risk.ts | 7 +++-- test/audit-batch.local.mjs | 17 ++++++++++++ test/local.mjs | 50 ++++++++++++++++++++++++++++++++++ 17 files changed, 276 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c4a7e1..6b8526b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Franklin Agent 3.29.8 — wallet spend-ceiling + paid-tool accounting (round-4 money audit) + +A fourth review round swept the paid-tool surface beyond the changed files. Headline: the `--max-spend` hard cap and several paid tools were blind to real USDC spend. All adversarially verified. + +- **`--max-spend` now bounds TOTAL USDC, not just LLM tokens.** The session cap only counted LLM token cost; every paid tool (ImageGen/VideoGen/MusicGen, Exa, Surf, RealFace, Voice, Phone, Modal, DeFiLlama, RPC, Prediction) settled USDC through its own x402 path and counted **$0** against the cap — so `franklin -p "generate 30 images" --max-spend 0.50` could spend multiples of the cap with the guard never firing. A new live-spend accumulator (`getLiveSpendUsd`) is diffed around tool execution and folded into the session total, and the ceiling is re-checked after every tool batch. +- **Charged-but-failed LLM calls are now counted.** A paid LLM call whose stream then failed (idle timeout, mid-stream error, or Esc) debited USDC but was never folded into the session cost / cap — and the drift compounded across `--resume`. The loop's error path now captures `getLastPaidUsd()` before the next attempt resets it. +- **Paid-but-invisible tools now record spend.** DeFiLlama (5 tools), MultiChainRPC, PredictionMarket (now persisted to stats, not just in-memory telemetry), MusicGen, and panel-initiated phone Buy/Renew/List all settled real USDC but never called `recordUsage` — so `franklin stats` under-reported and its reconciliation couldn't even detect the gap. All now record the settled amount (parity with `surf.ts`). +- **$5 phone Buy/Renew now confirm the spend.** The two most expensive autonomous actions had no spend-confirm while a $0.05 Modal create did; both now gate on `onAskUser` (skipped under `FRANKLIN_MEDIA_AUTO_APPROVE_ALL`). +- **VoiceStatus no longer floods stats.** Its internal poll loop recorded one telemetry row per 5s poll (up to 420/call), evicting real spend history (1000-row cap); it now records once per invocation. +- **Trading guardrails.** The per-position risk cap now values the held position at cost basis (not the incoming order price), so a buy can't slip the cap after a price drop; an over-sized close now flattens to the held qty instead of throwing a confusing `only X held` error. +- **Merged + refined PR #89** (thanks @samsamtrum): Jupiter swaps no longer round a sub-precision amount **up** to one atomic unit. Follow-up on top: floor excess precision to the atomic unit (so agent-computed amounts and float noise like `0.1 + 0.2` still swap) and reject only true dust, with a clearer message + unit test. + +New no-spend regression tests cover the live-spend ceiling basis, the risk cost-basis cap, the over-sized close clamp, and the Jupiter atomic-unit math. Verified: local suite 479/479. + ## Franklin Agent 3.29.7 — finish the Exa wire-format fix (twin prefetch path) + budget-safety follow-ups A second multi-agent review of the 3.29.5/3.29.6 branch found that both the Exa wire-format fix and the image-budget fix each missed a path. This lands the adversarially-verified fixes: diff --git a/package-lock.json b/package-lock.json index 76443fe6..8d21f5a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@blockrun/franklin", - "version": "3.29.7", + "version": "3.29.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/franklin", - "version": "3.29.7", + "version": "3.29.8", "license": "Apache-2.0", "dependencies": { "@blockrun/llm": "^2.0.0", diff --git a/package.json b/package.json index c450b848..e3f96184 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/franklin", - "version": "3.29.7", + "version": "3.29.8", "description": "Franklin Agent — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.", "type": "module", "exports": { diff --git a/src/agent/loop.ts b/src/agent/loop.ts index efe4d7e0..c2a3fb43 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -20,7 +20,7 @@ import { ToolCallRepair } from './repair/index.js'; import { resetToolSessionState } from '../tools/index.js'; import { CORE_TOOL_NAMES, dynamicToolsEnabled } from '../tools/tool-categories.js'; import { createActivateToolCapability } from '../tools/activate.js'; -import { recordUsage } from '../stats/tracker.js'; +import { recordUsage, getLiveSpendUsd } from '../stats/tracker.js'; import { loadConfig } from '../commands/config.js'; import { recordSessionUsage } from '../stats/session-tracker.js'; import { appendAudit, extractLastUserPrompt } from '../stats/audit.js'; @@ -1599,6 +1599,20 @@ export async function interactiveSession( } } } catch (err) { + // ── Count a paid-but-dropped LLM charge before any retry resets it ── + // recordSettledPayment debits the wallet BEFORE the SSE stream is parsed; + // if the stream then fails (idle timeout, mid-stream error, or Esc), that + // USDC is real but the success-path accounting (which reads + // getLastPaidUsd) never ran, and the next attempt resets the accumulator. + // Fold it into the session total NOW so --max-spend and the running cost + // stay honest. The on-chain charge is already in cost_log via the SDK; + // this fixes the in-session view / ceiling, not the wire ledger. + const droppedPaidUsd = client.getLastPaidUsd(); + if (droppedPaidUsd > 0) { + sessionCostUsd += droppedPaidUsd; + turnCostUsd += droppedPaidUsd; + } + // ── User abort (Esc key) ── if ((err as Error).name === 'AbortError' || abort.signal.aborted) { // Save any partial response that was streamed before abort @@ -1613,6 +1627,21 @@ export async function interactiveSession( break; } + // If the dropped charge pushed us over the hard cap, stop rather than + // retry (a retry would spend more). Abort already returned above. + const capAfterDrop = (config as { maxSpendUsd?: number }).maxSpendUsd; + if (droppedPaidUsd > 0 && typeof capAfterDrop === 'number' && Number.isFinite(capAfterDrop) && + capAfterDrop > 0 && sessionCostUsd >= capAfterDrop) { + onEvent({ + kind: 'text_delta', + text: `\n\n_Max-spend reached: $${sessionCostUsd.toFixed(4)} ≥ cap $${capAfterDrop.toFixed(2)} ` + + `(incl. a charged-but-failed call). Stopping session._\n`, + }); + persistSessionMeta(); + onEvent({ kind: 'turn_done', reason: 'budget' }); + return history; + } + const errMsg = (err as Error).message || ''; const classified = classifyAgentError(errMsg); @@ -2281,12 +2310,36 @@ export async function interactiveSession( } // Collect results — concurrent tools may already be running from streaming + const toolSpendBefore = getLiveSpendUsd(); const results = await streamExec.collectResults(invocations); for (const [inv, result] of results) { onEvent({ kind: 'capability_done', id: inv.id, result }); } + // ── Fold paid-TOOL spend into the session cost ceiling ── + // sessionCostUsd above only tracks LLM token cost; paid tools (ImageGen, + // VideoGen, MusicGen, Exa, Surf, RealFace, Voice, Phone, Modal, DeFiLlama, + // RPC, Prediction) settle USDC through their own x402 paths and report it + // via recordUsage. Diff the live-spend accumulator across tool execution so + // a --max-spend cap actually bounds total USDC, not just LLM tokens. + const toolSpendUsd = getLiveSpendUsd() - toolSpendBefore; + if (toolSpendUsd > 0) { + sessionCostUsd += toolSpendUsd; + turnCostUsd += toolSpendUsd; + const cap = (config as { maxSpendUsd?: number }).maxSpendUsd; + if (typeof cap === 'number' && Number.isFinite(cap) && cap > 0 && sessionCostUsd >= cap) { + onEvent({ + kind: 'text_delta', + text: `\n\n_Max-spend reached: $${sessionCostUsd.toFixed(4)} ≥ cap $${cap.toFixed(2)} (incl. paid-tool spend). ` + + `Stopping session — further calls would exceed the budget._\n`, + }); + persistSessionMeta(); + onEvent({ kind: 'turn_done', reason: 'budget' }); + return history; + } + } + // ── Tool call guardrails ── turnToolCalls += results.length; for (const [inv, result] of results) { diff --git a/src/phone/client.ts b/src/phone/client.ts index 9f90a198..bffa1313 100644 --- a/src/phone/client.ts +++ b/src/phone/client.ts @@ -15,6 +15,7 @@ import { API_URLS, loadChain, type Chain } from '../config.js'; import { postWithPayment } from '../payments/post-with-payment.js'; +import { recordUsage } from '../stats/tracker.js'; import { writeCache, type PhoneNumberRecord } from './cache.js'; function phoneEndpoint(chain: Chain, path: string): string { @@ -36,6 +37,7 @@ export interface ListNumbersResult { */ export async function listNumbers(opts: { walletAddress: string }): Promise { const chain = loadChain(); + const startedAt = Date.now(); const result = await postWithPayment( phoneEndpoint(chain, 'numbers/list'), {}, @@ -53,6 +55,11 @@ export async function listNumbers(opts: { walletAddress: string }): Promise { const chain = loadChain(); + const startedAt = Date.now(); const result = await postWithPayment( phoneEndpoint(chain, 'numbers/renew'), { phoneNumber }, @@ -73,6 +81,8 @@ export async function renewNumber(phoneNumber: string): Promise { const message = typeof result.body.error === 'string' ? result.body.error : `gateway ${result.status}`; throw new Error(message); } + // Record the $5 x402 spend (parity with the agent's RenewPhoneNumber tool). + try { recordUsage('RenewPhoneNumber', 0, 0, 5.0, Date.now() - startedAt); } catch { /* best-effort */ } return { phone_number: String(result.body.phone_number ?? phoneNumber), expires_at: String(result.body.expires_at ?? ''), @@ -92,6 +102,7 @@ export async function buyNumber(opts: { areaCode?: string; }): Promise { const chain = loadChain(); + const startedAt = Date.now(); const body: Record = { country: opts.country || 'US' }; if (opts.areaCode) body.areaCode = opts.areaCode; const result = await postWithPayment( @@ -103,6 +114,9 @@ export async function buyNumber(opts: { const message = typeof result.body.error === 'string' ? result.body.error : `gateway ${result.status}`; throw new Error(message); } + // Record the $5 x402 spend (parity with the agent's BuyPhoneNumber tool) so a + // panel-initiated buy is no longer invisible to franklin stats / the ledger. + try { recordUsage('BuyPhoneNumber', 0, 0, 5.0, Date.now() - startedAt); } catch { /* best-effort */ } return { phone_number: String(result.body.phone_number ?? ''), expires_at: String(result.body.expires_at ?? ''), diff --git a/src/stats/tracker.ts b/src/stats/tracker.ts index 0122b8ce..ded8bf75 100644 --- a/src/stats/tracker.ts +++ b/src/stats/tracker.ts @@ -193,6 +193,24 @@ export function flushStats(): void { if (cachedStats) saveStats(cachedStats); } +// ── Live spend accumulator (process-lifetime) ────────────────────────────── +// Sum of every positive costUsd passed to recordUsage, counted BEFORE the +// test/audit gates below so it reflects REAL USDC spend regardless of whether +// the row is persisted to history. The agent loop diffs this around tool +// execution to fold paid-tool spend into the --max-spend session ceiling, which +// would otherwise only see LLM token cost. See src/agent/loop.ts. +let liveSpendUsd = 0; + +/** Cumulative USDC recorded via recordUsage this process. */ +export function getLiveSpendUsd(): number { + return liveSpendUsd; +} + +/** Test helper: reset the live-spend accumulator. */ +export function resetLiveSpend(): void { + liveSpendUsd = 0; +} + /** * Record a completed request for stats tracking */ @@ -204,6 +222,10 @@ export function recordUsage( latencyMs: number, fallback: boolean = false ): void { + // Count real spend BEFORE the test/audit gates — the --max-spend ceiling must + // see every paid tool call even when history persistence is suppressed. + if (Number.isFinite(costUsd) && costUsd > 0) liveSpendUsd += costUsd; + // Same rationale as appendAudit — tests run in-process with // local/test* models and would otherwise mix into franklin-stats.json // history (verified: 8.4% of a real user's 1000-entry history was diff --git a/src/tools/defillama.ts b/src/tools/defillama.ts index a54b3d4e..a650128f 100644 --- a/src/tools/defillama.ts +++ b/src/tools/defillama.ts @@ -29,6 +29,7 @@ import { import type { CapabilityHandler, CapabilityResult, ExecutionScope } from '../agent/types.js'; import { loadChain, API_URLS, VERSION } from '../config.js'; import { logger } from '../logger.js'; +import { recordUsage } from '../stats/tracker.js'; const TIMEOUT_MS = 30_000; @@ -48,6 +49,8 @@ async function getWithPayment(path: string, ctx: ExecutionScope): Promise const onAbort = () => controller.abort(); ctx.abortSignal.addEventListener('abort', onAbort, { once: true }); + const startedAt = Date.now(); + let paidUsd = 0; try { let response = await fetch(endpoint, { method: 'GET', @@ -56,14 +59,15 @@ async function getWithPayment(path: string, ctx: ExecutionScope): Promise }); if (response.status === 402) { - const paymentHeaders = await signPayment(response, chain, endpoint); - if (!paymentHeaders) { + const signed = await signPayment(response, chain, endpoint); + if (!signed) { throw new Error('Payment signing failed — check wallet balance'); } + paidUsd = signed.amountUsd; response = await fetch(endpoint, { method: 'GET', signal: controller.signal, - headers: { ...headers, ...paymentHeaders }, + headers: { ...headers, ...signed.headers }, }); } @@ -72,6 +76,9 @@ async function getWithPayment(path: string, ctx: ExecutionScope): Promise throw new Error(`DefiLlama ${path} failed (${response.status}): ${errText.slice(0, 200)}`); } + // Record the settled x402 spend so DeFiLlama calls show up in franklin + // stats / audit AND count against the --max-spend ceiling (parity with surf.ts). + try { recordUsage(`DeFiLlama:${path}`, 0, 0, paidUsd, Date.now() - startedAt); } catch { /* best-effort */ } return (await response.json()) as T; } finally { clearTimeout(timeout); @@ -83,7 +90,7 @@ async function signPayment( response: Response, chain: 'base' | 'solana', endpoint: string, -): Promise | null> { +): Promise<{ headers: Record; amountUsd: number } | null> { try { const paymentHeader = await extractPaymentReq(response); if (!paymentHeader) return null; @@ -107,7 +114,7 @@ async function signPayment( extra: details.extra as Record | undefined, }, ); - return { 'PAYMENT-SIGNATURE': payload }; + return { headers: { 'PAYMENT-SIGNATURE': payload }, amountUsd: Number(details.amount) / 1_000_000 }; } const wallet = await getOrCreateWallet(); const paymentRequired = parsePaymentRequired(paymentHeader); @@ -125,7 +132,7 @@ async function signPayment( extra: details.extra as Record | undefined, }, ); - return { 'PAYMENT-SIGNATURE': payload }; + return { headers: { 'PAYMENT-SIGNATURE': payload }, amountUsd: Number(details.amount) / 1_000_000 }; } catch (err) { logger.warn(`[franklin] DefiLlama payment error: ${(err as Error).message}`); return null; diff --git a/src/tools/jupiter.ts b/src/tools/jupiter.ts index 118d2109..64496c6b 100644 --- a/src/tools/jupiter.ts +++ b/src/tools/jupiter.ts @@ -124,7 +124,7 @@ function symbolFor(mint: string): string { return mint.slice(0, 4) + '…'; } -function toAtomicUnits(amount: number, decimals: number): string { +export function toAtomicUnits(amount: number, decimals: number): string { if (!Number.isFinite(amount) || amount <= 0) { throw new Error('amount must be a positive finite number'); } @@ -133,21 +133,30 @@ function toAtomicUnits(amount: number, decimals: number): string { throw new Error('decimals must be a nonnegative safe integer'); } + // Parse the decimal string directly — no `float * scale`, which loses + // precision on high-decimal tokens (the original bug #89 fixed). Excess + // precision beyond the token's `decimals` is FLOORED to the atomic unit + // (slice keeps the leading `decimals` digits), so an agent-computed amount — + // ⅓ of a balance, or float noise like 0.1 + 0.2 = 0.30000000000000004 — still + // swaps its representable part instead of being rejected. The original + // Math.round approach instead rounded a sub-unit amount UP to one atomic unit. const amountText = amount.toString().includes('e') ? amount.toFixed(decimals + 1) : amount.toString(); const [wholePart, fractionalPart = ''] = amountText.split('.'); const normalizedFraction = fractionalPart.padEnd(decimals, '0').slice(0, decimals); - const roundedAwayFraction = fractionalPart.slice(decimals); - const hasRoundedAwayValue = /[1-9]/.test(roundedAwayFraction); const whole = BigInt(wholePart); const fraction = normalizedFraction === '' ? 0n : BigInt(normalizedFraction); const scale = 10n ** BigInt(decimals); const atomic = whole * scale + fraction; - if (atomic === 0n || hasRoundedAwayValue) { - throw new Error(`amount is below the token precision (${decimals} decimals)`); + // Reject only TRUE dust: an amount so small it floors to zero atomic units, + // which Jupiter can't swap. (The old code rounded this UP to 1 unit instead.) + if (atomic === 0n) { + throw new Error( + `amount ${amount} is below the minimum atomic unit for a ${decimals}-decimal token — too small to swap`, + ); } return atomic.toString(); diff --git a/src/tools/musicgen.ts b/src/tools/musicgen.ts index 59607a9b..529d1c7f 100644 --- a/src/tools/musicgen.ts +++ b/src/tools/musicgen.ts @@ -31,6 +31,7 @@ import { loadChain, API_URLS, VERSION } from '../config.js'; import { logger } from '../logger.js'; import type { ContentLibrary } from '../content/library.js'; import { findModel, estimateCostUsd, type GatewayModel } from '../gateway-models.js'; +import { recordUsage } from '../stats/tracker.js'; interface MusicGenInput { prompt: string; @@ -137,6 +138,7 @@ function buildExecute(deps: MusicGenDeps) { const onAbort = () => controller.abort(); ctx.abortSignal.addEventListener('abort', onAbort, { once: true }); + const callStartedAt = Date.now(); try { let response = await fetch(endpoint, { method: 'POST', @@ -174,6 +176,11 @@ function buildExecute(deps: MusicGenDeps) { return { output: 'No track URL returned from API', isError: true }; } + // Record the settled x402 spend so MusicGen lands in franklin stats / + // insights AND counts against the --max-spend ceiling — image/video gen + // already do this; music was the lone media tool that bypassed recordUsage. + try { recordUsage(musicModel, 0, 0, trackCostUsd, Date.now() - callStartedAt); } catch { /* best-effort */ } + // CDN URLs expire in ~24h — download NOW. const dlCtrl = new AbortController(); const dlTimeout = setTimeout(() => dlCtrl.abort(), DOWNLOAD_TIMEOUT_MS); diff --git a/src/tools/phone.ts b/src/tools/phone.ts index 82893d91..a0bf4a18 100644 --- a/src/tools/phone.ts +++ b/src/tools/phone.ts @@ -211,6 +211,19 @@ export const buyPhoneNumberCapability: CapabilityHandler = { const body: Record = {}; if (typeof input.country === 'string') body.country = input.country; if (typeof input.area_code === 'string') body.areaCode = input.area_code; + // Confirm the $5 spend before charging — this is the most expensive single + // autonomous action, so it gets the same gate as a $0.05 ModalCreate. Direct + // callers (FRANKLIN_MEDIA_AUTO_APPROVE_ALL=1 or no onAskUser bridge) proceed. + const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1'; + if (!autoApprove && ctx.onAskUser) { + const answer = await ctx.onAskUser( + `Buy a new ${body.country || 'US'} phone number for $5.00 USDC? No USDC is spent if you cancel.`, + ['Buy', 'Cancel'], + ); + if (answer !== 'Buy') { + return { output: `## Phone number purchase cancelled\n\nNo USDC was spent.` }; + } + } try { const res = await postWithPayment>( '/v1/phone/numbers/buy', body, ctx, { tool: 'BuyPhoneNumber', priceUsd: 5.0 }, @@ -245,6 +258,17 @@ export const renewPhoneNumberCapability: CapabilityHandler = { if (typeof input.phone_number !== 'string') { return { output: 'phone_number (E.164) required', isError: true }; } + // Confirm the $5 spend before charging (parity with BuyPhoneNumber / ModalCreate). + const autoApprove = process.env.FRANKLIN_MEDIA_AUTO_APPROVE_ALL === '1'; + if (!autoApprove && ctx.onAskUser) { + const answer = await ctx.onAskUser( + `Renew ${input.phone_number} for 30 days at $5.00 USDC? No USDC is spent if you cancel.`, + ['Renew', 'Cancel'], + ); + if (answer !== 'Renew') { + return { output: `## Renewal cancelled\n\nNo USDC was spent.` }; + } + } try { const res = await postWithPayment>( '/v1/phone/numbers/renew', diff --git a/src/tools/prediction.ts b/src/tools/prediction.ts index c32739da..81cabc2f 100644 --- a/src/tools/prediction.ts +++ b/src/tools/prediction.ts @@ -47,6 +47,7 @@ import type { CapabilityHandler, CapabilityResult, ExecutionScope } from '../age import { loadChain, API_URLS, VERSION } from '../config.js'; import { logger } from '../logger.js'; import { recordFetch } from '../trading/providers/telemetry.js'; +import { recordUsage } from '../stats/tracker.js'; const TIMEOUT_MS = 30_000; const DEFAULT_LIMIT = 20; @@ -130,6 +131,10 @@ async function getWithPayment(path: string, query: Record 0 ? costRecorded : undefined, }); + // ALSO persist to franklin-stats (recordFetch is in-memory only, lost on + // restart). Without this, Predexon spend is absent from `franklin stats` + // and invisible to the --max-spend ceiling (parity with surf.ts). + try { recordUsage(`PredictionMarket:${path}`, 0, 0, costRecorded, Date.now() - startedAt); } catch { /* best-effort */ } return (await response.json()) as T; } finally { clearTimeout(timeout); diff --git a/src/tools/rpc.ts b/src/tools/rpc.ts index 04b16fc8..bdd58c69 100644 --- a/src/tools/rpc.ts +++ b/src/tools/rpc.ts @@ -30,6 +30,7 @@ import { import type { CapabilityHandler, CapabilityResult, ExecutionScope } from '../agent/types.js'; import { loadChain, API_URLS, VERSION } from '../config.js'; import { logger } from '../logger.js'; +import { recordUsage } from '../stats/tracker.js'; const TIMEOUT_MS = 30_000; @@ -89,6 +90,8 @@ async function postRpcWithPayment( const onAbort = () => controller.abort(); ctx.abortSignal.addEventListener('abort', onAbort, { once: true }); + const startedAt = Date.now(); + let paidUsd = 0; try { let response = await fetch(endpoint, { method: 'POST', @@ -98,14 +101,15 @@ async function postRpcWithPayment( }); if (response.status === 402) { - const paymentHeaders = await signPayment(response, chain, endpoint); - if (!paymentHeaders) { + const signed = await signPayment(response, chain, endpoint); + if (!signed) { throw new Error('Payment signing failed — check wallet balance'); } + paidUsd = signed.amountUsd; response = await fetch(endpoint, { method: 'POST', signal: controller.signal, - headers: { ...headers, ...paymentHeaders }, + headers: { ...headers, ...signed.headers }, body: bodyStr, }); } @@ -115,6 +119,9 @@ async function postRpcWithPayment( throw new Error(`RPC ${network} failed (${response.status}): ${errText.slice(0, 200)}`); } + // Record the settled x402 spend so RPC calls show up in franklin stats / + // audit AND count against the --max-spend ceiling (parity with surf.ts). + try { recordUsage(`MultiChainRPC:${network}`, 0, 0, paidUsd, Date.now() - startedAt); } catch { /* best-effort */ } return { body: await response.json(), network: response.headers.get('x-network') || network, @@ -131,7 +138,7 @@ async function signPayment( response: Response, chain: 'base' | 'solana', endpoint: string, -): Promise | null> { +): Promise<{ headers: Record; amountUsd: number } | null> { try { const paymentHeader = await extractPaymentReq(response); if (!paymentHeader) return null; @@ -155,7 +162,7 @@ async function signPayment( extra: details.extra as Record | undefined, }, ); - return { 'PAYMENT-SIGNATURE': payload }; + return { headers: { 'PAYMENT-SIGNATURE': payload }, amountUsd: Number(details.amount) / 1_000_000 }; } const wallet = await getOrCreateWallet(); const paymentRequired = parsePaymentRequired(paymentHeader); @@ -173,7 +180,7 @@ async function signPayment( extra: details.extra as Record | undefined, }, ); - return { 'PAYMENT-SIGNATURE': payload }; + return { headers: { 'PAYMENT-SIGNATURE': payload }, amountUsd: Number(details.amount) / 1_000_000 }; } catch (err) { logger.warn(`[franklin] RPC payment error: ${(err as Error).message}`); return null; diff --git a/src/tools/voice.ts b/src/tools/voice.ts index c5cbd6e8..68064637 100644 --- a/src/tools/voice.ts +++ b/src/tools/voice.ts @@ -125,7 +125,7 @@ async function postWithPayment( } } -async function getNoPayment(path: string, ctx: ExecutionScope, meta: PaidCallMeta): Promise { +async function getNoPayment(path: string, ctx: ExecutionScope, meta: PaidCallMeta, record = true): Promise { const startMs = Date.now(); const chain = loadChain(); const apiUrl = API_URLS[chain]; @@ -146,9 +146,13 @@ async function getNoPayment(path: string, ctx: ExecutionScope, meta: PaidCall } const data = (await resp.json()) as T; // Record even free calls so the audit tab shows the activity (cost 0). - try { - recordUsage(meta.tool, 0, 0, meta.priceUsd, Date.now() - startMs); - } catch { /* telemetry best-effort */ } + // `record: false` for internal poll loops — recording one row PER poll (up to + // 420 for VoiceStatus) would evict real spend history (capped at 1000 rows). + if (record) { + try { + recordUsage(meta.tool, 0, 0, meta.priceUsd, Date.now() - startMs); + } catch { /* telemetry best-effort */ } + } return data; } finally { clearTimeout(timeout); @@ -479,6 +483,11 @@ export const voiceStatusCapability: CapabilityHandler = { } const callId = input.call_id; const deadline = Date.now() + VOICE_POLL_MAX_WAIT_MS; + const statusStartedAt = Date.now(); + // Record ONE telemetry row per VoiceStatus invocation, not one per poll. + const recordStatusOnce = () => { + try { recordUsage('VoiceStatus', 0, 0, 0, Date.now() - statusStartedAt); } catch { /* best-effort */ } + }; // Internal poll-until-terminal loop — mirrors videogen.ts pollUntilReady // and imagegen.ts pollImageJob. The agent emits one VoiceStatus tool_use @@ -495,6 +504,7 @@ export const voiceStatusCapability: CapabilityHandler = { `/v1/voice/call/${encodeURIComponent(callId)}`, ctx, { tool: 'VoiceStatus', priceUsd: 0 }, + false, // don't record per-poll — record once when this VoiceStatus returns ); } catch (err) { return { output: `VoiceStatus failed: ${(err as Error).message}`, isError: true }; @@ -502,6 +512,7 @@ export const voiceStatusCapability: CapabilityHandler = { patchCallJournal(callId, lastRes); const status = String(lastRes.status ?? lastRes.queue_status ?? '').toLowerCase(); if (VOICE_TERMINAL_STATUSES.has(status)) { + recordStatusOnce(); return { output: `## Voice call status (terminal: ${status})\n\n` + @@ -517,6 +528,7 @@ export const voiceStatusCapability: CapabilityHandler = { // Hit the 35-min ceiling without seeing a terminal state — return the // latest snapshot we have so the agent + journal still have partial // context, but flag it as still in progress. + recordStatusOnce(); return { output: `## Voice call status (still in progress after ${Math.round(VOICE_POLL_MAX_WAIT_MS / 60_000)} min)\n\n` + diff --git a/src/trading/engine.ts b/src/trading/engine.ts index 7f974b18..b80ee122 100644 --- a/src/trading/engine.ts +++ b/src/trading/engine.ts @@ -77,7 +77,11 @@ export class TradingEngine { if (!existing) { return { status: 'noop', reason: `No open ${req.symbol} position` }; } - const qty = req.qty ?? existing.qty; + // Clamp to the held size: an over-sized partial close just flattens the + // position rather than throwing 'only X held' out of applyFill (which the + // tool layer surfaced as a confusing generic error). You can never sell more + // than you hold, so flatten-on-over-size is the sensible interpretation. + const qty = Math.min(req.qty ?? existing.qty, existing.qty); const price = (await exchange.getPrice(req.symbol)); if (price == null) { return { status: 'blocked', reason: `Exchange returned no price for ${req.symbol}` }; diff --git a/src/trading/risk.ts b/src/trading/risk.ts index 365507c4..c19cf818 100644 --- a/src/trading/risk.ts +++ b/src/trading/risk.ts @@ -56,9 +56,12 @@ export class RiskEngine { }; } - // Projected position value after fill. + // Projected position value after fill. Value the existing holding at its + // cost basis (avgPriceUsd), consistent with the total-exposure loop below — + // valuing it at the incoming order price let a buy slip the cap after a + // price drop (and over-block after a rise). const existing = portfolio.getPosition(order.symbol); - const projectedPositionUsd = (existing ? existing.qty * order.priceUsd : 0) + notional; + const projectedPositionUsd = (existing ? existing.qty * existing.avgPriceUsd : 0) + notional; if (projectedPositionUsd > this.config.maxPositionUsd) { return { allowed: false, diff --git a/test/audit-batch.local.mjs b/test/audit-batch.local.mjs index 231422ae..727c0396 100644 --- a/test/audit-batch.local.mjs +++ b/test/audit-batch.local.mjs @@ -181,3 +181,20 @@ test('blockrun spend diff counts a fresh paid fetch but $0 on a cache hit (no ov assert.equal(Math.max(0, blockrunSpendUsdToday() - before), 0, 'cache hit books $0 — no phantom spend'); assert.equal(paidCalls, 1, 'the paid fetch ran exactly once'); }); + +// ── --max-spend ceiling basis: getLiveSpendUsd counts paid-tool USDC ── +// The agent loop diffs getLiveSpendUsd() around tool execution to fold paid-tool +// spend into the session cost ceiling. recordUsage feeds it BEFORE the test/audit +// gates, so the ceiling sees real spend even when history persistence is off. +test('getLiveSpendUsd counts paid-tool spend even when history persistence is suppressed', async () => { + const { recordUsage, getLiveSpendUsd, resetLiveSpend } = await import('../dist/stats/tracker.js'); + resetLiveSpend(); + const before = getLiveSpendUsd(); + // FRANKLIN_NO_AUDIT=1 (set at file top) suppresses the history rows below, but + // the --max-spend ceiling must still see the real USDC these paid tools spent. + recordUsage('DeFiLlama:/v1/defi/protocols', 0, 0, 0.005, 12); + recordUsage('MultiChainRPC:base', 0, 0, 0.002, 8); + recordUsage('free/model', 0, 0, 0, 5); // a free call adds nothing + assert.equal(+(getLiveSpendUsd() - before).toFixed(6), 0.007, + 'live spend = $0.005 + $0.002; the free call is ignored'); +}); diff --git a/test/local.mjs b/test/local.mjs index d1705fa1..08ce06cd 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -4012,6 +4012,38 @@ test('RiskEngine: sell is allowed even when caps are exceeded, as long as positi assert.equal(decision.allowed, true, 'exits should not be blocked by exposure caps'); }); +test('RiskEngine: per-position cap values the existing holding at cost basis, not the order price', async () => { + const { RiskEngine } = await import('../dist/trading/risk.js'); + const { Portfolio } = await import('../dist/trading/portfolio.js'); + const pf = new Portfolio({ startingCashUsd: 10_000 }); + pf.applyFill({ symbol: 'BTC', side: 'buy', qty: 0.005, priceUsd: 70_000 }); // cost basis $350 + const risk = new RiskEngine({ maxPositionUsd: 400, maxTotalExposureUsd: 100_000 }); + // Price dropped to $30k. Buying 0.005 more (notional $150) → true cost-basis + // exposure $350 + $150 = $500 > $400 cap. The old code valued the holding at + // the $30k order price ($150 + $150 = $300) and wrongly ALLOWED the buy. + const decision = risk.check(pf, { symbol: 'BTC', side: 'buy', qty: 0.005, priceUsd: 30_000 }); + assert.equal(decision.allowed, false, 'cost-basis exposure ($500) must exceed the $400 cap'); + assert.match(decision.reason ?? '', /position cap/i); +}); + +test('TradingEngine.closePosition clamps an over-sized close to the held qty (flattens, no throw)', async () => { + const { TradingEngine } = await import('../dist/trading/engine.js'); + const { Portfolio } = await import('../dist/trading/portfolio.js'); + const { RiskEngine } = await import('../dist/trading/risk.js'); + const { MockExchange } = await import('../dist/trading/mock-exchange.js'); + const portfolio = new Portfolio({ startingCashUsd: 10_000 }); + const risk = new RiskEngine({ maxPositionUsd: 100_000, maxTotalExposureUsd: 100_000 }); + const exchange = new MockExchange({ prices: { BTC: 70_000 }, feeBps: 0 }); + const engine = new TradingEngine({ portfolio, risk, exchange }); + portfolio.applyFill({ symbol: 'BTC', side: 'buy', qty: 0.01, priceUsd: 70_000 }); + // Ask to close MORE than held (0.05 vs 0.01) — should flatten to 0.01, not + // throw 'only 0.01 held' (which the tool layer surfaced as a generic error). + const outcome = await engine.closePosition({ symbol: 'BTC', qty: 0.05 }); + assert.equal(outcome.status, 'filled', `expected filled, got ${outcome.status}: ${outcome.reason ?? ''}`); + assert.equal(outcome.fill.qty, 0.01, 'over-sized close flattens to the held qty'); + assert.equal(portfolio.getPosition('BTC'), undefined, 'position fully closed'); +}); + test('createTradingCapabilities: TradingHistory reports last N trades and windowed realized P&L', async () => { const { createTradingCapabilities } = await import('../dist/tools/trading-execute.js'); const { TradingEngine } = await import('../dist/trading/engine.js'); @@ -9941,6 +9973,24 @@ test('VoiceCall: a 200 with no call_id surfaces isError (no silent "initiated" a } finally { globalThis.fetch = original; } }); +// jupiter.ts toAtomicUnits — PR #89 (reject sub-precision) + follow-up (floor +// excess precision instead of rejecting it; reject only true dust). +test('toAtomicUnits floors excess precision and rejects only true dust', async () => { + const { toAtomicUnits } = await import('../dist/tools/jupiter.js'); + // Clean amounts at/under token precision convert exactly. + assert.equal(toAtomicUnits(1.5, 6), '1500000'); + assert.equal(toAtomicUnits(2, 9), '2000000000'); + // Excess precision is FLOORED to the atomic unit, not rejected (the follow-up). + assert.equal(toAtomicUnits(33.333333333333, 9), '33333333333', 'computed ⅓-style amount floors, not rejects'); + assert.equal(toAtomicUnits(0.1 + 0.2, 9), '300000000', 'float noise 0.30000000000000004 floors to 0.3'); + // True dust (floors to 0 atomic units) is rejected — the round-UP bug #89 fixed. + assert.throws(() => toAtomicUnits(0.0000005, 6), /too small to swap/, '0.0000005 < 1e-6 unit → reject, not round up to 1'); + assert.throws(() => toAtomicUnits(1e-12, 6), /too small to swap/); + // Input validation. + assert.throws(() => toAtomicUnits(-1, 6), /positive finite/); + assert.throws(() => toAtomicUnits(Number.NaN, 6), /positive finite/); +}); + test('CallLog: append + read round-trip preserves all fields', async () => { const { CallLog } = await import('../dist/phone/call-log.js'); const tmpFile = join(mkdtempSync(join(tmpdir(), 'franklin-calls-')), 'calls.jsonl'); From 928cfea1e6f429d773d9a782d0dc9b8ce3c31215 Mon Sep 17 00:00:00 2001 From: 1bcMax <195689928+1bcMax@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:20:51 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(loop,jupiter):=20address=20PR=20#91=20r?= =?UTF-8?q?eview=20=E2=80=94=20concurrent-tool=20spend=20ceiling=20+=20Jup?= =?UTF-8?q?iter=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review of #91 caught two issues in the round-4 fixes: - --max-spend MISSED concurrent paid tools (DeFiLlama/RPC/Prediction): they start mid-stream via onToolReceived and could settle x402 BEFORE the post-stream snapshot, so their spend was baked into the baseline and excluded from the cap. Now snapshot getLiveSpendUsd() BEFORE the stream and net out the LLM's own callCost, so the fold captures concurrent + sequential tools alike. - Jupiter toAtomicUnits: the scientific-notation toFixed path ROUNDED (9.999e-10 → 1 atomic unit — the dust round-up #89 fixed) and threw a raw BigInt error on >=1e21. Now uses toLocaleString (plain decimal, floor-by-truncation) so it floors excess precision and rejects only true dust on all inputs. New boundary tests (exponential dust, 1.9999e-9 floor, 1e21). Suite 479/479. --- CHANGELOG.md | 2 +- src/agent/loop.ts | 18 ++++++++++++++---- src/tools/jupiter.ts | 9 ++++++--- test/local.mjs | 6 ++++++ 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8526b2..1cd2ff22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ A fourth review round swept the paid-tool surface beyond the changed files. Headline: the `--max-spend` hard cap and several paid tools were blind to real USDC spend. All adversarially verified. -- **`--max-spend` now bounds TOTAL USDC, not just LLM tokens.** The session cap only counted LLM token cost; every paid tool (ImageGen/VideoGen/MusicGen, Exa, Surf, RealFace, Voice, Phone, Modal, DeFiLlama, RPC, Prediction) settled USDC through its own x402 path and counted **$0** against the cap — so `franklin -p "generate 30 images" --max-spend 0.50` could spend multiples of the cap with the guard never firing. A new live-spend accumulator (`getLiveSpendUsd`) is diffed around tool execution and folded into the session total, and the ceiling is re-checked after every tool batch. +- **`--max-spend` now bounds TOTAL USDC, not just LLM tokens.** The session cap only counted LLM token cost; every paid tool (ImageGen/VideoGen/MusicGen, Exa, Surf, RealFace, Voice, Phone, Modal, DeFiLlama, RPC, Prediction) settled USDC through its own x402 path and counted **$0** against the cap — so `franklin -p "generate 30 images" --max-spend 0.50` could spend multiples of the cap with the guard never firing. A new live-spend accumulator (`getLiveSpendUsd`) is diffed across the whole turn — snapshotted **before the stream** (so mid-stream concurrent paid tools like DeFiLlama/RPC are captured, not raced past) and netted against the LLM's own `callCost` — then folded into the session total, with the ceiling re-checked after every tool batch. - **Charged-but-failed LLM calls are now counted.** A paid LLM call whose stream then failed (idle timeout, mid-stream error, or Esc) debited USDC but was never folded into the session cost / cap — and the drift compounded across `--resume`. The loop's error path now captures `getLastPaidUsd()` before the next attempt resets it. - **Paid-but-invisible tools now record spend.** DeFiLlama (5 tools), MultiChainRPC, PredictionMarket (now persisted to stats, not just in-memory telemetry), MusicGen, and panel-initiated phone Buy/Renew/List all settled real USDC but never called `recordUsage` — so `franklin stats` under-reported and its reconciliation couldn't even detect the gap. All now record the settled amount (parity with `surf.ts`). - **$5 phone Buy/Renew now confirm the spend.** The two most expensive autonomous actions had no spend-confirm while a $0.05 Modal create did; both now gate on `onAskUser` (skipped under `FRANKLIN_MEDIA_AUTO_APPROVE_ALL`). diff --git a/src/agent/loop.ts b/src/agent/loop.ts index c2a3fb43..8b7c8137 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -1453,6 +1453,14 @@ export async function interactiveSession( // "this model is consistently slow" or "fallback was faster" until // this was fixed. const llmCallStartedAt = Date.now(); + // Snapshot live spend BEFORE the stream starts. Concurrent paid tools + // (DeFiLlama/RPC/Prediction/...) are kicked off mid-stream via + // onToolReceived and can settle their x402 charge before collectResults — + // a snapshot taken AFTER the stream would miss them and let them slip the + // --max-spend cap. We subtract the LLM's own callCost from the post-tool + // delta below (it is added to sessionCostUsd separately), leaving a clean + // paid-tool-only total that captures concurrent and sequential tools alike. + const turnSpendBefore = getLiveSpendUsd(); try { const result = await client.complete( { @@ -2310,7 +2318,6 @@ export async function interactiveSession( } // Collect results — concurrent tools may already be running from streaming - const toolSpendBefore = getLiveSpendUsd(); const results = await streamExec.collectResults(invocations); for (const [inv, result] of results) { @@ -2321,9 +2328,12 @@ export async function interactiveSession( // sessionCostUsd above only tracks LLM token cost; paid tools (ImageGen, // VideoGen, MusicGen, Exa, Surf, RealFace, Voice, Phone, Modal, DeFiLlama, // RPC, Prediction) settle USDC through their own x402 paths and report it - // via recordUsage. Diff the live-spend accumulator across tool execution so - // a --max-spend cap actually bounds total USDC, not just LLM tokens. - const toolSpendUsd = getLiveSpendUsd() - toolSpendBefore; + // via recordUsage. Diff the live-spend accumulator across the WHOLE turn + // (snapshot taken before the stream, so mid-stream concurrent tools are + // captured too) and subtract the LLM's own callCost — which recordUsage at + // line ~1948 already added to liveSpendUsd and which sessionCostUsd counts + // separately — leaving paid-tool-only spend so --max-spend bounds total USDC. + const toolSpendUsd = Math.max(0, getLiveSpendUsd() - turnSpendBefore - callCost); if (toolSpendUsd > 0) { sessionCostUsd += toolSpendUsd; turnCostUsd += toolSpendUsd; diff --git a/src/tools/jupiter.ts b/src/tools/jupiter.ts index 64496c6b..188e86ea 100644 --- a/src/tools/jupiter.ts +++ b/src/tools/jupiter.ts @@ -140,9 +140,12 @@ export function toAtomicUnits(amount: number, decimals: number): string { // ⅓ of a balance, or float noise like 0.1 + 0.2 = 0.30000000000000004 — still // swaps its representable part instead of being rejected. The original // Math.round approach instead rounded a sub-unit amount UP to one atomic unit. - const amountText = amount.toString().includes('e') - ? amount.toFixed(decimals + 1) - : amount.toString(); + // Plain-decimal string (never exponential form, so BigInt(wholePart) can't + // throw on large amounts like 1e21). toLocaleString keeps the exact value to + // 20 fraction digits — more than any token's decimals — and the slice() below + // FLOORS by truncation, so sub-precision dust is never rounded UP to one unit + // (unlike the old toFixed path, which rounded 9.999e-10 up to 1). + const amountText = amount.toLocaleString('en-US', { useGrouping: false, maximumFractionDigits: 20 }); const [wholePart, fractionalPart = ''] = amountText.split('.'); const normalizedFraction = fractionalPart.padEnd(decimals, '0').slice(0, decimals); diff --git a/test/local.mjs b/test/local.mjs index 08ce06cd..41ccccff 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -9986,6 +9986,12 @@ test('toAtomicUnits floors excess precision and rejects only true dust', async ( // True dust (floors to 0 atomic units) is rejected — the round-UP bug #89 fixed. assert.throws(() => toAtomicUnits(0.0000005, 6), /too small to swap/, '0.0000005 < 1e-6 unit → reject, not round up to 1'); assert.throws(() => toAtomicUnits(1e-12, 6), /too small to swap/); + // Exponential-form boundary: must FLOOR, not round up (toFixed used to round + // 9.999e-10 up to 1 atomic unit — exactly the dust round-up bug #89 killed). + assert.throws(() => toAtomicUnits(9.999e-10, 9), /too small to swap/, '9.999e-10 floors to 0 → reject, not round to 1'); + assert.equal(toAtomicUnits(1.9999e-9, 9), '1', '1.9999e-9 floors to 1 unit, not rounds to 2'); + // Large amount in exponential form must not throw a raw BigInt error. + assert.equal(toAtomicUnits(1e21, 6), '1' + '0'.repeat(27), '1e21 tokens (6 dec) = 1e27 atomic units, no throw'); // Input validation. assert.throws(() => toAtomicUnits(-1, 6), /positive finite/); assert.throws(() => toAtomicUnits(Number.NaN, 6), /positive finite/);