Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions oracledesk-stellar/agents/core/due-diligence.ts
Original file line number Diff line number Diff line change
@@ -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.",
},
],
};
}
57 changes: 54 additions & 3 deletions oracledesk-stellar/agents/runtime/tasks/maker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,6 +28,8 @@ export interface MakerDeps {
signals: SignalSource;
/** Question hashes that already have a market on-chain. */
knownQuestionHashes?: () => Promise<Set<string>>;
/** Verifies the proposed metadata, commitment, resolution path, and trust policy. */
validateProposal?: (signal: MarketSignal, question: string) => Promise<DueDiligenceVerdict>;
/**
* Reveals the market's resolution spec. Until one is wired in (see the
* commit/reveal issue), created markets stay "created" and the trader
Expand Down Expand Up @@ -66,12 +74,28 @@ export async function makerTick(deps: MakerDeps, ctx: TaskContext): Promise<void
continue;
}

const verdict = deps.validateProposal
? await deps.validateProposal(signal, decision.question!)
: unavailableDueDiligence(id);
if (!isDueDiligenceApproved(verdict, id, ctx.now())) {
const rejected = buildTrace({
agent: "market-maker",
marketId: null,
action: "skip",
reasoning: `market proposal rejected by due diligence: ${verdict.reasons.map((reason) => 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 });
Expand All @@ -95,7 +119,34 @@ export async function makerTick(deps: MakerDeps, ctx: TaskContext): Promise<void
}
let result: Awaited<ReturnType<MakerDeps["adapter"]["agentCreateMarket"]>>;
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.
Expand Down
96 changes: 81 additions & 15 deletions oracledesk-stellar/agents/runtime/tasks/trader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<DueDiligenceVerdict>;
}

export const TRADES = "trader.trades";
export const EVALUATED = "trader.markets";

async function verifyTrade(deps: TraderDeps, marketId: bigint, nowSecs: () => number): Promise<DueDiligenceVerdict> {
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<TraderCandidate[]> {
return [{ market: { marketId: 0, question: "demo", yesBps: 4000, closeTime: nowSecs + 3600 }, fairValueBps: 6000 }];
Expand All @@ -65,16 +83,6 @@ export async function traderTick(deps: TraderDeps, ctx: TaskContext): Promise<vo
ctx.log.info("TRADER_DECISION", { market: market.marketId, action: decision.action, reasoning: decision.reasoning });
if (decision.action === "skip") return;

const built = buildTrace({
agent: "trader",
marketId: market.marketId,
action: "buy",
reasoning: decision.reasoning,
inputs: { market, fairValueBps, decision },
});
const { cid } = await deps.traceStore.put(built.hash, built.json);
ctx.log.info("TRADER_TRACE", { trace: built.hash, cid });

const outcome = { tag: decision.outcome!, values: undefined as void };
const collateralIn = decision.collateralInStroops!;
const quoted = await deps.adapter.quoteBuy({ market_id: BigInt(market.marketId), outcome, collateral_in: collateralIn });
Expand All @@ -84,20 +92,78 @@ export async function traderTick(deps: TraderDeps, ctx: TaskContext): Promise<vo
ctx.log.info("TRADER_QUOTE", { quoted, minSharesOut, limitPriceBps, deadline });
if (ctx.signal.aborted) return;

const verdict = await verifyTrade(deps, BigInt(market.marketId), deps.nowSecs);
const approved = isDueDiligenceApproved(verdict, String(market.marketId), ctx.now());
ctx.log.info("TRADER_DUE_DILIGENCE", { market: market.marketId, verdict });
const built = buildTrace({
agent: "trader",
marketId: market.marketId,
action: approved ? "buy" : "skip",
reasoning: approved
? decision.reasoning
: `trade rejected by due diligence: ${verdict.reasons.map((reason) => 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,
min_shares_out: minSharesOut,
limit_price_bps: limitPriceBps,
deadline,
}),
`buy:${key}`,
);
};
}, `buy:${key}`);
if (submission.kind === "rejected") {
const rejectedTrace = buildTrace({
agent: "trader",
marketId: market.marketId,
action: "skip",
reasoning: `trade rejected by final due-diligence recheck: ${submission.verdict.reasons.map((reason) => 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() });
Expand Down
31 changes: 31 additions & 0 deletions oracledesk-stellar/docs/agent-readiness.md
Original file line number Diff line number Diff line change
@@ -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.