From 77ce2c5897d84b963c231ae0f7f240c99168a019 Mon Sep 17 00:00:00 2001 From: markosiks Date: Thu, 11 Jun 2026 09:57:20 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix(byreal):=20=D0=B7=D0=BD=D0=B0=D0=BA=20p?= =?UTF-8?q?osition=5Fdelta=20=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D0=B8=20=D1=88=D0=BE=D1=80=D1=82=D0=B0=20+?= =?UTF-8?q?=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B0=20=D0=BE=D1=82=20=D0=B8=D0=BD=D1=8A?= =?UTF-8?q?=D0=B5=D0=BA=D1=86=D0=B8=D0=B8=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse.ts: close-интент не несёт side (closeShape), и buildOutcome безусловно негировал filledSize — закрытие шорта записывалось на credibility-поверхность с инвертированным знаком. Теперь знак выводится из остаточной позиции: отрицательный residual (шорт) => положительная дельта. Полное закрытие (позиция flat, residual нет) сохраняет историческое поведение — задокументировано. envelope.ts: parseEnvelope брал первый сбалансированный JSON-объект из stdout. Теперь собираются все top-level объекты: не-конвертный JSON-баннер пропускается, а >1 валидного конверта — fail-closed (ByrealParseError), чтобы подставленный фейковый конверт не мог заменить реальный fill. Заодно passthrough -> strip: неизвестные top-level ключи конверта не просачиваются в executions.response_json. Регрессионные тесты: закрытие шорта/лонга с residual, JSON-баннер, двойной конверт (инъекция), strip лишних ключей. --- lib/rail/byreal/envelope.ts | 93 ++++++++++++++++++++------------- lib/rail/byreal/parse.ts | 19 +++++-- tests/unit/byreal/parse.test.ts | 50 ++++++++++++++++++ 3 files changed, 123 insertions(+), 39 deletions(-) diff --git a/lib/rail/byreal/envelope.ts b/lib/rail/byreal/envelope.ts index 0a4abe8..56dae87 100644 --- a/lib/rail/byreal/envelope.ts +++ b/lib/rail/byreal/envelope.ts @@ -27,21 +27,22 @@ export class ByrealParseError extends Error { } } -const metaSchema = z - .object({ timestamp: z.string().optional(), version: z.string().optional() }) - .passthrough(); +// B-07: strip unknown top-level keys instead of passing them through. The +// parsed envelope is persisted verbatim in `executions.response_json`; a CLI +// that prints an extra top-level field (e.g. an echoed credential) must not have +// it carried into the database. Payload-bearing `data` stays `unknown` so the +// genuine result is preserved; only unrecognized *envelope/meta/error* keys drop. +const metaSchema = z.object({ timestamp: z.string().optional(), version: z.string().optional() }); -const errorSchema = z.object({ code: z.string(), message: z.string() }).passthrough(); +const errorSchema = z.object({ code: z.string(), message: z.string() }); /** The validated envelope. `data` stays `unknown` — payload schemas live in `parse.ts`. */ -export const envelopeSchema = z - .object({ - success: z.boolean(), - meta: metaSchema.optional(), - data: z.unknown().optional(), - error: errorSchema.optional(), - }) - .passthrough(); +export const envelopeSchema = z.object({ + success: z.boolean(), + meta: metaSchema.optional(), + data: z.unknown().optional(), + error: errorSchema.optional(), +}); export type ByrealEnvelope = z.infer; @@ -49,19 +50,19 @@ export type ByrealEnvelope = z.infer; const ANSI = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PR-TZcf-ntqry=><]/g; /** - * Extract the first balanced top-level JSON object from `text`, ignoring any - * leading/trailing noise (a banner line, a trailing newline, a stray warning). - * Returns the substring `{…}` or `null` when no balanced object is present. - * String literals are scanned so a `}` inside a JSON string never closes early. + * Extract *every* balanced top-level JSON object from `text`, in order, ignoring + * any leading/trailing/interleaving noise (a banner line, a trailing newline, a + * stray warning, a JSON log line). String literals are scanned so a `}` inside a + * JSON string never closes a level early. An unbalanced/truncated tail yields no + * extra object. */ -function extractJsonObject(text: string): string | null { - const start = text.indexOf('{'); - if (start === -1) return null; - +function extractJsonObjects(text: string): string[] { + const objects: string[] = []; + let start = -1; let depth = 0; let inString = false; let escaped = false; - for (let i = start; i < text.length; i += 1) { + for (let i = 0; i < text.length; i += 1) { const ch = text[i]; if (inString) { if (escaped) escaped = false; @@ -69,14 +70,20 @@ function extractJsonObject(text: string): string | null { else if (ch === '"') inString = false; continue; } - if (ch === '"') inString = true; - else if (ch === '{') depth += 1; - else if (ch === '}') { + if (ch === '"') { + inString = true; + } else if (ch === '{') { + if (depth === 0) start = i; + depth += 1; + } else if (ch === '}' && depth > 0) { depth -= 1; - if (depth === 0) return text.slice(start, i + 1); + if (depth === 0 && start !== -1) { + objects.push(text.slice(start, i + 1)); + start = -1; + } } } - return null; // Unbalanced — truncated output. + return objects; } /** @@ -91,21 +98,35 @@ export function parseEnvelope(stdout: string): ByrealEnvelope { throw new ByrealParseError('byreal output exceeds size bound'); } - const json = extractJsonObject(stdout.replace(ANSI, '')); - if (json === null) { + const candidates = extractJsonObjects(stdout.replace(ANSI, '')); + if (candidates.length === 0) { throw new ByrealParseError('byreal output contains no JSON object'); } - let raw: unknown; - try { - raw = JSON.parse(json); - } catch { - throw new ByrealParseError('byreal output is not valid JSON'); + // B-06 (banner injection): collect *all* well-formed envelopes, not just the + // first balanced object. A non-envelope banner object (`{"debug":true}`) is + // skipped instead of aborting the parse. But the genuine CLI prints exactly + // one envelope, so if more than one envelope-shaped object appears we refuse + // to guess which is authoritative — a prepended/appended fake envelope must + // not be able to substitute a forged fill. Fail closed: the caller degrades to + // the deterministic seed fallback. + const envelopes: ByrealEnvelope[] = []; + for (const json of candidates) { + let raw: unknown; + try { + raw = JSON.parse(json); + } catch { + continue; // Not valid JSON (e.g. `{ not: json }`) — ignore this candidate. + } + const parsed = envelopeSchema.safeParse(raw); + if (parsed.success) envelopes.push(parsed.data); } - const parsed = envelopeSchema.safeParse(raw); - if (!parsed.success) { + if (envelopes.length === 0) { throw new ByrealParseError('byreal output is not a valid CLI envelope'); } - return parsed.data; + if (envelopes.length > 1) { + throw new ByrealParseError('byreal output contains multiple CLI envelopes'); + } + return envelopes[0] as ByrealEnvelope; } diff --git a/lib/rail/byreal/parse.ts b/lib/rail/byreal/parse.ts index cb449da..19b4986 100644 --- a/lib/rail/byreal/parse.ts +++ b/lib/rail/byreal/parse.ts @@ -229,15 +229,28 @@ export interface OutcomeParts { * is unknown from the order result alone, so `'0'` (documented; credibility). * - `pnl_marked` — the position's unrealized PnL (read), else `'0'`. * - `pnl_realized` — booked by the order (closes only). - * - `position_delta` — signed filled size: an open takes the intent's side; a - * close reduces the position (negative of the filled size). + * - `position_delta` — signed filled size that moves the position toward zero + * on a close and away on an open. A `close` Intent carries no `side` + * (`closeShape`), so the close sign is derived from the *resulting* position + * read: closing a short (a still-negative residual size, or a buy-back) is a + * positive delta, closing a long is negative. When the close flattens the + * position the venue reports no residual (`position` is `undefined`); the side + * is then unknowable from the order result alone, so we keep the historical + * long-assumption (negative). This only affects the verifiable credibility + * surface — Byreal outcomes never feed the deterministic score. * - `drawdown` — not derivable per-fill from the venue; `'0'` by contract (it is * a scoring-only quantity and Byreal outcomes never feed the score). */ export function buildOutcome(parts: OutcomeParts): SeedOutcome { const { order, position, openSide, isClose } = parts; + // A residual short position (negative size) means the close bought back size, + // so the delta is positive; otherwise (long residual, or flat → unknown) the + // close reduces a long and the delta is negative. + const closeIsShort = position !== undefined && position.size.startsWith('-'); const positionDelta = isClose - ? negateDecimal(order.filledSize) + ? closeIsShort + ? absDecimal(order.filledSize) + : negateDecimal(order.filledSize) : openSide === 'short' ? negateDecimal(order.filledSize) : order.filledSize; diff --git a/tests/unit/byreal/parse.test.ts b/tests/unit/byreal/parse.test.ts index 2568333..8d47a49 100644 --- a/tests/unit/byreal/parse.test.ts +++ b/tests/unit/byreal/parse.test.ts @@ -59,6 +59,32 @@ describe('parseEnvelope', () => { expect(() => parseEnvelope(JSON.stringify({ data: {} }))).toThrow(ByrealParseError); }); + test('skips a non-envelope JSON banner and parses the real envelope', () => { + const env = parseEnvelope(`{"debug":true,"config":"loaded"}\n${ok({ oid: 'REAL' })}`); + expect(env.success).toBe(true); + expect((env.data as { oid: string }).oid).toBe('REAL'); + }); + + // Regression (Z5): a forged envelope prepended to the genuine one must not be + // able to substitute a fake fill. Two envelope-shaped objects ⇒ fail closed. + test('refuses output containing more than one CLI envelope (injection)', () => { + const injected = `${ok({ oid: 'FAKE', totalSz: '999999' })}\n${ok({ oid: 'REAL', totalSz: '0.01' })}`; + expect(() => parseEnvelope(injected)).toThrow(ByrealParseError); + }); + + test('strips unknown top-level envelope keys (does not persist echoed fields)', () => { + const env = parseEnvelope( + JSON.stringify({ + success: true, + meta: { version: '0.3.7' }, + data: { oid: 1 }, + agent_key: 'sk_live_LEAK', + }), + ); + expect((env as Record).agent_key).toBeUndefined(); + expect(env.success).toBe(true); + }); + test('throws on output past the size bound', () => { expect(() => parseEnvelope(`{"success":true,"x":"${'a'.repeat(1_048_577)}"}`)).toThrow( ByrealParseError, @@ -201,6 +227,30 @@ describe('buildOutcome', () => { drawdown: '0', }); }); + + // Regression: a partial close of a *long* leaves a positive residual size, so + // the delta reduces the position (negative). + test('close long (positive residual): negative delta', () => { + const o = buildOutcome({ + order, + position: { notional: '650', markedPnl: '1', size: '0.02' }, + isClose: true, + }); + expect(o.position_delta).toBe('-0.01'); + }); + + // Regression (Z5-HIGH): a partial close of a *short* leaves a negative residual + // size; the close bought size back, so the delta must be POSITIVE. The old code + // unconditionally negated the filled size and recorded an inverted (negative) + // delta on the verifiable credibility surface. + test('close short (negative residual): positive delta', () => { + const o = buildOutcome({ + order, + position: { notional: '650', markedPnl: '-1', size: '-0.02' }, + isClose: true, + }); + expect(o.position_delta).toBe('0.01'); + }); }); // B-02 regression: scientific-notation totalSz must not silently downgrade a From 9e0f0623b99bbfc87f7d055a2cbcc28c1372ee9b Mon Sep 17 00:00:00 2001 From: markosiks Date: Thu, 11 Jun 2026 09:57:44 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(rate-limit):=20=D0=B0=D0=BC=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BE=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D0=B8=D1=85=20?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=B9=20=D0=B2=20check()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check() никогда не удалял записи из map — атакующий, варьирующий ключ (например, спуфингом X-Forwarded-For), наращивал по записи на запрос до OOM. Теперь раз в окно выполняется полная зачистка устаревших записей (амортизированный O(1) на вызов); map ограничен числом уникальных ключей, активных в пределах одного окна. prune() переиспользует ту же логику. Регрессионный тест: 50 устаревших ключей удаляются следующим check(). --- lib/api/rate-limit.ts | 41 ++++++++++++++++++++++--------- tests/unit/api/rate-limit.test.ts | 16 ++++++++++++ 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/lib/api/rate-limit.ts b/lib/api/rate-limit.ts index f412553..ff72c3e 100644 --- a/lib/api/rate-limit.ts +++ b/lib/api/rate-limit.ts @@ -26,6 +26,8 @@ export class RateLimiter { private readonly map = new Map(); private readonly limit: number; private readonly windowMs: number; + /** Wall-clock of the last full sweep; gates the amortized prune in `check`. */ + private lastPrune = 0; constructor({ limit, windowMs }: RateLimiterOptions) { this.limit = limit; @@ -41,30 +43,45 @@ export class RateLimiter { const now = Date.now(); const cutoff = now - this.windowMs; - let w = this.map.get(key); - if (w === undefined) { - w = { hits: [] }; - this.map.set(key, w); + // F-01 hardening: an attacker who can vary the key (e.g. by spoofing + // X-Forwarded-For) would otherwise add an entry per request that `check` + // never reclaimed, growing the map without bound until OOM. Sweep stale + // entries at most once per window so the map stays bounded by the number of + // *distinct keys seen within a single window* at amortized O(1) per call. + if (now - this.lastPrune >= this.windowMs) { + this.pruneBefore(cutoff); + this.lastPrune = now; } - // Slide: drop timestamps older than the window. - w.hits = w.hits.filter((t) => t > cutoff); + const existing = this.map.get(key); + const hits = (existing?.hits ?? []).filter((t) => t > cutoff); - if (w.hits.length >= this.limit) { + if (hits.length >= this.limit) { + this.map.set(key, { hits }); return false; } - w.hits.push(now); + hits.push(now); + this.map.set(key, { hits }); return true; } - /** Remove stale entries (call periodically if many IPs are expected). */ - prune(): void { - const cutoff = Date.now() - this.windowMs; + /** Remove every entry whose hits are all at/older than `cutoff`. */ + private pruneBefore(cutoff: number): void { for (const [key, w] of this.map) { - if (w.hits.every((t) => t <= cutoff)) { + if (w.hits.length === 0 || w.hits.every((t) => t <= cutoff)) { this.map.delete(key); } } } + + /** Remove stale entries (call periodically if many IPs are expected). */ + prune(): void { + this.pruneBefore(Date.now() - this.windowMs); + } + + /** Current number of tracked keys. Test/observability only. */ + size(): number { + return this.map.size; + } } diff --git a/tests/unit/api/rate-limit.test.ts b/tests/unit/api/rate-limit.test.ts index bbb1482..6768cc6 100644 --- a/tests/unit/api/rate-limit.test.ts +++ b/tests/unit/api/rate-limit.test.ts @@ -52,4 +52,20 @@ describe('RateLimiter', () => { rl.prune(); // should not blow up and should keep 'keep' active expect(rl.check('keep')).toBe(true); // still within limit }); + + // Regression (F-01 leak): distinct keys whose hits have all expired must be + // reclaimed by the amortized sweep in `check`, not accumulate forever (the + // X-Forwarded-For spoofing DoS vector). + test('check() reclaims stale keys so the map stays bounded', async () => { + const rl = new RateLimiter({ limit: 5, windowMs: 10 }); + // Burst of distinct keys within one window: all are retained. + for (let i = 0; i < 50; i++) rl.check(`ip-${i}`); + expect(rl.size()).toBe(50); + // Let the whole window lapse so every recorded hit is now stale. + await Bun.sleep(15); + // The next check triggers the once-per-window sweep, dropping all 50 stale + // entries; only the freshly-touched key remains. + rl.check('fresh'); + expect(rl.size()).toBe(1); + }); }); From 28d79bbb165e1a5dfe0e29b03834d367ec2b9dfa Mon Sep 17 00:00:00 2001 From: markosiks Date: Thu, 11 Jun 2026 09:57:45 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(replay):=20=D0=B0=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=20=D1=82=D0=B8=D0=BA=D0=B0=20=E2=80=94=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B5=D1=80=D0=B2=D0=B0=D1=86=D0=B8=D1=8F=20nonce=20?= =?UTF-8?q?=D0=B8=20settle=20=D0=B2=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processAgentTick писал intent (с резервацией nonce), policy events, execution и outcome отдельными autocommit-стейтментами. Падение между резервацией и insertOutcome навсегда сжигало nonce: повторный прогон молча скипал тик (анти-replay читал собственную рваную запись), fill терялся. Тело тика обёрнуто в BEGIN..COMMIT на выделенном клиенте (тот же паттерн, что settleRound); при ошибке ROLLBACK откатывает и резервацию — повторный прогон дообрабатывает тик. settleCredibility вынесен ЗА транзакцию: он best-effort и глотает свои ошибки, но проглоченная ошибка БД внутри открытой транзакции Postgres абортила бы её и откатила seed-строки. Happy-path байт-в-байт идентичен (те же строки, тот же порядок) — детерминизм арки сохранён. ТРЕБУЕТ прогона integration-сьюта с DATABASE_URL. --- lib/replay/orchestrator.ts | 192 +++++++++++++++++++++---------------- 1 file changed, 109 insertions(+), 83 deletions(-) diff --git a/lib/replay/orchestrator.ts b/lib/replay/orchestrator.ts index 7bfb4ab..3a2bd51 100644 --- a/lib/replay/orchestrator.ts +++ b/lib/replay/orchestrator.ts @@ -302,97 +302,123 @@ async function processAgentTick( const validated = await validateIntent(signed, tickValidate); if (!validated.ok) return; // Structurally invalid: nothing to persist or settle. - // Reserve the nonce + persist the Intent before the referee runs. The referee - // re-validates (defense in depth) but is NOT given an `isNonceUsed` probe: the - // nonce is now reserved in the DB, so probing it would falsely reject this very - // Intent as a replay. Durable anti-replay is the reservation, not the probe. - const intentRow = await insertIntentReserving( - db, - intentToColumns(validated.intent, { - roundId: round.id, + // Atomicity (R-08): the nonce reservation and every row it gates — the policy + // events, the execution, the outcome — commit as one unit on the dedicated + // client (same pattern as `settleRound`). Without this, a crash after + // `insertIntentReserving` but before `insertOutcome` burned the nonce with no + // settled rows; a re-run then skipped the tick silently (the anti-replay + // reservation reading its own torn write) and the fill was lost permanently. + // The credibility settlement stays *outside* the transaction: it is + // best-effort and swallows its own errors, but a swallowed DB error inside an + // open Postgres transaction would abort it and roll back the seed rows. + const settleTick = async (): Promise => { + // Reserve the nonce + persist the Intent before the referee runs. The referee + // re-validates (defense in depth) but is NOT given an `isNonceUsed` probe: the + // nonce is now reserved in the DB, so probing it would falsely reject this very + // Intent as a replay. Durable anti-replay is the reservation, not the probe. + const intentRow = await insertIntentReserving( + db, + intentToColumns(validated.intent, { + roundId: round.id, + agentUuid, + hash: validated.intent_hash, + }), + ); + if (intentRow === null) return; // Nonce already used (replay): skip silently. + + const state: RefereeState = { + killSwitch: round.killSwitch, + agent: { + allocation, + remaining_budget: allocation, + drawdown: '0', + // An operator per-agent HALT cuts execution here too (rule #1b), mirroring + // the router's gate-out. Seed agents default to 'active', so the arc stays + // byte-identical unless an operator explicitly halts one. + halted: agentRow?.status === 'halted', + }, + }; + const decision = await runReferee({ + db, + input: signed, + ids: { intent_id: intentRow.id, agent_id: agentUuid, round_id: round.id }, + state, + validate: tickValidate, + }); + + // Only an ALLOW or CLIP reaches the rail; a REJECT/HALT already recorded its + // `policy_event` and produces no execution/outcome (the drain's path). + if (decision.decision !== 'ALLOW' && decision.decision !== 'CLIP') return; + + const seedOutcome = arc.outcomes[agentId]?.[tick.index]; + if (seedOutcome === undefined) return; + const executed = decision.modified_intent ?? validated.intent; + const { fill, degraded } = await settleWithFallback( + rail, + { intent: executed, agentId, tickIndex: tick.index, intentHash: validated.intent_hash }, + { + status: 'filled', + outcome: seedOutcome, + rail_order_id: `seed-${agentId}-${tick.index}`, + }, + ); + // R-02: surface live-rail degradation so operator dashboards can detect + // prolonged outages instead of silently serving seed-frozen fills. + if (degraded) { + console.warn( + `[seed-rail] degraded to seed fill for agent=${agentId} tick=${tick.index}: live rail failed`, + ); + } + + const execution = await insertExecution(db, { + intent_id: intentRow.id, + status: fill.status, + rail: 'seed', + rail_order_id: fill.rail_order_id ?? null, + request_json: executed, + response_json: fill.response ?? fill.outcome, + }); + await insertOutcome(db, { + agent_id: agentUuid, + round_id: round.id, + execution_id: execution.id, + pnl_realized: fill.outcome.pnl_realized, + pnl_marked: fill.outcome.pnl_marked, + capital_at_risk: fill.outcome.capital_at_risk, + fees: fill.outcome.fees, + position_delta: fill.outcome.position_delta, + drawdown: fill.outcome.drawdown, + }); + + return { + intent: executed, + agentId, + tickIndex: tick.index, + intentHash: validated.intent_hash, + intentId: intentRow.id, agentUuid, - hash: validated.intent_hash, - }), - ); - if (intentRow === null) return; // Nonce already used (replay): skip silently. - - const state: RefereeState = { - killSwitch: round.killSwitch, - agent: { - allocation, - remaining_budget: allocation, - drawdown: '0', - // An operator per-agent HALT cuts execution here too (rule #1b), mirroring - // the router's gate-out. Seed agents default to 'active', so the arc stays - // byte-identical unless an operator explicitly halts one. - halted: agentRow?.status === 'halted', - }, + roundId: round.id, + }; }; - const decision = await runReferee({ - db, - input: signed, - ids: { intent_id: intentRow.id, agent_id: agentUuid, round_id: round.id }, - state, - validate: tickValidate, - }); - // Only an ALLOW or CLIP reaches the rail; a REJECT/HALT already recorded its - // `policy_event` and produces no execution/outcome (the drain's path). - if (decision.decision !== 'ALLOW' && decision.decision !== 'CLIP') return; - - const seedOutcome = arc.outcomes[agentId]?.[tick.index]; - if (seedOutcome === undefined) return; - const executed = decision.modified_intent ?? validated.intent; - const { fill, degraded } = await settleWithFallback( - rail, - { intent: executed, agentId, tickIndex: tick.index, intentHash: validated.intent_hash }, - { - status: 'filled', - outcome: seedOutcome, - rail_order_id: `seed-${agentId}-${tick.index}`, - }, - ); - // R-02: surface live-rail degradation so operator dashboards can detect - // prolonged outages instead of silently serving seed-frozen fills. - if (degraded) { - console.warn( - `[seed-rail] degraded to seed fill for agent=${agentId} tick=${tick.index}: live rail failed`, - ); + await db.query('BEGIN'); + let credibility: CredibilitySettleArgs | undefined; + try { + credibility = await settleTick(); + await db.query('COMMIT'); + } catch (err) { + await db.query('ROLLBACK').catch(() => {}); + throw err; } - const execution = await insertExecution(db, { - intent_id: intentRow.id, - status: fill.status, - rail: 'seed', - rail_order_id: fill.rail_order_id ?? null, - request_json: executed, - response_json: fill.response ?? fill.outcome, - }); - await insertOutcome(db, { - agent_id: agentUuid, - round_id: round.id, - execution_id: execution.id, - pnl_realized: fill.outcome.pnl_realized, - pnl_marked: fill.outcome.pnl_marked, - capital_at_risk: fill.outcome.capital_at_risk, - fees: fill.outcome.fees, - position_delta: fill.outcome.position_delta, - drawdown: fill.outcome.drawdown, - }); - // Credibility settlement (P2.1): also settle the same allowed Intent on the // live Byreal rail, recording a separate `rail='byreal'` execution+outcome for // the verifiable surface. It is best-effort and *excluded from scoring*; a - // miss or error never affects the deterministic seed settlement above. - await settleCredibility(db, credibilityRail, { - intent: executed, - agentId, - tickIndex: tick.index, - intentHash: validated.intent_hash, - intentId: intentRow.id, - agentUuid, - roundId: round.id, - }); + // miss or error never affects the deterministic seed settlement above (already + // committed at this point). + if (credibility !== undefined) { + await settleCredibility(db, credibilityRail, credibility); + } } /** Inputs to the credibility settlement: the executed Intent plus its row ids. */ From 9e7dd64d4d14edf8336e92e1fbb97418870d278e Mon Sep 17 00:00:00 2001 From: markosiks Date: Thu, 11 Jun 2026 09:57:45 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(scripts,db):=20PUBLIC=5FBASE=5FURL=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=81=D0=B2=D0=B8=D0=BF=D0=B0=20+=20?= =?UTF-8?q?=D1=80=D0=B5=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=20operat?= =?UTF-8?q?or-actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sweep-attestations.ts: feedbackURI пишется on-chain навсегда (канонический ERC-8004 не имеет updateFeedback); дефолт https://localhost зашивал бы в чейн вечно недостижимый URI и ломал проверку целостности у любого верификатора. Теперь без явного PUBLIC_BASE_URL свип падает сразу. lib/db/repos/index.ts: operator-actions не реэкспортировался из barrel — единственный репозиторий вне индекса (коллизий имён нет). --- lib/db/repos/index.ts | 1 + scripts/sweep-attestations.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/db/repos/index.ts b/lib/db/repos/index.ts index b3ddd0b..06f647f 100644 --- a/lib/db/repos/index.ts +++ b/lib/db/repos/index.ts @@ -14,3 +14,4 @@ export * from './scores'; export * from './capital-allocations'; export * from './attestations'; export * from './kill-switch'; +export * from './operator-actions'; diff --git a/scripts/sweep-attestations.ts b/scripts/sweep-attestations.ts index bdb1729..352a33d 100644 --- a/scripts/sweep-attestations.ts +++ b/scripts/sweep-attestations.ts @@ -40,7 +40,16 @@ if (typeof DATABASE_URL !== 'string' || DATABASE_URL.length === 0) { } const SWEEP_LIMIT = Number(process.env.SWEEP_LIMIT ?? 100); -const BASE_URL = process.env.PUBLIC_BASE_URL ?? 'https://localhost'; +// The sweep writes `feedbackURI` permanently on-chain (canonical ERC-8004 has no +// updateFeedback). A `https://localhost` fallback would bake an unreachable URI +// into the chain forever, breaking every verifier's integrity check — so require +// an explicit public base URL rather than silently defaulting. +const BASE_URL = process.env.PUBLIC_BASE_URL; +if (typeof BASE_URL !== 'string' || BASE_URL.length === 0) { + throw new Error( + 'PUBLIC_BASE_URL is required: the on-chain feedbackURI is immutable, so it must point at the public deployment, not a localhost fallback', + ); +} const pool = new Pool({ connectionString: DATABASE_URL }); const db = toQueryable(await pool.connect());