diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index ca9a998..f8bc861 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,74 @@ # @helix-agent/core changelog +## 2.8.1 - 2026-05-28 + +**The repair path had not been exercised against real Circle until 2.8.1's +pre-publish testing.** (The 2.8.0 bench measured outcomes but did not route +failures through `wrap()`'s repair loop; the canary used `wrap()` but never +errored.) That first real exercise revealed two engine defects — the +amount-corruption (L1) and the silent-underpay policy (L2) — and prompted a +detection-vs-repair audit that reclassified prior "measured" q-values as +**detection-only**. We fixed the defects and corrected the record rather than +ship around them. + +### Fixed +- **Engine no longer corrupts non-scalar amounts (L1).** `auto-detect` / + `applyOverrides` (and `split_transaction`) treated any payload `amount` as a + scalar number it could halve. Circle's API uses `amount: string[]`, so a + repair that touched the amount silently broke the array shape and zeroed the + value — turning a recoverable error into a self-inflicted "invalid parameter". + Amount mutation is now type-guarded: arrays, objects, and ambiguous strings + are never scalar-mutated; numeric strings round-trip to preserve shape. +- **`insufficient-funds` defaults to `hold_and_notify`, not `reduce_request` (L2).** + Auto-halving a fixed obligation (payroll, invoice) silently underpays — a + contractor owed 10 must not receive 5. The correct response to "not enough + money" is to stop and alert the operator. `reduce_request` now fires only when + the caller explicitly opts in via `allowPartial: true` (meaningful for + best-effort transfers such as gas top-ups), and even then the L1 guard applies. + +### Changed — q-value reclassification (honesty) +A detection-vs-repair audit of every non-prior capsule found that most +"validated" q-values were validated for **detection** (we correctly classify the +failure) but **not for repair** (the strategy actually completing the original +call). Net result: **zero capsules are engine-repair-validated; one +(`stale_quote`) has validated efficacy and it is advisory.** Reclassified: +- `circle-insufficient-funds` and generic `payment-insufficient`: marked + **non-repairable** (`nonRepairable: true`) — Helix cannot create funds; the + halt is correct, there is no repair to score. q set to a neutral 0.50. +- `wallets-api-rate-limit` (was 0.76): demoted to **0.50, detection-only**. The + bench's 76% was first-attempt API acceptance, not repair recovery; the + `serialize_and_backoff` strategy does not actually serialize and recovered ~0% + of rate-limited calls under real concurrency (see KNOWN_ISSUES KI-1, PR #4). +- `stale_quote` (0.96): retained, but reclassified as **advisory (`observe`)** — + the 96% E2E (Exp D, 932 tx) is agent-side workflow reordering, not an in-engine + repair. +- All other generic priors (0.68–0.88) normalized to **0.50** — they were + unvalidated priors with no supporting artifact. +- Circle adapter `successProbability` values aligned to `seed-genes.ts` so the + prior is not fragmented across two sources. + +We would rather ship this honestly than carry confident-looking numbers we +cannot defend. + +### Added +- `WrapOptions.allowPartial` — opt-in for partial-payment strategies. +- `WrapOptions.freezeArgs` — when true, no strategy may auto-mutate call args; + `parameterModifier` becomes the only path that can change them. +- `GeneCapsule.nonRepairable` — marks halt-not-repair capsules; for these, + qValue is a neutral prior, not a repair-success probability. +- `KNOWN_ISSUES.md` — tracks KI-1 (serialize_and_backoff no-op) and KI-2 + (immune-stat inflation: halts currently count toward immuneHits/savedRevenue; + do not cite those figures externally until fixed). + +### Migration from 2.8.0 +- **Auto-detect no longer mutates non-scalar amounts.** Defensive; no correct + caller depended on the broken behavior, but if you relied on the engine + halving an array/string amount, it now leaves it intact. +- **`insufficient-funds` now halts by default.** Callers that want the old + reduce-and-retry behavior must set `allowPartial: true`. +- **q-values changed** per the audit above — seeded priors are re-seeded on next + load; learned q-values from real traffic are unaffected. + ## 2.8.0 - 2026-05-26 Three Circle-focused PRs landed since 2.7.3: a full Circle platform diff --git a/packages/core/KNOWN_ISSUES.md b/packages/core/KNOWN_ISSUES.md new file mode 100644 index 0000000..ec476a8 --- /dev/null +++ b/packages/core/KNOWN_ISSUES.md @@ -0,0 +1,47 @@ +# Known Issues — @helix-agent/core + +Tracked honesty-debt and known-broken behavior. Surfaced by the 2.8.1 audit. + +--- + +## KI-1 — `serialize_and_backoff` does not serialize (no-op) + +- **Tracking:** https://github.com/usehelix/helix/issues/9 +- **Status:** open · **Severity:** high (repair-flawed) · **Target:** PR #4 +- **Surfaced by:** 2.8.1 audit; `scripts/circle-bench/results/v1-serialize-and-backoff-anomaly.md` + +The `serialize_and_backoff` strategy (Circle `wallets-api-rate-limit`) sets +`_helix_serialize` / `_helix_concurrency` override flags that **no caller ever +reads**. It then sleeps once (~2s) and retries — **in parallel** — so under real +concurrency the retries land in the same rate-limit window and all 429 again. +Bench 2.1 (5×20-concurrent Arc Testnet) showed the repair recovered ~0% of +rate-limited calls; the measured "76%" was ambient first-attempt API acceptance. + +- **Consequence:** `wallets-api-rate-limit` was demoted to q=0.50 (detection-only) + in 2.8.1 and flagged KNOWN-BROKEN inline (`seed-genes.ts`, `platforms/circle/strategies.ts`). +- **Do NOT** re-validate or re-raise this capsule's q-value without first + implementing real serialization (per-wallet semaphore, or a wrap-layer + concurrency gate that consumes `_helix_concurrency`). The v2 `chunk_concurrent` + path (`scripts/circle-bench/`) is the intended replacement. + +--- + +## KI-2 — Immune-stat inflation: halts count as "immune successes" + +- **Tracking:** https://github.com/usehelix/helix/issues/10 +- **Status:** open · **Severity:** high (honesty-debt) · **Target:** 2.8.2 / 2.9.0 +- **Surfaced by:** 2.8.1 audit (`pcec.ts` immune branch) + +`PcecEngine.repair()` increments `stats.immuneHits` and `stats.savedRevenue`, and +records `immune: true` in the audit log, for **any** existing gene with +`qValue > 0.3` — **including `hold_and_notify` / `nonRepairable` capsules that do +not complete the original call.** A capsule that merely *halts* (e.g. +insufficient-funds) therefore counts toward "immune success" and "saved revenue". + +- **Consequence:** every published `immuneHits` / `savedRevenue` figure is + **inflated by halts** and cannot be cleanly read as "repairs that completed the + original intent." +- **Until fixed, do NOT cite `immuneHits` or `savedRevenue` in external + material.** Distinguish repair-completions from correct-halts in the stats: + e.g. exclude `nonRepairable` genes (and `observe`-mode capsules) from + `immuneHits`/`savedRevenue`, or split into `repairHits` vs `haltHits`. diff --git a/packages/core/package.json b/packages/core/package.json index c0bce19..035af09 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@helix-agent/core", - "version": "2.8.0", + "version": "2.8.1", "description": "Agent payment intelligence — predict costs, optimize execution, fix failures. Powered by VialOS Runtime.", "type": "module", "main": "dist/index.js", diff --git a/packages/core/src/engine/auto-detect.ts b/packages/core/src/engine/auto-detect.ts index b7691a9..3e19311 100644 --- a/packages/core/src/engine/auto-detect.ts +++ b/packages/core/src/engine/auto-detect.ts @@ -3,28 +3,60 @@ * Removes the need for users to write parameterModifier. */ +export type AmountShape = 'number' | 'numeric-string' | 'string' | 'array' | 'object' | 'undefined'; + export interface DetectedSignature { type: 'viem-tx' | 'fetch' | 'generic-payment' | 'unknown'; paramIndex: number; + /** + * Shape of the payment amount field, when detectable. Determines whether a + * strategy may safely apply scalar arithmetic (halve, split) to it. Only + * 'number' and 'numeric-string' are safe; arrays/objects/ambiguous strings + * are NOT (e.g. Circle's `amount: string[]`). Optional for backward-compat + * with callers that construct DetectedSignature directly; detectSignature + * always populates it. + */ + amountShape?: AmountShape; +} + +/** + * Classify a payment amount's shape. Only 'number' / 'numeric-string' (and + * bigint, treated as 'number') are safe for scalar math. Arrays (Circle's + * `amount: string[]`), objects ({ value, currency }), and non-numeric strings + * MUST NOT be scalar-mutated — doing so corrupts the shape the SDK requires + * and turns a recoverable error into a self-inflicted invalid-param failure. + */ +export function amountShapeOf(v: unknown): AmountShape { + if (v === undefined || v === null) return 'undefined'; + if (typeof v === 'number' || typeof v === 'bigint') return 'number'; + if (typeof v === 'string') { + const t = v.trim(); + return t !== '' && Number.isFinite(Number(t)) ? 'numeric-string' : 'string'; + } + if (Array.isArray(v)) return 'array'; + if (typeof v === 'object') return 'object'; + return 'string'; } export function detectSignature(args: unknown[]): DetectedSignature { - if (!args || args.length === 0) return { type: 'unknown', paramIndex: -1 }; + if (!args || args.length === 0) return { type: 'unknown', paramIndex: -1, amountShape: 'undefined' }; const first = args[0] as Record; // Viem transaction: has 'to' + at least one other tx field if (typeof first === 'object' && first !== null && 'to' in first) { const txFields = ['to', 'value', 'nonce', 'gas', 'gasPrice', 'maxFeePerGas', 'maxPriorityFeePerGas', 'data', 'chainId']; - if (txFields.filter(f => f in first).length >= 2) return { type: 'viem-tx', paramIndex: 0 }; + if (txFields.filter(f => f in first).length >= 2) return { type: 'viem-tx', paramIndex: 0, amountShape: amountShapeOf(first.value) }; } // Fetch-like: first arg is URL string - if (typeof first === 'string' && (first as string).startsWith('http')) return { type: 'fetch', paramIndex: 0 }; + if (typeof first === 'string' && (first as string).startsWith('http')) return { type: 'fetch', paramIndex: 0, amountShape: 'undefined' }; // Generic payment object - if (typeof first === 'object' && first !== null && ('amount' in first || 'value' in first)) return { type: 'generic-payment', paramIndex: 0 }; + if (typeof first === 'object' && first !== null && ('amount' in first || 'value' in first)) { + return { type: 'generic-payment', paramIndex: 0, amountShape: amountShapeOf(first.amount ?? first.value) }; + } - return { type: 'unknown', paramIndex: -1 }; + return { type: 'unknown', paramIndex: -1, amountShape: 'undefined' }; } export function applyOverrides(args: unknown[], overrides: Record, strategy: string, sig: DetectedSignature): unknown[] | null { @@ -82,9 +114,24 @@ export function applyOverrides(args: unknown[], overrides: Record) }; + const shape = sig.amountShape ?? amountShapeOf(p.amount); + + // L1 guard: the generic-payment path only understands SCALAR numeric + // amounts. If the amount is an array (e.g. Circle's `amount: string[]`), + // an object, or an ambiguous non-numeric string, REFUSE to mutate it — + // scaling or replacing it would corrupt the shape the SDK requires and + // self-inflict an invalid-param failure. Returning null makes wrap() + // retry with the original args / surface the real error unchanged. + if (shape === 'array' || shape === 'object' || shape === 'string') { + return null; + } + if (strategy === 'reduce_request') { if (overrides.amount !== undefined) p.amount = overrides.amount; - else if (p.amount) p.amount = (p.amount as number) * 0.5; + else if (shape === 'number') p.amount = (p.amount as number) * 0.5; + // numeric-string: halve but round-trip back to a string so the shape + // the SDK received (a scalar string) is preserved. + else if (shape === 'numeric-string') p.amount = String(Number(p.amount) * 0.5); } else { for (const [k, v] of Object.entries(overrides)) { if (k in p) p[k] = v; } } diff --git a/packages/core/src/engine/pcec.ts b/packages/core/src/engine/pcec.ts index 113ea7d..78f5499 100644 --- a/packages/core/src/engine/pcec.ts +++ b/packages/core/src/engine/pcec.ts @@ -230,10 +230,10 @@ export class PcecEngine { }; } - private constructCandidates(failure: FailureClassification): RepairCandidate[] { + private constructCandidates(failure: FailureClassification, context?: RepairContext): RepairCandidate[] { const candidates: RepairCandidate[] = []; for (const adapter of this.adapters) { - candidates.push(...adapter.construct(failure)); + candidates.push(...adapter.construct(failure, context)); } return candidates.map((c) => ({ ...c, @@ -476,7 +476,7 @@ export class PcecEngine { } // ── CONSTRUCT ── - let candidates = this.constructCandidates(failure); + let candidates = this.constructCandidates(failure, context as RepairContext | undefined); if (registryCandidate) candidates.unshift(registryCandidate); // ── LLM CONSTRUCT FALLBACK (when no adapter has strategies) ── diff --git a/packages/core/src/engine/seed-genes.ts b/packages/core/src/engine/seed-genes.ts index 26aedb3..12575a3 100644 --- a/packages/core/src/engine/seed-genes.ts +++ b/packages/core/src/engine/seed-genes.ts @@ -9,33 +9,50 @@ import type { GeneCapsule } from './types.js'; // when successCount=0: the q came from a bench, no live repairs are recorded yet.) export const SEED_GENES: Omit[] = [ // ── Generic platform priors (tempo / privy / coinbase) ── - // Hand-seeded. q-values are unvalidated priors; successCount zeroed (no artifact). - { failureCode: 'nonce-mismatch', category: 'nonce', strategy: 'refresh_nonce', params: {}, successCount: 0, avgRepairMs: 180, platforms: ['tempo', 'privy', 'coinbase'], qValue: 0.85, consecutiveFailures: 0 }, - { failureCode: 'verification-failed', category: 'signature', strategy: 'refresh_nonce', params: {}, successCount: 0, avgRepairMs: 300, platforms: ['coinbase', 'privy'], qValue: 0.7, consecutiveFailures: 0 }, - { failureCode: 'payment-insufficient', category: 'balance', strategy: 'reduce_request', params: {}, successCount: 0, avgRepairMs: 45, platforms: ['tempo', 'privy', 'coinbase'], qValue: 0.82, consecutiveFailures: 0 }, - { failureCode: 'rate-limited', category: 'auth', strategy: 'backoff_retry', params: { defaultDelayMs: 2000 }, successCount: 0, avgRepairMs: 2100, platforms: ['generic', 'coinbase'], qValue: 0.88, consecutiveFailures: 0 }, - { failureCode: 'token-uninitialized', category: 'network', strategy: 'switch_network', params: {}, successCount: 0, avgRepairMs: 210, platforms: ['tempo', 'privy', 'coinbase'], qValue: 0.80, consecutiveFailures: 0 }, - { failureCode: 'server-error', category: 'service', strategy: 'retry', params: {}, successCount: 0, avgRepairMs: 500, platforms: ['generic', 'coinbase', 'tempo'], qValue: 0.78, consecutiveFailures: 0 }, - { failureCode: 'timeout', category: 'service', strategy: 'backoff_retry', params: { defaultDelayMs: 3000 }, successCount: 0, avgRepairMs: 3200, platforms: ['generic', 'coinbase'], qValue: 0.75, consecutiveFailures: 0 }, - { failureCode: 'policy-violation', category: 'policy', strategy: 'split_transaction', params: {}, successCount: 0, avgRepairMs: 520, platforms: ['privy', 'coinbase'], qValue: 0.76, consecutiveFailures: 0 }, - { failureCode: 'invalid-challenge', category: 'session', strategy: 'renew_session', params: {}, successCount: 0, avgRepairMs: 150, platforms: ['tempo'], qValue: 0.82, consecutiveFailures: 0 }, - { failureCode: 'malformed-credential', category: 'service', strategy: 'fix_params', params: {}, successCount: 0, avgRepairMs: 50, platforms: ['privy', 'coinbase'], qValue: 0.80, consecutiveFailures: 0 }, - { failureCode: 'tip-403', category: 'compliance', strategy: 'switch_stablecoin', params: {}, successCount: 0, avgRepairMs: 800, platforms: ['tempo'], qValue: 0.72, consecutiveFailures: 0 }, - { failureCode: 'swap-reverted', category: 'dex', strategy: 'split_swap', params: { defaultChunks: 3 }, successCount: 0, avgRepairMs: 3500, platforms: ['tempo'], qValue: 0.68, consecutiveFailures: 0 }, - { failureCode: 'tx-reverted', category: 'batch', strategy: 'remove_and_resubmit', params: {}, successCount: 0, avgRepairMs: 450, platforms: ['tempo', 'coinbase'], qValue: 0.74, consecutiveFailures: 0 }, + // HONESTY (2.8.1 audit): these were hand-seeded priors of 0.68–0.88 with NO + // supporting artifact (successCount=0). Detection is unit-tested for some + // (nonce-mismatch, rate-limited, server-error, timeout); REPAIR is validated + // for none — no real call→error→repair→success was ever measured. A high + // "prior" reads to users as measured confidence, so all are normalized to a + // neutral 0.50. They re-climb only on real Q-learning rewards from live traffic. + { failureCode: 'nonce-mismatch', category: 'nonce', strategy: 'refresh_nonce', params: {}, successCount: 0, avgRepairMs: 180, platforms: ['tempo', 'privy', 'coinbase'], qValue: 0.50, consecutiveFailures: 0 }, + { failureCode: 'verification-failed', category: 'signature', strategy: 'refresh_nonce', params: {}, successCount: 0, avgRepairMs: 300, platforms: ['coinbase', 'privy'], qValue: 0.50, consecutiveFailures: 0 }, + // NON-REPAIRABLE. detection: validated (insufficient-balance classification + // is unit-tested — see error-embedding.test.ts). repair: non-applicable — + // Helix cannot create funds; hold_and_notify is the correct HALT, not a + // recovery. qValue is a neutral prior (halt-confidence), NOT a repair-success + // probability — was an unvalidated 0.82 in 2.8.0, demoted here. reduce_request + // fires only via a context-aware adapter construct when allowPartial:true. + { failureCode: 'payment-insufficient', category: 'balance', strategy: 'hold_and_notify', params: {}, successCount: 0, avgRepairMs: 0, platforms: ['tempo', 'privy', 'coinbase'], qValue: 0.50, nonRepairable: true, consecutiveFailures: 0 }, + { failureCode: 'rate-limited', category: 'auth', strategy: 'backoff_retry', params: { defaultDelayMs: 2000 }, successCount: 0, avgRepairMs: 2100, platforms: ['generic', 'coinbase'], qValue: 0.50, consecutiveFailures: 0 }, + { failureCode: 'token-uninitialized', category: 'network', strategy: 'switch_network', params: {}, successCount: 0, avgRepairMs: 210, platforms: ['tempo', 'privy', 'coinbase'], qValue: 0.50, consecutiveFailures: 0 }, + { failureCode: 'server-error', category: 'service', strategy: 'retry', params: {}, successCount: 0, avgRepairMs: 500, platforms: ['generic', 'coinbase', 'tempo'], qValue: 0.50, consecutiveFailures: 0 }, + { failureCode: 'timeout', category: 'service', strategy: 'backoff_retry', params: { defaultDelayMs: 3000 }, successCount: 0, avgRepairMs: 3200, platforms: ['generic', 'coinbase'], qValue: 0.50, consecutiveFailures: 0 }, + { failureCode: 'policy-violation', category: 'policy', strategy: 'split_transaction', params: {}, successCount: 0, avgRepairMs: 520, platforms: ['privy', 'coinbase'], qValue: 0.50, consecutiveFailures: 0 }, + { failureCode: 'invalid-challenge', category: 'session', strategy: 'renew_session', params: {}, successCount: 0, avgRepairMs: 150, platforms: ['tempo'], qValue: 0.50, consecutiveFailures: 0 }, + { failureCode: 'malformed-credential', category: 'service', strategy: 'fix_params', params: {}, successCount: 0, avgRepairMs: 50, platforms: ['privy', 'coinbase'], qValue: 0.50, consecutiveFailures: 0 }, + { failureCode: 'tip-403', category: 'compliance', strategy: 'switch_stablecoin', params: {}, successCount: 0, avgRepairMs: 800, platforms: ['tempo'], qValue: 0.50, consecutiveFailures: 0 }, + { failureCode: 'swap-reverted', category: 'dex', strategy: 'split_swap', params: { defaultChunks: 3 }, successCount: 0, avgRepairMs: 3500, platforms: ['tempo'], qValue: 0.50, consecutiveFailures: 0 }, + { failureCode: 'tx-reverted', category: 'batch', strategy: 'remove_and_resubmit', params: {}, successCount: 0, avgRepairMs: 450, platforms: ['tempo', 'coinbase'], qValue: 0.50, consecutiveFailures: 0 }, // ── Circle capsules ── // apiLayer is REQUIRED for Gene Map UNIQUE(failure_code, category, COALESCE(api_layer, '')) — null != 'wallets-api'. - // wallets-api-rate-limit: q=0.76 hand-seeded from Bench 2.1 (v1-vs-v2-rate-limit, - // 5×20-concurrent Arc Testnet, measured 76% success). REAL bench measurement. - // Caveat: serialize_and_backoff does NOT serialize cross-instance retries - // (see scripts/circle-bench/results/v1-serialize-and-backoff-anomaly.md); 0.76 - // reflects measured load-degraded success, not the 0.95 ideal. successCount=0 - // and avgRepairMs=0: 0.76 is the measured prior, no live repairs recorded in - // this map yet. (The 2000ms in params is the configured backoff delay, not a - // measured repair latency — the bench reports per-trial wall-clock, not per-repair.) - { failureCode: 'wallets-api-rate-limit', category: 'auth', apiLayer: 'wallets-api', strategy: 'serialize_and_backoff', params: { defaultDelayMs: 2000 }, successCount: 0, avgRepairMs: 0, platforms: ['circle'], qValue: 0.76, consecutiveFailures: 0 }, + // wallets-api-rate-limit: DETECTION-VALIDATED ONLY (2.8.1 audit). Demoted + // 0.76 → 0.50. The Bench 2.1 "76%" (v1-vs-v2-rate-limit.ts) counts FULFILLED + // createTransaction promises (API-accepted, NOT on-chain-settled — no + // getTransaction poll) and is the ambient FIRST-ATTEMPT acceptance rate: the + // ~5/20 calls that hit 429 engaged serialize_and_backoff, retried, and ALL + // hit 429 again ("permanently lost" — see results/v1-serialize-and-backoff- + // anomaly.md). The repair recovered ~0% at 20-concurrent, so 0.76 was NOT a + // repair-success rate. + // + // ⚠️ KNOWN-BROKEN STRATEGY — DO NOT re-validate without fixing first: + // serialize_and_backoff does NOT serialize. It sets _helix_serialize / + // _helix_concurrency overrides that NO caller reads, then retries in + // parallel. Effective fix (per-wallet semaphore / true serialization) is + // tracked for PR #4 alongside the v2 chunk_concurrent migration. + { failureCode: 'wallets-api-rate-limit', category: 'auth', apiLayer: 'wallets-api', strategy: 'serialize_and_backoff', params: { defaultDelayMs: 2000 }, successCount: 0, avgRepairMs: 0, platforms: ['circle'], qValue: 0.50, consecutiveFailures: 0 }, // gateway-rate-limit: PRIOR SEED — strategy is structurally reasoned but NOT yet // validated by bench or telemetry. q-value is a conservative prior, not a measurement. diff --git a/packages/core/src/engine/types.ts b/packages/core/src/engine/types.ts index 862bb69..981356b 100644 --- a/packages/core/src/engine/types.ts +++ b/packages/core/src/engine/types.ts @@ -155,6 +155,14 @@ export interface GeneCapsule { avgRepairMs: number; platforms: Platform[]; qValue: number; + /** + * True when the capsule's strategy does NOT attempt to complete the + * original call — it correctly HALTS (e.g. hold_and_notify on + * insufficient funds). For these, qValue is a neutral prior / halt- + * confidence, NOT a repair-success probability: there is no repair to + * succeed. Detection may still be validated; repair is non-applicable. + */ + nonRepairable?: boolean; qVariance?: number; qCount?: number; last5Rewards?: number[]; @@ -207,7 +215,7 @@ export interface RepairResult { export interface PlatformAdapter { name: Platform; perceive(error: Error, context?: Record): FailureClassification | null; - construct(failure: FailureClassification): RepairCandidate[]; + construct(failure: FailureClassification, context?: RepairContext): RepairCandidate[]; } // ── Provider Config ───────────────────────────────────────────── @@ -278,6 +286,24 @@ export interface WrapOptions { approvedTokens?: string[]; allowCategories?: string[]; blockStrategies?: string[]; + /** + * Opt-in: allow partial-payment strategies (e.g. reduce_request on + * insufficient funds) to fire. Default false. For fixed-obligation payments + * (payroll, invoices) silent underpay is incorrect, so insufficient-funds + * defaults to hold_and_notify. Only set this for best-effort transfers + * (gas top-ups, swap slippage) where a reduced amount is meaningful. + */ + allowPartial?: boolean; + /** + * Defense-in-depth: when true, NO repair strategy may auto-mutate the call + * args. The auto-detect/applyOverrides path, split_transaction, and + * renew_session arg-injection are all bypassed (debug-logged). Retries still + * happen, but with the ORIGINAL args. `parameterModifier`, if provided, + * remains the one authoritative way to change args. Default false (preserves + * existing behavior). Use for safety-critical payments where you would rather + * fail-safe than have Helix rewrite your payload. + */ + freezeArgs?: boolean; provider?: HelixProviderConfig; onRepair?: (result: RepairResult) => void; onFailure?: (result: RepairResult) => void; @@ -297,14 +323,23 @@ export interface WrapOptions { /** Log format. 'pretty' (colored) or 'json' (structured). Default: 'pretty'. */ logFormat?: 'pretty' | 'json'; /** - * Business-level verification after successful repair + retry. + * Business-level verification after a successful repair + retry. + * + * Called only when a retry (after at least one repair) returns a value — it + * is NOT called on a first-attempt success. Use it to assert the result + * actually fulfilled the original intent, not merely that the call resolved + * (e.g. "the amount paid equals the amount requested" — catches a silent + * underpay). It receives the ORIGINAL args, never the repaired/mutated ones. * - * Called after the retried function succeeds. If verify returns false, - * the repair is treated as a failure (Gene q_value decreases). + * If verify returns false (or throws): + * - the gene's q_value is decremented (recordFailure), AND + * - wrap() throws immediately with `_helix.verifyFailed = true` — it does + * NOT re-enter PCEC or run further Self-Refine retries. The call surfaces + * as a failure for the caller to handle. * * @param result - The return value of the retried function * @param originalArgs - The original arguments passed to wrap(fn) - * @returns true if the result is valid, false to treat as failure + * @returns true if the result is valid, false to treat as a failure */ verify?: (result: unknown, originalArgs: unknown[]) => Promise | boolean; /** OpenTelemetry configuration. Provide your own tracer/meter for distributed tracing and metrics. */ diff --git a/packages/core/src/engine/wrap.ts b/packages/core/src/engine/wrap.ts index bc54821..b2e08b3 100644 --- a/packages/core/src/engine/wrap.ts +++ b/packages/core/src/engine/wrap.ts @@ -52,6 +52,7 @@ export function wrap( const enabled = typeof options?.enabled === 'function' ? options.enabled() : (options?.enabled ?? true); if (!enabled) return fn(...args); + const freezeArgs = options?.freezeArgs === true; let currentArgs = args; let lastRepairResult: RepairResult | null = null; const refineCtx = createRefinementContext((args as any)?.[0]?.toString?.() ?? '', maxRetries); @@ -141,6 +142,7 @@ export function wrap( const result: RepairResult = await engine.repair(wrappedError, { ...options?.context, + allowPartial: options?.allowPartial ?? (options?.context as Record | undefined)?.allowPartial, chainId: (error as any)?.chain?.id, walletAddress: (error as any)?.account?.address, _avoidStrategies: refinement.excludeStrategies.length > 0 ? refinement.excludeStrategies : undefined, @@ -170,6 +172,13 @@ export function wrap( log.info(result.immune ? `IMMUNE via ${strategy}` : `REPAIRED via ${strategy}`, { ms: result.totalMs, immune: result.immune }); + // freezeArgs: no strategy may auto-mutate args. parameterModifier + // (handled below) remains the only authoritative way to change them. + if (freezeArgs && (strategy === 'renew_session' || strategy === 'split_transaction')) { + log.debug(`freezeArgs: skipping arg-mutating strategy '${strategy}' — retrying with original args`); + continue; + } + // ── renew_session: call sessionRefresher ── if (strategy === 'renew_session' && options?.sessionRefresher) { try { @@ -210,19 +219,26 @@ export function wrap( if (lastResult !== undefined) return lastResult; } } else if (sig.type === 'generic-payment') { - const p = currentArgs[0] as Record; - const amt = p.amount as number | undefined; - if (amt && amt > 0) { - const partAmt = amt / parts; - log.info(`Splitting payment into ${parts} parts of ${partAmt}`); - let lastResult: TResult | undefined; - for (let i = 0; i < parts; i++) { - try { - lastResult = await fn(...([{ ...p, amount: partAmt }, ...currentArgs.slice(1)] as TArgs)); - if (i < parts - 1) await new Promise(r => setTimeout(r, delayMs)); - } catch { log.warn(`Split part ${i + 1}/${parts} failed`); } + // L1 guard: only split a SCALAR numeric amount. Array/object/ + // ambiguous amounts (e.g. Circle's `amount: string[]`) must not + // be divided — skip split and fall through to retry-as-is. + if (sig.amountShape === 'number' || sig.amountShape === 'numeric-string') { + const p = currentArgs[0] as Record; + const amt = Number(p.amount); + if (Number.isFinite(amt) && amt > 0) { + const partAmt = amt / parts; + // Preserve the original scalar shape (string stays a string). + const partVal: unknown = sig.amountShape === 'numeric-string' ? String(partAmt) : partAmt; + log.info(`Splitting payment into ${parts} parts of ${String(partVal)}`); + let lastResult: TResult | undefined; + for (let i = 0; i < parts; i++) { + try { + lastResult = await fn(...([{ ...p, amount: partVal }, ...currentArgs.slice(1)] as TArgs)); + if (i < parts - 1) await new Promise(r => setTimeout(r, delayMs)); + } catch { log.warn(`Split part ${i + 1}/${parts} failed`); } + } + if (lastResult !== undefined) return lastResult; } - if (lastResult !== undefined) return lastResult; } } continue; // fallback: retry as-is @@ -232,12 +248,16 @@ export function wrap( if (!SIMPLE_RETRY.includes(strategy)) { const overrides = result.commitOverrides ?? {}; - // Priority 1: User parameterModifier + // Priority 1: User parameterModifier — authoritative, allowed even + // under freezeArgs (the caller is explicitly controlling mutation). if (options?.parameterModifier && Object.keys(overrides).length > 0) { currentArgs = options.parameterModifier(currentArgs as unknown[], overrides, strategy) as TArgs; log.info('Applied overrides via parameterModifier'); } - // Priority 2: Auto-detect + // Priority 2: Auto-detect — bypassed entirely when freezeArgs is set. + else if (freezeArgs) { + log.debug(`freezeArgs: skipping auto-detect override for '${strategy}' — retrying with original args`); + } else { const sig = detectSignature(currentArgs as unknown[]); const applied = applyOverrides([...currentArgs] as unknown[], overrides, strategy, sig); diff --git a/packages/core/src/platforms/circle/strategies.ts b/packages/core/src/platforms/circle/strategies.ts index 5dddc27..cdfac96 100644 --- a/packages/core/src/platforms/circle/strategies.ts +++ b/packages/core/src/platforms/circle/strategies.ts @@ -43,6 +43,7 @@ import type { PlatformAdapter, RepairCandidate, FailureClassification, + RepairContext, } from '../../engine/types.js'; import { circlePerceive } from './perceive.js'; @@ -51,7 +52,7 @@ import { circlePerceive } from './perceive.js'; // Construct: failure → candidates // ────────────────────────────────────────────────────────────────── -function construct(failure: FailureClassification): RepairCandidate[] { +function construct(failure: FailureClassification, context?: RepairContext): RepairCandidate[] { const candidates: RepairCandidate[] = []; // ════════════════════════════════════════════════════════════════ @@ -61,8 +62,14 @@ function construct(failure: FailureClassification): RepairCandidate[] { // ════════════════════════════════════════════════════════════════ // ─── Wallets API rate limit ───────────────────────────────────── - // Hand-seeded q=0.76 from Bench 2.1 (5×20-concurrent Arc Testnet, 76% success). - // Root cause: concurrency lock on wallet entity. + // DETECTION-VALIDATED ONLY (2.8.1 audit). successProbability 0.76 → 0.50 to + // match seed-genes.ts. The Bench 2.1 "76%" was ambient first-attempt API + // acceptance, not repair recovery (the repair recovered ~0% of 429s at + // 20-concurrent). See seed-genes.ts for the full provenance. + // + // ⚠️ KNOWN-BROKEN STRATEGY: serialize_and_backoff does NOT serialize (the + // _helix_serialize / _helix_concurrency overrides are never consumed). Do + // NOT re-validate without fixing the underlying mechanism — tracked PR #4. if (failure.code === 'wallets-api-rate-limit') { candidates.push({ id: 'circle_serialize_walletsapi', @@ -73,13 +80,14 @@ function construct(failure: FailureClassification): RepairCandidate[] { estimatedSpeedMs: 2500, requirements: [], score: 0, - successProbability: 0.76, + successProbability: 0.50, platform: 'circle', source: 'adapter', reasoning: 'Circle Wallets API uses concurrency locks per wallet entity. Parallel requests get 429. ' + - 'Serialized retry with backoff recovers most failures at low concurrency, but degrades ' + - 'under sustained load — cross-instance retries are not yet serialized (Bench 2.1: 76%).', + 'DETECTION validated; REPAIR not validated — serialize_and_backoff is a no-op for ' + + 'serialization and recovered ~0% of rate-limited calls at 20-concurrent (Bench 2.1). ' + + 'Effective serialization tracked PR #4.', }); } @@ -191,7 +199,8 @@ function construct(failure: FailureClassification): RepairCandidate[] { estimatedSpeedMs: 60000, requirements: [], score: 0, - successProbability: 0.4, + // 0.50 to match seed-genes.ts (detection ✓ / non-repair halt, not a prior claim). + successProbability: 0.50, platform: 'circle', source: 'adapter', reasoning: @@ -312,23 +321,60 @@ function construct(failure: FailureClassification): RepairCandidate[] { }); } - // code === 155201 — wallet has insufficient funds + // code === 155201 / 155258 — wallet has insufficient funds + // + // DEFAULT: hold_and_notify. Silently reducing a fixed-obligation payment + // (payroll, invoice) is INCORRECT — a contractor owed 10 must not receive 5. + // The correct response to "not enough money" is to stop, alert the operator, + // and let them top up. Helix cannot conjure funds, so this is not an + // auto-repair — it is a safe halt. + // + // OPT-IN: reduce_request fires ONLY when the caller sets `allowPartial: true` + // on the wrap config — meaningful for best-effort transfers (gas top-ups, + // swap slippage) where a reduced amount is acceptable. Even then the L1 + // type-guard still refuses to corrupt non-scalar amounts. if (failure.code === 'circle-insufficient-funds') { - candidates.push({ - id: 'circle_insufficient_reduce', - strategy: 'reduce_request', - description: 'Insufficient funds — reduce amount and retry', - estimatedCostUsd: 0, - estimatedSpeedMs: 100, - requirements: ['amount'], - score: 0, - successProbability: 0.7, - platform: 'circle', - source: 'adapter', - reasoning: - 'Code=155201 means the wallet balance < request. Either reduce to available balance or ' + - 'caller must top up — reduce_request lets the engine try the reduced amount first.', - }); + if (context?.allowPartial === true) { + candidates.push({ + id: 'circle_insufficient_reduce', + strategy: 'reduce_request', + description: 'Insufficient funds — reduce amount and retry (allowPartial)', + estimatedCostUsd: 0, + estimatedSpeedMs: 100, + requirements: ['amount'], + score: 0, + successProbability: 0.7, + platform: 'circle', + source: 'adapter', + reasoning: + 'Code=155201/155258: wallet balance < request, and the caller opted into partial ' + + 'payments (allowPartial). Reduce toward the available balance. Only valid for ' + + 'best-effort transfers — never for fixed obligations.', + }); + } else { + candidates.push({ + id: 'circle_insufficient_hold', + strategy: 'hold_and_notify', + description: 'Insufficient funds — halt and alert operator to top up', + estimatedCostUsd: 0, + estimatedSpeedMs: 60000, + requirements: [], + // NON-REPAIRABLE. detection: validated (155201/155258 probe-verified in + // perceive.ts). repair: non-applicable — Helix cannot create funds, so + // this is a HALT, not a recovery. successProbability here is the + // confidence that halting is the correct response, NOT a repair-success + // rate (was advertised as a 0.7 repair prior in 2.8.0; reclassified). + score: 0, + successProbability: 0.4, + platform: 'circle', + source: 'adapter', + reasoning: + 'Code=155201/155258: wallet balance < request. This is NOT auto-repairable — Helix ' + + 'cannot create funds. Silently reducing a fixed-obligation payment would underpay. ' + + 'Correct behavior is to stop and notify the operator to top up. Set allowPartial:true ' + + 'only for best-effort transfers where a reduced amount is acceptable.', + }); + } } // code === 155203 — single-tx withdraw limit exceeded diff --git a/packages/core/tests/auto-detect.test.ts b/packages/core/tests/auto-detect.test.ts index 6c6c2b0..5ae60a3 100644 --- a/packages/core/tests/auto-detect.test.ts +++ b/packages/core/tests/auto-detect.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { detectSignature, applyOverrides } from '../src/engine/auto-detect.js'; +import { detectSignature, applyOverrides, amountShapeOf } from '../src/engine/auto-detect.js'; describe('Auto-Detect', () => { it('detects viem transaction', () => { @@ -57,3 +57,87 @@ describe('Apply Overrides', () => { expect(r![0]).toBe('https://new.api.com'); }); }); + +// ───────────────────────────────────────────────────────────────────────── +// L1 — amount-shape type guard (2.8.1). The generic-payment repair path must +// only do scalar arithmetic on genuinely scalar amounts. Non-scalar amounts +// (Circle's amount: string[], {value,currency} objects, ambiguous strings) +// must NOT be mutated — doing so corrupts the SDK payload shape. +// ───────────────────────────────────────────────────────────────────────── +describe('amountShapeOf', () => { + it('classifies scalar number / bigint', () => { + expect(amountShapeOf(10)).toBe('number'); + expect(amountShapeOf(10n)).toBe('number'); + }); + it('classifies a clean numeric string', () => { + expect(amountShapeOf('10')).toBe('numeric-string'); + expect(amountShapeOf('0.001')).toBe('numeric-string'); + }); + it('classifies a non-numeric / ambiguous string', () => { + expect(amountShapeOf('ten')).toBe('string'); + expect(amountShapeOf('')).toBe('string'); + }); + it('classifies Circle-style array amount', () => { + expect(amountShapeOf(['10'])).toBe('array'); + }); + it('classifies an object amount', () => { + expect(amountShapeOf({ value: '10', currency: 'USD' })).toBe('object'); + }); + it('classifies undefined / null', () => { + expect(amountShapeOf(undefined)).toBe('undefined'); + expect(amountShapeOf(null)).toBe('undefined'); + }); +}); + +describe('detectSignature reports amountShape', () => { + it('scalar number amount → number', () => { + expect(detectSignature([{ amount: 100, currency: 'USD' }]).amountShape).toBe('number'); + }); + it('Circle array amount → array', () => { + expect(detectSignature([{ destinationAddress: '0xabc', amount: ['10'] }]).amountShape).toBe('array'); + }); + it('numeric-string amount → numeric-string', () => { + expect(detectSignature([{ amount: '10' }]).amountShape).toBe('numeric-string'); + }); +}); + +describe('applyOverrides — L1 scalar-amount guard (generic-payment)', () => { + it('reduce_request HALVES a scalar number amount (safe — behavior preserved)', () => { + const sig = detectSignature([{ amount: 10, currency: 'USD' }]); + const r = applyOverrides([{ amount: 10, currency: 'USD' }], {}, 'reduce_request', sig); + expect(r).not.toBeNull(); + expect(r![0].amount).toBe(5); + }); + + it('reduce_request HALVES a numeric-string amount and round-trips to a string', () => { + const sig = detectSignature([{ amount: '10' }]); + const r = applyOverrides([{ amount: '10' }], {}, 'reduce_request', sig); + expect(r).not.toBeNull(); + expect(r![0].amount).toBe('5'); // stays a string, not 5 or [0] + }); + + it('reduce_request REFUSES to mutate a Circle array amount (returns null)', () => { + const sig = detectSignature([{ destinationAddress: '0xabc', amount: ['10'] }]); + const r = applyOverrides([{ destinationAddress: '0xabc', amount: ['10'] }], {}, 'reduce_request', sig); + expect(r).toBeNull(); // <-- the core bug fix: no corruption, no [0] + }); + + it('reduce_request REFUSES to mutate an object amount (returns null)', () => { + const sig = detectSignature([{ amount: { value: '10', currency: 'USD' } }]); + const r = applyOverrides([{ amount: { value: '10', currency: 'USD' } }], {}, 'reduce_request', sig); + expect(r).toBeNull(); + }); + + it('reduce_request REFUSES to mutate a non-numeric string amount (returns null)', () => { + const sig = detectSignature([{ amount: 'lots' }]); + const r = applyOverrides([{ amount: 'lots' }], {}, 'reduce_request', sig); + expect(r).toBeNull(); + }); + + it('non-reduce strategy never overwrites a non-scalar amount via generic override copy', () => { + const sig = detectSignature([{ destinationAddress: '0xabc', amount: ['10'] }]); + // an override that names `amount` must not clobber the array shape + const r = applyOverrides([{ destinationAddress: '0xabc', amount: ['10'] }], { amount: 0 }, 'fix_params', sig); + expect(r).toBeNull(); + }); +}); diff --git a/packages/core/tests/business-verify.test.ts b/packages/core/tests/business-verify.test.ts index 729ba9b..6ce0f4b 100644 --- a/packages/core/tests/business-verify.test.ts +++ b/packages/core/tests/business-verify.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { wrap, shutdown } from '../src/engine/wrap.js'; +import { GeneMap } from '../src/engine/gene-map.js'; afterEach(() => { shutdown(); }); @@ -163,4 +167,40 @@ describe('Business-Level Verify', () => { expect((capturedArgs![0] as any).to).toBe('0xRecipient'); expect((capturedArgs![0] as any).amount).toBe(100); }); + + // 2.8.1: an underpay that "succeeds" must be caught by verify, penalize the + // gene (q decremented via recordFailure), and surface as a failure WITHOUT + // further Self-Refine retries. + it('verify-false on an underpay: decrements gene q, records failure, no extra retry', async () => { + const dir = mkdtempSync(join(tmpdir(), 'helix-verify-')); + const dbPath = join(dir, 'genes.db'); + let calls = 0; + + const fn = async (p: { amount: number }) => { + calls++; + if (calls === 1) throw new Error('nonce mismatch'); // → refresh_nonce repair + return { amount: 50 }; // UNDERPAID: requested 100, "paid" 50 + }; + + const safe = wrap(fn, { + mode: 'auto', + geneMapPath: dbPath, + logLevel: 'silent', + verify: (r: any, a: any[]) => r.amount === a[0].amount, // 50 !== 100 → false + }); + + await expect(safe({ amount: 100 })).rejects.toThrow(/business verification failed/i); + // verify-false exits immediately: 1 original + 1 repair-retry, NO further loop. + expect(calls).toBe(2); + + // Inspect the persisted gene: recordFailure ran → failure counted + q down. + shutdown(); + const gm = new GeneMap(dbPath); + const gene = gm.lookup('nonce-mismatch', 'nonce'); + expect(gene).not.toBeNull(); + expect(gene!.consecutiveFailures).toBeGreaterThanOrEqual(1); + expect(gene!.qValue).toBeLessThan(0.5); // decremented from the 0.50 seed prior + gm.close(); + rmSync(dir, { recursive: true, force: true }); + }); }); diff --git a/packages/core/tests/insufficient-funds-policy.test.ts b/packages/core/tests/insufficient-funds-policy.test.ts new file mode 100644 index 0000000..0a9b440 --- /dev/null +++ b/packages/core/tests/insufficient-funds-policy.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { circleAdapter } from '../src/platforms/circle/strategies.js'; +import { SEED_GENES } from '../src/engine/seed-genes.js'; +import { detectSignature, applyOverrides } from '../src/engine/auto-detect.js'; +import type { FailureClassification } from '../src/engine/types.js'; + +// L2 (2.8.1): insufficient funds is NOT auto-repairable. Default to a safe +// halt (hold_and_notify); reduce_request fires ONLY when the caller opts into +// partial payments (allowPartial:true) — and even then the L1 type-guard +// still protects non-scalar amounts. + +function insufficientFailure(): FailureClassification { + return { + code: 'circle-insufficient-funds', + category: 'balance', + severity: 'high', + platform: 'circle', + details: 'wallet balance < request', + timestamp: Date.now(), + }; +} + +describe('L2 — Circle insufficient-funds construct policy', () => { + it('defaults to hold_and_notify and NEVER reduce_request (no context)', () => { + const candidates = circleAdapter.construct(insufficientFailure()); + expect(candidates).toHaveLength(1); + expect(candidates[0].strategy).toBe('hold_and_notify'); + expect(candidates.some((c) => c.strategy === 'reduce_request')).toBe(false); + }); + + it('still hold_and_notify when allowPartial is explicitly false', () => { + const candidates = circleAdapter.construct(insufficientFailure(), { allowPartial: false }); + expect(candidates[0].strategy).toBe('hold_and_notify'); + }); + + it('fires reduce_request ONLY when allowPartial:true', () => { + const candidates = circleAdapter.construct(insufficientFailure(), { allowPartial: true }); + expect(candidates).toHaveLength(1); + expect(candidates[0].strategy).toBe('reduce_request'); + }); +}); + +describe('L2 — generic payment-insufficient seed gene', () => { + it('maps to hold_and_notify, not reduce_request', () => { + const gene = SEED_GENES.find((g) => g.failureCode === 'payment-insufficient'); + expect(gene).toBeDefined(); + expect(gene!.strategy).toBe('hold_and_notify'); + }); + + it('is marked non-repairable with a neutral prior (honesty reclassification)', () => { + const gene = SEED_GENES.find((g) => g.failureCode === 'payment-insufficient'); + expect(gene!.nonRepairable).toBe(true); + expect(gene!.qValue).toBe(0.5); // demoted from an unvalidated 0.82 + expect(gene!.successCount).toBe(0); + }); +}); + +describe('L2 + L1 — allowPartial reduce_request is still subject to the L1 guard', () => { + it('reduce_request (allowPartial) must NOT corrupt a Circle array amount', () => { + // allowPartial re-enables the reduce_request candidate... + const candidates = circleAdapter.construct(insufficientFailure(), { allowPartial: true }); + expect(candidates[0].strategy).toBe('reduce_request'); + // ...but applying it to an array amount is still refused (no corruption). + const arg = { destinationAddress: '0xabc', amount: ['10'] }; + const sig = detectSignature([arg]); + const applied = applyOverrides([arg], {}, 'reduce_request', sig); + expect(applied).toBeNull(); + }); + + it('reduce_request (allowPartial) DOES halve a genuinely scalar amount', () => { + const arg = { amount: 10, currency: 'USD' }; + const sig = detectSignature([arg]); + const applied = applyOverrides([arg], {}, 'reduce_request', sig); + expect(applied).not.toBeNull(); + expect((applied![0] as { amount: number }).amount).toBe(5); + }); +}); diff --git a/packages/core/tests/real-execution.test.ts b/packages/core/tests/real-execution.test.ts index c96e509..2dfed95 100644 --- a/packages/core/tests/real-execution.test.ts +++ b/packages/core/tests/real-execution.test.ts @@ -60,6 +60,91 @@ describe('Real Execution — split_transaction', () => { const result = await safe({ amount: 100, to: '0x456' }); expect(calls).toContain(50); }); + + it('does NOT split a Circle-style array amount — guarded, no corruption (L1)', async () => { + const seen: unknown[] = []; + const fn = async (payment: { amount: unknown; to: string }) => { + seen.push(payment.amount); + // Same error that routes to split_transaction, but amount is an array. + throw new Error('max per user op spend limit exceeded'); + }; + + const safe = wrap(fn, { + mode: 'auto', + geneMapPath: ':memory:', + logLevel: 'silent', + maxRetries: 2, + splitConfig: { parts: 2, delayMs: 1 }, + }); + + // It can't succeed, but it must NEVER divide/corrupt the array amount. + await expect(safe({ amount: ['100'], to: '0x456' })).rejects.toThrow(); + expect(seen.length).toBeGreaterThan(0); + for (const a of seen) expect(a).toEqual(['100']); // every call saw the original array + }); +}); + +describe('Real Execution — freezeArgs (2.8.1)', () => { + it('freezeArgs:true — an arg-mutating strategy (split_transaction) does NOT modify the payload', async () => { + const seen: number[] = []; + const fn = async (payment: { amount: number; to: string }) => { + seen.push(payment.amount); + // Without freezeArgs this would split 100 → 50 (see the split test above). + throw new Error('max per user op spend limit exceeded'); + }; + + const safe = wrap(fn, { + mode: 'auto', + geneMapPath: ':memory:', + logLevel: 'silent', + maxRetries: 2, + splitConfig: { parts: 2, delayMs: 1 }, + freezeArgs: true, + }); + + await expect(safe({ amount: 100, to: '0x456' })).rejects.toThrow(); + expect(seen.length).toBeGreaterThan(0); + for (const a of seen) expect(a).toBe(100); // never mutated to 50 + }); + + it('freezeArgs:false (default) — split_transaction DOES modify the payload (contrast)', async () => { + const seen: number[] = []; + const fn = async (payment: { amount: number; to: string }) => { + seen.push(payment.amount); + if (payment.amount > 50) throw new Error('max per user op spend limit exceeded'); + return { status: 'sent' }; + }; + + const safe = wrap(fn, { + mode: 'auto', + geneMapPath: ':memory:', + logLevel: 'silent', + splitConfig: { parts: 2, delayMs: 1 }, + }); + + await safe({ amount: 100, to: '0x456' }); + expect(seen).toContain(50); // proves the path mutates when not frozen + }); + + it('freezeArgs + L1 compose — array amount under a mutating strategy stays intact', async () => { + const seen: unknown[] = []; + const fn = async (payment: { amount: unknown; to: string }) => { + seen.push(payment.amount); + throw new Error('max per user op spend limit exceeded'); + }; + + const safe = wrap(fn, { + mode: 'auto', + geneMapPath: ':memory:', + logLevel: 'silent', + maxRetries: 2, + splitConfig: { parts: 2, delayMs: 1 }, + freezeArgs: true, + }); + + await expect(safe({ amount: ['100'], to: '0x456' })).rejects.toThrow(); + for (const a of seen) expect(a).toEqual(['100']); // both freezeArgs AND L1 agree: no mutation + }); }); describe('Real Execution — remove_and_resubmit', () => { diff --git a/packages/core/tests/seed-genes.test.ts b/packages/core/tests/seed-genes.test.ts index 1a160dd..250aba5 100644 --- a/packages/core/tests/seed-genes.test.ts +++ b/packages/core/tests/seed-genes.test.ts @@ -12,12 +12,20 @@ describe('Seed Gene Map (D9)', () => { expect(geneMap.immuneCount()).toBe(SEED_GENES.length); }); - it('seed genes have correct q_values', () => { + it('generic priors are normalized to a neutral 0.50 (2.8.1 honesty audit)', () => { geneMap = new GeneMap(':memory:'); const nonce = geneMap.lookup('verification-failed', 'signature'); expect(nonce).not.toBeNull(); - expect(nonce!.qValue).toBeGreaterThan(0.6); + // Was an unvalidated 0.70 prior in 2.8.0; demoted to a neutral 0.50 since + // no real repair was ever validated. Strategy unchanged. + expect(nonce!.qValue).toBe(0.5); expect(nonce!.strategy).toBe('refresh_nonce'); + + // No generic prior should ship ABOVE the neutral 0.50 without evidence. + for (const g of SEED_GENES) { + if (g.platforms.includes('circle')) continue; // Circle audited separately + expect(g.qValue).toBeLessThanOrEqual(0.5); + } }); it('does not overwrite existing genes on re-seed', () => {