diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c4a7e..1cd2ff2 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 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`). +- **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 76443fe..8d21f5a 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 c450b84..e3f9618 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 efe4d7e..8b7c813 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'; @@ -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( { @@ -1599,6 +1607,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 +1635,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); @@ -2287,6 +2324,32 @@ export async function interactiveSession( 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 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; + 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 9f90a19..bffa131 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 0122b8c..ded8bf7 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 a54b3d4..a650128 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 118d210..188e86e 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,33 @@ function toAtomicUnits(amount: number, decimals: number): string { throw new Error('decimals must be a nonnegative safe integer'); } - const amountText = amount.toString().includes('e') - ? amount.toFixed(decimals + 1) - : amount.toString(); + // 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. + // 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); - 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 59607a9..529d1c7 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 82893d9..a0bf4a1 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 c32739d..81cabc2 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 04b16fc..bdd58c6 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 c5cbd6e..6806463 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 7f974b1..b80ee12 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 365507c..c19cf81 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 231422a..727c039 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 d1705fa..41ccccf 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,30 @@ 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/); + // 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/); +}); + 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');