diff --git a/oracledesk-stellar/agents/core/due-diligence.ts b/oracledesk-stellar/agents/core/due-diligence.ts new file mode 100644 index 0000000..a706b3f --- /dev/null +++ b/oracledesk-stellar/agents/core/due-diligence.ts @@ -0,0 +1,78 @@ +export type DueDiligenceReasonCode = + | "CHAIN_STATE_UNAVAILABLE" + | "STALE_MARKET_STATE" + | "COMMITMENT_MISMATCH" + | "METADATA_UNAVAILABLE" + | "METADATA_MALFORMED" + | "QUESTION_HASH_MISMATCH" + | "RESOLUTION_UNAVAILABLE" + | "ORACLE_UNTRUSTED" + | "SIGNERS_UNTRUSTED" + | "VERIFICATION_UNAVAILABLE"; + +export type VerificationStatus = "verified" | "failed" | "unavailable" | "not_applicable"; + +export interface DueDiligenceVerdict { + outcome: "allow" | "reject"; + reasons: Array<{ code: DueDiligenceReasonCode; explanation: string }>; + commitments: VerificationStatus; + metadata: VerificationStatus; + resolutionPath: VerificationStatus; + oracleTrust: VerificationStatus; + signerTrust: VerificationStatus; + marketId: string; + stateFingerprint?: string; + checkedAt: number; +} + +export function unavailableDueDiligence(marketId: bigint | string, checkedAt = Date.now()): DueDiligenceVerdict { + return { + outcome: "reject", + reasons: [{ + code: "VERIFICATION_UNAVAILABLE", + explanation: "No chain-backed commitment, metadata, resolution, and trust verifier is configured.", + }], + commitments: "unavailable", + metadata: "unavailable", + resolutionPath: "unavailable", + oracleTrust: "unavailable", + signerTrust: "unavailable", + marketId: marketId.toString(), + checkedAt, + }; +} + +export function isDueDiligenceApproved( + verdict: DueDiligenceVerdict, + expectedMarketId: string, + nowMs = Date.now(), + maxAgeMs = 30_000, +): boolean { + return verdict.outcome === "allow" && + verdict.commitments === "verified" && + verdict.metadata === "verified" && + verdict.resolutionPath === "verified" && + (verdict.oracleTrust === "verified" || verdict.oracleTrust === "not_applicable") && + (verdict.signerTrust === "verified" || verdict.signerTrust === "not_applicable") && + verdict.reasons.length === 0 && + verdict.marketId === expectedMarketId && + verdict.stateFingerprint !== undefined && + verdict.stateFingerprint.length > 0 && + Number.isFinite(verdict.checkedAt) && + verdict.checkedAt <= nowMs && + nowMs - verdict.checkedAt <= maxAgeMs; +} + +export function rejectChangedState(verdict: DueDiligenceVerdict): DueDiligenceVerdict { + return { + ...verdict, + outcome: "reject", + reasons: [ + ...verdict.reasons, + { + code: "STALE_MARKET_STATE", + explanation: "The verified market fingerprint changed between due-diligence checks.", + }, + ], + }; +} diff --git a/oracledesk-stellar/agents/runtime/tasks/maker.ts b/oracledesk-stellar/agents/runtime/tasks/maker.ts index 684cf9e..15ebe74 100644 --- a/oracledesk-stellar/agents/runtime/tasks/maker.ts +++ b/oracledesk-stellar/agents/runtime/tasks/maker.ts @@ -11,7 +11,13 @@ */ import { createHash } from "node:crypto"; import { buildTrace, type TraceStore } from "../../core/trace.js"; -import { decideMarketProposal, type SignalSource } from "../../core/strategy.js"; +import { decideMarketProposal, type MarketSignal, type SignalSource } from "../../core/strategy.js"; +import { + isDueDiligenceApproved, + rejectChangedState, + unavailableDueDiligence, + type DueDiligenceVerdict, +} from "../../core/due-diligence.js"; import type { StellarAdapter } from "../../stellar/adapter.js"; import type { Task, TaskContext } from "../runtime.js"; import { publishPendingTraces, recordPendingTrace } from "./traces.js"; @@ -22,6 +28,8 @@ export interface MakerDeps { signals: SignalSource; /** Question hashes that already have a market on-chain. */ knownQuestionHashes?: () => Promise>; + /** Verifies the proposed metadata, commitment, resolution path, and trust policy. */ + validateProposal?: (signal: MarketSignal, question: string) => Promise; /** * Reveals the market's resolution spec. Until one is wired in (see the * commit/reveal issue), created markets stay "created" and the trader @@ -66,12 +74,28 @@ export async function makerTick(deps: MakerDeps, ctx: TaskContext): Promise reason.code).join(", ")}`, + inputs: { signal, decision, dueDiligence: verdict }, + }); + await deps.traceStore.put(rejected.hash, rejected.json); + ctx.log.warn("MAKER_DUE_DILIGENCE_REJECTED", { signal: id, verdict, trace: rejected.hash }); + continue; + } + const built = buildTrace({ agent: "market-maker", marketId: null, action: "propose_market", reasoning: decision.reasoning, - inputs: { signal, decision }, + inputs: { signal, decision, dueDiligence: verdict }, }); const { cid } = await deps.traceStore.put(built.hash, built.json); ctx.log.info("MAKER_TRACE", { trace: built.hash, cid }); @@ -95,7 +119,34 @@ export async function makerTick(deps: MakerDeps, ctx: TaskContext): Promise>; try { - result = await ctx.submit(() => deps.adapter.agentCreateMarket(args), `create:${questionHashHex}`); + const submission = await ctx.submit(async () => { + const observedVerdict = deps.validateProposal + ? await deps.validateProposal(signal, decision.question!) + : unavailableDueDiligence(id); + const finalVerdict = observedVerdict.stateFingerprint === verdict.stateFingerprint + ? observedVerdict + : rejectChangedState(observedVerdict); + if (!isDueDiligenceApproved(finalVerdict, id, ctx.now())) { + return { kind: "rejected" as const, verdict: finalVerdict }; + } + return { + kind: "submitted" as const, + result: await deps.adapter.agentCreateMarket(args), + }; + }, `create:${questionHashHex}`); + if (submission.kind === "rejected") { + const rejected = buildTrace({ + agent: "market-maker", + marketId: null, + action: "skip", + reasoning: `market proposal rejected by final due-diligence recheck: ${submission.verdict.reasons.map((reason) => reason.code).join(", ")}`, + inputs: { signal, decision, dueDiligence: submission.verdict }, + }); + await deps.traceStore.put(rejected.hash, rejected.json); + ctx.log.warn("MAKER_DUE_DILIGENCE_REJECTED", { signal: id, verdict: submission.verdict, trace: rejected.hash }); + continue; + } + result = submission.result; } catch (err) { // Left as "intent": the create may or may not have landed. It is never // retried blindly; an operator (or the tx journal) reconciles it. diff --git a/oracledesk-stellar/agents/runtime/tasks/trader.ts b/oracledesk-stellar/agents/runtime/tasks/trader.ts index f629323..9e91f72 100644 --- a/oracledesk-stellar/agents/runtime/tasks/trader.ts +++ b/oracledesk-stellar/agents/runtime/tasks/trader.ts @@ -10,6 +10,12 @@ import { BPS } from "../../core/fpmm.js"; import { decideTrade, type MarketState } from "../../core/strategy.js"; import { buildTrace, type TraceStore } from "../../core/trace.js"; +import { + isDueDiligenceApproved, + rejectChangedState, + unavailableDueDiligence, + type DueDiligenceVerdict, +} from "../../core/due-diligence.js"; import type { StellarAdapter } from "../../stellar/adapter.js"; import type { Task, TaskContext } from "../runtime.js"; import { publishPendingTraces, recordPendingTrace } from "./traces.js"; @@ -36,11 +42,23 @@ export interface TraderDeps { deadlineSecs: number; /** Unix seconds. */ nowSecs: () => number; + /** + * Must re-read and verify current on-chain state, metadata, commitments, + * resolution viability, and applicable trust policy. Missing verifier + * configuration rejects trades. + */ + dueDiligence?: (marketId: bigint) => Promise; } export const TRADES = "trader.trades"; export const EVALUATED = "trader.markets"; +async function verifyTrade(deps: TraderDeps, marketId: bigint, nowSecs: () => number): Promise { + return deps.dueDiligence + ? deps.dueDiligence(marketId) + : unavailableDueDiligence(marketId, nowSecs() * 1000); +} + /** The pre-chain-state demo pair (YES at 40% vs fair 60%). */ export async function fixtureCandidates(nowSecs: number): Promise { return [{ market: { marketId: 0, question: "demo", yesBps: 4000, closeTime: nowSecs + 3600 }, fairValueBps: 6000 }]; @@ -65,16 +83,6 @@ export async function traderTick(deps: TraderDeps, ctx: TaskContext): Promise reason.code).join(", ")}`, + inputs: { market, fairValueBps, decision, dueDiligence: verdict }, + }); + const { cid } = await deps.traceStore.put(built.hash, built.json); + ctx.log.info("TRADER_TRACE", { trace: built.hash, cid }); + + if (!approved) { + if (live) { + recordPendingTrace(ctx, { + marketId: BigInt(market.marketId), + action: "skip", + traceHash: built.hash, + cid, + }); + await publishPendingTraces(deps.adapter, ctx); + } + return; + } + const key = `${market.marketId}:${built.hash}`; if (live) ctx.state.set(TRADES, key, { status: "intent", at: ctx.now() }); - const result = await ctx.submit( - () => - deps.adapter.agentBuyV2({ + const submission = await ctx.submit(async () => { + const observedVerdict = await verifyTrade(deps, BigInt(market.marketId), deps.nowSecs); + const finalVerdict = observedVerdict.stateFingerprint === verdict.stateFingerprint + ? observedVerdict + : rejectChangedState(observedVerdict); + if (!isDueDiligenceApproved(finalVerdict, String(market.marketId), ctx.now())) { + return { kind: "rejected" as const, verdict: finalVerdict }; + } + return { + kind: "submitted" as const, + result: await deps.adapter.agentBuyV2({ market_id: BigInt(market.marketId), outcome, collateral_in: collateralIn, @@ -96,8 +140,30 @@ export async function traderTick(deps: TraderDeps, ctx: TaskContext): Promise reason.code).join(", ")}`, + inputs: { market, fairValueBps, decision, dueDiligence: submission.verdict }, + }); + const rejectedTraceStore = await deps.traceStore.put(rejectedTrace.hash, rejectedTrace.json); + ctx.log.warn("TRADER_DUE_DILIGENCE_REJECTED", { market: market.marketId, verdict: submission.verdict }); + if (live) { + recordPendingTrace(ctx, { + marketId: BigInt(market.marketId), + action: "skip", + traceHash: rejectedTrace.hash, + cid: rejectedTraceStore.cid, + }); + await publishPendingTraces(deps.adapter, ctx); + } + return; + } + const result = submission.result; ctx.log.info("TRADER_BUY", { result }); if (result.dryRun) return; ctx.state.set(TRADES, key, { status: "done", shares: result.value, at: ctx.now() }); diff --git a/oracledesk-stellar/docs/agent-readiness.md b/oracledesk-stellar/docs/agent-readiness.md new file mode 100644 index 0000000..4556838 --- /dev/null +++ b/oracledesk-stellar/docs/agent-readiness.md @@ -0,0 +1,31 @@ +# Agent safety and readiness limits + +The trader task now requires an injected due-diligence verifier and rejects +trades when one is not configured. The market-maker similarly requires an +injected proposal verifier before seeding treasury capital. Both defaults +are intentionally fail-closed: the current agent interfaces do not provide +enough verified information to approve an arbitrary market. + +The remaining integration blockers are concrete: + +- The generated market-core client can read `Market`, but the resolver client + exposes no view for the committed price/signers specification. Its public + `state` view is not the specification itself. A caller cannot verify the + commitment or determine all resolution paths from that state alone. +- The resolver's Reflector client reads one price at the market close time. + There is no agent-side historical-price source or documented sampling + cadence from which to build and validate a volatility series. +- The maker currently stores metadata using `local://` in its runtime task; + no metadata retrieval/validation interface is wired into the trader. +- No maintained oracle allowlist or signer-set trust policy is configured. + A contract address or a committed signer list is not evidence of trust. +- The adapter's current `dry-run` path returns a description without RPC + simulation. The locally available checkout has no installed SDK declaration + files to establish a safe `simulate()` result type; dependency installation + and runtime validation were explicitly out of scope. + +Consequently, this change does not claim that market due diligence, RPC-backed +simulation, observability/audit export, historical fair-value estimation, or +market-maker integration is complete. A production verifier must be wired to +fresh chain reads, metadata retrieval, resolver configuration, and explicit +trust configuration before trader submissions can be enabled.