diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c08ac34..e9ccb57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,3 +149,11 @@ jobs: npm install npm run build npm test + + # Fake-RPC fault injection against the real live adapter (#106). Uses + # the wasm downloaded above when it fits the adapter, else the vendored + # copies; deterministic (seeded faults, fake ledger clock), no network. + - name: agents — chaos tests + run: | + cd agents + npm run test:chaos diff --git a/oracledesk-stellar/.env.example b/oracledesk-stellar/.env.example index 786a131..8612bfd 100644 --- a/oracledesk-stellar/.env.example +++ b/oracledesk-stellar/.env.example @@ -2,8 +2,23 @@ # Testnet only — nothing here should ever point at mainnet or hold real funds. # --- Stellar network ----------------------------------------------------- +# The agents validate everything below at startup (agents/core/config.ts) and +# report every problem at once. `npm run trader -- --print-config` prints the +# resolved config with secrets redacted and exits without network access. +# +# STELLAR_NETWORK: testnet (default when unset) | futurenet | local | mainnet. +# STELLAR_NETWORK_PASSPHRASE is optional; if set it must equal the canonical +# passphrase for STELLAR_NETWORK or startup fails: +# testnet "Test SDF Network ; September 2015" +# futurenet "Test SDF Future Network ; October 2022" +# local "Standalone Network ; February 2017" +# mainnet "Public Global Stellar Network ; September 2015" +# STELLAR_RPC_URL defaults per network (testnet https://soroban-testnet.stellar.org, +# futurenet https://rpc-futurenet.stellar.org, local http://localhost:8000/soroban/rpc). +# Mainnet has no default and must be set explicitly. STELLAR_NETWORK=testnet STELLAR_RPC_URL=https://soroban-testnet.stellar.org +# STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 # Portion of each trading fee kept by the protocol treasury (bps), set on # market-core at deploy time. The remainder accrues to the market creator @@ -32,10 +47,18 @@ AGENT_IDENTITY=oracledesk-agent # TRADER_MAX_DAILY=5000000000 # --- Deployed contract addresses ------------------------------------------ -# Populated by scripts/deploy-testnet.sh into deployments/.json -# (testnet by default, local when NETWORK=local). Only set these to override. -# DEPLOYMENTS_PATH selects a different record for the agents: -# DEPLOYMENTS_PATH=deployments/local.json +# Populated by scripts/deploy-testnet.sh into deployments/.json. +# The agents read deployments/.json unless DEPLOYMENTS_FILE +# (or its older alias DEPLOYMENTS_PATH; if both are set they must match) names +# another record. The record is schema-checked: deployer, agent (and optional +# agents.maker / agents.trader) must be G... keys, and contracts.usdc, +# market_core, resolver, treasury and reasoning_registry must all be present +# C... ids, or the record is reported as stale. A record without a "network" +# field is treated as testnet; one with it must match STELLAR_NETWORK. +# DEPLOYMENTS_FILE=deployments/local.json +# +# Each *_CONTRACT_ID below, when set to a C... id, replaces that contract in +# the record after validation, and startup logs the override. Blank = unset. # MARKET_CORE_CONTRACT_ID= # RESOLVER_CONTRACT_ID= # TREASURY_CONTRACT_ID= @@ -61,29 +84,87 @@ TESTNET_FACILITATOR_API_KEY= # to use here; none is hardcoded anywhere in this repo. --> # --- Agents (agents/) ------------------------------------------------------- -# "dry-run" (default, never signs or submits) or "live" (real testnet txs). +# Numbers are parsed strictly: base-10 integers only. A variable that is set +# but empty, fractional, negative, NaN, hex or out of range fails startup; +# only an unset variable takes its default. +# +# AGENT_MODE: "dry-run" (default only when unset; never signs or submits) or +# "live" (real transactions). Any other value, including "", is rejected. AGENT_MODE=dry-run # Signing keys (S...). Each CLI prefers its own role key and falls back to -# the shared AGENT_SECRET_KEY (the constructor agent, which has every role): -# bin/market-maker.ts reads MAKER_SECRET_KEY, bin/trader.ts TRADER_SECRET_KEY. -# Treasury calls are made as that key's address. +# the shared AGENT_SECRET_KEY: bin/market-maker.ts uses MAKER_SECRET_KEY, +# bin/trader.ts TRADER_SECRET_KEY. Live mode requires one, and its public key +# must equal the role's address in the deployment record (agents.maker / +# agents.trader, else agent). Dry-run never passes the key to the adapter. +# Blank = unset; a non-blank value must be a valid S... key in either mode. AGENT_SECRET_KEY= MAKER_SECRET_KEY= TRADER_SECRET_KEY= -# DEPLOYMENTS_PATH=deployments/testnet.json -# Local E2E / live-trader overrides (see scripts/e2e-local.sh): -# TRADER_MARKET_ID=0 -# TRADER_MARKET_YES_BPS=4000 -# TRADER_FAIR_VALUE_BPS=6000 -# Optional: an off-chain reference price source for the trader's fair-value -# estimate. Read-only; never used for execution (see docs/architecture.md). -POLYMARKET_READ_ONLY_API_URL= -# Execution bounds for real trades (agents/bin/trader.ts). The trader quotes -# first, then sends min_shares_out / limit_price_bps derived from the quote: -# SLIPPAGE_BPS is how far below the quoted average price the fill may land, -# DEADLINE_SECS is how long the signed trade stays valid after submission. +# Startup probes check that STELLAR_RPC_URL serves STELLAR_NETWORK's +# passphrase and that every contract in the record exists and is not +# archived. Mandatory in live mode (failure stops startup); best effort in +# dry-run (failures are warnings). `--offline` skips them, in dry-run only. +# Timeout per probe, 1-600000 ms: +AGENT_STARTUP_PROBE_TIMEOUT_MS=5000 +# Trader. MIN_EDGE_BPS: 0-10000 (default 500). TRADE_SIZE_STROOPS: 1 up to +# the i128 maximum (default 100000000 = 10 USDC). FAIR_VALUE_BPS: 0-10000; +# unset = each market's own price (no external oracle is wired in yet). +# MIN_EDGE_BPS=500 +# TRADE_SIZE_STROOPS=100000000 +# FAIR_VALUE_BPS= +# Execution bounds for real trades. The trader quotes first, then sends +# min_shares_out / limit_price_bps derived from the quote: SLIPPAGE_BPS +# (0-10000) is how far below the quoted average price the fill may land, +# DEADLINE_SECS (1-86400) is how long the signed trade stays valid. SLIPPAGE_BPS=50 DEADLINE_SECS=60 +# Market maker. CONFIDENCE_THRESHOLD_BPS: 0-10000 (default 1000). +# DEFAULT_SEED_STROOPS: at least 1000000000 (100 USDC; default 1500000000). +# MARKET_CLOSE_IN_SECS: 1 to one year (default 3600). +# CONFIDENCE_THRESHOLD_BPS=1000 +# DEFAULT_SEED_STROOPS=1500000000 +# MARKET_CLOSE_IN_SECS=3600 +# Where reasoning traces are written (non-empty path, default ./.traces). +# TRACE_STORE_DIR=./.traces +# +# Event triggers for `npm run trader -- --follow` (#105). The trader polls +# contract events and re-evaluates only the markets they point at. +# EVENTS_STATE_DIR: checkpoints + dead-letter.jsonl, one checkpoint file +# per network and contract set (default ./.events, gitignored). +# EVENTS_TRADE_THRESHOLD: a trade of at least this many stroops queues its +# market for re-evaluation (default 500000000 = 50 USDC). +# EVENTS_POLL_INTERVAL_MS: 100-3600000 (default 10000). +# EVENTS_PAGE_LIMIT: events per getEvents page, 1-10000 (default 100). +# EVENTS_CONCURRENCY: markets handled in parallel, 1-64 (default 4). +# EVENTS_MAX_ATTEMPTS: tries per handler before dead-lettering, 1-100 (default 5). +# EVENTS_DISPUTE_WINDOW_SECS: dispute window used for proposal alerts (default 600). +# EVENTS_STATE_DIR=./.events +# EVENTS_TRADE_THRESHOLD=500000000 +# +# Cross-market arbitrage (#108), run by the trader when ARB_ENABLED=1 (or +# true). See docs/architecture.md, "Cross-market arbitrage". Dry-run only +# plans and logs; live trades both legs sequentially. +# ARB_RELATIONS_FILE: required when enabled; JSON list of relations. +# ARB_JOURNAL_FILE: crash-recovery journal (default ./.arb/journal.jsonl). +# ARB_MAX_NOTIONAL: most one plan may spend across both legs, stroops (default 1000000000). +# ARB_MIN_PROFIT_STROOPS: smallest guaranteed profit worth trading (default 1000000). +# ARB_MIN_PROFIT_BPS: smallest profit as a share of cost, 0-10000 (default 50). +# ARB_MAX_UNHEDGED_STROOPS: cap on the first leg, left naked if the second fails (default 500000000). +# ARB_MARKET_HEADROOM_BPS: share of each market's max_market kept free, 0-9999 (default 2000). +# ARB_MAX_LEG_LOSS: how much more the second leg may cost than planned before unwinding (default 10000000). +# ARB_MAX_OPEN_LEGS: arbitrage legs in flight at once, 2-64 (default 2). +# ARB_MAX_UNWIND_RETRIES: attempts to sell back a stranded first leg, 1-20 (default 3). +# ARB_ENABLED=0 +# ARB_RELATIONS_FILE=./arb-relations.json +# +# Mainnet interlock. The agents refuse STELLAR_NETWORK=mainnet unless ALL of: +# - STELLAR_NETWORK_PASSPHRASE is the public network passphrase (or unset), +# - STELLAR_RPC_URL is set explicitly, +# - the deployment file is not testnet.json, +# - the deployment record declares "network": "mainnet", +# - AGENT_ALLOW_MAINNET is exactly I_ACCEPT_REAL_FUNDS_ +# Every unmet condition is reported. Leave this unset on testnet. +# AGENT_ALLOW_MAINNET= # x402: strict startup by default; false drops unavailable networks from accepts. X402_REQUIRE_ALL_NETWORKS=true diff --git a/oracledesk-stellar/.gitignore b/oracledesk-stellar/.gitignore index 3e64af5..2209f34 100644 --- a/oracledesk-stellar/.gitignore +++ b/oracledesk-stellar/.gitignore @@ -24,6 +24,11 @@ yarn-error.log* .env.* !.env.example +# Agent event-trigger checkpoints and dead letters (EVENTS_STATE_DIR, #105) +.events/ +# Arbitrage execution journal (ARB_JOURNAL_FILE, #108) +.arb/ + # Stellar / Soroban CLI local state (funded testnet identities, RPC caches) .soroban/ .stellar/ diff --git a/oracledesk-stellar/agents/bin/agentd.ts b/oracledesk-stellar/agents/bin/agentd.ts index c1728a2..46fadf5 100644 --- a/oracledesk-stellar/agents/bin/agentd.ts +++ b/oracledesk-stellar/agents/bin/agentd.ts @@ -23,7 +23,8 @@ import { Runtime, type Task } from "../runtime/runtime.js"; import type { StateStore } from "../runtime/state.js"; import { makerTask } from "../runtime/tasks/maker.js"; import { traderTask } from "../runtime/tasks/trader.js"; -import { buildAdapter, createLease, createStateStore, loadDeployments, makerDeps, runtimeOptions, traderDeps } from "../runtime/wiring.js"; +import { loadAgentConfig } from "../core/config.js"; +import { buildAdapter, createLease, createStateStore, makerDeps, runtimeOptions, traderDeps } from "../runtime/wiring.js"; export interface AgentdArgs { once: boolean; @@ -46,7 +47,7 @@ export function parseArgs(argv: string[]): AgentdArgs { export interface AgentdOverrides { /** Task factories by name (tests inject fakes; default: maker, trader). */ - taskFactories?: Record Task>; + taskFactories?: Record; state?: StateStore; lease?: Lease; log?: Logger; @@ -54,20 +55,27 @@ export interface AgentdOverrides { handleSignals?: boolean; } -function defaultFactories(): Record Task> { +type TaskFactory = (env: NodeJS.ProcessEnv) => Task | Promise; + +/** Each real task validates its own role's config (core/config.ts), including + * the network probes and mainnet interlock; `scope` records the treasury the + * state store is keyed by. */ +function defaultFactories(scope: { treasury?: string }): Record { return { - maker: (env) => { - const deployments = loadDeployments(); - const adapter = buildAdapter("maker", parseAgentMode(env), env, deployments); - return makerTask(makerDeps(env, adapter, deployments), { + maker: async (env) => { + const cfg = await loadAgentConfig({ role: "maker", env, argv: [] }); + scope.treasury = cfg.deployments.contracts.treasury; + const adapter = buildAdapter(cfg); + return makerTask(makerDeps(cfg, adapter, cfg.deployments), { intervalMs: intEnv(env, "MAKER_INTERVAL_MS", 5 * 60_000), jitterMs: intEnv(env, "MAKER_JITTER_MS", 15_000), }); }, - trader: (env) => { - const deployments = loadDeployments(); - const adapter = buildAdapter("trader", parseAgentMode(env), env, deployments); - return traderTask(traderDeps(env, adapter, deployments, () => Math.floor(Date.now() / 1000)), { + trader: async (env) => { + const cfg = await loadAgentConfig({ role: "trader", env, argv: [] }); + scope.treasury = cfg.deployments.contracts.treasury; + const adapter = buildAdapter(cfg); + return traderTask(traderDeps(cfg, adapter, cfg.deployments, () => Math.floor(Date.now() / 1000)), { intervalMs: intEnv(env, "TRADER_INTERVAL_MS", 60_000), jitterMs: intEnv(env, "TRADER_JITTER_MS", 5_000), }); @@ -85,13 +93,16 @@ export async function runAgentd(argv: string[], baseEnv: NodeJS.ProcessEnv, o: A const overrides = args.config ? (JSON.parse(readFileSync(args.config, "utf8")) as Record) : {}; const env: NodeJS.ProcessEnv = { ...baseEnv, ...Object.fromEntries(Object.entries(overrides).map(([k, v]) => [k, String(v)])) }; const mode = parseAgentMode(env); - const factories = o.taskFactories ?? defaultFactories(); - const tasks = args.tasks.map((name) => { - const make = factories[name]; - if (!make) throw new ConfigError(`unknown task ${name} (known: ${Object.keys(factories).join(", ")})`); - return make(env); - }); - const scope = o.taskFactories ? "agentd" : loadDeployments().contracts.treasury; + const real: { treasury?: string } = {}; + const factories = o.taskFactories ?? defaultFactories(real); + const tasks = await Promise.all( + args.tasks.map((name) => { + const make = factories[name]; + if (!make) throw new ConfigError(`unknown task ${name} (known: ${Object.keys(factories).join(", ")})`); + return make(env); + }), + ); + const scope = o.taskFactories ? "agentd" : real.treasury ?? "agentd"; runtime = new Runtime({ tasks, state: o.state ?? (await createStateStore(mode, env, scope)), diff --git a/oracledesk-stellar/agents/bin/market-maker.ts b/oracledesk-stellar/agents/bin/market-maker.ts index 1f162d3..f3c68e6 100644 --- a/oracledesk-stellar/agents/bin/market-maker.ts +++ b/oracledesk-stellar/agents/bin/market-maker.ts @@ -10,302 +10,36 @@ * exactly once under the single-instance lease; `npm run agentd` runs the * same task continuously. Defaults to dry-run. Set AGENT_MODE=live and * MAKER_SECRET_KEY (or the shared AGENT_SECRET_KEY) to submit real testnet - * transactions — see .env.example. + * transactions — see .env.example. Configuration is validated by + * core/config.ts first; `--print-config` prints it (secret redacted) and + * exits, `--offline` skips the network probes in dry-run. */ import "dotenv/config"; -import { EXIT_CONFIG, EXIT_TEMPFAIL, parseAgentMode } from "../runtime/config.js"; +import { describeConfig, loadAgentConfig } from "../core/config.js"; +import { ConfigError, EXIT_CONFIG, EXIT_TEMPFAIL } from "../runtime/config.js"; import { LeaseHeldError } from "../runtime/lease.js"; import { Runtime } from "../runtime/runtime.js"; import { makerTask } from "../runtime/tasks/maker.js"; -import { buildAdapter, createLease, createStateStore, loadDeployments, makerDeps, runtimeOptions } from "../runtime/wiring.js"; - * Market Maker agent: polls signal sources, decides whether a signal is - * confident enough to seed a market, builds and records a reasoning trace, - * and (in "live" mode) creates the market through the treasury. - * Skips markets whose question_hash already exists on-chain or in the spec - * store. - * - * Every market commits to a real resolution spec (docs/adr/0001, "Agent flow - * and recovery"): the spec is built from MM_* config, hashed with - * scripts/spec-hash (or the TS library), durably stored in AGENT_SPEC_DIR - * BEFORE agent_create_market is submitted, and revealed right after. A - * recovery pass at startup and after every create reveals anything a crash - * or RPC failure left behind. - * - * Defaults to dry-run. Set AGENT_MODE=live and MAKER_SECRET_KEY (or the - * shared AGENT_SECRET_KEY) to submit real testnet transactions — see - * .env.example. Pass --verify-spec-hash to cross-check the TS spec library - * against the Rust CLI. - */ -import "dotenv/config"; -import { createHash } from "node:crypto"; -import { buildTrace, canonicalize } from "../core/trace.js"; -import { createTraceStore } from "../core/trace-store-factory.js"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { buildTrace, LocalTraceStore } from "../core/trace.js"; -import { createHash } from "node:crypto"; -import { buildTrace, LocalTraceStore, NO_MARKET } from "../core/trace.js"; -import { decideMarketProposal, type MarketSignal, type SignalSource } from "../core/strategy.js"; -import { agentCredentials, loadDeployments, StellarAdapter } from "../stellar/adapter.js"; -import { ChainStateReader } from "../core/state/reader.js"; -import { SnapshotCache } from "../core/state/state_cache.js"; -import { existingQuestionHashes } from "../core/state/select.js"; -import { buildSpec, questionHashOf } from "../core/spec.js"; -import { createSpecHasher } from "../core/spec-hasher.js"; -import { - FileSpecStore, - MemorySpecStore, - recoverSpecs, - type RecoveryChain, - type SpecStore, -} from "../core/spec-store.js"; -import { createMarketWithSpec } from "../core/maker-flow.js"; -import { Server } from "@stellar/stellar-sdk/rpc"; - -const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); - -/** Placeholder signal source until a real one (news, an oracle feed, a - * comparable market) is wired in — see docs/wave-issues for that as an - * open issue. Deterministic so dry-runs are reproducible. */ -const demoSignalSource: SignalSource = { - name: "demo-fixture", - async poll(): Promise { - return [ - { - summary: "Fixture signal for dry-run demonstration", - proposedQuestion: "Will the OracleDesk Stellar port pass its testnet demo?", - fairValueBps: 7500, - source: "demo-fixture", - observedAt: Math.floor(Date.now() / 1000), - }, - ]; - }, -}; - -function recoveryChainFor(adapter: StellarAdapter): RecoveryChain { - return { - marketCount: () => adapter.marketCount(), - getMarket: (id) => adapter.getMarket(id), - isRevealed: (id) => adapter.isSpecRevealed(id), - reveal: async (id, spec) => { - await adapter.revealSpec(id, spec); - }, - }; -} +import { buildAdapter, createLease, createStateStore, makerDeps, runtimeOptions } from "../runtime/wiring.js"; async function main() { - const mode = parseAgentMode(process.env); - const deployments = loadDeployments(); - const adapter = buildAdapter("maker", mode, process.env, deployments); - console.log(`[market-maker] mode=${mode}`); + const cfg = await loadAgentConfig({ role: "maker" }); + if (cfg.printConfig) { + console.log(JSON.stringify(describeConfig(cfg), null, 2)); + return; + } + const adapter = buildAdapter(cfg); + console.log(`[market-maker] mode=${cfg.mode} network=${cfg.network}`); const runtime = new Runtime({ - tasks: [makerTask(makerDeps(process.env, adapter, deployments), { intervalMs: 0 })], - state: await createStateStore(mode, process.env, deployments.contracts.treasury), - lease: createLease(process.env), - ...runtimeOptions(process.env), + tasks: [makerTask(makerDeps(cfg, adapter, cfg.deployments), { intervalMs: 0 })], + state: await createStateStore(cfg.mode, cfg.env, cfg.deployments.contracts.treasury), + lease: createLease(cfg.env), + ...runtimeOptions(cfg.env), }); await runtime.runOnce(); - const adapter = new StellarAdapter({ - mode, - networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015", - rpcUrl: process.env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org", - ...agentCredentials("maker", process.env, deployments), - deployments, - }); - const { store: traceStore, kind: traceStoreKind } = createTraceStore(process.env, mode); - const traceStore = new LocalTraceStore(process.env.TRACE_STORE_DIR ?? "./.traces"); - // Dry-run never creates a market, so it must not leave pending specs - // behind for a later live run to chase. - const specStore: SpecStore = - mode === "live" - ? new FileSpecStore(process.env.AGENT_SPEC_DIR ?? "./.specs", { backupCmd: process.env.SPEC_BACKUP_CMD }) - : new MemorySpecStore(); - const hasher = await createSpecHasher({ - manifestPath: process.env.SPEC_HASH_MANIFEST ?? join(REPO_ROOT, "scripts", "spec-hash", "Cargo.toml"), - bin: process.env.SPEC_HASH_BIN || undefined, - verify: process.argv.includes("--verify-spec-hash") || process.env.MM_VERIFY_SPEC_HASH === "1", - }); - const chain = recoveryChainFor(adapter); - const recover = async () => { - if (mode !== "live") return; - const report = await recoverSpecs({ - store: specStore, - chain, - creator: deployments.contracts.treasury, - pendingTtlSecs: Number(process.env.MM_PENDING_TTL_SECS ?? 900), - maxScan: Number(process.env.MM_RECOVERY_MAX_SCAN ?? 200), - staleRevealSecs: Number(process.env.MM_STALE_REVEAL_SECS ?? 600), - }); - console.log("[market-maker] spec recovery:", report); - }; - - await recover(); - - // Load chain state to check for duplicate question hashes - const rpcUrl = process.env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org"; - const server = new Server(rpcUrl); - const stateReader = new ChainStateReader({ - marketContractId: deployments.contracts.market_core, - portfolioContractId: deployments.contracts.treasury, - server, - }); - const cache = new SnapshotCache(); - const snapshot = await cache.get(() => stateReader.readSnapshot([])); - const knownHashes = existingQuestionHashes(snapshot); - for (const s of await specStore.list()) if (s.state !== "abandoned") knownHashes.add(s.questionHash); - - console.log(`[market-maker] mode=${mode}, known markets=${knownHashes.size}`); - - const closeInSecs = Number(process.env.MM_CLOSE_IN_SECS ?? 3600); - const signals = await demoSignalSource.poll(); - for (const signal of signals) { - const decision = decideMarketProposal(signal, { - confidenceThresholdBps: Number(process.env.CONFIDENCE_THRESHOLD_BPS ?? 1000), - defaultSeedStroops: BigInt(process.env.DEFAULT_SEED_STROOPS ?? "1500000000"), - }); - console.log(`[market-maker] ${decision.action}: ${decision.reasoning}`); - if (decision.action === "skip") continue; - - const questionHash = questionHashOf(decision.question!); - const questionHashHex = questionHash.toString("hex"); - - // Skip if this question already has a market on-chain - if (knownHashes.has(questionHashHex)) { - console.log(`[market-maker] skipping duplicate question_hash=${questionHashHex}`); - continue; - } - - const built = buildTrace({ - agent: "market-maker", - marketId: null, - action: "propose_market", - reasoning: decision.reasoning, - inputs: { signal, decision }, - }); - const closeTime = BigInt(Math.floor(Date.now() / 1000) + 3600); - - // The trace (and, with the IPFS store, the market metadata) must be - // pinned and verified before the create: never act without a - // publishable trace. - let cid: string; - let metaUri: string; - try { - ({ cid } = await traceStore.put(built.hash, built.json)); - if (traceStoreKind === "ipfs") { - const metaJson = canonicalize({ - question: decision.question, - category: "Macro", - closeTime, - source: signal.source, - traceHash: built.hash, - traceCid: cid, - }); - const metaHash = createHash("sha256").update(metaJson).digest("hex"); - metaUri = `ipfs://${(await traceStore.put(metaHash, metaJson)).cid}`; - } else { - metaUri = `local://${built.hash}`; - } - } catch (err) { - console.error(`[market-maker] skipped: trace ${built.hash} could not be stored:`, err); - continue; - } - console.log(`[market-maker] trace ${built.hash} -> ${cid}, meta_uri=${metaUri}`); - const spec = buildSpec(process.env, deployments.agent, signal.priceRule); - const closeTime = BigInt(Math.floor(Date.now() / 1000) + closeInSecs); - let traceHash = ""; - let traceCid = ""; - - try { - const outcome = await createMarketWithSpec({ - spec, - questionHash, - closeTime, - closeMarginSecs: Number(process.env.MM_CLOSE_MARGIN_SECS ?? 60), - store: specStore, - hasher, - chain, - // Runs only after the spec is durably stored. - create: async (resolutionHash) => { - const built = buildTrace({ - agent: "market-maker", - marketId: null, - action: "propose_market", - reasoning: decision.reasoning, - inputs: { signal, decision, spec, commitment: resolutionHash.toString("hex") }, - }); - const { cid } = await traceStore.put(built.hash, built.json); - traceHash = built.hash; - traceCid = cid; - console.log(`[market-maker] trace ${built.hash} -> ${cid}`); - const metaUri = `local://${built.hash}`; - if (mode === "live") { - console.warn(`[market-maker] WARNING: live market created with non-durable meta_uri ${metaUri}`); - } - return adapter.agentCreateMarket({ - question_hash: questionHash, - resolution_hash: resolutionHash, - meta_uri: metaUri, - category: { tag: "Macro", values: undefined }, - close_time: closeTime, - seed_amount: decision.seedAmountStroops!, - initial_yes_bps: decision.initialYesBps!, - }); - }, - }); - console.log(`[market-maker] commitment=${outcome.commitment} spec=${JSON.stringify(spec)}`); - console.log("[market-maker] agent_create_market:", outcome.create); - knownHashes.add(questionHashHex); - - if (!outcome.create.dryRun) { - console.log(`[market-maker] spec ${outcome.revealed ? "revealed" : "NOT yet revealed"} for market ${outcome.entry.marketId}`); - const traceResult = await adapter.publishTrace({ - agent: adapter.agent, - market_id: outcome.create.value, - action: "create_market", - trace_hash: Buffer.from(traceHash, "hex"), - ipfs_cid: traceCid, - }); - console.log("[market-maker] publish_trace:", traceResult); - } else { - console.log("[market-maker] would reveal:", await adapter.revealSpec(0n, spec)); - } - } finally { - await recover().catch((err) => console.error("[market-maker] spec recovery failed:", err)); - const result = await adapter.agentCreateMarket({ - question_hash: questionHash, - resolution_hash: placeholderResolutionHash, - meta_uri: metaUri, - category: { tag: "Macro", values: undefined }, - close_time: closeTime, - seed_amount: decision.seedAmountStroops!, - initial_yes_bps: decision.initialYesBps!, - }); - console.log("[market-maker] agent_create_market:", result); - - if (!result.dryRun) { - // Record proposal trace prior to creation using NO_MARKET sentinel (#55) - const proposalResult = await adapter.publishTrace({ - agent: adapter.agent, - market_id: NO_MARKET, - action: { tag: "ProposeMarket", values: undefined }, - trace_hash: Buffer.from(built.hash, "hex"), - ipfs_cid: cid, - }); - console.log("[market-maker] publish_trace (propose_market):", proposalResult); - - const traceResult = await adapter.publishTrace({ - agent: adapter.agent, - market_id: result.value, - action: { tag: "CreateMarket", values: undefined }, - trace_hash: Buffer.from(built.hash, "hex"), - ipfs_cid: cid, - }); - console.log("[market-maker] publish_trace (create_market):", traceResult); - } - } } main().catch((err) => { - console.error(err); - process.exit(err instanceof LeaseHeldError ? EXIT_TEMPFAIL : err?.name === "ConfigError" ? EXIT_CONFIG : 1); + console.error(err instanceof ConfigError ? err.message : err); + process.exit(err instanceof LeaseHeldError ? EXIT_TEMPFAIL : err instanceof ConfigError ? EXIT_CONFIG : 1); }); diff --git a/oracledesk-stellar/agents/bin/trader.test.ts b/oracledesk-stellar/agents/bin/trader.test.ts index 6ba6c3f..e2ea39f 100644 --- a/oracledesk-stellar/agents/bin/trader.test.ts +++ b/oracledesk-stellar/agents/bin/trader.test.ts @@ -3,6 +3,7 @@ import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { parseTunables } from "../core/config.js"; import type { StellarAdapter } from "../stellar/adapter.js"; import { main } from "./trader.js"; @@ -26,7 +27,7 @@ function fakeAdapter(quoteShares: bigint, calls: Record[]): Ste test("trader computes non-zero min_shares_out and limit_price_bps from a fresh quote", async () => { const calls: Record[] = []; const env = { TRACE_STORE_DIR: await mkdtemp(join(tmpdir(), "trader-test-")) }; - await main(fakeAdapter(QUOTE_SHARES, calls), env, () => 1_000_000); + await main(fakeAdapter(QUOTE_SHARES, calls), parseTunables(env), () => 1_000_000); assert.equal(calls.length, 1); const args = calls[0]; @@ -48,7 +49,7 @@ test("trader honours SLIPPAGE_BPS and DEADLINE_SECS overrides", async () => { SLIPPAGE_BPS: "100", DEADLINE_SECS: "30", }; - await main(fakeAdapter(QUOTE_SHARES, calls), env, () => 2_000_000); + await main(fakeAdapter(QUOTE_SHARES, calls), parseTunables(env), () => 2_000_000); const args = calls[0]; assert.equal(args.min_shares_out, (QUOTE_SHARES * 9_900n) / 10_000n); diff --git a/oracledesk-stellar/agents/bin/trader.ts b/oracledesk-stellar/agents/bin/trader.ts index ea4361b..b6bb5b2 100644 --- a/oracledesk-stellar/agents/bin/trader.ts +++ b/oracledesk-stellar/agents/bin/trader.ts @@ -14,167 +14,100 @@ * run exactly once under the single-instance lease; `npm run agentd` runs * the same task continuously. Defaults to dry-run. Set AGENT_MODE=live and * TRADER_SECRET_KEY (or the shared AGENT_SECRET_KEY) to submit real testnet - * transactions — see .env.example. + * transactions — see .env.example. Configuration is validated by + * core/config.ts first; `--print-config` prints it (secret redacted) and + * exits, `--offline` skips the network probes in dry-run. `--follow` keeps + * running and reacts to contract events instead (see runtime/follow.ts). */ import "dotenv/config"; import { pathToFileURL } from "node:url"; -import { EXIT_CONFIG, EXIT_TEMPFAIL, parseAgentMode } from "../runtime/config.js"; +import { describeConfig, loadAgentConfig, type AgentConfig, type Tunables } from "../core/config.js"; +import { LocalTraceStore } from "../core/trace.js"; +import { followTask } from "../runtime/follow.js"; +import { arbitrageTask } from "../runtime/tasks/arbitrage.js"; +import { jsonLogger } from "../runtime/log.js"; +import { ConfigError, EXIT_CONFIG, EXIT_TEMPFAIL } from "../runtime/config.js"; import { LeaseHeldError } from "../runtime/lease.js"; import { Runtime } from "../runtime/runtime.js"; import { traderTask } from "../runtime/tasks/trader.js"; -import { buildAdapter, createLease, createStateStore, loadDeployments, runtimeOptions, traderDeps } from "../runtime/wiring.js"; +import { buildAdapter, createLease, createStateStore, runtimeOptions, traderDeps } from "../runtime/wiring.js"; import type { StellarAdapter } from "../stellar/adapter.js"; /** * Exported (and injectable) so tests can drive the whole trade flow with a * fake adapter and clock. `now` returns unix seconds. An adapter bound to a * deployment reads markets from chain state; one without (a test fake) - * trades the demo fixture market. -import { buildTrace, type TraceStore } from "../core/trace.js"; -import { createTraceStore } from "../core/trace-store-factory.js"; -import { BPS } from "../core/fpmm.js"; -import { decideTrade, type MarketState } from "../core/strategy.js"; -import { agentCredentials, loadDeployments, StellarAdapter } from "../stellar/adapter.js"; -import { ChainStateReader } from "../core/state/reader.js"; -import { SnapshotCache } from "../core/state/cache.js"; -import { tradableMarkets, availableCapital } from "../core/state/select.js"; -import { toMarketState } from "../core/state/types.js"; -import { Server } from "@stellar/stellar-sdk/rpc"; - -/** - * Exported (and injectable) so tests can drive the whole trade flow with a - * fake adapter and clock. `now` returns unix seconds. The trace store - * defaults to `createTraceStore(env, adapter.mode)` (TRACE_STORE=local|ipfs). + * trades the demo fixture market. `run` is the validated config (or, in + * tests, `parseTunables(env)`). */ export async function main( adapter: StellarAdapter, - env: NodeJS.ProcessEnv = process.env, + run: Tunables, now: () => number = () => Math.floor(Date.now() / 1000), - traceStore: TraceStore = createTraceStore(env, adapter.mode).store, ): Promise { const deployments = (adapter as Partial).deployments; const runtime = new Runtime({ - tasks: [traderTask(traderDeps(env, adapter, deployments, now), { intervalMs: 0 })], - state: await createStateStore(adapter.mode, env, deployments?.contracts.treasury ?? "unbound"), - lease: createLease(env), - ...runtimeOptions(env), + tasks: [traderTask(traderDeps(run, adapter, deployments, now), { intervalMs: 0 }), ...arbTasks(run, adapter, now, 0)], + state: await createStateStore(adapter.mode, run.env, deployments?.contracts.treasury ?? "unbound"), + lease: createLease(run.env), + ...runtimeOptions(run.env), }); await runtime.runOnce(); - const deployments = adapter.deployments ?? {}; - - const rpcUrl = env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org"; - const server = new Server(rpcUrl); - const stateReader = new ChainStateReader({ - marketContractId: deployments.market_contract ?? "", - portfolioContractId: deployments.portfolio_contract ?? "", - server, - }); - const cache = new SnapshotCache(); - - console.log(`[trader] mode=${adapter.mode}`); - - const snapshot = await cache.get(() => stateReader.readSnapshot([])); - - if (!snapshot.consistent) { - console.warn(`[trader] ledger drift ${snapshot.ledgers.max - snapshot.ledgers.min} — snapshot may be inconsistent`); - } - - const markets = tradableMarkets(snapshot); - if (markets.length === 0) { - console.log("[trader] no tradable markets on-chain"); - return; - } - - const capital = availableCapital(snapshot); - const tradeSizeStroops = BigInt(env.TRADE_SIZE_STROOPS ?? "100000000"); - if (capital < tradeSizeStroops) { - console.log(`[trader] insufficient capital: ${capital} < ${tradeSizeStroops}`); - return; - } - - for (const chainMarket of markets) { - const market: MarketState = toMarketState(chainMarket); - // Fair value: use env override or default to complement of current price - // (the agent holds no external oracle right now — strategy.ts decides edge) - const fairValueBps = Number(env.FAIR_VALUE_BPS ?? String(market.yesBps)); - - const decision = decideTrade(market, fairValueBps, { - minEdgeBps: Number(env.MIN_EDGE_BPS ?? 500), - tradeSizeStroops, - }); - console.log(`[trader] market=${market.marketId} ${decision.action}: ${decision.reasoning}`); - if (decision.action === "skip") continue; - - const built = buildTrace({ - agent: "trader", - marketId: market.marketId, - action: "buy", - reasoning: decision.reasoning, - inputs: { market, fairValueBps, decision }, - }); - // The trace must be pinned and verified before the trade: never trade - // without a publishable trace. - let cid: string; - try { - ({ cid } = await traceStore.put(built.hash, built.json)); - } catch (err) { - console.error(`[trader] market=${market.marketId} skipped: trace ${built.hash} could not be stored:`, err); - continue; - } - console.log(`[trader] trace ${built.hash} -> ${cid}`); - - const outcome = decision.outcome as "Yes" | "No"; - const collateralInStroops = decision.tradeSizeStroops!; - - const quotedShares = await adapter.quoteBuy({ - market_id: BigInt(market.marketId), - outcome, - collateral_in: collateralInStroops, - }); - const slippageBps = BigInt(env.SLIPPAGE_BPS ?? 50); - const minSharesOut = (quotedShares * (BPS - slippageBps)) / BPS; - const limitPriceBps = decision.outcome === "Yes" ? fairValueBps : Number(BPS) - fairValueBps; - const deadline = BigInt(now() + Number(env.DEADLINE_SECS ?? 60)); - console.log( - `[trader] quote ${quotedShares} shares -> min_shares_out=${minSharesOut} limit_price_bps=${limitPriceBps} deadline=${deadline}`, - ); +} - const result = await adapter.agentBuyV2({ - market_id: BigInt(market.marketId), - outcome, - collateral_in: collateralInStroops, - min_shares_out: minSharesOut, - limit_price_bps: limitPriceBps, - deadline, - }); - console.log("[trader] agent_buy_v2:", result); +/** The arbitrage task when ARB_ENABLED (#108); see runtime/tasks/arbitrage.ts. */ +function arbTasks(run: Tunables, adapter: StellarAdapter, now: () => number, intervalMs: number) { + if (!run.arb.enabled) return []; + return [arbitrageTask({ adapter, cfg: run.arb, traceStore: new LocalTraceStore(run.traceStoreDir), nowSecs: now }, { intervalMs })]; +} - if (!result.dryRun) { - const traceResult = await adapter.publishTrace({ - agent: adapter.agent, - market_id: BigInt(market.marketId), - action: "buy", - trace_hash: Buffer.from(built.hash, "hex"), - ipfs_cid: cid, - }); - console.log("[trader] publish_trace:", traceResult); - } - } +/** + * `--follow`: runs until stopped (SIGINT/SIGTERM), re-evaluating only the + * markets that contract events point at (#105). Without the flag, behaviour + * is unchanged: one evaluation of every market, then exit. + */ +export async function follow(cfg: AgentConfig, adapter: StellarAdapter, now: () => number = () => Math.floor(Date.now() / 1000)): Promise { + const log = jsonLogger(); + const task = await followTask({ + cfg, + adapter, + trader: traderDeps(cfg, adapter, cfg.deployments, now), + nowSecs: now, + log: (event, fields) => log.info(event, fields), + }); + const runtime = new Runtime({ + tasks: [task, ...arbTasks(cfg, adapter, now, cfg.events.pollIntervalMs)], + state: await createStateStore(cfg.mode, cfg.env, cfg.deployments.contracts.treasury), + lease: createLease(cfg.env), + log, + ...runtimeOptions(cfg.env), + }); + const stop = (signal: NodeJS.Signals) => void runtime.stop(signal); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + await runtime.start(); + return runtime.exitCode; } const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) { - let mode; - try { - mode = parseAgentMode(process.env); - } catch (err) { - console.error((err as Error).message); - process.exit(EXIT_CONFIG); - } - const deployments = loadDeployments(); - main(buildAdapter("trader", mode, process.env, deployments)).catch((err) => { - console.error(err); - process.exit(err instanceof LeaseHeldError ? EXIT_TEMPFAIL : 1); - }); + loadAgentConfig({ role: "trader" }) + .then((cfg) => { + if (cfg.printConfig) { + console.log(JSON.stringify(describeConfig(cfg), null, 2)); + return; + } + console.log(`[trader] mode=${cfg.mode} network=${cfg.network}${process.argv.includes("--follow") ? " follow" : ""}`); + if (process.argv.includes("--follow")) { + return follow(cfg, buildAdapter(cfg)).then((code) => { + process.exitCode = code; + }); + } + return main(buildAdapter(cfg), cfg); + }) + .catch((err) => { + console.error(err instanceof ConfigError ? err.message : err); + process.exit(err instanceof LeaseHeldError ? EXIT_TEMPFAIL : err instanceof ConfigError ? EXIT_CONFIG : 1); + }); } diff --git a/oracledesk-stellar/agents/core/config.test.ts b/oracledesk-stellar/agents/core/config.test.ts new file mode 100644 index 0000000..f8ba3a7 --- /dev/null +++ b/oracledesk-stellar/agents/core/config.test.ts @@ -0,0 +1,318 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import { + AgentConfigError, + describeConfig, + loadAgentConfig, + MAINNET_OPT_IN, + NETWORKS, + parseTunables, + type ContractsReport, + type LoadAgentConfigOptions, + type NetworkProbe, +} from "./config.js"; + +// Real StrKeys, derived from fixed seeds so every run is identical. +const keypair = (n: number) => Keypair.fromRawEd25519Seed(Buffer.alloc(32, n)); +const contract = (n: number) => StrKey.encodeContract(Buffer.alloc(32, n)); +const AGENT = keypair(1); +const MAKER = keypair(2); +const OTHER = keypair(3); + +const CONTRACTS = { + usdc: contract(10), + market_core: contract(11), + resolver: contract(12), + treasury: contract(13), + reasoning_registry: contract(14), +}; + +function deployment(overrides: Record = {}) { + return { deployer: keypair(9).publicKey(), agent: AGENT.publicKey(), contracts: { ...CONTRACTS }, ...overrides }; +} + +class FakeProbe implements NetworkProbe { + calls = 0; + constructor( + private readonly served: string = NETWORKS.testnet.passphrase, + private readonly report: ContractsReport = { missing: [], archived: [] }, + ) {} + async passphrase(): Promise { + this.calls++; + return this.served; + } + async contractsExist(): Promise { + this.calls++; + return this.report; + } +} + +interface Setup extends Partial { + record?: unknown; +} + +function load(env: NodeJS.ProcessEnv, s: Setup = {}) { + const files: Record = {}; + const logs: string[] = []; + return { + logs, + files, + run: () => + loadAgentConfig({ + role: "trader", + argv: [], + probe: new FakeProbe(), + readFile: (path) => { + files[path] = "read"; + return JSON.stringify(s.record ?? deployment()); + }, + log: (m) => logs.push(m), + ...s, + env, + }), + }; +} + +async function issuesOf(promise: Promise): Promise { + try { + await promise; + } catch (err) { + assert.ok(err instanceof AgentConfigError, `expected AgentConfigError, got ${String(err)}`); + return err.issues; + } + assert.fail("expected the config to be rejected"); +} + +const LIVE = { AGENT_MODE: "live", AGENT_SECRET_KEY: AGENT.secret() }; + +test("an unset AGENT_MODE defaults to dry-run with documented defaults", async () => { + const cfg = await load({}).run(); + assert.equal(cfg.mode, "dry-run"); + assert.equal(cfg.network, "testnet"); + assert.equal(cfg.networkPassphrase, NETWORKS.testnet.passphrase); + assert.equal(cfg.rpcUrl, NETWORKS.testnet.rpcUrl); + assert.equal(cfg.trading.minEdgeBps, 500); + assert.equal(cfg.trading.tradeSizeStroops, 100_000_000n); + assert.equal(cfg.maker.defaultSeedStroops, 1_500_000_000n); + assert.equal(cfg.agentSecret, undefined); + assert.match(cfg.deploymentsFile, /deployments\/testnet\.json$/); +}); + +test("unknown and empty agent modes are rejected", async () => { + for (const AGENT_MODE of ["yolo", "Live", "", " dry-run"]) { + const issues = await issuesOf(load({ AGENT_MODE }).run()); + assert.ok(issues.some((i) => i.startsWith("AGENT_MODE:")), `${JSON.stringify(AGENT_MODE)}: ${issues}`); + } +}); + +test("invalid, non-integer and out-of-range numbers fail, all reported together", async () => { + const issues = await issuesOf( + load({ + MIN_EDGE_BPS: "", + CONFIDENCE_THRESHOLD_BPS: "10001", + SLIPPAGE_BPS: "12.5", + DEADLINE_SECS: "NaN", + FAIR_VALUE_BPS: "-1", + AGENT_STARTUP_PROBE_TIMEOUT_MS: "0x10", + }).run(), + ); + for (const name of ["MIN_EDGE_BPS", "CONFIDENCE_THRESHOLD_BPS", "SLIPPAGE_BPS", "DEADLINE_SECS", "FAIR_VALUE_BPS", "AGENT_STARTUP_PROBE_TIMEOUT_MS"]) { + assert.ok(issues.some((i) => i.startsWith(`${name}:`)), `${name} missing from ${issues.join(" | ")}`); + } +}); + +test("invalid stroop amounts fail", async () => { + const cases: Record[] = [ + { TRADE_SIZE_STROOPS: "0" }, + { TRADE_SIZE_STROOPS: "-5" }, + { TRADE_SIZE_STROOPS: "1.5" }, + { TRADE_SIZE_STROOPS: "1e9" }, + { TRADE_SIZE_STROOPS: (1n << 127n).toString() }, + { DEFAULT_SEED_STROOPS: "999999999" }, + { DEFAULT_SEED_STROOPS: "" }, + ]; + for (const env of cases) { + const [name] = Object.keys(env); + const issues = await issuesOf(load(env).run()); + assert.ok(issues.some((i) => i.startsWith(`${name}:`)), `${JSON.stringify(env)}: ${issues}`); + } + const ok = await load({ TRADE_SIZE_STROOPS: ((1n << 127n) - 1n).toString(), DEFAULT_SEED_STROOPS: "1000000000" }).run(); + assert.equal(ok.maker.defaultSeedStroops, 1_000_000_000n); +}); + +test("live mode requires a secret", async () => { + const issues = await issuesOf(load({ AGENT_MODE: "live" }).run()); + assert.ok(issues.some((i) => /TRADER_SECRET_KEY or AGENT_SECRET_KEY/.test(i)), issues.join(" | ")); +}); + +test("a malformed secret is rejected even in dry-run", async () => { + const issues = await issuesOf(load({ AGENT_SECRET_KEY: "SNOTAKEY" }).run()); + assert.ok(issues.some((i) => i.startsWith("AGENT_SECRET_KEY:"))); +}); + +test("live mode requires the secret to match deployments.agent", async () => { + const issues = await issuesOf(load({ AGENT_MODE: "live", AGENT_SECRET_KEY: OTHER.secret() }).run()); + assert.ok(issues.some((i) => i.includes(OTHER.publicKey()) && i.includes(AGENT.publicKey())), issues.join(" | ")); + + const cfg = await load(LIVE).run(); + assert.equal(cfg.agentAddress, AGENT.publicKey()); + assert.equal(cfg.agentSecret, AGENT.secret()); +}); + +test("a role key must match that role's registered agent", async () => { + const record = deployment({ agents: { maker: MAKER.publicKey(), trader: OTHER.publicKey() } }); + const cfg = await load({ AGENT_MODE: "live", MAKER_SECRET_KEY: MAKER.secret() }, { role: "maker", record }).run(); + assert.equal(cfg.agentAddress, MAKER.publicKey()); + + const issues = await issuesOf(load({ AGENT_MODE: "live", AGENT_SECRET_KEY: AGENT.secret() }, { role: "trader", record }).run()); + assert.ok(issues.some((i) => i.includes("agents.trader")), issues.join(" | ")); +}); + +test("dry-run never carries the secret, even when one is configured", async () => { + const cfg = await load({ AGENT_SECRET_KEY: AGENT.secret() }).run(); + assert.equal(cfg.agentSecret, undefined); +}); + +test("an RPC that serves another network's passphrase prevents live startup", async () => { + const probe = new FakeProbe(NETWORKS.futurenet.passphrase); + const issues = await issuesOf(load(LIVE, { probe }).run()); + assert.ok(issues.some((i) => i.includes(NETWORKS.futurenet.passphrase)), issues.join(" | ")); +}); + +test("in dry-run a failing probe is a warning, not an error", async () => { + const probe: NetworkProbe = { + passphrase: async () => Promise.reject(new Error("connect ECONNREFUSED")), + contractsExist: async () => ({ missing: [], archived: [] }), + }; + const { run, logs } = load({}, { probe }); + const cfg = await run(); + assert.equal(cfg.mode, "dry-run"); + assert.ok(logs.some((l) => l.includes("ECONNREFUSED"))); +}); + +test("live mode fails when the RPC cannot be reached", async () => { + const probe: NetworkProbe = { + passphrase: async () => Promise.reject(new Error("getNetwork timed out after 5000 ms")), + contractsExist: async () => ({ missing: [], archived: [] }), + }; + const issues = await issuesOf(load(LIVE, { probe }).run()); + assert.ok(issues.some((i) => i.includes("timed out"))); +}); + +test("an explicit passphrase that disagrees with STELLAR_NETWORK is rejected", async () => { + const issues = await issuesOf(load({ STELLAR_NETWORK_PASSPHRASE: NETWORKS.mainnet.passphrase }).run()); + assert.ok(issues.some((i) => i.startsWith("STELLAR_NETWORK_PASSPHRASE"))); +}); + +test("contracts missing on chain produce an actionable stale-deployment error", async () => { + const probe = new FakeProbe(undefined, { missing: [CONTRACTS.treasury], archived: [CONTRACTS.resolver] }); + const issues = await issuesOf(load(LIVE, { probe }).run()); + const missing = issues.find((i) => i.includes(CONTRACTS.treasury)); + assert.ok(missing && /contracts\.treasury/.test(missing) && /stale/.test(missing) && /deploy-testnet\.sh|DEPLOYMENTS_FILE/.test(missing), missing); + const archived = issues.find((i) => i.includes(CONTRACTS.resolver)); + assert.ok(archived && /archived/.test(archived) && /restore/.test(archived), archived); +}); + +test("a deployment record without a required contract is stale", async () => { + const { treasury: _drop, ...partial } = CONTRACTS; + const issues = await issuesOf(load({}, { record: deployment({ contracts: partial }) }).run()); + assert.ok(issues.some((i) => /stale/.test(i) && i.includes("contracts.treasury")), issues.join(" | ")); +}); + +test("deployment StrKeys are validated", async () => { + const issues = await issuesOf(load({}, { record: deployment({ agent: "GNOTAKEY", contracts: { ...CONTRACTS, usdc: "CBAD" } }) }).run()); + assert.ok(issues.some((i) => i.includes("agent:"))); + assert.ok(issues.some((i) => i.includes("contracts.usdc:"))); +}); + +test("a record with no network field is treated as testnet", async () => { + const issues = await issuesOf(load({ STELLAR_NETWORK: "futurenet", DEPLOYMENTS_FILE: "deployments/futurenet.json" }).run()); + assert.ok(issues.some((i) => i.includes("assumed testnet")), issues.join(" | ")); + const ok = await load({ STELLAR_NETWORK: "futurenet" }, { record: deployment({ network: "futurenet" }) }).run(); + assert.equal(ok.rpcUrl, NETWORKS.futurenet.rpcUrl); +}); + +test("DEPLOYMENTS_FILE selects the record, and must agree with DEPLOYMENTS_PATH", async () => { + const { run, files } = load({ DEPLOYMENTS_FILE: "/tmp/custom.json" }); + assert.equal((await run()).deploymentsFile, "/tmp/custom.json"); + assert.ok(files["/tmp/custom.json"]); + const issues = await issuesOf(load({ DEPLOYMENTS_FILE: "a.json", DEPLOYMENTS_PATH: "b.json" }).run()); + assert.ok(issues.some((i) => i.includes("disagree"))); +}); + +const MAINNET_OK = { + STELLAR_NETWORK: "mainnet", + STELLAR_NETWORK_PASSPHRASE: NETWORKS.mainnet.passphrase, + STELLAR_RPC_URL: "https://rpc.example.org", + DEPLOYMENTS_FILE: "deployments/mainnet.json", + AGENT_ALLOW_MAINNET: MAINNET_OPT_IN, +}; + +test("mainnet starts only when every condition holds", async () => { + const cfg = await load(MAINNET_OK, { + record: deployment({ network: "mainnet" }), + probe: new FakeProbe(NETWORKS.mainnet.passphrase), + }).run(); + assert.equal(cfg.network, "mainnet"); +}); + +test("mainnet is refused when any condition is missing, and every miss is reported", async () => { + const record = deployment({ network: "mainnet" }); + const cases: [Record, unknown, RegExp][] = [ + [{ AGENT_ALLOW_MAINNET: undefined }, record, /AGENT_ALLOW_MAINNET/], + [{ AGENT_ALLOW_MAINNET: "yes" }, record, /AGENT_ALLOW_MAINNET/], + [{ DEPLOYMENTS_FILE: "deployments/testnet.json" }, record, /testnet\.json/], + [{}, deployment(), /declare "network": "mainnet"/], + [{ STELLAR_RPC_URL: undefined }, record, /STELLAR_RPC_URL/], + ]; + for (const [change, rec, expected] of cases) { + const issues = await issuesOf(load({ ...MAINNET_OK, ...change }, { record: rec }).run()); + assert.ok(issues.some((i) => i.startsWith("mainnet refused") && expected.test(i)), `${JSON.stringify(change)}: ${issues.join(" | ")}`); + } + // Nothing opted in: every condition is listed at once. + const all = await issuesOf(load({ STELLAR_NETWORK: "mainnet", DEPLOYMENTS_FILE: "deployments/testnet.json" }).run()); + assert.equal(all.filter((i) => i.startsWith("mainnet refused")).length, 4, all.join(" | ")); +}); + +test("documented contract id overrides replace the record's ids and are logged", async () => { + const replacement = contract(42); + const { run, logs } = load({ TREASURY_CONTRACT_ID: replacement, USDC_CONTRACT_ID: "" }); + const cfg = await run(); + assert.equal(cfg.deployments.contracts.treasury, replacement); + assert.equal(cfg.deployments.contracts.usdc, CONTRACTS.usdc); + assert.deepEqual(cfg.contractOverrides, [{ variable: "TREASURY_CONTRACT_ID", contract: "treasury", from: CONTRACTS.treasury, to: replacement }]); + assert.ok(logs.some((l) => l.includes("TREASURY_CONTRACT_ID") && l.includes(replacement))); + + const issues = await issuesOf(load({ RESOLVER_CONTRACT_ID: "CNOTACONTRACT" }).run()); + assert.ok(issues.some((i) => i.startsWith("RESOLVER_CONTRACT_ID:"))); +}); + +test("redacted output never contains the secret", async () => { + const cfg = await load({ ...LIVE, MAKER_SECRET_KEY: MAKER.secret() }).run(); + const printed = JSON.stringify(describeConfig(cfg)); + assert.ok(!printed.includes(AGENT.secret())); + assert.ok(!printed.includes(MAKER.secret())); + assert.deepEqual(describeConfig(cfg).agentSecret, { publicKey: AGENT.publicKey(), redacted: true }); +}); + +test("offline dry-run and --print-config never call the network probe", async () => { + const probe = new FakeProbe(); + await load({}, { probe, argv: ["--offline"] }).run(); + await load(LIVE, { probe, argv: ["--print-config"] }).run(); + assert.equal(probe.calls, 0); + + const issues = await issuesOf(load(LIVE, { argv: ["--offline"] }).run()); + assert.ok(issues.some((i) => i.includes("--offline"))); +}); + +test("live startup runs both probes", async () => { + const probe = new FakeProbe(); + await load(LIVE, { probe }).run(); + assert.equal(probe.calls, 2); +}); + +test("parseTunables validates the same fields without a deployment", () => { + assert.equal(parseTunables({ SLIPPAGE_BPS: "100" }).trading.slippageBps, 100); + assert.throws(() => parseTunables({ SLIPPAGE_BPS: "1.0" }), AgentConfigError); +}); diff --git a/oracledesk-stellar/agents/core/config.ts b/oracledesk-stellar/agents/core/config.ts new file mode 100644 index 0000000..b349523 --- /dev/null +++ b/oracledesk-stellar/agents/core/config.ts @@ -0,0 +1,544 @@ +/** + * Typed, validated agent configuration (#107). Every agent entrypoint calls + * `loadAgentConfig()` instead of reading `process.env`, so a bad value fails + * startup with every problem listed at once, before any RPC call is made. + * + * Order of checks: + * 1. environment variables (EnvSchema), strictly parsed; + * 2. the deployment record (DeploymentsSchema), its network, and the + * documented *_CONTRACT_ID overrides; + * 3. the signing key (live only) against the deployment's agent address; + * 4. the mainnet interlock; + * 5. network probes: RPC passphrase and contract instances. Mandatory in + * live mode, best effort in dry-run, skipped by --offline (dry-run only) + * and by --print-config. + */ +import { readFileSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Address, Keypair, StrKey, rpc, xdr } from "@stellar/stellar-sdk"; +import { z } from "zod"; +import { ConfigError, type AgentMode } from "../runtime/config.js"; +import type { AgentRole, Deployments } from "../stellar/adapter.js"; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +/** Canonical passphrase and default RPC for each supported network. Mainnet + * has no default RPC on purpose: pointing an agent at real funds must be an + * explicit choice. */ +export const NETWORKS = { + testnet: { passphrase: "Test SDF Network ; September 2015", rpcUrl: "https://soroban-testnet.stellar.org" }, + futurenet: { passphrase: "Test SDF Future Network ; October 2022", rpcUrl: "https://rpc-futurenet.stellar.org" }, + local: { passphrase: "Standalone Network ; February 2017", rpcUrl: "http://localhost:8000/soroban/rpc" }, + mainnet: { passphrase: "Public Global Stellar Network ; September 2015", rpcUrl: undefined }, +} as const; + +export type NetworkName = keyof typeof NETWORKS; +const NETWORK_NAMES = Object.keys(NETWORKS) as [NetworkName, ...NetworkName[]]; + +/** Exact value AGENT_ALLOW_MAINNET must hold before mainnet is allowed. */ +export const MAINNET_OPT_IN = "I_ACCEPT_REAL_FUNDS_"; + +/** Largest amount a contract i128 can hold. */ +export const MAX_STROOPS = (1n << 127n) - 1n; +/** 100 USDC: the smallest seed a new market may be opened with. */ +export const MIN_SEED_STROOPS = 1_000_000_000n; +const BPS_MAX = 10_000; +const DEFAULT_PROBE_TIMEOUT_MS = 5_000; + +/** Documented contract id overrides, applied after the deployment record is validated. */ +export const CONTRACT_OVERRIDES = { + USDC_CONTRACT_ID: "usdc", + MARKET_CORE_CONTRACT_ID: "market_core", + RESOLVER_CONTRACT_ID: "resolver", + TREASURY_CONTRACT_ID: "treasury", + REASONING_REGISTRY_CONTRACT_ID: "reasoning_registry", +} as const satisfies Record; + +/** Thrown for any invalid configuration. `issues` holds every problem found. */ +export class AgentConfigError extends ConfigError { + constructor(readonly issues: string[]) { + super(`invalid agent configuration:\n - ${issues.join("\n - ")}`); + } +} + +// ─── Field parsers ─────────────────────────────────────────────────────────── +// Unset (undefined) takes the default. A set value, including "", must parse. + +const DECIMAL = /^(0|[1-9][0-9]*)$/; + +function intField(fallback: number | undefined, min: number, max: number) { + return z.string().optional().transform((raw, ctx): number | undefined => { + if (raw === undefined) return fallback; + const n = DECIMAL.test(raw) ? Number(raw) : NaN; + if (!Number.isSafeInteger(n) || n < min || n > max) { + ctx.addIssue({ code: "custom", message: `must be an integer from ${min} to ${max}, got ${JSON.stringify(raw)}` }); + return z.NEVER; + } + return n; + }); +} + +const bpsField = (fallback?: number) => intField(fallback, 0, BPS_MAX); + +function stroopsField(fallback: bigint, min = 1n) { + return z.string().optional().transform((raw, ctx): bigint => { + if (raw === undefined) return fallback; + if (!DECIMAL.test(raw)) { + ctx.addIssue({ code: "custom", message: `must be a whole number of stroops, got ${JSON.stringify(raw)}` }); + return z.NEVER; + } + const n = BigInt(raw); + if (n < min || n > MAX_STROOPS) { + ctx.addIssue({ code: "custom", message: `must be from ${min} to ${MAX_STROOPS} stroops, got ${raw}` }); + return z.NEVER; + } + return n; + }); +} + +/** Secrets and contract ids may be left blank (the .env.example template ships + * them empty); blank means unset. Anything else must be a valid StrKey. */ +const blankToUndefined = (v: unknown) => (v === "" ? undefined : v); +const secretField = z.preprocess( + blankToUndefined, + z.string().refine((v) => StrKey.isValidEd25519SecretSeed(v), "must be an S... secret key").optional(), +); +const contractIdField = z.preprocess( + blankToUndefined, + z.string().refine((v) => StrKey.isValidContract(v), "must be a C... contract id").optional(), +); +const nonEmptyPath = z.string().min(1, "must not be empty").optional(); + +export const EnvSchema = z.object({ + AGENT_MODE: z.enum(["dry-run", "live"], { error: (i) => `must be "dry-run" or "live", got ${JSON.stringify(i.input)}` }).default("dry-run"), + STELLAR_NETWORK: z.enum(NETWORK_NAMES, { error: (i) => `must be one of ${NETWORK_NAMES.join(", ")}, got ${JSON.stringify(i.input)}` }).default("testnet"), + STELLAR_NETWORK_PASSPHRASE: z.string().min(1, "must not be empty").optional(), + STELLAR_RPC_URL: z.url({ protocol: /^https?$/, error: "must be an http(s) URL" }).optional(), + AGENT_SECRET_KEY: secretField, + MAKER_SECRET_KEY: secretField, + TRADER_SECRET_KEY: secretField, + DEPLOYMENTS_FILE: nonEmptyPath, + DEPLOYMENTS_PATH: nonEmptyPath, + TRACE_STORE_DIR: nonEmptyPath, + MIN_EDGE_BPS: bpsField(500), + CONFIDENCE_THRESHOLD_BPS: bpsField(1000), + SLIPPAGE_BPS: bpsField(50), + FAIR_VALUE_BPS: bpsField(undefined), + TRADE_SIZE_STROOPS: stroopsField(100_000_000n), + DEFAULT_SEED_STROOPS: stroopsField(1_500_000_000n, MIN_SEED_STROOPS), + DEADLINE_SECS: intField(60, 1, 86_400), + MARKET_CLOSE_IN_SECS: intField(3600, 1, 365 * 86_400), + AGENT_STARTUP_PROBE_TIMEOUT_MS: intField(DEFAULT_PROBE_TIMEOUT_MS, 1, 600_000), + AGENT_ALLOW_MAINNET: z.string().optional(), + EVENTS_STATE_DIR: nonEmptyPath, + EVENTS_TRADE_THRESHOLD: stroopsField(500_000_000n), + EVENTS_POLL_INTERVAL_MS: intField(10_000, 100, 3_600_000), + EVENTS_PAGE_LIMIT: intField(100, 1, 10_000), + EVENTS_CONCURRENCY: intField(4, 1, 64), + EVENTS_MAX_ATTEMPTS: intField(5, 1, 100), + EVENTS_DISPUTE_WINDOW_SECS: intField(600, 0, 30 * 86_400), + ARB_ENABLED: z.enum(["0", "1", "false", "true"], { error: (i) => `must be 0, 1, false or true, got ${JSON.stringify(i.input)}` }).default("0"), + ARB_RELATIONS_FILE: nonEmptyPath, + ARB_JOURNAL_FILE: nonEmptyPath, + ARB_MAX_NOTIONAL: stroopsField(1_000_000_000n), + ARB_MIN_PROFIT_STROOPS: stroopsField(1_000_000n, 0n), + ARB_MIN_PROFIT_BPS: bpsField(50), + ARB_MAX_UNHEDGED_STROOPS: stroopsField(500_000_000n), + ARB_MARKET_HEADROOM_BPS: intField(2_000, 0, 9_999), + ARB_MAX_LEG_LOSS: stroopsField(10_000_000n, 0n), + ARB_MAX_OPEN_LEGS: intField(2, 2, 64), + ARB_MAX_UNWIND_RETRIES: intField(3, 1, 20), + USDC_CONTRACT_ID: contractIdField, + MARKET_CORE_CONTRACT_ID: contractIdField, + RESOLVER_CONTRACT_ID: contractIdField, + TREASURY_CONTRACT_ID: contractIdField, + REASONING_REGISTRY_CONTRACT_ID: contractIdField, +}); +export type ParsedEnv = z.output; + +const publicKey = z.string().refine((v) => StrKey.isValidEd25519PublicKey(v), "must be a G... public key"); +const contractId = z.string().refine((v) => StrKey.isValidContract(v), "must be a C... contract id"); + +/** A deployment record (deployments/.json). Extra metadata keys are kept. */ +export const DeploymentsSchema = z.looseObject({ + network: z.enum(NETWORK_NAMES).optional(), + deployer: publicKey, + agent: publicKey, + agents: z.object({ maker: publicKey, trader: publicKey }).optional(), + contracts: z.looseObject({ + usdc: contractId, + market_core: contractId, + resolver: contractId, + treasury: contractId, + reasoning_registry: contractId, + }), + wired: z.boolean().optional(), + agent_registered: z.boolean().optional(), + treasury_funded_amount: z.string().regex(DECIMAL, "must be a whole number of stroops").optional(), + last_deployed_at: z.string().optional(), +}); +export type ValidatedDeployments = Deployments & { network?: NetworkName }; + +function formatIssues(prefix: string, error: z.ZodError): string[] { + return error.issues.map((i) => `${prefix}${i.path.join(".") || "(root)"}: ${i.message}`); +} + +// ─── Probes ────────────────────────────────────────────────────────────────── + +export interface ContractsReport { + /** No instance entry on the ledger: never deployed here, or a reset network. */ + missing: string[]; + /** Instance entry exists but its TTL has lapsed; restore it before use. */ + archived: string[]; +} + +/** Network checks run at startup. Injected so tests never touch the network. */ +export interface NetworkProbe { + passphrase(rpcUrl: string): Promise; + contractsExist(rpcUrl: string, ids: string[]): Promise; +} + +function withTimeout(promise: Promise, ms: number, what: string): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms} ms`)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +/** The real probe: Soroban RPC `getNetwork` and contract-instance ledger entries. */ +export function rpcNetworkProbe(timeoutMs = DEFAULT_PROBE_TIMEOUT_MS): NetworkProbe { + const server = (url: string) => new rpc.Server(url, { allowHttp: url.startsWith("http://") }); + return { + async passphrase(rpcUrl) { + return (await withTimeout(server(rpcUrl).getNetwork(), timeoutMs, "getNetwork")).passphrase; + }, + async contractsExist(rpcUrl, ids) { + const s = server(rpcUrl); + const keys = ids.map((id) => + xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(id).toScAddress(), + key: xdr.ScVal.scvLedgerKeyContractInstance(), + durability: xdr.ContractDataDurability.persistent(), + }), + ), + ); + const [{ entries }, latest] = await withTimeout( + Promise.all([s.getLedgerEntries(...keys), s.getLatestLedger()]), + timeoutMs, + "getLedgerEntries", + ); + const live = new Map(); + for (const e of entries) { + live.set(Address.fromScAddress(e.key.contractData().contract()).toString(), e.liveUntilLedgerSeq); + } + return { + missing: ids.filter((id) => !live.has(id)), + archived: ids.filter((id) => { + const until = live.get(id); + return until !== undefined && until < latest.sequence; + }), + }; + }, + }; +} + +// ─── Loading ───────────────────────────────────────────────────────────────── + +export interface TradingConfig { + minEdgeBps: number; + tradeSizeStroops: bigint; + slippageBps: number; + deadlineSecs: number; + /** Fixed fair-value override; unset means each market's own price. */ + fairValueBps?: number; +} + +/** Event-driven triggers (#105), used by `trader --follow`. */ +export interface EventsConfig { + stateDir: string; + tradeThresholdStroops: bigint; + pollIntervalMs: number; + pageLimit: number; + concurrency: number; + maxAttempts: number; + /** Dispute window assumed for alerts, seconds. */ + disputeWindowSecs: number; +} + +/** Cross-market arbitrage (#108); off unless ARB_ENABLED. */ +export interface ArbConfig { + enabled: boolean; + relationsFile?: string; + journalFile: string; + maxNotional: bigint; + minProfit: bigint; + minProfitBps: bigint; + maxUnhedged: bigint; + marketHeadroomBps: bigint; + maxLegLoss: bigint; + maxOpenLegs: number; + maxUnwindRetries: number; +} + +export interface MakerConfig { + confidenceThresholdBps: number; + defaultSeedStroops: bigint; + closeInSecs: number; +} + +/** The settings a trader or maker run needs that do not depend on a deployment. */ +export interface Tunables { + rpcUrl: string; + traceStoreDir: string; + trading: TradingConfig; + maker: MakerConfig; + events: EventsConfig; + arb: ArbConfig; + /** Runtime knobs (lease, state file, backoff, trace store selection) still + * read by runtime/wiring.ts. Never printed: it may hold secrets. */ + env: NodeJS.ProcessEnv; +} + +export interface AgentConfig extends Tunables { + role: AgentRole; + mode: AgentMode; + network: NetworkName; + networkPassphrase: string; + /** Present only in live mode. Dry-run never carries the secret. */ + agentSecret?: string; + /** Address the agent acts as: the secret's public key in live mode. */ + agentAddress: string; + deployments: ValidatedDeployments; + deploymentsFile: string; + contractOverrides: { variable: string; contract: keyof Deployments["contracts"]; from: string; to: string }[]; + probeTimeoutMs: number; + offline: boolean; + printConfig: boolean; +} + +export interface LoadAgentConfigOptions { + role: AgentRole; + env?: NodeJS.ProcessEnv; + /** CLI arguments; --offline and --print-config are recognised. */ + argv?: string[]; + probe?: NetworkProbe; + readFile?: (path: string) => string; + /** Where overrides and dry-run probe warnings are reported. */ + log?: (message: string) => void; +} + +/** + * Parses only the deployment-independent settings. Offline and side-effect + * free; loadAgentConfig() uses it, and tests can build a run config with it. + */ +export function parseTunables(env: NodeJS.ProcessEnv): Tunables { + const parsed = EnvSchema.safeParse(env); + if (!parsed.success) throw new AgentConfigError(formatIssues("", parsed.error)); + return tunablesFrom(parsed.data, env); +} + +function tunablesFrom(e: ParsedEnv, env: NodeJS.ProcessEnv): Tunables { + return { + rpcUrl: e.STELLAR_RPC_URL ?? NETWORKS[e.STELLAR_NETWORK].rpcUrl ?? "", + traceStoreDir: e.TRACE_STORE_DIR ?? "./.traces", + trading: { + minEdgeBps: e.MIN_EDGE_BPS!, + tradeSizeStroops: e.TRADE_SIZE_STROOPS, + slippageBps: e.SLIPPAGE_BPS!, + deadlineSecs: e.DEADLINE_SECS!, + fairValueBps: e.FAIR_VALUE_BPS, + }, + maker: { + confidenceThresholdBps: e.CONFIDENCE_THRESHOLD_BPS!, + defaultSeedStroops: e.DEFAULT_SEED_STROOPS, + closeInSecs: e.MARKET_CLOSE_IN_SECS!, + }, + events: { + stateDir: e.EVENTS_STATE_DIR ?? "./.events", + tradeThresholdStroops: e.EVENTS_TRADE_THRESHOLD, + pollIntervalMs: e.EVENTS_POLL_INTERVAL_MS!, + pageLimit: e.EVENTS_PAGE_LIMIT!, + concurrency: e.EVENTS_CONCURRENCY!, + maxAttempts: e.EVENTS_MAX_ATTEMPTS!, + disputeWindowSecs: e.EVENTS_DISPUTE_WINDOW_SECS!, + }, + arb: { + enabled: e.ARB_ENABLED === "1" || e.ARB_ENABLED === "true", + relationsFile: e.ARB_RELATIONS_FILE, + journalFile: e.ARB_JOURNAL_FILE ?? "./.arb/journal.jsonl", + maxNotional: e.ARB_MAX_NOTIONAL, + minProfit: e.ARB_MIN_PROFIT_STROOPS, + minProfitBps: BigInt(e.ARB_MIN_PROFIT_BPS!), + maxUnhedged: e.ARB_MAX_UNHEDGED_STROOPS, + marketHeadroomBps: BigInt(e.ARB_MARKET_HEADROOM_BPS!), + maxLegLoss: e.ARB_MAX_LEG_LOSS, + maxOpenLegs: e.ARB_MAX_OPEN_LEGS!, + maxUnwindRetries: e.ARB_MAX_UNWIND_RETRIES!, + }, + env, + }; +} + +/** Every unmet mainnet condition, empty when mainnet is fully opted into. */ +export function mainnetInterlock(e: ParsedEnv, deploymentsFile: string, deployments?: { network?: string }): string[] { + const unmet: string[] = []; + const passphrase = e.STELLAR_NETWORK_PASSPHRASE ?? NETWORKS.mainnet.passphrase; + if (passphrase !== NETWORKS.mainnet.passphrase) unmet.push(`STELLAR_NETWORK_PASSPHRASE must be "${NETWORKS.mainnet.passphrase}"`); + if (basename(deploymentsFile) === "testnet.json") unmet.push(`deployment file must not be testnet.json (got ${deploymentsFile})`); + if (deployments?.network !== "mainnet") unmet.push(`deployment record must declare "network": "mainnet"`); + if (e.AGENT_ALLOW_MAINNET !== MAINNET_OPT_IN) unmet.push(`AGENT_ALLOW_MAINNET must be exactly ${MAINNET_OPT_IN}`); + if (!e.STELLAR_RPC_URL) unmet.push("STELLAR_RPC_URL must be set explicitly (mainnet has no default)"); + return unmet.map((m) => `mainnet refused: ${m}`); +} + +export async function loadAgentConfig(o: LoadAgentConfigOptions): Promise { + const env = o.env ?? process.env; + const argv = o.argv ?? process.argv.slice(2); + const log = o.log ?? ((m: string) => console.warn(m)); + const readFile = o.readFile ?? ((p: string) => readFileSync(p, "utf8")); + const offline = argv.includes("--offline"); + const printConfig = argv.includes("--print-config"); + const issues: string[] = []; + + const parsed = EnvSchema.safeParse(env); + if (!parsed.success) throw new AgentConfigError(formatIssues("", parsed.error)); + const e = parsed.data; + const mode: AgentMode = e.AGENT_MODE; + const network = e.STELLAR_NETWORK; + const canonical = NETWORKS[network].passphrase; + if (e.STELLAR_NETWORK_PASSPHRASE !== undefined && e.STELLAR_NETWORK_PASSPHRASE !== canonical) { + issues.push(`STELLAR_NETWORK_PASSPHRASE "${e.STELLAR_NETWORK_PASSPHRASE}" does not match STELLAR_NETWORK=${network} ("${canonical}")`); + } + if (offline && mode === "live") issues.push("--offline is only allowed in dry-run: live mode must verify the network first"); + if ((e.ARB_ENABLED === "1" || e.ARB_ENABLED === "true") && !e.ARB_RELATIONS_FILE) issues.push("ARB_ENABLED needs ARB_RELATIONS_FILE"); + + // Deployment record. + if (e.DEPLOYMENTS_FILE && e.DEPLOYMENTS_PATH && e.DEPLOYMENTS_FILE !== e.DEPLOYMENTS_PATH) { + issues.push(`DEPLOYMENTS_FILE (${e.DEPLOYMENTS_FILE}) and DEPLOYMENTS_PATH (${e.DEPLOYMENTS_PATH}) disagree; set one`); + } + const deploymentsFile = e.DEPLOYMENTS_FILE ?? e.DEPLOYMENTS_PATH ?? join(REPO_ROOT, "deployments", `${network}.json`); + let deployments: ValidatedDeployments | undefined; + let raw: unknown; + try { + raw = JSON.parse(readFile(deploymentsFile)); + } catch (err) { + issues.push(`cannot read deployment record ${deploymentsFile}: ${err instanceof Error ? err.message : String(err)}`); + } + if (raw !== undefined) { + const d = DeploymentsSchema.safeParse(raw); + if (!d.success) { + const missing = d.error.issues.filter((i) => i.path[0] === "contracts" && i.code === "invalid_type").map((i) => i.path.join(".")); + if (missing.length > 0) { + issues.push(`deployment record ${deploymentsFile} is stale: missing ${missing.join(", ")}. Re-run scripts/deploy-testnet.sh or point DEPLOYMENTS_FILE at a current record`); + } + issues.push(...formatIssues(`${basename(deploymentsFile)}: `, d.error).filter((m) => !missing.some((p) => m.includes(`${p}:`)))); + } else { + deployments = structuredClone(d.data) as ValidatedDeployments; + const declared = deployments.network ?? "testnet"; + if (declared !== network) { + issues.push(`deployment record ${deploymentsFile} is for ${declared}${deployments.network ? "" : " (no network field, assumed testnet)"}, but STELLAR_NETWORK=${network}`); + } + } + } + + const contractOverrides: AgentConfig["contractOverrides"] = []; + if (deployments) { + for (const [variable, contract] of Object.entries(CONTRACT_OVERRIDES) as [keyof typeof CONTRACT_OVERRIDES, keyof Deployments["contracts"]][]) { + const to = e[variable]; + if (to === undefined) continue; + const from = deployments.contracts[contract]; + contractOverrides.push({ variable, contract, from, to }); + deployments.contracts[contract] = to; + log(`[config] ${variable} overrides contracts.${contract}: ${from} -> ${to}`); + } + } + + // Signing identity. Each role prefers its own key and registered address. + const roleSecret = o.role === "maker" ? e.MAKER_SECRET_KEY : e.TRADER_SECRET_KEY; + const secret = roleSecret ?? e.AGENT_SECRET_KEY; + const expected = deployments ? deployments.agents?.[o.role] ?? deployments.agent : undefined; + let agentAddress = expected ?? ""; + if (mode === "live") { + if (!secret) { + issues.push(`live mode needs ${o.role === "maker" ? "MAKER_SECRET_KEY" : "TRADER_SECRET_KEY"} or AGENT_SECRET_KEY`); + } else { + const pub = Keypair.fromSecret(secret).publicKey(); + if (expected && pub !== expected) { + issues.push(`signing key ${pub} does not match the deployment's ${deployments?.agents?.[o.role] ? `agents.${o.role}` : "agent"} ${expected}`); + } + agentAddress = pub; + } + } + + if (network === "mainnet") issues.push(...mainnetInterlock(e, deploymentsFile, deployments)); + + if (issues.length > 0 || !deployments) throw new AgentConfigError(issues); + + const cfg: AgentConfig = { + ...tunablesFrom(e, env), + role: o.role, + mode, + network, + networkPassphrase: canonical, + agentSecret: mode === "live" ? secret : undefined, + agentAddress, + deployments, + deploymentsFile, + contractOverrides, + probeTimeoutMs: e.AGENT_STARTUP_PROBE_TIMEOUT_MS!, + offline, + printConfig, + }; + + if (printConfig || (offline && mode === "dry-run")) return cfg; + await verifyNetwork(cfg, o.probe ?? rpcNetworkProbe(cfg.probeTimeoutMs), log); + return cfg; +} + +/** Live: any failure is fatal. Dry-run: failures are reported and startup continues. */ +async function verifyNetwork(cfg: AgentConfig, probe: NetworkProbe, log: (m: string) => void): Promise { + const problems: string[] = []; + try { + const actual = await probe.passphrase(cfg.rpcUrl); + if (actual !== cfg.networkPassphrase) { + problems.push(`RPC ${cfg.rpcUrl} serves "${actual}", but STELLAR_NETWORK=${cfg.network} expects "${cfg.networkPassphrase}"`); + } + } catch (err) { + problems.push(`could not read the network passphrase from ${cfg.rpcUrl}: ${err instanceof Error ? err.message : String(err)}`); + } + if (problems.length === 0) { + const names = Object.fromEntries(Object.entries(cfg.deployments.contracts).map(([k, v]) => [v, k])); + try { + const { missing, archived } = await probe.contractsExist(cfg.rpcUrl, Object.values(cfg.deployments.contracts)); + for (const id of missing) { + problems.push(`contracts.${names[id]} ${id} does not exist on ${cfg.network}: the deployment record ${cfg.deploymentsFile} is stale (network reset or wrong file). Redeploy with scripts/deploy-testnet.sh or set DEPLOYMENTS_FILE`); + } + for (const id of archived) { + problems.push(`contracts.${names[id]} ${id} is archived on ${cfg.network}: restore it (stellar contract restore --id ${id}) before running the agent`); + } + } catch (err) { + problems.push(`could not check contract instances on ${cfg.rpcUrl}: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (problems.length === 0) return; + if (cfg.mode === "live") throw new AgentConfigError(problems); + for (const p of problems) log(`[config] warning (dry-run continues): ${p}`); +} + +/** A printable copy of the config. The secret becomes its public key; the raw + * environment is dropped entirely. */ +export function describeConfig(cfg: AgentConfig): Record { + const { env: _env, agentSecret, trading, maker, events, arb, ...rest } = cfg; + const strings = (o: object) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, typeof v === "bigint" ? v.toString() : v])); + return { + ...rest, + arb: strings(arb), + events: { ...events, tradeThresholdStroops: events.tradeThresholdStroops.toString() }, + agentSecret: agentSecret ? { publicKey: Keypair.fromSecret(agentSecret).publicKey(), redacted: true } : null, + trading: { ...trading, tradeSizeStroops: trading.tradeSizeStroops.toString() }, + maker: { ...maker, defaultSeedStroops: maker.defaultSeedStroops.toString() }, + }; +} diff --git a/oracledesk-stellar/agents/core/events/decode.test.ts b/oracledesk-stellar/agents/core/events/decode.test.ts new file mode 100644 index 0000000..7fb5489 --- /dev/null +++ b/oracledesk-stellar/agents/core/events/decode.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Contract, xdr } from "@stellar/stellar-sdk"; +import { encodeEvent, FakeEventsRpc, IDS, testDecoder, trade, TRADER } from "./testing.js"; + +async function decodeOne(e: Parameters[0]) { + const rpc = await FakeEventsRpc.create(); + rpc.emit(e); + const page = await rpc.getEvents({ filters: [{ type: "contract" }], startLedger: 1 } as never); + return (await testDecoder()).decode(page.events[0]); +} + +test("a market-core Trade decodes into typed fields from the binding spec", async () => { + const e = await decodeOne(trade(7n, 250_000_000n, 10)); + assert.equal(e?.kind, "Trade"); + if (e?.kind !== "Trade") return; + assert.equal(e.marketId, 7n); + assert.equal(e.trader, TRADER); + assert.equal(e.outcome, "Yes"); + assert.equal(e.isBuy, true); + assert.equal(e.collateral, 250_000_000n); + assert.equal(e.shares, 500_000_000n); + assert.equal(e.contract, "market_core"); + assert.equal(e.contractId, IDS.market_core); + assert.equal(e.ledger, 10); + assert.equal(e.closedAt, 1_700_000_050); +}); + +test("MarketResolved carries an explicit nullable outcome", async () => { + const yes = await decodeOne({ contract: "market_core", name: "MarketResolved", ledger: 3, values: { market_id: 1n, outcome: { tag: "No", values: undefined } } }); + assert.deepEqual(yes?.kind === "MarketResolved" && [yes.marketId, yes.outcome], [1n, "No"]); + const voided = await decodeOne({ contract: "market_core", name: "MarketResolved", ledger: 3, values: { market_id: 2n, outcome: undefined } }); + assert.equal(voided?.kind, "MarketResolved"); + assert.equal(voided?.kind === "MarketResolved" ? voided.outcome : "unset", null); +}); + +test("resolver proposals, finalization and price resolutions decode", async () => { + const proposed = await decodeOne({ + contract: "resolver", + name: "ResolutionProposedV2", + ledger: 4, + values: { market_id: 3n, outcome: { tag: "Invalid", values: undefined }, proposed_at: 1_700_000_100n }, + }); + assert.deepEqual(proposed?.kind === "ResolutionProposed" && [proposed.outcome, proposed.proposedAt], ["Invalid", 1_700_000_100n]); + const finalized = await decodeOne({ + contract: "resolver", + name: "ResolutionFinalizedV2", + ledger: 5, + values: { market_id: 3n, outcome: { tag: "Yes", values: undefined }, finalized_at: 1_700_000_900n }, + }); + assert.equal(finalized?.kind, "ResolutionFinalized"); + for (const name of ["PriceResolution", "PriceResolutionV2"]) { + const p = await decodeOne({ + contract: "resolver", + name, + ledger: 6, + values: { market_id: 4n, price: 123n, price_timestamp: 99n, outcome: { tag: "No", values: undefined }, from_snapshot: true }, + }); + assert.equal(p?.kind, "PriceResolution"); + assert.equal(p?.kind === "PriceResolution" && p.fromSnapshot, name === "PriceResolutionV2"); + } +}); + +test("registry and treasury events decode through their own specs", async () => { + const traced = await decodeOne({ + contract: "reasoning_registry", + name: "TracePublished", + ledger: 7, + values: { trace_id: 9n, market_id: 4n, agent: TRADER, action: "buy", trace_hash: Buffer.alloc(32, 0xab), ipfs_cid: "bafy", published_at: 1n }, + }); + assert.deepEqual(traced?.kind === "TracePublished" && [traced.traceId, traced.traceHash], [9n, "ab".repeat(32)]); + const authorized = await decodeOne({ + contract: "treasury", + name: "TradeAuthorized", + ledger: 8, + values: { market_id: 4n, agent: TRADER, amount: 10n, market_total: 10n, daily_total: 10n }, + }); + assert.equal(authorized?.kind, "Unknown"); + assert.equal(authorized?.kind === "Unknown" && authorized.name, "TradeAuthorized"); + assert.equal(authorized?.kind === "Unknown" && (authorized.data as { amount: bigint }).amount, 10n); +}); + +test("an event the spec cannot decode is Unknown, and a foreign contract is ignored", async () => { + const garbage = await decodeOne({ contract: "market_core", ledger: 9, raw: { topic: [xdr.ScVal.scvSymbol("no_such_event")], value: xdr.ScVal.scvVoid() } }); + assert.equal(garbage?.kind, "Unknown"); + assert.equal(garbage?.kind === "Unknown" ? garbage.name : "x", undefined); + + const decoder = await testDecoder(); + const spec = decoder.spec("market_core"); + const { topic, value } = encodeEvent(spec, "MarketResolved", { market_id: 1n, outcome: undefined }); + const foreign = decoder.decode({ + id: "1", + type: "contract", + ledger: 1, + ledgerClosedAt: new Date().toISOString(), + transactionIndex: 0, + operationIndex: 0, + inSuccessfulContractCall: true, + txHash: "00", + contractId: new Contract("CA2WQQJ4OHQCLHQW6XN4BCLILGRV6V4YDYDT3GVIWXB53BTOO7EMREQH"), + topic, + value, + }); + assert.equal(foreign, undefined); +}); diff --git a/oracledesk-stellar/agents/core/events/decode.ts b/oracledesk-stellar/agents/core/events/decode.ts new file mode 100644 index 0000000..d815934 --- /dev/null +++ b/oracledesk-stellar/agents/core/events/decode.ts @@ -0,0 +1,177 @@ +/** + * Typed decoding of OracleDesk contract events (#105). + * + * Every event is decoded with the contract specification embedded in the + * generated bindings (packages/bindings/*), via the SDK's `Spec.parseEvent`: + * no XDR layouts are written by hand here. Events this module does not map + * to a typed variant, and events that do not decode, become `Unknown`. + */ +import type { contract, rpc } from "@stellar/stellar-sdk"; + +export type ContractName = "market_core" | "resolver" | "treasury" | "reasoning_registry"; + +/** Binding package per contract; each exports a `Client` carrying the spec. */ +export const BINDINGS: Record = { + market_core: "@oracledesk/bindings-market-core", + resolver: "@oracledesk/bindings-resolver", + treasury: "@oracledesk/bindings-treasury", + reasoning_registry: "@oracledesk/bindings-reasoning-registry", +}; + +export type Side = "Yes" | "No"; +/** A resolver's attested outcome: Invalid voids the market. */ +export type AttestedOutcome = Side | "Invalid"; + +export interface EventMeta { + /** RPC event id; unique and ordered. Used for idempotency. */ + id: string; + ledger: number; + /** Ledger close time, unix seconds. */ + closedAt: number; + txHash: string; + contract: ContractName; + contractId: string; +} + +export type OracleDeskEvent = EventMeta & + ( + | { + kind: "Trade"; + marketId: bigint; + trader: string; + outcome: Side; + isBuy: boolean; + collateral: bigint; + shares: bigint; + fee: bigint; + reserveYes: bigint; + reserveNo: bigint; + } + | { kind: "MarketCreated"; marketId: bigint; creator: string; closeTime: bigint; seedAmount: bigint; initialYesBps: number } + /** `outcome` is null when the market resolved void (no winning side). */ + | { kind: "MarketResolved"; marketId: bigint; outcome: Side | null } + | { kind: "ResolutionProposed"; marketId: bigint; outcome: AttestedOutcome; proposedAt: bigint } + | { kind: "ResolutionFinalized"; marketId: bigint; outcome: AttestedOutcome; finalizedAt: bigint } + | { kind: "PriceResolution"; marketId: bigint; price: bigint; priceTimestamp: bigint; outcome: Side; fromSnapshot: boolean } + | { kind: "TracePublished"; traceId: bigint; marketId: bigint; agent: string; action: string; traceHash: string; ipfsCid: string } + /** Not a typed variant: `name` is the spec event name when it decoded. */ + | { kind: "Unknown"; name?: string; data?: Record } + ); + +export type EventKind = OracleDeskEvent["kind"]; + +/** The market an event concerns, if any; drives per-market ordering. */ +export function marketOf(e: OracleDeskEvent): bigint | undefined { + return "marketId" in e ? e.marketId : undefined; +} + +type Tagged = { tag: string }; +const tag = (v: unknown): string => (v as Tagged).tag; +const side = (v: unknown): Side => tag(v) as Side; + +/** Decodes raw RPC events with the four contracts' binding specs. */ +export class EventDecoder { + private readonly byId: Map; + + constructor(contracts: Partial>) { + this.byId = new Map( + (Object.entries(contracts) as [ContractName, { id: string; spec: contract.Spec }][]).map(([name, c]) => [c.id, { name, spec: c.spec }]), + ); + } + + /** Loads each contract's spec from its generated bindings package. */ + static async fromBindings(ids: Record): Promise { + const contracts: Partial> = {}; + for (const name of Object.keys(BINDINGS) as ContractName[]) { + const mod = (await import(BINDINGS[name])) as { Client: new (o: unknown) => { spec: contract.Spec } }; + // The spec is embedded in the generated Client; nothing here talks to an RPC. + const client = new mod.Client({ contractId: ids[name], networkPassphrase: "", rpcUrl: "https://unused.invalid" }); + contracts[name] = { id: ids[name], spec: client.spec }; + } + return new EventDecoder(contracts); + } + + contractIds(): string[] { + return [...this.byId.keys()]; + } + + /** The binding spec used for `name`'s events. */ + spec(name: ContractName): contract.Spec { + const found = [...this.byId.values()].find((c) => c.name === name); + if (!found) throw new Error(`no spec loaded for ${name}`); + return found.spec; + } + + decode(raw: rpc.Api.EventResponse): OracleDeskEvent | undefined { + const contractId = raw.contractId?.contractId(); + const known = contractId ? this.byId.get(contractId) : undefined; + if (!contractId || !known) return undefined; + const meta: EventMeta = { + id: raw.id, + ledger: raw.ledger, + closedAt: Math.floor(Date.parse(raw.ledgerClosedAt) / 1000), + txHash: raw.txHash, + contract: known.name, + contractId, + }; + const parsed = known.spec.parseEvent(raw.topic, raw.value); + if (!parsed) return { ...meta, kind: "Unknown" }; + const d = parsed.data as Record; + switch (`${known.name}.${parsed.name}`) { + case "market_core.Trade": + return { + ...meta, + kind: "Trade", + marketId: d.market_id as bigint, + trader: d.trader as string, + outcome: side(d.outcome), + isBuy: d.is_buy as boolean, + collateral: d.collateral as bigint, + shares: d.shares as bigint, + fee: d.fee as bigint, + reserveYes: d.reserve_yes as bigint, + reserveNo: d.reserve_no as bigint, + }; + case "market_core.MarketCreated": + return { + ...meta, + kind: "MarketCreated", + marketId: d.market_id as bigint, + creator: d.creator as string, + closeTime: d.close_time as bigint, + seedAmount: d.seed_amount as bigint, + initialYesBps: d.initial_yes_bps as number, + }; + case "market_core.MarketResolved": + return { ...meta, kind: "MarketResolved", marketId: d.market_id as bigint, outcome: d.outcome == null ? null : side(d.outcome) }; + case "resolver.ResolutionProposedV2": + return { ...meta, kind: "ResolutionProposed", marketId: d.market_id as bigint, outcome: tag(d.outcome) as AttestedOutcome, proposedAt: d.proposed_at as bigint }; + case "resolver.ResolutionFinalizedV2": + return { ...meta, kind: "ResolutionFinalized", marketId: d.market_id as bigint, outcome: tag(d.outcome) as AttestedOutcome, finalizedAt: d.finalized_at as bigint }; + case "resolver.PriceResolution": + case "resolver.PriceResolutionV2": + return { + ...meta, + kind: "PriceResolution", + marketId: d.market_id as bigint, + price: d.price as bigint, + priceTimestamp: d.price_timestamp as bigint, + outcome: side(d.outcome), + fromSnapshot: (d.from_snapshot as boolean | undefined) ?? false, + }; + case "reasoning_registry.TracePublished": + return { + ...meta, + kind: "TracePublished", + traceId: d.trace_id as bigint, + marketId: d.market_id as bigint, + agent: d.agent as string, + action: String(d.action), + traceHash: Buffer.from(d.trace_hash as Uint8Array).toString("hex"), + ipfsCid: String(d.ipfs_cid), + }; + default: + return { ...meta, kind: "Unknown", name: parsed.name, data: d }; + } + } +} diff --git a/oracledesk-stellar/agents/core/events/dispatch.test.ts b/oracledesk-stellar/agents/core/events/dispatch.test.ts new file mode 100644 index 0000000..4f40337 --- /dev/null +++ b/oracledesk-stellar/agents/core/events/dispatch.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { OracleDeskEvent } from "./decode.js"; +import { Dispatcher, ProcessedLru } from "./dispatch.js"; +import { FakeEventsRpc, testDecoder, trade } from "./testing.js"; + +async function events(specs: [bigint, number][]): Promise { + const rpc = await FakeEventsRpc.create(); + specs.forEach(([market, ledger]) => rpc.emit(trade(market, BigInt(ledger), ledger))); + const page = await rpc.getEvents({ filters: [{ type: "contract" }], startLedger: 1, limit: 1_000 } as never); + const d = await testDecoder(); + return page.events.map((e) => d.decode(e)!); +} + +const noSleep = async () => undefined; + +test("events of one market run in order while different markets run concurrently", async () => { + // Interleaved: market 0..3, five events each. + const batch = await events(Array.from({ length: 20 }, (_, i) => [BigInt(i % 4), 10 + i] as [bigint, number])); + const order = new Map(); + let running = 0; + let peak = 0; + const dispatcher = new Dispatcher({ + concurrency: 3, + sleep: noSleep, + handlers: [ + { + name: "slow", + kinds: ["Trade"], + async handle(e) { + if (e.kind !== "Trade") return; + running++; + peak = Math.max(peak, running); + // Deterministic, uneven delays so lanes finish out of step. + await new Promise((r) => setTimeout(r, Number((e.collateral * 7n) % 5n))); + order.set(String(e.marketId), [...(order.get(String(e.marketId)) ?? []), e.collateral]); + running--; + }, + }, + ], + }); + await dispatcher.dispatch(batch); + for (const [market, seen] of order) { + assert.deepEqual(seen, [...seen].sort((a, b) => Number(a - b)), `market ${market} out of order`); + assert.equal(seen.length, 5); + } + assert.equal(peak, 3, "three markets in flight at once, never more"); +}); + +test("a processed event is not delivered twice", async () => { + const [e] = await events([[1n, 10]]); + const calls: string[] = []; + const lru = new ProcessedLru(2); + const dispatcher = new Dispatcher({ lru, sleep: noSleep, handlers: [{ name: "h", kinds: ["Trade"], handle: async (x) => void calls.push(x.id) }] }); + await dispatcher.dispatch([e]); + await dispatcher.dispatch([e]); + assert.deepEqual(calls, [e.id]); + lru.add("a"); + lru.add("b"); + assert.equal(lru.has(e.id), false, "the LRU forgets the oldest id at capacity"); +}); + +test("a transient handler failure is retried with backoff", async () => { + const [e] = await events([[1n, 10]]); + const waits: number[] = []; + let attempts = 0; + const dispatcher = new Dispatcher({ + maxAttempts: 4, + backoffBaseMs: 100, + sleep: async (ms) => void waits.push(ms), + deadLetterPath: join(mkdtempSync(join(tmpdir(), "dl-")), "dead-letter.jsonl"), + handlers: [{ name: "flaky", kinds: ["Trade"], handle: async () => { if (++attempts < 3) throw new Error("rpc down"); } }], + }); + await dispatcher.dispatch([e]); + assert.equal(attempts, 3); + assert.deepEqual(waits, [100, 200]); +}); + +test("after retries are exhausted the event is dead-lettered and the batch still completes", async () => { + const batch = await events([[1n, 10], [2n, 11]]); + const path = join(mkdtempSync(join(tmpdir(), "dl-")), "nested", "dead-letter.jsonl"); + const handled: bigint[] = []; + const dispatcher = new Dispatcher({ + maxAttempts: 3, + sleep: noSleep, + deadLetterPath: path, + handlers: [ + { + name: "poisoned", + kinds: ["Trade"], + async handle(e) { + if (e.kind === "Trade" && e.marketId === 1n) throw new Error("cannot handle market 1"); + if (e.kind === "Trade") handled.push(e.marketId); + }, + }, + ], + }); + await dispatcher.dispatch(batch); + assert.deepEqual(handled, [2n]); + const lines = readFileSync(path, "utf8").trim().split("\n").map((l) => JSON.parse(l)); + assert.equal(lines.length, 1); + assert.equal(lines[0].handler, "poisoned"); + assert.equal(lines[0].attempts, 3); + assert.equal(lines[0].event.marketId, "1"); + assert.match(lines[0].error, /cannot handle market 1/); + assert.ok(dispatcher.lru.has(batch[0].id), "a dead-lettered event is not redelivered forever"); +}); + +test("handlers only see the kinds they subscribe to", async () => { + const [e] = await events([[1n, 10]]); + let called = false; + await new Dispatcher({ handlers: [{ name: "final", kinds: ["MarketResolved"], handle: async () => void (called = true) }] }).dispatch([e]); + assert.equal(called, false); +}); diff --git a/oracledesk-stellar/agents/core/events/dispatch.ts b/oracledesk-stellar/agents/core/events/dispatch.ts new file mode 100644 index 0000000..cdbe6ac --- /dev/null +++ b/oracledesk-stellar/agents/core/events/dispatch.ts @@ -0,0 +1,143 @@ +/** + * Delivers decoded events to handlers (#105). + * + * - Events for one market run sequentially, in RPC order; different markets + * run concurrently, at most `concurrency` at a time. Events without a + * market share one sequential lane. + * - Delivery is at least once (a crash before acknowledgment redelivers), so + * handlers must be idempotent. An LRU of processed event ids, persisted with + * the checkpoint, suppresses the common duplicates. + * - A failing handler is retried with exponential backoff; once retries are + * exhausted the event is appended to the dead-letter log and the batch + * moves on, so one poisoned event cannot stall every market. + * - `dispatch` resolves only when every handler for every event has finished + * (succeeded or been dead-lettered); that is the batch acknowledgment. + */ +import { appendFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { marketOf, type EventKind, type OracleDeskEvent } from "./decode.js"; + +export interface EventHandler { + name: string; + /** Kinds this handler wants; the dispatcher skips the rest. */ + kinds: EventKind[]; + handle(event: OracleDeskEvent): Promise; +} + +/** Fixed-capacity set that forgets the oldest ids first. */ +export class ProcessedLru { + private readonly ids = new Map(); + + constructor( + private readonly capacity = 10_000, + initial: string[] = [], + ) { + for (const id of initial) this.add(id); + } + + has(id: string): boolean { + return this.ids.has(id); + } + + add(id: string): void { + this.ids.delete(id); + this.ids.set(id, true); + while (this.ids.size > this.capacity) this.ids.delete(this.ids.keys().next().value!); + } + + /** Oldest first, for persistence. */ + toJSON(): string[] { + return [...this.ids.keys()]; + } +} + +export interface DeadLetter { + at: string; + handler: string; + attempts: number; + error: string; + event: OracleDeskEvent; +} + +export interface DispatcherOptions { + handlers: EventHandler[]; + lru?: ProcessedLru; + concurrency?: number; + maxAttempts?: number; + backoffBaseMs?: number; + /** JSON Lines file; default ./.events/dead-letter.jsonl. */ + deadLetterPath?: string; + sleep?: (ms: number) => Promise; + log?: (event: string, fields?: Record) => void; +} + +function json(value: unknown): string { + return JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v)); +} + +export class Dispatcher { + readonly lru: ProcessedLru; + private readonly concurrency: number; + private readonly maxAttempts: number; + private readonly deadLetterPath: string; + private readonly sleep: (ms: number) => Promise; + + constructor(private readonly o: DispatcherOptions) { + this.lru = o.lru ?? new ProcessedLru(); + this.concurrency = Math.max(1, o.concurrency ?? 4); + this.maxAttempts = Math.max(1, o.maxAttempts ?? 5); + this.deadLetterPath = o.deadLetterPath ?? "./.events/dead-letter.jsonl"; + this.sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); + } + + /** Delivers one batch; resolves once every event is handled or dead-lettered. */ + async dispatch(events: OracleDeskEvent[]): Promise { + const lanes = new Map(); + for (const e of events) { + if (this.lru.has(e.id)) { + this.o.log?.("EVENT_DUPLICATE", { id: e.id }); + continue; + } + const key = marketOf(e)?.toString() ?? "global"; + lanes.set(key, [...(lanes.get(key) ?? []), e]); + } + const queue = [...lanes.values()]; + const workers = Array.from({ length: Math.min(this.concurrency, queue.length) }, async () => { + for (let lane = queue.shift(); lane; lane = queue.shift()) { + for (const e of lane) await this.deliver(e); + } + }); + await Promise.all(workers); + } + + private async deliver(e: OracleDeskEvent): Promise { + for (const h of this.o.handlers) { + if (!h.kinds.includes(e.kind)) continue; + await this.withRetries(h, e); + } + this.lru.add(e.id); + } + + private async withRetries(h: EventHandler, e: OracleDeskEvent): Promise { + for (let attempt = 1; ; attempt++) { + try { + await h.handle(e); + return; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + if (attempt >= this.maxAttempts) { + this.deadLetter({ at: new Date().toISOString(), handler: h.name, attempts: attempt, error, event: e }); + return; + } + this.o.log?.("EVENT_HANDLER_RETRY", { handler: h.name, id: e.id, attempt, error }); + await this.sleep((this.o.backoffBaseMs ?? 500) * 2 ** (attempt - 1)); + } + } + } + + private deadLetter(entry: DeadLetter): void { + mkdirSync(dirname(this.deadLetterPath), { recursive: true }); + appendFileSync(this.deadLetterPath, json(entry) + "\n"); + this.o.log?.("EVENT_DEAD_LETTER", { handler: entry.handler, id: entry.event.id, error: entry.error }); + } +} diff --git a/oracledesk-stellar/agents/core/events/handlers.test.ts b/oracledesk-stellar/agents/core/events/handlers.test.ts new file mode 100644 index 0000000..d4a3656 --- /dev/null +++ b/oracledesk-stellar/agents/core/events/handlers.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { OracleDeskEvent } from "./decode.js"; +import { disputeDeadline, finalityTracker, proposalWatch, tradeReevaluate, type ProposalAlert } from "./handlers.js"; + +const meta = { id: "1", ledger: 10, closedAt: 1_700_000_000, txHash: "00", contract: "market_core" as const, contractId: "C" }; +const tradeOf = (marketId: bigint, collateral: bigint): OracleDeskEvent => ({ + ...meta, + kind: "Trade", + marketId, + trader: "G", + outcome: "Yes", + isBuy: true, + collateral, + shares: 1n, + fee: 0n, + reserveYes: 1n, + reserveNo: 1n, +}); + +test("TradeReevaluate queues a market only after a trade above the threshold, and re-reads it first", async () => { + const queued: bigint[] = []; + const reads: bigint[] = []; + const h = tradeReevaluate({ + threshold: 1_000n, + readMarket: async (id) => { + reads.push(id); + return { marketId: id, open: id !== 3n, yesBps: 5000 }; + }, + enqueue: (id) => queued.push(id), + }); + await h.handle(tradeOf(1n, 999n)); + await h.handle(tradeOf(2n, 1_000n)); + await h.handle(tradeOf(3n, 5_000n)); // closed since the trade + assert.deepEqual(reads, [2n, 3n], "small trades do not even read the chain"); + assert.deepEqual(queued, [2n]); +}); + +test("ProposalWatch alerts on a live proposal for a market the treasury is exposed to", async () => { + const alerts: ProposalAlert[] = []; + const exposure = new Map([[5n, 300n], [6n, 0n], [7n, 100n]]); + const state = new Map([[5n, "SignersPending"], [6n, "SignersPending"], [7n, "Finalized"]]); + const h = proposalWatch({ + exposure: async (id) => exposure.get(id) ?? 0n, + proposalState: async (id) => state.get(id) ?? "Unconfigured", + disputeWindowSecs: async () => 600n, + alert: (a) => void alerts.push(a), + }); + for (const marketId of [5n, 6n, 7n]) { + await h.handle({ ...meta, contract: "resolver", kind: "ResolutionProposed", marketId, outcome: "Yes", proposedAt: 1_699_999_900n }); + } + assert.equal(alerts.length, 1, "no exposure, or a proposal no longer standing (cancelled/finalized), is not alerted"); + assert.deepEqual(alerts[0], { + marketId: 5n, + outcome: "Yes", + exposure: 300n, + proposedAt: 1_699_999_900n, + disputeDeadline: 1_700_000_500n, + secondsLeft: 500n, + }); +}); + +test("dispute deadlines come from the proposal's ledger timestamp", () => { + assert.equal(disputeDeadline(1_700_000_000n, 86_400n), 1_700_086_400n); +}); + +test("FinalityTracker marks a market final from any resolution path", async () => { + const finals: [bigint, string][] = []; + const h = finalityTracker({ markFinal: (id, via) => void finals.push([id, via]) }); + assert.deepEqual(h.kinds, ["MarketResolved", "ResolutionFinalized", "PriceResolution"]); + await h.handle({ ...meta, kind: "MarketResolved", marketId: 1n, outcome: null }); + await h.handle({ ...meta, contract: "resolver", kind: "ResolutionFinalized", marketId: 2n, outcome: "Invalid", finalizedAt: 1n }); + await h.handle({ ...meta, contract: "resolver", kind: "PriceResolution", marketId: 3n, price: 1n, priceTimestamp: 1n, outcome: "No", fromSnapshot: false }); + assert.deepEqual(finals, [[1n, "MarketResolved"], [2n, "ResolutionFinalized"], [3n, "PriceResolution"]]); +}); diff --git a/oracledesk-stellar/agents/core/events/handlers.ts b/oracledesk-stellar/agents/core/events/handlers.ts new file mode 100644 index 0000000..f8750ad --- /dev/null +++ b/oracledesk-stellar/agents/core/events/handlers.ts @@ -0,0 +1,111 @@ +/** + * Example event handlers (#105). Each is idempotent: handling the same event + * twice re-reads chain state and reaches the same result, because events are + * delivered at least once. None trusts the event alone; each re-reads the + * chain before acting. + */ +import type { OracleDeskEvent } from "./decode.js"; +import type { EventHandler } from "./dispatch.js"; + +type Of = Extract; + +export interface MarketSnapshot { + marketId: bigint; + open: boolean; + yesBps: number; +} + +/** + * After a trade of at least `threshold` stroops, re-reads the market and, if + * it is still open, queues it for the trader to re-evaluate. + */ +export function tradeReevaluate(deps: { + threshold: bigint; + readMarket: (marketId: bigint) => Promise; + enqueue: (marketId: bigint) => void; + log?: (event: string, fields?: Record) => void; +}): EventHandler { + return { + name: "TradeReevaluate", + kinds: ["Trade"], + async handle(e) { + const t = e as Of<"Trade">; + if (t.collateral < deps.threshold) return; + const market = await deps.readMarket(t.marketId); + if (!market?.open) return; + deps.log?.("EVENT_REEVALUATE", { market: t.marketId, collateral: t.collateral, yesBps: market.yesBps }); + deps.enqueue(t.marketId); + }, + }; +} + +/** Unix seconds by which a dispute must be raised, from ledger timestamps. */ +export function disputeDeadline(proposedAt: bigint, disputeWindowSecs: bigint): bigint { + return proposedAt + disputeWindowSecs; +} + +export interface ProposalAlert { + marketId: bigint; + outcome: string; + exposure: bigint; + proposedAt: bigint; + /** Unix seconds; derived from the proposal's ledger timestamp. */ + disputeDeadline: bigint; + /** Seconds left at the ledger the event closed in. */ + secondsLeft: bigint; +} + +/** + * Alerts when a resolution is proposed for a market the treasury holds a + * position in. Proposals can be cancelled without an event, so it checks the + * resolver's current state first and alerts only while the proposal stands. + */ +export function proposalWatch(deps: { + exposure: (marketId: bigint) => Promise; + /** The resolver's state tag for the market (e.g. "SignersPending"). */ + proposalState: (marketId: bigint) => Promise; + disputeWindowSecs: (marketId: bigint) => Promise; + alert: (a: ProposalAlert) => void | Promise; +}): EventHandler { + return { + name: "ProposalWatch", + kinds: ["ResolutionProposed"], + async handle(e) { + const p = e as Of<"ResolutionProposed">; + const exposure = await deps.exposure(p.marketId); + if (exposure <= 0n) return; + if ((await deps.proposalState(p.marketId)) !== "SignersPending") return; + const deadline = disputeDeadline(p.proposedAt, await deps.disputeWindowSecs(p.marketId)); + await deps.alert({ + marketId: p.marketId, + outcome: p.outcome, + exposure, + proposedAt: p.proposedAt, + disputeDeadline: deadline, + secondsLeft: deadline - BigInt(p.closedAt), + }); + }, + }; +} + +/** Marks a market final once any resolution path reports it. */ +export function finalityTracker(deps: { markFinal: (marketId: bigint, via: string) => void | Promise }): EventHandler { + return { + name: "FinalityTracker", + kinds: ["MarketResolved", "ResolutionFinalized", "PriceResolution"], + async handle(e) { + await deps.markFinal((e as Of<"MarketResolved" | "ResolutionFinalized" | "PriceResolution">).marketId, e.kind); + }, + }; +} + +/** + * The retention-gap handler: events were missed, so reload every market's + * state from the chain instead of trusting incremental updates. + */ +export function resync(deps: { reloadAll: () => Promise; log?: (event: string, fields?: Record) => void }) { + return async (reason: string): Promise => { + deps.log?.("EVENTS_RESYNC", { reason }); + await deps.reloadAll(); + }; +} diff --git a/oracledesk-stellar/agents/core/events/subscriber.test.ts b/oracledesk-stellar/agents/core/events/subscriber.test.ts new file mode 100644 index 0000000..19ecbf8 --- /dev/null +++ b/oracledesk-stellar/agents/core/events/subscriber.test.ts @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { OracleDeskEvent } from "./decode.js"; +import { Dispatcher, ProcessedLru } from "./dispatch.js"; +import { checkFilters, EventSubscriber, FileCheckpointStore, MemoryCheckpointStore, RpcFilterError } from "./subscriber.js"; +import { FakeEventsRpc, IDS, testDecoder, trade } from "./testing.js"; + +async function setup(opts: { pageLimit?: number; checkpoints?: MemoryCheckpointStore } = {}) { + const rpc = await FakeEventsRpc.create(); + const checkpoints = opts.checkpoints ?? new MemoryCheckpointStore(); + const batches: OracleDeskEvent[][] = []; + const resyncs: string[] = []; + let failNext = 0; + const sub = new EventSubscriber({ + rpc, + decoder: await testDecoder(), + checkpoints, + pageLimit: opts.pageLimit, + onBatch: async (events) => { + if (failNext > 0) { + failNext--; + throw new Error("handler crashed"); + } + batches.push(events); + }, + onResync: async (reason) => void resyncs.push(reason), + }); + return { rpc, checkpoints, batches, resyncs, sub, failOnce: () => void (failNext = 1) }; +} + +test("with no checkpoint it starts at the latest ledger instead of replaying history", async () => { + const s = await setup(); + s.rpc.emit(trade(1n, 1n, 900)); + s.rpc.latest = 1_000; + assert.equal(await s.sub.pollOnce(), 0); + assert.deepEqual(s.checkpoints.saved, { cursor: undefined, lastLedger: 1_000, processed: [] }); + s.rpc.emit(trade(1n, 2n, 1_001)); + assert.equal(await s.sub.pollOnce(), 1); + assert.equal(s.batches[0][0].kind === "Trade" && s.batches[0][0].collateral, 2n); +}); + +test("the checkpoint advances only after the batch is acknowledged; a crash redelivers it", async () => { + const s = await setup(); + await s.sub.pollOnce(); + s.rpc.emit(trade(1n, 5n, 1_001)); + const before = structuredClone(s.checkpoints.saved); + s.failOnce(); + await assert.rejects(s.sub.pollOnce(), /handler crashed/); + assert.deepEqual(s.checkpoints.saved, before, "nothing acknowledged, nothing saved"); + assert.equal(await s.sub.pollOnce(), 1, "the same event is delivered again"); + assert.equal(s.batches.length, 1); + assert.notDeepEqual(s.checkpoints.saved.cursor, before.cursor); +}); + +test("checkpoints survive a restart through the file store", async () => { + const dir = mkdtempSync(join(tmpdir(), "events-")); + const key = { network: "testnet", contractIds: Object.values(IDS) }; + const rpc = await FakeEventsRpc.create(); + const delivered: string[] = []; + const make = () => + testDecoder().then( + (decoder) => + new EventSubscriber({ + rpc, + decoder, + checkpoints: new FileCheckpointStore(dir, key), + onBatch: async (events) => void delivered.push(...events.map((e) => e.id)), + onResync: async () => undefined, + }), + ); + await (await make()).pollOnce(); + const first = rpc.emit(trade(1n, 1n, 1_001)); + await (await make()).pollOnce(); + const second = rpc.emit(trade(1n, 1n, 1_002)); + await (await make()).pollOnce(); + assert.deepEqual(delivered, [first, second]); + const file = new FileCheckpointStore(dir, key).path; + assert.match(file, /testnet-[0-9a-f]{16}\.json$/); + assert.equal(JSON.parse(readFileSync(file, "utf8")).lastLedger, 1_002); +}); + +test("duplicate deliveries are suppressed by the persisted LRU of processed ids", async () => { + const checkpoints = new MemoryCheckpointStore(); + const rpc = await FakeEventsRpc.create(); + const seen: string[] = []; + const run = async () => { + const lru = new ProcessedLru(100, (await checkpoints.load()).processed); + const dispatcher = new Dispatcher({ lru, handlers: [{ name: "count", kinds: ["Trade"], handle: async (e) => void seen.push(e.id) }] }); + return new EventSubscriber({ rpc, decoder: await testDecoder(), checkpoints, onBatch: (e) => dispatcher.dispatch(e), onResync: async () => undefined, processedIds: () => lru.toJSON() }); + }; + await (await run()).pollOnce(); + const id = rpc.emit(trade(1n, 1n, 1_001)); + await (await run()).pollOnce(); + // A restart that lost the cursor (but kept the processed ids) re-reads the ledger. + checkpoints.saved = { ...checkpoints.saved, cursor: undefined, lastLedger: 1_000 }; + await (await run()).pollOnce(); + assert.deepEqual(seen, [id]); + assert.ok(checkpoints.saved.processed.includes(id)); +}); + +test("a retention gap triggers a full resync instead of skipping events", async () => { + const s = await setup(); + await s.sub.pollOnce(); + // The agent is down while RPC retention moves past its checkpoint. + s.rpc.latest = 5_000; + s.rpc.oldest = 3_000; + s.rpc.emit(trade(1n, 1n, 4_000)); + await s.sub.pollOnce(); + assert.equal(s.resyncs.length, 1); + assert.match(s.resyncs[0], /retention/); + assert.equal(s.batches.length, 0, "the stale window is not partially replayed"); + assert.equal(s.checkpoints.saved.lastLedger, 5_000); + assert.equal(s.checkpoints.saved.cursor, undefined); +}); + +test("idle polls advance the checkpoint so quiet periods are not mistaken for gaps", async () => { + const s = await setup(); + await s.sub.pollOnce(); + for (const latest of [2_000, 3_000, 4_000]) { + s.rpc.latest = latest; + s.rpc.oldest = latest - 1_500; + await s.sub.pollOnce(); + assert.equal(s.checkpoints.saved.lastLedger, latest); + } + assert.deepEqual(s.resyncs, []); +}); + +test("pages are fetched until caught up, each acknowledged before the next", async () => { + const s = await setup({ pageLimit: 2 }); + await s.sub.pollOnce(); + for (let i = 0; i < 5; i++) s.rpc.emit(trade(BigInt(i), 1n, 1_001 + i)); + assert.equal(await s.sub.pollOnce(), 5); + assert.deepEqual(s.batches.map((b) => b.length), [2, 2, 1]); + assert.ok(s.rpc.requests.every((r) => (r as { limit: number }).limit === 2)); + assert.equal(s.checkpoints.saved.lastLedger, 1_005); +}); + +test("events from failed contract calls are filtered out", async () => { + const s = await setup(); + await s.sub.pollOnce(); + s.rpc.emit(trade(1n, 1n, 1_001, { successful: false })); + const ok = s.rpc.emit(trade(2n, 1n, 1_002)); + await s.sub.pollOnce(); + assert.deepEqual(s.batches.flat().map((e) => e.id), [ok]); +}); + +test("filters beyond the RPC limits are rejected up front", async () => { + const id = IDS.market_core; + assert.throws(() => checkFilters(Array.from({ length: 6 }, () => ({ type: "contract" as const }))), RpcFilterError); + assert.throws(() => checkFilters([{ type: "contract", contractIds: Array(6).fill(id) }]), /at most 5 contract ids/); + assert.throws(() => checkFilters([{ type: "contract", topics: [["a", "b", "c", "d", "e"]] }]), /1-4 segments/); + assert.throws(() => checkFilters([{ type: "contract", topics: Array(6).fill(["*"]) }]), /at most 5 topic filters/); + assert.throws(() => checkFilters([]), RpcFilterError); + assert.doesNotThrow(() => checkFilters([{ type: "contract", contractIds: Object.values(IDS) }])); + const rpc = await FakeEventsRpc.create(); + await assert.rejects( + (async () => + new EventSubscriber({ rpc, decoder: await testDecoder(), checkpoints: new MemoryCheckpointStore(), pageLimit: 10_001, onBatch: async () => undefined, onResync: async () => undefined }))(), + /page limit/, + ); +}); diff --git a/oracledesk-stellar/agents/core/events/subscriber.ts b/oracledesk-stellar/agents/core/events/subscriber.ts new file mode 100644 index 0000000..bd5d344 --- /dev/null +++ b/oracledesk-stellar/agents/core/events/subscriber.ts @@ -0,0 +1,218 @@ +/** + * Polls Soroban RPC `getEvents` and hands decoded OracleDesk events to a + * batch handler (#105). This is a trigger system, not an indexer: it keeps + * only a cursor, the last ledger it covered and recently processed ids. + * + * - The checkpoint `{cursor, lastLedger}` is written only after the batch + * handler resolves (acknowledges). A crash before that redelivers the batch. + * - With no checkpoint it starts at the latest ledger; history is not replayed. + * - If RPC retention has moved past `lastLedger`, the missed events cannot be + * fetched: it calls `onResync` for a full state reload and restarts at the tip. + * - Idle polls still advance the checkpoint, so a quiet period never looks + * like a retention gap. + * - Events from failed contract calls are dropped; filters are checked + * against the RPC's limits up front. + */ +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { rpc } from "@stellar/stellar-sdk"; +import type { EventDecoder, OracleDeskEvent } from "./decode.js"; + +/** Soroban RPC getEvents limits. */ +export const RPC_LIMITS = { filters: 5, contractIds: 5, topicFilters: 5, topicSegments: 4, pageLimit: 10_000 } as const; + +export interface Checkpoint { + cursor?: string; + /** Highest ledger whose events have all been acknowledged. */ + lastLedger?: number; + /** Recently processed event ids (the dispatcher's LRU), oldest first. */ + processed: string[]; +} + +export interface CheckpointStore { + load(): Promise; + save(cp: Checkpoint): Promise; +} + +/** The two RPC calls the subscriber needs; `rpc.Server` satisfies it. */ +export interface EventsRpc { + getEvents: rpc.Server["getEvents"]; + getLatestLedger(): Promise<{ sequence: number }>; +} + +/** + * One JSON file per (network, contract set) under `dir`, written atomically + * (temp file + rename) so a crash never leaves a torn checkpoint. + */ +export class FileCheckpointStore implements CheckpointStore { + readonly path: string; + + constructor(dir: string, key: { network: string; contractIds: string[] }) { + mkdirSync(dir, { recursive: true }); + const identity = createHash("sha256").update([...key.contractIds].sort().join(",")).digest("hex").slice(0, 16); + this.path = join(dir, `${key.network}-${identity}.json`); + } + + async load(): Promise { + try { + const raw = JSON.parse(readFileSync(this.path, "utf8")) as Partial; + return { cursor: raw.cursor, lastLedger: raw.lastLedger, processed: raw.processed ?? [] }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return { processed: [] }; + throw err; + } + } + + async save(cp: Checkpoint): Promise { + const tmp = `${this.path}.tmp`; + writeFileSync(tmp, JSON.stringify(cp, null, 2)); + renameSync(tmp, this.path); + } +} + +export class MemoryCheckpointStore implements CheckpointStore { + saved: Checkpoint = { processed: [] }; + saves = 0; + async load(): Promise { + return structuredClone(this.saved); + } + async save(cp: Checkpoint): Promise { + this.saved = structuredClone(cp); + this.saves++; + } +} + +export interface SubscriberOptions { + rpc: EventsRpc; + decoder: EventDecoder; + checkpoints: CheckpointStore; + /** Defaults to one contract filter over every contract the decoder knows. */ + filters?: rpc.Api.EventFilter[]; + pageLimit?: number; + pollIntervalMs?: number; + /** Handles one page of events; resolving acknowledges it. */ + onBatch: (events: OracleDeskEvent[]) => Promise; + /** Called after a retention gap, before restarting at the tip. */ + onResync: (reason: string) => Promise; + /** The dispatcher's processed ids, persisted with each checkpoint. */ + processedIds?: () => string[]; + log?: (event: string, fields?: Record) => void; +} + +export class RpcFilterError extends Error {} + +/** Throws if `filters` exceed what Soroban RPC accepts. */ +export function checkFilters(filters: rpc.Api.EventFilter[]): void { + if (filters.length === 0) throw new RpcFilterError("at least one event filter is required"); + if (filters.length > RPC_LIMITS.filters) throw new RpcFilterError(`at most ${RPC_LIMITS.filters} filters per request, got ${filters.length}`); + for (const [i, f] of filters.entries()) { + if ((f.contractIds?.length ?? 0) > RPC_LIMITS.contractIds) { + throw new RpcFilterError(`filter ${i}: at most ${RPC_LIMITS.contractIds} contract ids, got ${f.contractIds!.length}`); + } + if ((f.topics?.length ?? 0) > RPC_LIMITS.topicFilters) { + throw new RpcFilterError(`filter ${i}: at most ${RPC_LIMITS.topicFilters} topic filters, got ${f.topics!.length}`); + } + for (const t of f.topics ?? []) { + if (t.length === 0 || t.length > RPC_LIMITS.topicSegments) { + throw new RpcFilterError(`filter ${i}: a topic filter needs 1-${RPC_LIMITS.topicSegments} segments, got ${t.length}`); + } + } + } +} + +/** True when an RPC error means the requested ledger or cursor is outside retention. */ +export function isRetentionError(err: unknown): boolean { + const message = typeof err === "object" && err && "message" in err ? String((err as { message: unknown }).message) : String(err); + return /oldest ledger|out of (?:the )?(?:retention|range)|ledger range|before the oldest|cursor .*(?:too old|not found|invalid)/i.test(message); +} + +export class EventSubscriber { + private readonly filters: rpc.Api.EventFilter[]; + private readonly pageLimit: number; + private stopped = false; + + constructor(private readonly o: SubscriberOptions) { + this.filters = o.filters ?? [{ type: "contract", contractIds: o.decoder.contractIds() }]; + checkFilters(this.filters); + this.pageLimit = o.pageLimit ?? 100; + if (this.pageLimit < 1 || this.pageLimit > RPC_LIMITS.pageLimit) { + throw new RpcFilterError(`page limit must be 1-${RPC_LIMITS.pageLimit}, got ${this.pageLimit}`); + } + } + + private log(event: string, fields?: Record): void { + this.o.log?.(event, fields); + } + + private async save(cp: Omit): Promise { + await this.o.checkpoints.save({ ...cp, processed: this.o.processedIds?.() ?? [] }); + } + + private async resync(reason: string, latest: number): Promise { + this.log("EVENTS_RETENTION_GAP", { reason }); + await this.o.onResync(reason); + await this.save({ cursor: undefined, lastLedger: latest }); + } + + /** + * Fetches every page available now, acknowledging each before the next. + * Returns the number of events delivered. + */ + async pollOnce(): Promise { + const cp = await this.o.checkpoints.load(); + const latest = (await this.o.rpc.getLatestLedger()).sequence; + if (cp.lastLedger === undefined) { + // No checkpoint: start at the tip rather than replaying history. + await this.save({ cursor: undefined, lastLedger: latest }); + this.log("EVENTS_START_AT_TIP", { ledger: latest }); + return 0; + } + let cursor = cp.cursor; + let lastLedger = cp.lastLedger; + let delivered = 0; + for (;;) { + const request = (cursor + ? { filters: this.filters, cursor, limit: this.pageLimit } + : { filters: this.filters, startLedger: lastLedger + 1, limit: this.pageLimit }) as rpc.Server.GetEventsRequest; + let page: rpc.Api.GetEventsResponse; + try { + page = await this.o.rpc.getEvents(request); + } catch (err) { + if (isRetentionError(err)) { + await this.resync(`ledger ${lastLedger + 1} is outside RPC retention`, latest); + return delivered; + } + throw err; + } + if (page.oldestLedger > lastLedger + 1) { + await this.resync(`RPC retention starts at ledger ${page.oldestLedger}, after ${lastLedger}`, page.latestLedger); + return delivered; + } + const events = page.events + .filter((e) => e.inSuccessfulContractCall) + .map((e) => this.o.decoder.decode(e)) + .filter((e): e is OracleDeskEvent => e !== undefined); + const full = page.events.length >= this.pageLimit; + if (events.length > 0) await this.o.onBatch(events); + delivered += events.length; + cursor = page.cursor || cursor; + // A full page may stop mid-ledger, so only the ledger before its last event is complete. + lastLedger = full ? Math.max(lastLedger, page.events.at(-1)!.ledger - 1) : Math.max(lastLedger, page.latestLedger); + await this.save({ cursor, lastLedger }); + if (!full) return delivered; + } + } + + /** Polls until `stop()`; `sleep` is injectable for tests. */ + async run(sleep: (ms: number) => Promise = (ms) => new Promise((r) => setTimeout(r, ms))): Promise { + while (!this.stopped) { + await this.pollOnce(); + if (!this.stopped) await sleep(this.o.pollIntervalMs ?? 10_000); + } + } + + stop(): void { + this.stopped = true; + } +} diff --git a/oracledesk-stellar/agents/core/events/testing.ts b/oracledesk-stellar/agents/core/events/testing.ts new file mode 100644 index 0000000..ee32a2d --- /dev/null +++ b/oracledesk-stellar/agents/core/events/testing.ts @@ -0,0 +1,152 @@ +/** + * Test support for core/events (#105): real XDR events encoded from the + * binding specs, and a fake `getEvents` RPC with a retention window, cursor + * pagination and failed-call flags, shaped like the SDK's parsed responses. + */ +import { Contract, StrKey, xdr, type contract, type rpc } from "@stellar/stellar-sdk"; +import { EventDecoder, type ContractName } from "./decode.js"; +import type { EventsRpc } from "./subscriber.js"; + +export const IDS: Record = { + market_core: StrKey.encodeContract(Buffer.alloc(32, 1)), + resolver: StrKey.encodeContract(Buffer.alloc(32, 2)), + treasury: StrKey.encodeContract(Buffer.alloc(32, 3)), + reasoning_registry: StrKey.encodeContract(Buffer.alloc(32, 4)), +}; + +export const TRADER = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 5)); + +let decoder: Promise | undefined; +export function testDecoder(): Promise { + decoder ??= EventDecoder.fromBindings(IDS); + return decoder; +} + +/** Topics and data for spec event `name`, encoded exactly as the contract would. */ +export function encodeEvent(spec: contract.Spec, name: string, values: Record): { topic: xdr.ScVal[]; value: xdr.ScVal } { + const event = spec.findEvent(name); + if (!event) throw new Error(`no event ${name} in spec`); + const topicParam = (p: xdr.ScSpecEventParamV0) => p.location().value === xdr.ScSpecEventParamLocationV0.scSpecEventParamLocationTopicList().value; + const topic = [ + ...event.prefixTopics().map((t) => xdr.ScVal.scvSymbol(t.toString())), + ...event.params().filter(topicParam).map((p) => spec.nativeToScVal(values[p.name().toString()], p.type())), + ]; + const data = event.params().filter((p) => !topicParam(p)); + const format = event.dataFormat().value; + let value: xdr.ScVal; + if (format === xdr.ScSpecEventDataFormat.scSpecEventDataFormatMap().value) { + value = xdr.ScVal.scvMap( + data + .map((p) => new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol(p.name().toString()), val: spec.nativeToScVal(values[p.name().toString()], p.type()) })) + .sort((a, b) => a.key().sym().toString().localeCompare(b.key().sym().toString())), + ); + } else if (format === xdr.ScSpecEventDataFormat.scSpecEventDataFormatVec().value) { + value = xdr.ScVal.scvVec(data.map((p) => spec.nativeToScVal(values[p.name().toString()], p.type()))); + } else { + value = data[0] ? spec.nativeToScVal(values[data[0].name().toString()], data[0].type()) : xdr.ScVal.scvVoid(); + } + return { topic, value }; +} + +export interface StoredEvent { + contract: ContractName; + name?: string; + values?: Record; + ledger: number; + successful?: boolean; + /** Raw topics/data, for events that should not decode. */ + raw?: { topic: xdr.ScVal[]; value: xdr.ScVal }; +} + +const CLOSE_TIME_0 = 1_700_000_000; + +/** In-memory getEvents with RPC semantics: ids order events, cursors resume after an id. */ +export class FakeEventsRpc implements EventsRpc { + latest = 1_000; + oldest = 1; + readonly requests: rpc.Server.GetEventsRequest[] = []; + private readonly events: (rpc.Api.EventResponse & { contractRaw: string })[] = []; + private perLedger = new Map(); + + constructor(private readonly specs: Record) {} + + static async create(): Promise { + const d = await testDecoder(); + const specs = Object.fromEntries((Object.keys(IDS) as ContractName[]).map((n) => [n, d.spec(n)])) as Record; + return new FakeEventsRpc(specs); + } + + spec(name: ContractName): contract.Spec { + return this.specs[name]; + } + + /** Appends an event; returns its id. */ + emit(e: StoredEvent): string { + const index = this.perLedger.get(e.ledger) ?? 0; + this.perLedger.set(e.ledger, index + 1); + const { topic, value } = e.raw ?? encodeEvent(this.specs[e.contract], e.name!, e.values ?? {}); + const id = `${String(e.ledger).padStart(19, "0")}-${String(index).padStart(10, "0")}`; + this.events.push({ + id, + type: "contract", + ledger: e.ledger, + ledgerClosedAt: new Date((CLOSE_TIME_0 + e.ledger * 5) * 1000).toISOString(), + transactionIndex: index, + operationIndex: 0, + inSuccessfulContractCall: e.successful ?? true, + txHash: Buffer.alloc(32, e.ledger % 256).toString("hex"), + contractId: new Contract(IDS[e.contract]), + contractRaw: IDS[e.contract], + topic, + value, + }); + this.events.sort((a, b) => (a.id < b.id ? -1 : 1)); + this.latest = Math.max(this.latest, e.ledger); + return id; + } + + async getLatestLedger(): Promise<{ sequence: number }> { + return { sequence: this.latest }; + } + + async getEvents(request: rpc.Server.GetEventsRequest): Promise { + this.requests.push(request); + const r = request as { cursor?: string; startLedger?: number; limit?: number; filters: rpc.Api.EventFilter[] }; + if (r.startLedger !== undefined && (r.startLedger < this.oldest || r.startLedger > this.latest + 1)) { + throw { code: -32600, message: `startLedger must be between the oldest ledger: ${this.oldest} and the latest ledger: ${this.latest}` }; + } + const ids = new Set(r.filters.flatMap((f) => f.contractIds ?? [])); + const after = (e: { id: string; ledger: number }) => (r.cursor ? e.id > r.cursor : e.ledger >= r.startLedger!); + const matched = this.events.filter((e) => e.ledger >= this.oldest && after(e) && (ids.size === 0 || ids.has(e.contractRaw))); + const page = matched.slice(0, r.limit ?? 100); + const cursor = page.length > 0 ? page.at(-1)!.id : `${String(this.latest).padStart(19, "0")}-9999999999`; + return { + latestLedger: this.latest, + oldestLedger: this.oldest, + latestLedgerCloseTime: String(CLOSE_TIME_0 + this.latest * 5), + oldestLedgerCloseTime: String(CLOSE_TIME_0 + this.oldest * 5), + cursor, + events: page.map(({ contractRaw: _c, ...e }) => e), + }; + } +} + +export function trade(market: bigint, collateral: bigint, ledger: number, extra: Partial = {}): StoredEvent { + return { + contract: "market_core", + name: "Trade", + ledger, + values: { + market_id: market, + trader: TRADER, + outcome: { tag: "Yes", values: undefined }, + is_buy: true, + collateral, + shares: collateral * 2n, + fee: collateral / 100n, + reserve_yes: 1_000n, + reserve_no: 2_000n, + }, + ...extra, + }; +} diff --git a/oracledesk-stellar/agents/core/fpmm.test.ts b/oracledesk-stellar/agents/core/fpmm.test.ts index 1fbb6a2..9bcf559 100644 --- a/oracledesk-stellar/agents/core/fpmm.test.ts +++ b/oracledesk-stellar/agents/core/fpmm.test.ts @@ -18,6 +18,8 @@ import { seedReserves, sellWithinLimit, settleValue, + collateralForShares, + MathError, } from "./fpmm.js"; // Same worked examples as math.rs's unit tests and model/fpmm_model.py's @@ -142,7 +144,8 @@ test("committed cross-language vectors match", () => { const lines = readFileSync(vectorsPath, "utf8").trim().split("\n"); for (const [i, line] of lines.entries()) { const c = JSON.parse(line) as { fn: string; args: string[]; ok: Record }; - const a = c.args.map((x) => BigInt(x)); + // settle_value's first arg is an outcome name, not a number. + const a = c.args.map((x) => (/^-?\d+$/.test(x) ? BigInt(x) : 0n)); let actual: Record; switch (c.fn) { case "calc_buy": { @@ -218,3 +221,57 @@ test("buyWithinLimit and sellWithinLimit mirror the on-chain boundaries", () => assert.equal(sellWithinLimit(0n, 1n, 0n), true); assert.equal(buyWithinLimit(1n, 1n, 0n), false); }); + +// ─── collateralForShares (#108) ────────────────────────────────────────────── + +function sharesOrZero(rB: bigint, rO: bigint, c: bigint, fee: bigint): bigint { + if (c <= 0n) return 0n; + try { + return calcBuy(rB, rO, c, fee).sharesOut; + } catch { + return 0n; + } +} + +test("collateralForShares matches the fpmm_model.py fixtures", () => { + const path = fileURLToPath(new URL("../../scripts/fpmm-diff/collateral_for_shares.jsonl", import.meta.url)); + const lines = readFileSync(path, "utf8").trim().split("\n"); + assert.ok(lines.length >= 40); + for (const line of lines) { + const c = JSON.parse(line) as { args: string[]; collateral_in: string }; + const [rB, rO, s, fee] = c.args.map(BigInt); + assert.equal(collateralForShares(rB, rO, s, fee).toString(), c.collateral_in, line); + } +}); + +test("collateralForShares is the minimum collateral that buys the target (exhaustive, small reserves)", () => { + for (const [rB, rO] of [[50n, 50n], [7n, 90n], [90n, 7n], [1n, 30n]]) { + for (const fee of [0n, 100n, 3_000n]) { + for (let s = 1n; s <= rB * 2n; s++) { + let brute = 1n; + while (sharesOrZero(rB, rO, brute, fee) < s) brute++; + assert.equal(collateralForShares(rB, rO, s, fee), brute, `rB=${rB} rO=${rO} fee=${fee} s=${s}`); + } + } + } +}); + +test("collateralForShares stays minimal on large random pools", () => { + let seed = 108n; + const next = () => (seed = (seed * 6364136223846793005n + 1442695040888963407n) & ((1n << 64n) - 1n)); + for (let i = 0; i < 500; i++) { + const rB = (next() % 10n ** 13n) + 1n; + const rO = (next() % 10n ** 13n) + 1n; + const fee = [0n, 30n, 100n, 250n][Number(next() % 4n)]; + const s = (next() % (rB * 3n)) + 1n; + const c = collateralForShares(rB, rO, s, fee); + assert.ok(sharesOrZero(rB, rO, c, fee) >= s); + assert.ok(sharesOrZero(rB, rO, c - 1n, fee) < s); + } +}); + +test("collateralForShares rejects invalid input", () => { + assert.throws(() => collateralForShares(0n, 1n, 1n, 0n), MathError); + assert.throws(() => collateralForShares(1n, 1n, 0n, 0n), MathError); + assert.throws(() => collateralForShares(1n, 1n, 1n, 10_000n), MathError); +}); diff --git a/oracledesk-stellar/agents/core/fpmm.ts b/oracledesk-stellar/agents/core/fpmm.ts index b3de64e..257a44c 100644 --- a/oracledesk-stellar/agents/core/fpmm.ts +++ b/oracledesk-stellar/agents/core/fpmm.ts @@ -181,6 +181,60 @@ export function collateralOutForShares( return mulDivFloor(gross, BPS - feeBps, BPS); } +/** Search budget for collateralForShares; the bracket needs far fewer steps. */ +const COLLATERAL_SEARCH_STEPS = 256; + +/** + * Exact buy-side inverse of calcBuy (#108): the minimum `collateralIn` for + * which `calcBuy(rBought, rOther, collateralIn, feeBps).sharesOut` is at + * least `sharesTarget`. + * + * The constant-product solution for the net amount is + * n = (sqrt(b^2 + 4·s·rOther) - b) / 2, b = rBought + rOther - s, + * grossed up for the fee. calcBuy rounds the fee and the new reserve up, so + * that estimate can be off by a few units either way; a bracket around it + * is then binary-searched against calcBuy itself, which makes the result + * exact. sharesOut never decreases as collateralIn grows, so the search is + * valid. Mirrors model/fpmm_model.py::collateral_for_shares. + */ +export function collateralForShares(rBought: bigint, rOther: bigint, sharesTarget: bigint, feeBps: bigint): bigint { + if (sharesTarget <= 0n || rBought <= 0n || rOther <= 0n || feeBps < 0n || feeBps >= BPS) { + throw new MathError("invalid input"); + } + const sharesFor = (c: bigint): bigint => { + if (c <= 0n) return 0n; + try { + return calcBuy(rBought, rOther, c, feeBps).sharesOut; + } catch (err) { + if (err instanceof MathError) return 0n; // too small to buy anything + throw err; + } + }; + const b = rBought + rOther - sharesTarget; + const net = (isqrt(b * b + 4n * sharesTarget * rOther) - b) / 2n; + const estimate = mulDivCeil(net > 0n ? net : 1n, BPS, BPS - feeBps); + + // hi: smallest probe known to reach the target; lo: largest known not to. + let hi = estimate; + let steps = 0; + while (sharesFor(hi) < sharesTarget) { + hi = hi * 2n + 1n; + if (++steps > COLLATERAL_SEARCH_STEPS) throw new MathError("target not reachable"); + } + let lo = hi > 64n ? hi - 64n : 0n; + while (lo > 0n && sharesFor(lo) >= sharesTarget) { + lo /= 2n; + if (++steps > COLLATERAL_SEARCH_STEPS) throw new MathError("search did not converge"); + } + while (hi - lo > 1n) { + const mid = (lo + hi) / 2n; + if (sharesFor(mid) >= sharesTarget) hi = mid; + else lo = mid; + if (++steps > COLLATERAL_SEARCH_STEPS) throw new MathError("search did not converge"); + } + return hi; +} + /** * YES price in bps of the pool **after** applying an outcome-relative * reserve update (the `newReserve*` pair of a BuyQuote/SellQuote). Mirrors diff --git a/oracledesk-stellar/agents/core/risk.ts b/oracledesk-stellar/agents/core/risk.ts new file mode 100644 index 0000000..44b7c71 --- /dev/null +++ b/oracledesk-stellar/agents/core/risk.ts @@ -0,0 +1,60 @@ +/** + * Pre-trade risk checks for cross-market arbitrage (#108). The planner + * already sizes a plan inside the hard treasury caps; these checks add the + * arbitrage-specific limits that exist because the two legs are not atomic. + */ +import { BPS } from "./fpmm.js"; +import type { ArbPlan } from "./strategies/arbitrage.js"; + +export interface ArbRiskLimits { + /** ARB_MAX_NOTIONAL: most one plan may spend across both legs. */ + maxNotional: bigint; + /** ARB_MAX_UNHEDGED_STROOPS: the first leg alone, if the second never fills. */ + maxUnhedged: bigint; + /** ARB_MARKET_HEADROOM_BPS: share of each market's max_market kept free for hedges. */ + marketHeadroomBps: bigint; + /** ARB_MAX_OPEN_LEGS: arbitrage legs in flight at once, across plans. */ + maxOpenLegs: number; + /** ARB_MAX_UNWIND_RETRIES: attempts to sell a stranded first leg. */ + maxUnwindRetries: number; +} + +export interface TreasuryRiskState { + maxTrade: bigint; + maxMarket: bigint; + /** What the treasury has already committed per market (cost basis). */ + marketSpent: Map; + dailyRemaining: bigint; + /** Arbitrage legs currently submitted but not settled. */ + openLegs: number; +} + +/** Every limit `plan` breaks; empty means it may execute. */ +export function checkArbPlan(plan: ArbPlan, state: TreasuryRiskState, limits: ArbRiskLimits): string[] { + const problems: string[] = []; + if (limits.maxUnwindRetries < 1) problems.push("ARB_MAX_UNWIND_RETRIES must allow at least one unwind attempt"); + if (limits.marketHeadroomBps < 0n || limits.marketHeadroomBps >= BPS) problems.push("ARB_MARKET_HEADROOM_BPS must be 0-9999"); + // Usable share of max_market once the reserved headroom is set aside. + const usable = (state.maxMarket * (BPS - limits.marketHeadroomBps)) / BPS; + for (const leg of plan.legs) { + if (leg.collateral > state.maxTrade) { + problems.push(`leg on market ${leg.marketId}: ${leg.collateral} exceeds max_trade ${state.maxTrade}`); + } + const spent = state.marketSpent.get(leg.marketId) ?? 0n; + if (spent + leg.collateral > usable) { + problems.push( + `leg on market ${leg.marketId}: exposure ${spent + leg.collateral} would exceed ${usable} (max_market ${state.maxMarket} less ARB_MARKET_HEADROOM_BPS=${limits.marketHeadroomBps})`, + ); + } + } + if (plan.cost > state.dailyRemaining) problems.push(`cost ${plan.cost} exceeds the remaining daily budget ${state.dailyRemaining}`); + if (plan.cost > limits.maxNotional) problems.push(`cost ${plan.cost} exceeds ARB_MAX_NOTIONAL=${limits.maxNotional}`); + const firstLeg = plan.legs[0].collateral; + if (firstLeg > limits.maxUnhedged) { + problems.push(`first leg ${firstLeg} exceeds ARB_MAX_UNHEDGED_STROOPS=${limits.maxUnhedged}, the exposure left if the second leg fails`); + } + if (state.openLegs + plan.legs.length > limits.maxOpenLegs) { + problems.push(`${state.openLegs} arbitrage legs already open; ${plan.legs.length} more would exceed ARB_MAX_OPEN_LEGS=${limits.maxOpenLegs}`); + } + return problems; +} diff --git a/oracledesk-stellar/agents/core/signing.test.ts b/oracledesk-stellar/agents/core/signing.test.ts index f6d3fa5..2a3812a 100644 --- a/oracledesk-stellar/agents/core/signing.test.ts +++ b/oracledesk-stellar/agents/core/signing.test.ts @@ -127,9 +127,9 @@ test("trace attestation verifies with real reasoning trace JSON hash offline", ( networkPassphrase: NETWORK_PASSPHRASE, registryId: REGISTRY_ID, agent: AGENT_ADDRESS, - marketId: BigInt(trace.marketId), + marketId: BigInt(trace.marketId!), action: trace.action, - traceHash: new Uint8Array(traceHash), + traceHash: Buffer.from(traceHash, "hex"), }; const { publicKey, signature } = signTraceAttestation(kp, ctx); @@ -141,7 +141,7 @@ test("trace attestation verifies with real reasoning trace JSON hash offline", ( assert.equal( verifyTraceAttestation(publicKey, signature, { ...ctx, - traceHash: new Uint8Array(modifiedHash), + traceHash: Buffer.from(modifiedHash, "hex"), }), false, ); diff --git a/oracledesk-stellar/agents/core/strategies/arb-executor.test.ts b/oracledesk-stellar/agents/core/strategies/arb-executor.test.ts new file mode 100644 index 0000000..5d9c555 --- /dev/null +++ b/oracledesk-stellar/agents/core/strategies/arb-executor.test.ts @@ -0,0 +1,278 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { calcBuy, calcSell, seedReserves } from "../fpmm.js"; +import { checkArbPlan, type ArbRiskLimits, type TreasuryRiskState } from "../risk.js"; +import { LocalTraceStore } from "../trace.js"; +import { planArbitrage, type ArbPlan, type MarketSnapshot, type RelationDecl, type Side } from "./arbitrage.js"; +import { executeArbPlan, FileArbJournal, MemoryArbJournal, recoverArbitrage, sellableCollateral, type ArbChain, type ExecutorDeps } from "./arb-executor.js"; + +const NOW = 1_700_000_000; +const relation: RelationDecl = { + id: "imp", + type: "implies", + a: 0, + b: 1, + resolution: { a: { mechanism: "price", source: "reflector:XLM" }, b: { mechanism: "price", source: "reflector:XLM" } }, + voidCorrelation: "joint", + maxCloseSkewSecs: 0, +}; + +function snapshot(id: bigint, yesBps: bigint, seed: bigint): MarketSnapshot { + const r = seedReserves(seed, yesBps); + return { id, reserveYes: r.reserveYes, reserveNo: r.reserveNo, feeBps: 100n, closeTime: BigInt(NOW + 86_400), status: "Open", resolutionHash: "00".repeat(32) }; +} + +/** A chain whose markets trade with the production FPMM math, plus scripted failures. */ +class FakeChain implements ArbChain { + readonly markets = new Map(); + readonly positions = new Map(); + readonly calls: string[] = []; + readonly traces: string[] = []; + failBuy = new Set(); + /** Market id whose buy lands on chain but whose response is lost. */ + loseBuyResponse?: bigint; + failSells = 0; + /** Called before each buy: lets a test move the market between legs. */ + beforeBuy?: (marketId: bigint) => void; + + constructor(...ms: MarketSnapshot[]) { + for (const m of ms) this.markets.set(m.id, structuredClone(m)); + } + private key(id: bigint, side: Side) { + return `${id}:${side}`; + } + async market(id: bigint) { + return structuredClone(this.markets.get(id)!); + } + async position(id: bigint) { + return { yes: this.positions.get(this.key(id, "Yes")) ?? 0n, no: this.positions.get(this.key(id, "No")) ?? 0n }; + } + /** Moves YES price by trading the pool directly, as another trader would. */ + push(id: bigint, side: Side, collateral: bigint) { + const m = this.markets.get(id)!; + const q = calcBuy(side === "Yes" ? m.reserveYes : m.reserveNo, side === "Yes" ? m.reserveNo : m.reserveYes, collateral, m.feeBps); + if (side === "Yes") [m.reserveYes, m.reserveNo] = [q.newReserveBought, q.newReserveOther]; + else [m.reserveNo, m.reserveYes] = [q.newReserveBought, q.newReserveOther]; + } + async buy(a: { marketId: bigint; outcome: Side; collateralIn: bigint; minSharesOut: bigint }) { + this.calls.push(`buy:${a.marketId}:${a.outcome}`); + this.beforeBuy?.(a.marketId); + if (this.failBuy.has(a.marketId)) throw new Error(`buy on ${a.marketId} rejected`); + const m = this.markets.get(a.marketId)!; + const yes = a.outcome === "Yes"; + const q = calcBuy(yes ? m.reserveYes : m.reserveNo, yes ? m.reserveNo : m.reserveYes, a.collateralIn, m.feeBps); + if (q.sharesOut < a.minSharesOut) throw new Error("SlippageExceeded"); + if (yes) [m.reserveYes, m.reserveNo] = [q.newReserveBought, q.newReserveOther]; + else [m.reserveNo, m.reserveYes] = [q.newReserveBought, q.newReserveOther]; + const k = this.key(a.marketId, a.outcome); + this.positions.set(k, (this.positions.get(k) ?? 0n) + q.sharesOut); + if (this.loseBuyResponse === a.marketId) { + this.loseBuyResponse = undefined; + throw new Error("connection reset"); + } + return q.sharesOut; + } + async sell(a: { marketId: bigint; outcome: Side; collateralOut: bigint; maxSharesIn: bigint }) { + this.calls.push(`sell:${a.marketId}:${a.outcome}`); + if (this.failSells > 0) { + this.failSells--; + throw new Error("sell rejected"); + } + const m = this.markets.get(a.marketId)!; + const yes = a.outcome === "Yes"; + const q = calcSell(yes ? m.reserveYes : m.reserveNo, yes ? m.reserveNo : m.reserveYes, a.collateralOut, m.feeBps); + if (q.sharesIn > a.maxSharesIn) throw new Error("SlippageExceeded"); + if (yes) [m.reserveYes, m.reserveNo] = [q.newReserveSold, q.newReserveOther]; + else [m.reserveNo, m.reserveYes] = [q.newReserveSold, q.newReserveOther]; + const k = this.key(a.marketId, a.outcome); + this.positions.set(k, (this.positions.get(k) ?? 0n) - q.sharesIn); + return q.sharesIn; + } + async publishTrace(a: { marketId: bigint; traceHash: string }) { + this.traces.push(a.traceHash); + } +} + +function setup(o: { maxLegLoss?: bigint; journal?: MemoryArbJournal | FileArbJournal } = {}) { + const a = snapshot(0n, 7000n, 20_000_000_000n); + const b = snapshot(1n, 4000n, 5_000_000_000n); + const plan = planArbitrage({ + relation, + graphHash: "f".repeat(64), + markets: new Map([[0n, a], [1n, b]]), + holdings: new Map(), + caps: { maxTrade: 10n ** 15n, marketHeadroom: new Map([[0n, 10n ** 15n], [1n, 10n ** 15n]]), dailyRemaining: 10n ** 15n, maxNotional: 2_000_000_000n }, + thresholds: { minProfit: 1n, minProfitBps: 0n }, + }).plan!; + const chain = new FakeChain(a, b); + const journal = o.journal ?? new MemoryArbJournal(); + const deps: ExecutorDeps = { + chain, + journal, + traceStore: new LocalTraceStore(mkdtempSync(join(tmpdir(), "arb-traces-"))), + maxLegLoss: o.maxLegLoss ?? 10_000_000n, + maxUnwindRetries: 2, + now: () => NOW, + }; + return { plan, chain, journal, deps }; +} + +const steps = (j: { entries(): { step: string }[] }) => j.entries().map((e) => e.step); + +test("a plan executes the less liquid leg first, then the second, and publishes a trace", async () => { + const { plan, chain, journal, deps } = setup(); + assert.equal(plan.legs[0].marketId, 1n, "market 1 has the smaller pool"); + const r = await executeArbPlan(plan, deps); + assert.equal(r.status, "completed"); + assert.deepEqual(chain.calls, ["buy:1:Yes", "buy:0:No"]); + assert.ok(r.leg1!.shares >= plan.shares && r.leg2!.shares >= plan.shares); + assert.deepEqual(steps(journal), ["planned", "leg1:intent", "leg1:done", "leg2:intent", "leg2:done", "trace:published", "closed"]); + assert.equal(chain.traces.length, 1); +}); + +test("the trace records the relation, graph hash, snapshots, plan and leg results", async () => { + const { plan, deps } = setup(); + const r = await executeArbPlan(plan, deps); + const published = deps.journal.entries().find((e) => e.step === "trace:published")!.data!; + const trace = JSON.parse(readFileSync(String(published.cid).replace("local://", ""), "utf8")) as { inputs: Record }; + assert.equal(trace.inputs.graphHash, plan.graphHash); + assert.deepEqual((trace.inputs.relation as { id: string }).id, "imp"); + assert.equal((trace.inputs.snapshots as unknown[]).length, 2); + assert.equal((trace.inputs.result as { status: string }).status, r.status); +}); + +test("adverse movement of the second market beyond ARB_MAX_LEG_LOSS unwinds leg one", async () => { + const { plan, chain, journal, deps } = setup({ maxLegLoss: 1_000n }); + // Someone buys NO on market 0 right after our first leg fills. + chain.beforeBuy = (id) => { + if (id === 1n) chain.push(0n, "No", 3_000_000_000n); + }; + const r = await executeArbPlan(plan, deps); + assert.equal(r.status, "unwound"); + assert.match(r.reason!, /ARB_MAX_LEG_LOSS/); + assert.deepEqual(chain.calls, ["buy:1:Yes", "sell:1:Yes"]); + assert.ok(steps(journal).includes("unwind:done")); + assert.ok((await chain.position(1n)).yes < r.leg1!.shares, "leg one was sold back"); + assert.equal(chain.traces.length, 1, "an unwound plan still publishes its trace"); +}); + +test("a failed first leg never submits the second", async () => { + const { plan, chain, deps } = setup(); + chain.failBuy.add(1n); + const r = await executeArbPlan(plan, deps); + assert.equal(r.status, "aborted"); + assert.deepEqual(chain.calls, ["buy:1:Yes"]); + assert.equal(chain.traces.length, 0); +}); + +test("a first leg whose response is lost is reconciled from the position, not repeated", async () => { + const { plan, chain, deps } = setup(); + chain.loseBuyResponse = 1n; + const r = await executeArbPlan(plan, deps); + assert.equal(r.status, "completed"); + assert.deepEqual(chain.calls, ["buy:1:Yes", "buy:0:No"]); +}); + +test("a failed second leg unwinds the first, retrying the sell", async () => { + const { plan, chain, deps } = setup(); + chain.failBuy.add(0n); + chain.failSells = 1; + const r = await executeArbPlan(plan, deps); + assert.equal(r.status, "unwound"); + assert.deepEqual(chain.calls, ["buy:1:Yes", "buy:0:No", "sell:1:Yes", "sell:1:Yes"]); +}); + +test("unwind retries are bounded; exhausting them reports the leg as unhedged", async () => { + const { plan, chain, deps } = setup(); + chain.failBuy.add(0n); + chain.failSells = 10; + const r = await executeArbPlan(plan, deps); + assert.equal(r.status, "unwind-failed"); + assert.equal(chain.calls.filter((c) => c.startsWith("sell")).length, 2); +}); + +test("a crash after leg one is recovered from the journal: leg two completes", async () => { + const dir = mkdtempSync(join(tmpdir(), "arb-journal-")); + const journal = new FileArbJournal(join(dir, "journal.jsonl")); + const { plan, chain, deps } = setup({ journal }); + // Simulate the process dying right after leg one: run it, drop everything after leg1:done. + await executeArbPlan(plan, { ...deps, chain: { ...chain, buy: chain.buy.bind(chain), market: chain.market.bind(chain), position: chain.position.bind(chain), sell: chain.sell.bind(chain), publishTrace: chain.publishTrace.bind(chain) } }).catch(() => undefined); + const full = journal.entries(); + const cut = full.findIndex((e) => e.step === "leg1:done") + 1; + const crashed = new FileArbJournal(join(dir, "crashed.jsonl")); + full.slice(0, cut).forEach((e) => crashed.append(e)); + // Undo leg two on the fake chain so the world matches the crashed journal. + const fresh = setup(); + await fresh.chain.buy({ marketId: 1n, outcome: "Yes", collateralIn: full[1].data!.collateral as bigint, minSharesOut: plan.shares }); + fresh.chain.calls.length = 0; + + const results = await recoverArbitrage({ ...fresh.deps, journal: crashed }); + assert.equal(results.length, 1); + assert.equal(results[0].status, "completed"); + assert.deepEqual(fresh.chain.calls, ["buy:0:No"], "leg one is not bought again"); + assert.equal(crashed.entries().at(-1)!.step, "closed"); + assert.deepEqual(await recoverArbitrage({ ...fresh.deps, journal: crashed }), [], "a closed plan is not recovered twice"); +}); + +test("a crash with leg one in flight is settled from the treasury position", async () => { + const { plan, chain, deps } = setup(); + const journal = deps.journal as MemoryArbJournal; + journal.append({ planId: "p", step: "planned", at: NOW, data: { plan } }); + journal.append({ planId: "p", step: "leg1:intent", at: NOW, data: { collateral: plan.legs[0].collateral, positionBefore: 0n } }); + // It never landed: recovery aborts without trading. + const aborted = await recoverArbitrage(deps); + assert.equal(aborted[0].status, "aborted"); + assert.deepEqual(chain.calls, []); +}); + +// ─── risk.ts ───────────────────────────────────────────────────────────────── + +const LIMITS: ArbRiskLimits = { maxNotional: 10n ** 12n, maxUnhedged: 10n ** 12n, marketHeadroomBps: 2_000n, maxOpenLegs: 4, maxUnwindRetries: 2 }; +function state(over: Partial = {}): TreasuryRiskState { + return { maxTrade: 10n ** 12n, maxMarket: 10n ** 12n, marketSpent: new Map(), dailyRemaining: 10n ** 12n, openLegs: 0, ...over }; +} + +test("checkArbPlan passes a plan inside every limit", () => { + assert.deepEqual(checkArbPlan(setup().plan, state(), LIMITS), []); +}); + +test("checkArbPlan enforces treasury caps, existing exposure and headroom", () => { + const plan: ArbPlan = setup().plan; + const leg = plan.legs[0]; + assert.match(checkArbPlan(plan, state({ maxTrade: leg.collateral - 1n }), LIMITS).join(), /max_trade/); + assert.match(checkArbPlan(plan, state({ dailyRemaining: plan.cost - 1n }), LIMITS).join(), /daily budget/); + // 80% of max_market usable; existing spend leaves less than the leg. + const maxMarket = leg.collateral * 2n; + const spent = new Map([[leg.marketId, (maxMarket * 8n) / 10n - leg.collateral + 1n]]); + assert.match(checkArbPlan(plan, state({ maxMarket, marketSpent: spent }), LIMITS).join(), /ARB_MARKET_HEADROOM_BPS/); +}); + +test("checkArbPlan enforces unhedged exposure, notional, open legs and unwind retries", () => { + const plan = setup().plan; + assert.match(checkArbPlan(plan, state(), { ...LIMITS, maxUnhedged: plan.legs[0].collateral - 1n }).join(), /ARB_MAX_UNHEDGED_STROOPS/); + assert.match(checkArbPlan(plan, state(), { ...LIMITS, maxNotional: plan.cost - 1n }).join(), /ARB_MAX_NOTIONAL/); + assert.match(checkArbPlan(plan, state({ openLegs: 3 }), LIMITS).join(), /ARB_MAX_OPEN_LEGS/); + assert.match(checkArbPlan(plan, state(), { ...LIMITS, maxUnwindRetries: 0 }).join(), /ARB_MAX_UNWIND_RETRIES/); +}); + +test("sellableCollateral is the most a sell of at most `shares` can return", () => { + for (const [rS, rO, shares, fee] of [[5_000n, 9_000n, 700n, 100n], [10n ** 10n, 4n * 10n ** 9n, 123_456_789n, 30n], [800n, 800n, 30n, 0n]]) { + const c = sellableCollateral(rS, rO, shares, fee); + assert.ok(calcSell(rS, rO, c, fee).sharesIn <= shares); + let next: bigint | undefined; + try { + next = calcSell(rS, rO, c + 1n, fee).sharesIn; + } catch { + next = undefined; + } + assert.ok(next === undefined || next > shares, `${c + 1n} would still fit`); + } +}); + +test("sellableCollateral refuses a sell too small to return anything", () => { + // In an 800/800 pool even 1 stroop of collateral costs 3 shares. + assert.throws(() => sellableCollateral(800n, 800n, 1n, 0n), /nothing sellable/); +}); diff --git a/oracledesk-stellar/agents/core/strategies/arb-executor.ts b/oracledesk-stellar/agents/core/strategies/arb-executor.ts new file mode 100644 index 0000000..1cdeb63 --- /dev/null +++ b/oracledesk-stellar/agents/core/strategies/arb-executor.ts @@ -0,0 +1,368 @@ +/** + * Sequential execution of an arbitrage plan through the existing treasury + * entrypoints (#108). The legs are separate transactions, so the executor is + * a journaled state machine: + * + * planned -> leg1:intent -> leg1:done -> leg2:intent -> leg2:done -> closed + * \-> unwind:intent -> unwind:done -> closed + * + * 1. Leg one, the less liquid market, is re-quoted against fresh reserves + * and bought with min_shares_out = the plan's share count. + * 2. Market two is re-read and its cost recomputed. If it moved against the + * plan by more than ARB_MAX_LEG_LOSS, leg one is unwound with agent_sell. + * 3. Otherwise leg two is bought with the same min_shares_out. + * + * Every step is journaled before and after its submission (ARB_JOURNAL_FILE), + * so `recoverArbitrage` can finish or unwind a plan a crash interrupted, + * reading the treasury's positions to settle any ambiguous submission. + * A trace with the relation, graph hash, snapshots, plan and leg results is + * published once a plan completes or is unwound. + */ +import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { calcSell, collateralForShares, MathError } from "../fpmm.js"; +import { buildTrace, type TraceStore } from "../trace.js"; +import type { ArbLeg, ArbPlan, MarketSnapshot, Side } from "./arbitrage.js"; + +export type ArbStep = + | "planned" + | "leg1:intent" + | "leg1:done" + | "leg1:failed" + | "leg2:intent" + | "leg2:done" + | "leg2:failed" + | "unwind:intent" + | "unwind:done" + | "unwind:failed" + | "trace:published" + | "closed"; + +export type ArbStatus = "completed" | "unwound" | "aborted" | "unwind-failed"; + +export interface JournalEntry { + planId: string; + step: ArbStep; + at: number; + data?: Record; +} + +export interface ArbJournal { + append(e: JournalEntry): void; + entries(): JournalEntry[]; +} + +const encode = (v: unknown) => JSON.stringify(v, (_k, x) => (typeof x === "bigint" ? { $bigint: x.toString() } : x)); +const decode = (s: string) => JSON.parse(s, (_k, x) => (x && typeof x === "object" && "$bigint" in x ? BigInt(x.$bigint) : x)); + +/** Append-only JSON Lines journal; bigints round-trip exactly. */ +export class FileArbJournal implements ArbJournal { + constructor(readonly path: string) { + mkdirSync(dirname(path), { recursive: true }); + } + append(e: JournalEntry): void { + appendFileSync(this.path, encode(e) + "\n"); + } + entries(): JournalEntry[] { + if (!existsSync(this.path)) return []; + return readFileSync(this.path, "utf8") + .split("\n") + .filter(Boolean) + .map((l) => decode(l) as JournalEntry); + } +} + +export class MemoryArbJournal implements ArbJournal { + readonly log: JournalEntry[] = []; + append(e: JournalEntry): void { + this.log.push(structuredClone(e)); + } + entries(): JournalEntry[] { + return structuredClone(this.log); + } +} + +/** The chain operations the executor needs; the adapter supplies them in production. */ +export interface ArbChain { + market(id: bigint): Promise; + /** The treasury's shares of each side in a market. */ + position(id: bigint): Promise<{ yes: bigint; no: bigint }>; + /** agent_buy; resolves to the shares received. */ + buy(a: { marketId: bigint; outcome: Side; collateralIn: bigint; minSharesOut: bigint }): Promise; + /** agent_sell; resolves to the shares sold. */ + sell(a: { marketId: bigint; outcome: Side; collateralOut: bigint; maxSharesIn: bigint }): Promise; + publishTrace(a: { marketId: bigint; traceHash: string; cid: string }): Promise; +} + +export interface ExecutorDeps { + chain: ArbChain; + journal: ArbJournal; + traceStore: TraceStore; + /** ARB_MAX_LEG_LOSS: how much more leg two may cost than planned. */ + maxLegLoss: bigint; + /** ARB_MAX_UNWIND_RETRIES */ + maxUnwindRetries: number; + now: () => number; + log?: (event: string, fields?: Record) => void; +} + +export interface ArbResult { + planId: string; + status: ArbStatus; + leg1?: { collateral: bigint; shares: bigint }; + leg2?: { collateral: bigint; shares: bigint }; + unwind?: { collateralOut: bigint; sharesIn: bigint }; + reason?: string; +} + +export function planIdOf(plan: ArbPlan): string { + return `${plan.relationId}:${plan.graphHash.slice(0, 12)}:${plan.snapshots.map((s) => `${s.id}@${s.reserveYes}/${s.reserveNo}`).join(",")}`; +} + +/** + * The most collateral selling at most `shares` returns, exactly. The + * closed-form collateralOutForShares ignores calc_sell's rounding, so it can + * need one share more than is held (or reject a small pool outright), so this + * binary-searches calcSell directly. collateral_out is always below rOther. + */ +export function sellableCollateral(rSold: bigint, rOther: bigint, shares: bigint, feeBps: bigint): bigint { + const sharesIn = (c: bigint): bigint | undefined => { + try { + return calcSell(rSold, rOther, c, feeBps).sharesIn; + } catch (err) { + if (err instanceof MathError) return undefined; + throw err; + } + }; + let hi = rOther; + let lo = 0n; + // Invariant: lo sells within `shares` (or is 0); hi does not. + while (hi - lo > 1n) { + const mid = (lo + hi) / 2n; + const n = sharesIn(mid); + if (n !== undefined && n <= shares) lo = mid; + else hi = mid; + } + if (lo === 0n) throw new MathError("nothing sellable"); + return lo; +} + +const sideShares = (p: { yes: bigint; no: bigint }, side: Side) => (side === "Yes" ? p.yes : p.no); + +function buyCostNow(m: MarketSnapshot, leg: ArbLeg): bigint { + const [rB, rO] = leg.outcome === "Yes" ? [m.reserveYes, m.reserveNo] : [m.reserveNo, m.reserveYes]; + return collateralForShares(rB, rO, leg.shares, m.feeBps); +} + +class Run { + readonly result: ArbResult; + constructor( + readonly plan: ArbPlan, + readonly d: ExecutorDeps, + readonly planId: string, + ) { + this.result = { planId, status: "aborted" }; + } + + journal(step: ArbStep, data?: Record): void { + this.d.journal.append({ planId: this.planId, step, at: this.d.now(), data }); + this.d.log?.("ARB_STEP", { planId: this.planId, step, ...data }); + } + + async leg1(): Promise { + const leg = this.plan.legs[0]; + let collateral: bigint; + try { + collateral = buyCostNow(await this.d.chain.market(leg.marketId), leg); + } catch (err) { + return this.abort(`leg one cannot be re-quoted: ${message(err)}`); + } + if (collateral - leg.collateral > this.d.maxLegLoss) { + return this.abort(`leg one moved by ${collateral - leg.collateral} before submission, more than ARB_MAX_LEG_LOSS`); + } + const before = sideShares(await this.d.chain.position(leg.marketId), leg.outcome); + this.journal("leg1:intent", { collateral, positionBefore: before }); + try { + const shares = await this.d.chain.buy({ marketId: leg.marketId, outcome: leg.outcome, collateralIn: collateral, minSharesOut: leg.shares }); + this.result.leg1 = { collateral, shares }; + this.journal("leg1:done", { collateral, shares }); + return true; + } catch (err) { + // An error does not prove the buy did not land: check the position. + return this.settleLeg1(collateral, before, message(err)); + } + } + + async settleLeg1(collateral: bigint, before: bigint, error: string): Promise { + const leg = this.plan.legs[0]; + const delta = sideShares(await this.d.chain.position(leg.marketId), leg.outcome) - before; + if (delta >= leg.shares) { + this.result.leg1 = { collateral, shares: delta }; + this.journal("leg1:done", { collateral, shares: delta, reconciled: true }); + return true; + } + this.journal("leg1:failed", { error }); + return this.abort(`leg one failed: ${error}`); + } + + async leg2(): Promise { + const leg = this.plan.legs[1]; + let collateral: bigint; + try { + collateral = buyCostNow(await this.d.chain.market(leg.marketId), leg); + } catch (err) { + return this.unwind(`leg two cannot be re-quoted: ${message(err)}`); + } + const adverse = collateral - leg.collateral; + if (adverse > this.d.maxLegLoss) { + return this.unwind(`leg two moved against the plan by ${adverse}, more than ARB_MAX_LEG_LOSS=${this.d.maxLegLoss}`); + } + const before = sideShares(await this.d.chain.position(leg.marketId), leg.outcome); + this.journal("leg2:intent", { collateral, positionBefore: before }); + try { + const shares = await this.d.chain.buy({ marketId: leg.marketId, outcome: leg.outcome, collateralIn: collateral, minSharesOut: leg.shares }); + this.result.leg2 = { collateral, shares }; + this.journal("leg2:done", { collateral, shares }); + this.result.status = "completed"; + } catch (err) { + await this.settleLeg2(collateral, before, message(err)); + } + } + + async settleLeg2(collateral: bigint, before: bigint, error: string): Promise { + const leg = this.plan.legs[1]; + const delta = sideShares(await this.d.chain.position(leg.marketId), leg.outcome) - before; + if (delta >= leg.shares) { + this.result.leg2 = { collateral, shares: delta }; + this.journal("leg2:done", { collateral, shares: delta, reconciled: true }); + this.result.status = "completed"; + return; + } + this.journal("leg2:failed", { error }); + return this.unwind(`leg two failed: ${error}`); + } + + async unwind(reason: string, shares = this.result.leg1?.shares ?? this.plan.legs[0].shares): Promise { + const leg = this.plan.legs[0]; + this.result.reason = reason; + for (let attempt = 1; attempt <= this.d.maxUnwindRetries; attempt++) { + const before = sideShares(await this.d.chain.position(leg.marketId), leg.outcome); + let collateralOut: bigint; + try { + const m = await this.d.chain.market(leg.marketId); + const [rS, rO] = leg.outcome === "Yes" ? [m.reserveYes, m.reserveNo] : [m.reserveNo, m.reserveYes]; + collateralOut = sellableCollateral(rS, rO, shares, m.feeBps); + } catch (err) { + this.journal("unwind:failed", { attempt, error: message(err) }); + continue; + } + this.journal("unwind:intent", { attempt, collateralOut, shares, positionBefore: before, reason }); + try { + const sharesIn = await this.d.chain.sell({ marketId: leg.marketId, outcome: leg.outcome, collateralOut, maxSharesIn: shares }); + this.result.unwind = { collateralOut, sharesIn }; + this.journal("unwind:done", { collateralOut, sharesIn }); + this.result.status = "unwound"; + return; + } catch (err) { + const sold = before - sideShares(await this.d.chain.position(leg.marketId), leg.outcome); + if (sold > 0n) { + this.result.unwind = { collateralOut, sharesIn: sold }; + this.journal("unwind:done", { collateralOut, sharesIn: sold, reconciled: true }); + this.result.status = "unwound"; + return; + } + this.journal("unwind:failed", { attempt, error: message(err) }); + } + } + this.result.status = "unwind-failed"; + this.d.log?.("ARB_UNHEDGED", { planId: this.planId, market: leg.marketId, shares }); + } + + abort(reason: string): false { + this.result.status = "aborted"; + this.result.reason = reason; + return false; + } + + async finish(): Promise { + const status = this.result.status; + if (status === "completed" || status === "unwound") { + const built = buildTrace({ + agent: "trader", + marketId: Number(this.plan.legs[0].marketId), + action: "buy", + reasoning: `arbitrage ${this.plan.relationType} ${this.plan.relationId}: ${status}${this.result.reason ? ` (${this.result.reason})` : ""}`, + inputs: { + relation: { id: this.plan.relationId, type: this.plan.relationType }, + graphHash: this.plan.graphHash, + snapshots: this.plan.snapshots, + plan: this.plan, + result: this.result, + }, + }); + try { + const { cid } = await this.d.traceStore.put(built.hash, built.json); + await this.d.chain.publishTrace({ marketId: this.plan.legs[0].marketId, traceHash: built.hash, cid }); + this.journal("trace:published", { traceHash: built.hash, cid }); + } catch (err) { + // The legs are settled either way; a failed trace is reported, not retried here. + this.d.log?.("ARB_TRACE_FAILED", { planId: this.planId, error: message(err) }); + } + } + this.journal("closed", { status, reason: this.result.reason }); + return this.result; + } +} + +function message(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export async function executeArbPlan(plan: ArbPlan, d: ExecutorDeps): Promise { + const run = new Run(plan, d, planIdOf(plan)); + run.journal("planned", { plan }); + if (await run.leg1()) await run.leg2(); + return run.finish(); +} + +/** + * Finishes every plan the journal shows as not closed: a crash between steps + * leaves a "planned", an intent, or a done without its successor. Ambiguous + * submissions are settled by reading the treasury's position. + */ +export async function recoverArbitrage(d: ExecutorDeps): Promise { + const byPlan = new Map(); + for (const e of d.journal.entries()) byPlan.set(e.planId, [...(byPlan.get(e.planId) ?? []), e]); + const results: ArbResult[] = []; + for (const [planId, entries] of byPlan) { + if (entries.some((e) => e.step === "closed")) continue; + const planned = entries.find((e) => e.step === "planned"); + if (!planned) continue; + const run = new Run(planned.data!.plan as ArbPlan, d, planId); + const last = (step: ArbStep) => [...entries].reverse().find((e) => e.step === step); + const has = (step: ArbStep) => last(step) !== undefined; + d.log?.("ARB_RECOVER", { planId, last: entries.at(-1)!.step }); + + if (has("unwind:done")) run.result.status = "unwound"; + else if (has("leg2:done")) run.result.status = "completed"; + else if (has("unwind:intent")) { + const u = last("unwind:intent")!.data!; + await run.unwind(String(u.reason), u.shares as bigint); + } else if (has("leg2:intent")) { + const i = last("leg2:intent")!.data!; + run.result.leg1 = last("leg1:done")!.data as { collateral: bigint; shares: bigint }; + await run.settleLeg2(i.collateral as bigint, i.positionBefore as bigint, "interrupted after submission"); + } else if (has("leg1:done")) { + run.result.leg1 = last("leg1:done")!.data as { collateral: bigint; shares: bigint }; + await run.leg2(); + } else if (has("leg1:intent")) { + const i = last("leg1:intent")!.data!; + if (await run.settleLeg1(i.collateral as bigint, i.positionBefore as bigint, "interrupted after submission")) await run.leg2(); + } else { + run.abort("interrupted before leg one was submitted"); + } + results.push(await run.finish()); + } + return results; +} + diff --git a/oracledesk-stellar/agents/core/strategies/arbitrage.test.ts b/oracledesk-stellar/agents/core/strategies/arbitrage.test.ts new file mode 100644 index 0000000..6786bc4 --- /dev/null +++ b/oracledesk-stellar/agents/core/strategies/arbitrage.test.ts @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { calcBuy, collateralForShares, seedReserves } from "../fpmm.js"; +import { + guaranteedPayout, + parseRelations, + planArbitrage, + scenarios, + validateRelation, + type ArbCaps, + type Holding, + type MarketSnapshot, + type RelationDecl, +} from "./arbitrage.js"; + +const NOW = 1_700_000_000; +const HASH = "ab".repeat(32); +const resolution = { a: { mechanism: "price", source: "reflector:XLM" }, b: { mechanism: "price", source: "reflector:XLM" } }; + +function market(id: bigint, yesBps: bigint, seed = 10_000_000_000n, feeBps = 100n): MarketSnapshot { + const r = seedReserves(seed, yesBps); + return { id, reserveYes: r.reserveYes, reserveNo: r.reserveNo, feeBps, closeTime: BigInt(NOW + 86_400), status: "Open", resolutionHash: HASH }; +} + +function relation(type: RelationDecl["type"], voidCorrelation: "joint" | "independent" = "joint"): RelationDecl { + return { id: "r1", type, a: 0, b: 1, resolution, voidCorrelation, maxCloseSkewSecs: 0 }; +} + +const BIG = 10n ** 15n; +function caps(over: Partial = {}): ArbCaps { + return { maxTrade: BIG, marketHeadroom: new Map([[0n, BIG], [1n, BIG]]), dailyRemaining: BIG, maxNotional: BIG, ...over }; +} +const anyProfit = { minProfit: 1n, minProfitBps: 0n }; + +function plan(rel: RelationDecl, a: MarketSnapshot, b: MarketSnapshot, o: { caps?: ArbCaps; holdings?: Map; thresholds?: typeof anyProfit } = {}) { + return planArbitrage({ + relation: rel, + graphHash: "g", + markets: new Map([[a.id, a], [b.id, b]]), + holdings: o.holdings ?? new Map(), + caps: o.caps ?? caps(), + thresholds: o.thresholds ?? anyProfit, + }); +} + +test("a violated implication (P(A) > P(B)) produces a NO-A / YES-B plan", () => { + const r = plan(relation("implies"), market(0n, 7000n), market(1n, 4000n)); + assert.ok(r.plan, "reason" in r ? r.reason : ""); + const outcomes = Object.fromEntries(r.plan.legs.map((l) => [String(l.marketId), l.outcome])); + assert.deepEqual(outcomes, { "0": "No", "1": "Yes" }); + assert.ok(r.plan.profit > 0n); + assert.equal(r.plan.profit, r.plan.payoutGain - r.plan.cost); +}); + +test("coherent prices produce no plan", () => { + for (const [type, pa, pb] of [["implies", 4000n, 6000n], ["complement", 3000n, 7000n], ["exclusive", 3000n, 6500n]] as const) { + const r = plan(relation(type), market(0n, pa), market(1n, pb)); + assert.equal(r.plan, undefined, `${type} ${pa}/${pb}`); + } +}); + +test("complement and exclusive violations pick the underpriced side", () => { + const cheap = plan(relation("complement"), market(0n, 3000n), market(1n, 5000n)); + assert.deepEqual(cheap.plan?.legs.map((l) => l.outcome), ["Yes", "Yes"]); + const rich = plan(relation("exclusive"), market(0n, 7000n), market(1n, 6000n)); + assert.deepEqual(rich.plan?.legs.map((l) => l.outcome), ["No", "No"]); +}); + +test("costs include fees, price impact and rounding exactly", () => { + const r = plan(relation("implies"), market(0n, 7000n), market(1n, 4000n)); + assert.ok(r.plan); + for (const leg of r.plan.legs) { + const m = leg.marketId === 0n ? market(0n, 7000n) : market(1n, 4000n); + const [rB, rO] = leg.outcome === "Yes" ? [m.reserveYes, m.reserveNo] : [m.reserveNo, m.reserveYes]; + assert.equal(leg.collateral, collateralForShares(rB, rO, leg.shares, m.feeBps)); + assert.equal(leg.expectedShares, calcBuy(rB, rO, leg.collateral, m.feeBps).sharesOut); + } + const noFee = plan(relation("implies"), market(0n, 7000n, undefined, 0n), market(1n, 4000n, undefined, 0n)); + assert.ok(noFee.plan && noFee.plan.profit > r.plan.profit, "fees reduce profit"); +}); + +test("the planner finds the brute-force optimum on small reserves", () => { + for (const [pa, pb, seed] of [[7000n, 4000n, 2_000n], [8000n, 3000n, 900n], [6000n, 5000n, 3_000n]]) { + const a = market(0n, pa, seed, 100n); + const b = market(1n, pb, seed, 100n); + const r = plan(relation("implies"), a, b, { thresholds: { minProfit: 1n, minProfitBps: 0n } }); + let best = 0n; + const table = scenarios("implies", "joint"); + for (let s = 1n; s <= seed * 4n; s++) { + try { + const ca = collateralForShares(a.reserveNo, a.reserveYes, s, 100n); + const cb = collateralForShares(b.reserveYes, b.reserveNo, s, 100n); + const sa = calcBuy(a.reserveNo, a.reserveYes, ca, 100n).sharesOut; + const sb = calcBuy(b.reserveYes, b.reserveNo, cb, 100n).sharesOut; + const profit = guaranteedPayout(table, { yes: 0n, no: sa }, { yes: sb, no: 0n }) - ca - cb; + if (profit > best) best = profit; + } catch { + break; + } + } + assert.equal(r.plan?.profit ?? 0n, best, `${pa}/${pb} seed ${seed}`); + } +}); + +test("treasury caps and ARB_MAX_NOTIONAL bound the plan", () => { + const a = market(0n, 7000n); + const b = market(1n, 4000n); + const free = plan(relation("implies"), a, b).plan!; + const capped = plan(relation("implies"), a, b, { caps: caps({ maxTrade: free.legs[0].collateral / 4n }) }).plan!; + assert.ok(capped.legs.every((l) => l.collateral <= free.legs[0].collateral / 4n)); + const notional = plan(relation("implies"), a, b, { caps: caps({ maxNotional: 50_000_000n }) }).plan!; + assert.ok(notional.cost <= 50_000_000n); + const headroom = plan(relation("implies"), a, b, { caps: caps({ marketHeadroom: new Map([[0n, 10_000_000n], [1n, BIG]]) }) }).plan!; + assert.ok(headroom.legs.find((l) => l.marketId === 0n)!.collateral <= 10_000_000n); + const daily = plan(relation("implies"), a, b, { caps: caps({ dailyRemaining: 0n }) }); + assert.equal(daily.plan, undefined); +}); + +test("minimum absolute and percentage profit thresholds are enforced", () => { + const a = market(0n, 7000n); + const b = market(1n, 4000n); + const p = plan(relation("implies"), a, b).plan!; + assert.match((plan(relation("implies"), a, b, { thresholds: { minProfit: p.profit + 1n, minProfitBps: 0n } }) as { reason: string }).reason, /ARB_MIN_PROFIT_STROOPS/); + assert.match((plan(relation("implies"), a, b, { thresholds: { minProfit: 1n, minProfitBps: p.profitBps + 1n } }) as { reason: string }).reason, /ARB_MIN_PROFIT_BPS/); +}); + +test("void scenarios are priced in: independent voids can erase the edge", () => { + // Joint voids: both pay half, so NO-A + YES-B still pays 1 per pair. + assert.equal(guaranteedPayout(scenarios("implies", "joint"), { yes: 0n, no: 100n }, { yes: 100n, no: 0n }), 100n); + // Independent: A void (pays 50) while B resolves NO (pays 0) leaves 50. + assert.equal(guaranteedPayout(scenarios("implies", "independent"), { yes: 0n, no: 100n }, { yes: 100n, no: 0n }), 50n); + const joint = plan(relation("implies", "joint"), market(0n, 7000n), market(1n, 4000n)); + const independent = plan(relation("implies", "independent"), market(0n, 7000n), market(1n, 4000n)); + assert.ok(joint.plan); + assert.equal(independent.plan, undefined, "a 30-point gap does not survive a half-paying void leg"); +}); + +test("existing holdings are counted: a position that is already hedged needs less", () => { + const a = market(0n, 7000n); + const b = market(1n, 4000n); + const fresh = plan(relation("implies"), a, b).plan!; + // Already long NO-A without the YES-B hedge: adding legs is worth more. + const held = plan(relation("implies"), a, b, { holdings: new Map([[0n, { yes: 0n, no: 5_000_000_000n }]]) }).plan!; + assert.ok(held.profit > fresh.profit); +}); + +test("the less liquid market is traded first", () => { + const r = plan(relation("implies"), market(0n, 7000n, 50_000_000_000n), market(1n, 4000n, 5_000_000_000n)); + assert.equal(r.plan?.legs[0].marketId, 1n); +}); + +test("relations are validated against the markets and their resolution", () => { + const a = market(0n, 7000n); + const b = market(1n, 4000n); + const markets = new Map([[0n, a], [1n, b]]); + assert.deepEqual(validateRelation(relation("implies"), markets, NOW), []); + const unrelated = { ...relation("implies"), resolution: { a: resolution.a, b: { mechanism: "signers", source: "committee-7" } } }; + assert.match(validateRelation(unrelated, markets, NOW).join(), /unrelated resolution mechanisms/); + const wrongHash = { ...relation("implies"), resolution: { a: { ...resolution.a, resolutionHash: "cd".repeat(32) }, b: resolution.b } }; + assert.match(validateRelation(wrongHash, markets, NOW).join(), /does not match the declared/); + const closed = new Map([[0n, { ...a, status: "Resolved" as const }], [1n, b]]); + assert.match(validateRelation(relation("implies"), closed, NOW).join(), /not open/); + const skewed = new Map([[0n, a], [1n, { ...b, closeTime: b.closeTime + 7_200n }]]); + assert.match(validateRelation(relation("implies"), skewed, NOW).join(), /close times differ/); + assert.match(validateRelation({ ...relation("implies"), b: 9 }, markets, NOW).join(), /does not exist/); +}); + +test("a relations file without compatibility declarations is rejected, and the graph hash is stable", () => { + assert.throws(() => parseRelations(JSON.stringify({ relations: [{ id: "x", type: "implies", a: 0, b: 1, voidCorrelation: "joint" }] })), /resolution/); + assert.throws(() => parseRelations(JSON.stringify({ relations: [{ ...relation("implies"), voidCorrelation: undefined }] })), /voidCorrelation/); + const g1 = parseRelations(JSON.stringify({ relations: [relation("implies")] })); + const reordered = JSON.parse(JSON.stringify({ relations: [relation("implies")] }), (_k, v) => v); + const g2 = parseRelations(JSON.stringify({ relations: [Object.fromEntries(Object.entries(reordered.relations[0]).reverse())] })); + assert.equal(g1.hash, g2.hash); + assert.match(g1.hash, /^[0-9a-f]{64}$/); +}); diff --git a/oracledesk-stellar/agents/core/strategies/arbitrage.ts b/oracledesk-stellar/agents/core/strategies/arbitrage.ts new file mode 100644 index 0000000..dd16489 --- /dev/null +++ b/oracledesk-stellar/agents/core/strategies/arbitrage.ts @@ -0,0 +1,342 @@ +/** + * Cross-market arbitrage (#108): related markets whose independent FPMM + * pools price them inconsistently. + * + * Relations (P = probability of YES): + * implies(A, B): P(A) <= P(B) (A cannot happen without B) + * complement(A, B): P(A) + P(B) = 1 (exactly one happens) + * exclusive(A, B): P(A) + P(B) <= 1 (at most one happens) + * + * The planner never trusts prices alone: for every pair of legs it builds the + * payoff table over every outcome the relation allows, plus void scenarios + * (a voided market pays (yes + no) / 2 per share, see fpmm.settleValue), and + * takes the worst case as the guaranteed payout. Costs are exact integer + * FPMM collateral (fees, price impact and rounding included) from + * collateralForShares. Existing treasury holdings are part of the position + * whose worst case is measured, so the plan's value is what it adds. + */ +import { createHash } from "node:crypto"; +import { z } from "zod"; +import { BPS, calcBuy, collateralForShares, MathError, priceYesBps, settleValue, type Settlement } from "../fpmm.js"; + +export type Side = "Yes" | "No"; +export type RelationType = "implies" | "complement" | "exclusive"; +type Outcome = "yes" | "no" | "void"; + +const resolutionDecl = z.object({ + /** How the market resolves, e.g. "price", "signers", "committee". */ + mechanism: z.string().min(1), + /** What it resolves against, e.g. "reflector:XLM/USD" or a signer set id. */ + source: z.string().min(1), + /** Optional hex commitment; when given it must equal the market's on-chain resolution_hash. */ + resolutionHash: z.string().regex(/^[0-9a-f]{64}$/).optional(), +}); + +const relationDecl = z.object({ + id: z.string().min(1), + type: z.enum(["implies", "complement", "exclusive"]), + a: z.number().int().nonnegative(), + b: z.number().int().nonnegative(), + /** Required: a relation between markets with no declared resolution compatibility is rejected. */ + resolution: z.object({ a: resolutionDecl, b: resolutionDecl }), + /** "joint": the two markets void together or not at all; "independent": either may void alone. */ + voidCorrelation: z.enum(["joint", "independent"]), + /** Largest allowed difference between the two close times (default 0: equal). */ + maxCloseSkewSecs: z.number().int().nonnegative().default(0), +}); + +export const RelationsFileSchema = z.object({ relations: z.array(relationDecl).min(1) }); +export type RelationDecl = z.output; + +export interface RelationGraph { + relations: RelationDecl[]; + /** sha256 of the canonical relations JSON; recorded in every trace. */ + hash: string; +} + +function canonical(v: unknown): string { + if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`; + if (v && typeof v === "object") { + return `{${Object.keys(v as object) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonical((v as Record)[k])}`) + .join(",")}}`; + } + return JSON.stringify(v); +} + +/** Parses an ARB_RELATIONS_FILE document. Throws with every problem listed. */ +export function parseRelations(text: string): RelationGraph { + const parsed = RelationsFileSchema.safeParse(JSON.parse(text)); + if (!parsed.success) { + throw new Error(`invalid relations file:\n - ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n - ")}`); + } + const relations = parsed.data.relations; + const ids = new Set(); + for (const r of relations) { + if (ids.has(r.id)) throw new Error(`invalid relations file: duplicate relation id ${r.id}`); + ids.add(r.id); + if (r.a === r.b) throw new Error(`invalid relations file: relation ${r.id} relates market ${r.a} to itself`); + } + return { relations, hash: createHash("sha256").update(canonical(relations)).digest("hex") }; +} + +export interface MarketSnapshot { + id: bigint; + reserveYes: bigint; + reserveNo: bigint; + feeBps: bigint; + closeTime: bigint; + status: "Open" | "Resolved" | "Void"; + /** Hex of the market's on-chain resolution_hash. */ + resolutionHash: string; +} + +export interface Holding { + yes: bigint; + no: bigint; +} + +/** Every reason `relation` cannot be traded right now; empty when it can. */ +export function validateRelation(rel: RelationDecl, markets: Map, nowSecs: number): string[] { + const problems: string[] = []; + const a = markets.get(BigInt(rel.a)); + const b = markets.get(BigInt(rel.b)); + for (const [label, id, m] of [["a", rel.a, a], ["b", rel.b, b]] as const) { + if (!m) problems.push(`${rel.id}: market ${label}=${id} does not exist`); + else if (m.status !== "Open" || m.closeTime <= BigInt(nowSecs)) problems.push(`${rel.id}: market ${label}=${id} is not open`); + } + if (a && b) { + const skew = a.closeTime > b.closeTime ? a.closeTime - b.closeTime : b.closeTime - a.closeTime; + if (skew > BigInt(rel.maxCloseSkewSecs)) { + problems.push(`${rel.id}: close times differ by ${skew}s, more than maxCloseSkewSecs=${rel.maxCloseSkewSecs}`); + } + const ra = rel.resolution.a; + const rb = rel.resolution.b; + if (ra.mechanism !== rb.mechanism || ra.source !== rb.source) { + problems.push(`${rel.id}: unrelated resolution mechanisms (${ra.mechanism}:${ra.source} vs ${rb.mechanism}:${rb.source})`); + } + for (const [label, decl, m] of [["a", ra, a], ["b", rb, b]] as const) { + if (decl.resolutionHash && decl.resolutionHash !== m.resolutionHash) { + problems.push(`${rel.id}: market ${label} resolution_hash ${m.resolutionHash} does not match the declared ${decl.resolutionHash}`); + } + } + } + return problems; +} + +/** The (A, B) outcomes the relation allows, including void scenarios. */ +export function scenarios(type: RelationType, voidCorrelation: "joint" | "independent"): [Outcome, Outcome][] { + const normal: [Outcome, Outcome][] = ( + [ + ["yes", "yes"], + ["yes", "no"], + ["no", "yes"], + ["no", "no"], + ] as [Outcome, Outcome][] + ).filter(([a, b]) => { + if (type === "implies") return !(a === "yes" && b === "no"); + if (type === "complement") return a !== b; + return !(a === "yes" && b === "yes"); + }); + const voids: [Outcome, Outcome][] = + voidCorrelation === "joint" + ? [["void", "void"]] + : [ + ["void", "void"], + ["void", "yes"], + ["void", "no"], + ["yes", "void"], + ["no", "void"], + ]; + return [...normal, ...voids]; +} + +/** Worst-case payout of holding `a` and `b` across `table`. */ +export function guaranteedPayout(table: [Outcome, Outcome][], a: Holding, b: Holding): bigint { + let min: bigint | undefined; + for (const [oa, ob] of table) { + const v = settleValue(oa as Settlement, a.yes, a.no) + settleValue(ob as Settlement, b.yes, b.no); + if (min === undefined || v < min) min = v; + } + return min ?? 0n; +} + +export interface ArbCaps { + /** Treasury per-trade cap (stroops). */ + maxTrade: bigint; + /** Remaining max_market headroom per market (stroops). */ + marketHeadroom: Map; + /** Remaining rolling max_daily (stroops). */ + dailyRemaining: bigint; + /** ARB_MAX_NOTIONAL: most a plan may spend across both legs. */ + maxNotional: bigint; +} + +export interface ArbThresholds { + /** ARB_MIN_PROFIT_STROOPS */ + minProfit: bigint; + /** ARB_MIN_PROFIT_BPS: profit as a share of cost. */ + minProfitBps: bigint; +} + +export interface ArbLeg { + marketId: bigint; + outcome: Side; + /** min_shares_out for the buy: the plan's share count. */ + shares: bigint; + collateral: bigint; + /** What calcBuy returns for `collateral` (at least `shares`). */ + expectedShares: bigint; +} + +export interface ArbPlan { + relationId: string; + relationType: RelationType; + graphHash: string; + /** In execution order: the less liquid market first. */ + legs: [ArbLeg, ArbLeg]; + shares: bigint; + cost: bigint; + /** Increase in worst-case payout the legs add to existing holdings. */ + payoutGain: bigint; + profit: bigint; + profitBps: bigint; + snapshots: MarketSnapshot[]; +} + +export type PlanResult = { plan: ArbPlan } | { plan: undefined; reason: string }; + +interface Candidate { + a: Side; + b: Side; +} +const CANDIDATES: Candidate[] = [ + { a: "Yes", b: "Yes" }, + { a: "Yes", b: "No" }, + { a: "No", b: "Yes" }, + { a: "No", b: "No" }, +]; + +/** The exact cost of `shares` of `side` in `m`, or undefined if unbuyable. */ +function buyCost(m: MarketSnapshot, side: Side, shares: bigint): { collateral: bigint; shares: bigint } | undefined { + const [rB, rO] = side === "Yes" ? [m.reserveYes, m.reserveNo] : [m.reserveNo, m.reserveYes]; + try { + const collateral = collateralForShares(rB, rO, shares, m.feeBps); + return { collateral, shares: calcBuy(rB, rO, collateral, m.feeBps).sharesOut }; + } catch (err) { + if (err instanceof MathError) return undefined; + throw err; + } +} + +const add = (h: Holding, side: Side, n: bigint): Holding => (side === "Yes" ? { yes: h.yes + n, no: h.no } : { yes: h.yes, no: h.no + n }); + +/** The verification scan around the search result, in shares. */ +export const VERIFY_RADIUS = 64n; + +export function planArbitrage(input: { + relation: RelationDecl; + graphHash: string; + markets: Map; + holdings: Map; + caps: ArbCaps; + thresholds: ArbThresholds; +}): PlanResult { + const { relation: rel } = input; + const ma = input.markets.get(BigInt(rel.a)); + const mb = input.markets.get(BigInt(rel.b)); + if (!ma || !mb) return { plan: undefined, reason: "market missing" }; + const ha = input.holdings.get(ma.id) ?? { yes: 0n, no: 0n }; + const hb = input.holdings.get(mb.id) ?? { yes: 0n, no: 0n }; + const table = scenarios(rel.type, rel.voidCorrelation); + const base = guaranteedPayout(table, ha, hb); + const headroom = (id: bigint) => input.caps.marketHeadroom.get(id) ?? 0n; + + let best: { profit: bigint; s: bigint; c: Candidate; la: { collateral: bigint; shares: bigint }; lb: { collateral: bigint; shares: bigint } } | undefined; + + for (const c of CANDIDATES) { + const evaluate = (s: bigint) => { + const la = buyCost(ma, c.a, s); + const lb = buyCost(mb, c.b, s); + if (!la || !lb) return undefined; + const cost = la.collateral + lb.collateral; + const feasible = + la.collateral <= input.caps.maxTrade && + lb.collateral <= input.caps.maxTrade && + la.collateral <= headroom(ma.id) && + lb.collateral <= headroom(mb.id) && + cost <= input.caps.dailyRemaining && + cost <= input.caps.maxNotional; + const gain = guaranteedPayout(table, add(ha, c.a, la.shares), add(hb, c.b, lb.shares)) - base; + return { feasible, profit: gain - cost, la, lb }; + }; + const first = evaluate(1n); + if (!first?.feasible) continue; + // Largest feasible share count: feasibility only gets harder as s grows. + let lo = 1n; + let hi = 2n; + while (evaluate(hi)?.feasible && hi < 1n << 62n) [lo, hi] = [hi, hi * 2n]; + while (hi - lo > 1n) { + const mid = (lo + hi) / 2n; + if (evaluate(mid)?.feasible) lo = mid; + else hi = mid; + } + const sMax = lo; + const profitAt = (s: bigint) => { + const e = evaluate(s); + return e?.feasible ? e.profit : undefined; + }; + // Integer ternary search for the peak of a (near-)concave profit curve. + let l = 1n; + let r = sMax; + while (r - l > 2n) { + const m1 = l + (r - l) / 3n; + const m2 = r - (r - l) / 3n; + if ((profitAt(m1) ?? -(1n << 120n)) < (profitAt(m2) ?? -(1n << 120n))) l = m1 + 1n; + else r = m2 - 1n; + } + // Rounding makes the curve slightly jagged: scan around the result. + const center = (l + r) / 2n; + const from = center > VERIFY_RADIUS ? center - VERIFY_RADIUS : 1n; + const to = center + VERIFY_RADIUS < sMax ? center + VERIFY_RADIUS : sMax; + for (let s = from; s <= to; s++) { + const e = evaluate(s); + if (!e?.feasible) continue; + if (!best || e.profit > best.profit || (e.profit === best.profit && s < best.s)) best = { profit: e.profit, s, c, la: e.la, lb: e.lb }; + } + } + + if (!best || best.profit <= 0n) return { plan: undefined, reason: "no profitable combination: prices are coherent after costs" }; + const cost = best.la.collateral + best.lb.collateral; + const profitBps = (best.profit * BPS) / cost; + if (best.profit < input.thresholds.minProfit) { + return { plan: undefined, reason: `profit ${best.profit} below ARB_MIN_PROFIT_STROOPS=${input.thresholds.minProfit}` }; + } + if (profitBps < input.thresholds.minProfitBps) { + return { plan: undefined, reason: `profit ${profitBps} bps below ARB_MIN_PROFIT_BPS=${input.thresholds.minProfitBps}` }; + } + const legA: ArbLeg = { marketId: ma.id, outcome: best.c.a, shares: best.s, collateral: best.la.collateral, expectedShares: best.la.shares }; + const legB: ArbLeg = { marketId: mb.id, outcome: best.c.b, shares: best.s, collateral: best.lb.collateral, expectedShares: best.lb.shares }; + const liquidity = (m: MarketSnapshot) => m.reserveYes + m.reserveNo; + const legs: [ArbLeg, ArbLeg] = liquidity(ma) <= liquidity(mb) ? [legA, legB] : [legB, legA]; + return { + plan: { + relationId: rel.id, + relationType: rel.type, + graphHash: input.graphHash, + legs, + shares: best.s, + cost, + payoutGain: best.profit + cost, + profit: best.profit, + profitBps, + snapshots: [ma, mb], + }, + }; +} + +/** YES probability of a snapshot, in bps; for logs and traces. */ +export function yesBps(m: MarketSnapshot): bigint { + return priceYesBps(m.reserveYes, m.reserveNo); +} diff --git a/oracledesk-stellar/agents/package-lock.json b/oracledesk-stellar/agents/package-lock.json index 7899ba6..9bd0333 100644 --- a/oracledesk-stellar/agents/package-lock.json +++ b/oracledesk-stellar/agents/package-lock.json @@ -14,7 +14,8 @@ "@oracledesk/bindings-treasury": "file:../packages/bindings/treasury", "@stellar/stellar-sdk": "^16.0.1", "dotenv": "^17.4.2", - "multiformats": "14.0.5" + "multiformats": "14.0.5", + "zod": "4.6.5" }, "devDependencies": { "@types/node": "^22.10.0", @@ -1534,6 +1535,7 @@ "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-14.0.5.tgz", "integrity": "sha512-vbIm83F2yZ1pWJGS0yl0ysracIvv56LtbrIyiIQHoLdYDJOMoLfVFsXhh9DUH4SFdkdkFhucyWniihsNzVEjkQ==", "license": "Apache-2.0 OR MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -1913,6 +1915,12 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT" } } } diff --git a/oracledesk-stellar/agents/package.json b/oracledesk-stellar/agents/package.json index eef1a6d..76dd964 100644 --- a/oracledesk-stellar/agents/package.json +++ b/oracledesk-stellar/agents/package.json @@ -5,14 +5,12 @@ "type": "module", "scripts": { "build": "tsc --noEmit", - "test": "tsx --test core/*.test.ts stellar/*.test.ts bin/*.test.ts runtime/*.test.ts", + "test": "tsx --test core/*.test.ts core/events/*.test.ts core/strategies/*.test.ts stellar/*.test.ts bin/*.test.ts runtime/*.test.ts runtime/tasks/*.test.ts sim/*.test.ts", "market-maker": "tsx bin/market-maker.ts", "trader": "tsx bin/trader.ts", - "agentd": "tsx bin/agentd.ts" - "test": "tsx --test core/*.test.ts stellar/*.test.ts bin/*.test.ts sim/*.test.ts", - "market-maker": "tsx bin/market-maker.ts", - "trader": "tsx bin/trader.ts", - "coverage": "c8 --all --src . --reporter=lcovonly --reporter=text-summary --report-dir ../coverage/agents --exclude '**/*.test.ts' --exclude 'node_modules/**' --exclude 'dist/**' --exclude '**/*.d.ts' tsx --test core/*.test.ts stellar/*.test.ts bin/*.test.ts" + "agentd": "tsx bin/agentd.ts", + "coverage": "c8 --all --src . --reporter=lcovonly --reporter=text-summary --report-dir ../coverage/agents --exclude '**/*.test.ts' --exclude 'node_modules/**' --exclude 'dist/**' --exclude '**/*.d.ts' tsx --test core/*.test.ts core/events/*.test.ts core/strategies/*.test.ts stellar/*.test.ts bin/*.test.ts runtime/*.test.ts runtime/tasks/*.test.ts sim/*.test.ts", + "test:chaos": "tsx --test test/chaos/*.test.ts" }, "dependencies": { "@oracledesk/bindings-market-core": "file:../packages/bindings/market-core", @@ -21,7 +19,8 @@ "@oracledesk/bindings-treasury": "file:../packages/bindings/treasury", "@stellar/stellar-sdk": "^16.0.1", "dotenv": "^17.4.2", - "multiformats": "14.0.5" + "multiformats": "14.0.5", + "zod": "4.6.5" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/oracledesk-stellar/agents/runtime/config.ts b/oracledesk-stellar/agents/runtime/config.ts index 5e72f8b..e992809 100644 --- a/oracledesk-stellar/agents/runtime/config.ts +++ b/oracledesk-stellar/agents/runtime/config.ts @@ -14,16 +14,25 @@ export const EXIT_CONFIG = 2; export const EXIT_TEMPFAIL = 75; /** - * `AGENT_MODE` must be exactly "dry-run" or "live" (unset = "dry-run"). + * `AGENT_MODE` must be exactly "dry-run" or "live" (unset = "dry-run"; "" is rejected). * Anything else used to fall through to the live path; now it is fatal. */ export function parseAgentMode(env: NodeJS.ProcessEnv): AgentMode { const raw = env.AGENT_MODE; - if (raw === undefined || raw === "") return "dry-run"; + if (raw === undefined) return "dry-run"; if (raw === "dry-run" || raw === "live") return raw; throw new ConfigError(`AGENT_MODE must be "dry-run" or "live", got ${JSON.stringify(raw)}`); } +/** + * Compile-time exhaustiveness check; throws if an unexpected value slips + * through at runtime. `shown` is what the message prints, so callers can pass + * a whole narrowed object as `value` without echoing its other fields. + */ +export function assertNever(value: never, what: string, shown: unknown = value): never { + throw new Error(`unknown ${what} ${JSON.stringify(shown)}: expected "dry-run" or "live"`); +} + export function intEnv(env: NodeJS.ProcessEnv, name: string, fallback: number, min = 0): number { const raw = env[name]; if (raw === undefined || raw === "") return fallback; diff --git a/oracledesk-stellar/agents/runtime/follow.test.ts b/oracledesk-stellar/agents/runtime/follow.test.ts new file mode 100644 index 0000000..08beed5 --- /dev/null +++ b/oracledesk-stellar/agents/runtime/follow.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { LocalTraceStore } from "../core/trace.js"; +import { FakeEventsRpc, IDS, testDecoder, trade } from "../core/events/testing.js"; +import { followTask, type FollowAdapter } from "./follow.js"; +import { NoLease } from "./lease.js"; +import { Runtime } from "./runtime.js"; +import { MemoryStateStore } from "./state.js"; +import type { TraderCandidate } from "./tasks/trader.js"; + +const NOW = 1_700_000_000; + +function fakeAdapter(buys: bigint[], exposure: Record): FollowAdapter { + return { + mode: "dry-run", + agent: "GTRADER", + rpcServer: () => { + throw new Error("tests inject the RPC"); + }, + contractIds: () => ({ ...IDS, usdc: IDS.treasury }), + getMarket: async () => ({ creator: "G", questionHash: "", resolutionHash: "", closeTime: BigInt(NOW + 3600), status: "Open", reserveYes: 600n, reserveNo: 400n, feeBps: 100n }), + treasuryPosition: async (id) => ({ yes: exposure[String(id)] ?? 0n, no: 0n }), + resolutionState: async () => "SignersPending", + quoteBuy: async () => 1_000n, + agentBuyV2: async (args) => { + buys.push(args.market_id); + return { dryRun: true, contractId: "CTREASURY", method: "agent_buy_v2", args }; + }, + publishTrace: async () => ({ dryRun: true, contractId: "CREGISTRY", method: "publish_trace", args: {} }), + } as FollowAdapter; +} + +const candidates = async (): Promise => + [1, 2, 3].map((marketId) => ({ market: { marketId, question: "q", yesBps: 4000, closeTime: NOW + 3600 }, fairValueBps: 6000 })); + +async function setup(exposure: Record = {}) { + const rpc = await FakeEventsRpc.create(); + const buys: bigint[] = []; + const logs: { event: string; fields?: Record }[] = []; + const stateDir = mkdtempSync(join(tmpdir(), "follow-")); + const task = await followTask({ + cfg: { + network: "testnet", + events: { stateDir, tradeThresholdStroops: 1_000n, pollIntervalMs: 1, pageLimit: 100, concurrency: 2, maxAttempts: 2, disputeWindowSecs: 600 }, + }, + adapter: fakeAdapter(buys, exposure), + trader: { + adapter: fakeAdapter(buys, exposure), + traceStore: new LocalTraceStore(stateDir), + candidates, + minEdgeBps: 500, + tradeSizeStroops: 100n, + slippageBps: 50n, + deadlineSecs: 60, + nowSecs: () => NOW, + }, + rpc, + decoder: await testDecoder(), + nowSecs: () => NOW, + log: (event, fields) => void logs.push({ event, fields }), + }); + const runtime = new Runtime({ + tasks: [task], + state: new MemoryStateStore(), + lease: new NoLease(), + shutdownGraceMs: 1_000, + maxConsecutiveFailures: 3, + failureWindowMs: 60_000, + maxFailuresInWindow: 10, + log: { info: () => undefined, warn: () => undefined, error: () => undefined }, + }); + return { rpc, buys, logs, runtime, task }; +} + +test("--follow re-evaluates only markets with a significant trade", async () => { + const s = await setup(); + await s.runtime.runOnce(); // starts at the tip + assert.deepEqual(s.buys, [], "no periodic evaluation of every market"); + s.rpc.emit(trade(2n, 5_000n, 1_001)); // significant + s.rpc.emit(trade(3n, 10n, 1_002)); // below EVENTS_TRADE_THRESHOLD + await s.runtime.runOnce(); + assert.deepEqual(s.buys, [2n]); + await s.runtime.runOnce(); + assert.deepEqual(s.buys, [2n], "an acknowledged event does not trigger again"); +}); + +test("--follow alerts on a resolution proposal where the treasury has exposure", async () => { + const s = await setup({ "1": 400n }); + await s.runtime.runOnce(); + for (const market_id of [1n, 3n]) { + s.rpc.emit({ contract: "resolver", name: "ResolutionProposedV2", ledger: 1_001, values: { market_id, outcome: { tag: "No", values: undefined }, proposed_at: BigInt(NOW) } }); + } + await s.runtime.runOnce(); + const alerts = s.logs.filter((l) => l.event === "PROPOSAL_ALERT"); + assert.equal(alerts.length, 1); + assert.equal(alerts[0].fields?.marketId, 1n); + assert.equal(alerts[0].fields?.disputeDeadline, BigInt(NOW + 600)); +}); + +test("a retention gap in follow mode re-evaluates every tradable market", async () => { + const s = await setup(); + await s.runtime.runOnce(); + s.rpc.latest = 9_000; + s.rpc.oldest = 8_000; + await s.runtime.runOnce(); + assert.deepEqual(s.buys, [1n, 2n, 3n]); + assert.ok(s.logs.some((l) => l.event === "EVENTS_RESYNC")); +}); diff --git a/oracledesk-stellar/agents/runtime/follow.ts b/oracledesk-stellar/agents/runtime/follow.ts new file mode 100644 index 0000000..be4ecdd --- /dev/null +++ b/oracledesk-stellar/agents/runtime/follow.ts @@ -0,0 +1,130 @@ +/** + * `trader --follow` (#105): instead of re-evaluating every market on a timer, + * the trader polls contract events and re-evaluates only the markets the + * handlers queue. Each tick of the follow task polls once, delivers the + * events, then runs the normal trader tick restricted to the queued markets. + * + * Checkpoints live in EVENTS_STATE_DIR, one file per network and contract set. + */ +import { join } from "node:path"; +import type { AgentConfig } from "../core/config.js"; +import { priceYesBps } from "../core/fpmm.js"; +import { EventDecoder } from "../core/events/decode.js"; +import { Dispatcher, ProcessedLru } from "../core/events/dispatch.js"; +import { finalityTracker, proposalWatch, resync, tradeReevaluate } from "../core/events/handlers.js"; +import { EventSubscriber, FileCheckpointStore, type EventsRpc } from "../core/events/subscriber.js"; +import type { StellarAdapter } from "../stellar/adapter.js"; +import type { Task, TaskContext } from "./runtime.js"; +import { traderTick, type TraderCandidate, type TraderDeps } from "./tasks/trader.js"; + +export const FINAL = "events.final"; + +/** Market ids waiting for re-evaluation, deduplicated, in arrival order. */ +export class MarketQueue { + private readonly ids = new Set(); + enqueue(id: bigint): void { + this.ids.add(id); + } + drain(): bigint[] { + const out = [...this.ids]; + this.ids.clear(); + return out; + } + get size(): number { + return this.ids.size; + } +} + +export type FollowAdapter = Pick< + StellarAdapter, + "rpcServer" | "contractIds" | "getMarket" | "treasuryPosition" | "resolutionState" | "mode" | "agent" | "quoteBuy" | "agentBuyV2" | "publishTrace" +>; + +export interface FollowDeps { + cfg: Pick; + adapter: FollowAdapter; + /** The trader's deps; its `candidates` are filtered to queued markets. */ + trader: TraderDeps; + rpc?: EventsRpc; + decoder?: EventDecoder; + nowSecs: () => number; + log?: (event: string, fields?: Record) => void; +} + +export async function followTask(d: FollowDeps): Promise { + const ids = d.adapter.contractIds(); + const contracts = { market_core: ids.market_core, resolver: ids.resolver, treasury: ids.treasury, reasoning_registry: ids.reasoning_registry }; + const decoder = d.decoder ?? (await EventDecoder.fromBindings(contracts)); + const checkpoints = new FileCheckpointStore(d.cfg.events.stateDir, { network: d.cfg.network, contractIds: Object.values(contracts) }); + const lru = new ProcessedLru(10_000, (await checkpoints.load()).processed); + const queue = new MarketQueue(); + const log = d.log ?? (() => undefined); + let ctx: TaskContext | undefined; + + const readMarket = async (marketId: bigint) => { + const m = await d.adapter.getMarket(marketId); + if (!m) return undefined; + return { marketId, open: m.status === "Open" && m.closeTime > BigInt(d.nowSecs()), yesBps: Number(priceYesBps(m.reserveYes, m.reserveNo)) }; + }; + + const dispatcher = new Dispatcher({ + lru, + concurrency: d.cfg.events.concurrency, + maxAttempts: d.cfg.events.maxAttempts, + deadLetterPath: join(d.cfg.events.stateDir, "dead-letter.jsonl"), + log, + handlers: [ + tradeReevaluate({ threshold: d.cfg.events.tradeThresholdStroops, readMarket, enqueue: (id) => queue.enqueue(id), log }), + proposalWatch({ + exposure: async (id) => { + const p = await d.adapter.treasuryPosition(id); + return p.yes + p.no; + }, + proposalState: (id) => d.adapter.resolutionState(id), + disputeWindowSecs: async () => BigInt(d.cfg.events.disputeWindowSecs), + alert: (a) => log("PROPOSAL_ALERT", { ...a }), + }), + finalityTracker({ + markFinal: (id, via) => { + ctx?.state.set(FINAL, id.toString(), { via, at: d.nowSecs() }); + log("MARKET_FINAL", { market: id, via }); + }, + }), + ], + }); + + const subscriber = new EventSubscriber({ + rpc: d.rpc ?? d.adapter.rpcServer(), + decoder, + checkpoints, + pageLimit: d.cfg.events.pageLimit, + pollIntervalMs: d.cfg.events.pollIntervalMs, + onBatch: (events) => dispatcher.dispatch(events), + // Events were lost: re-evaluate every market the trader could trade. + onResync: resync({ + reloadAll: async () => { + for (const c of await d.trader.candidates()) queue.enqueue(BigInt(c.market.marketId)); + }, + log, + }), + processedIds: () => lru.toJSON(), + log, + }); + + return { + name: "trader", + mode: d.adapter.mode, + intervalMs: d.cfg.events.pollIntervalMs, + queue, + subscriber, + async run(c) { + ctx = c; + await subscriber.pollOnce(); + const wanted = new Set(queue.drain().map(String)); + if (wanted.size === 0) return; + const candidates = async (): Promise => + (await d.trader.candidates()).filter((x) => wanted.has(String(x.market.marketId))); + await traderTick({ ...d.trader, candidates }, c); + }, + }; +} diff --git a/oracledesk-stellar/agents/runtime/tasks/arbitrage.test.ts b/oracledesk-stellar/agents/runtime/tasks/arbitrage.test.ts new file mode 100644 index 0000000..42043a7 --- /dev/null +++ b/oracledesk-stellar/agents/runtime/tasks/arbitrage.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { ArbConfig } from "../../core/config.js"; +import { calcBuy, seedReserves } from "../../core/fpmm.js"; +import { MemoryArbJournal } from "../../core/strategies/arb-executor.js"; +import { LocalTraceStore } from "../../core/trace.js"; +import { NoLease } from "../lease.js"; +import { Runtime } from "../runtime.js"; +import { MemoryStateStore } from "../state.js"; +import { arbitrageTask, type ArbAdapter } from "./arbitrage.js"; + +const NOW = 1_700_000_000; +const HASH = "00".repeat(32); + +function fakeAdapter(mode: "live" | "dry-run", calls: string[]): ArbAdapter { + const pools = new Map( + [ + [0n, seedReserves(20_000_000_000n, 7000n)], + [1n, seedReserves(5_000_000_000n, 4000n)], + ].map(([id, r]) => [id as bigint, { yes: (r as { reserveYes: bigint }).reserveYes, no: (r as { reserveNo: bigint }).reserveNo }]), + ); + const held = new Map(); + return { + mode, + agent: "GAGENT", + getMarket: async (id) => { + const p = pools.get(id); + return p && { creator: "C", questionHash: "", resolutionHash: HASH, closeTime: BigInt(NOW + 86_400), status: "Open", reserveYes: p.yes, reserveNo: p.no, feeBps: 100n }; + }, + treasuryPosition: async (id) => ({ yes: held.get(`${id}:Yes`) ?? 0n, no: held.get(`${id}:No`) ?? 0n }), + riskCaps: async () => ({ maxTrade: 10n ** 12n, maxMarket: 10n ** 13n, maxDaily: 10n ** 13n, dailyUsed: 0n }), + marketSpent: async () => 0n, + agentBuy: async (a) => { + calls.push(`buy:${a.market_id}:${a.outcome.tag}`); + if (mode === "dry-run") return { dryRun: true, contractId: "T", method: "agent_buy", args: a }; + const p = pools.get(a.market_id)!; + const yes = a.outcome.tag === "Yes"; + const q = calcBuy(yes ? p.yes : p.no, yes ? p.no : p.yes, a.collateral_in, 100n); + if (yes) [p.yes, p.no] = [q.newReserveBought, q.newReserveOther]; + else [p.no, p.yes] = [q.newReserveBought, q.newReserveOther]; + held.set(`${a.market_id}:${a.outcome.tag}`, (held.get(`${a.market_id}:${a.outcome.tag}`) ?? 0n) + q.sharesOut); + return { dryRun: false, value: q.sharesOut }; + }, + agentSell: async (a) => { + calls.push(`sell:${a.market_id}`); + return { dryRun: false, value: 0n }; + }, + publishTrace: async () => { + calls.push("trace"); + return { dryRun: false, value: 0n }; + }, + } as ArbAdapter; +} + +const CFG: ArbConfig = { + enabled: true, + relationsFile: "relations.json", + journalFile: "unused", + maxNotional: 2_000_000_000n, + minProfit: 1n, + minProfitBps: 0n, + maxUnhedged: 10n ** 12n, + marketHeadroomBps: 2_000n, + maxLegLoss: 10_000_000n, + maxOpenLegs: 4, + maxUnwindRetries: 2, +}; + +function relations(over: Record = {}) { + return JSON.stringify({ + relations: [ + { + id: "imp", + type: "implies", + a: 0, + b: 1, + resolution: { a: { mechanism: "price", source: "reflector:XLM" }, b: { mechanism: "price", source: "reflector:XLM" } }, + voidCorrelation: "joint", + ...over, + }, + ], + }); +} + +async function run(mode: "live" | "dry-run", file = relations()) { + const calls: string[] = []; + const events: { event: string; fields?: Record }[] = []; + const at = (event: string, fields?: Record) => void events.push({ event, fields }); + const journal = new MemoryArbJournal(); + const task = arbitrageTask( + { adapter: fakeAdapter(mode, calls), cfg: CFG, traceStore: new LocalTraceStore(mkdtempSync(join(tmpdir(), "arb-"))), nowSecs: () => NOW, journal, readFile: () => file }, + { intervalMs: 0 }, + ); + const runtime = new Runtime({ + tasks: [task], + state: new MemoryStateStore(), + lease: new NoLease(), + shutdownGraceMs: 1_000, + maxConsecutiveFailures: 3, + failureWindowMs: 60_000, + maxFailuresInWindow: 10, + log: { info: at, warn: at, error: at }, + }); + await runtime.runOnce(); + return { calls, events, journal }; +} + +test("dry-run plans the arbitrage and logs it without trading", async () => { + const r = await run("dry-run"); + assert.ok(r.events.some((e) => e.event === "ARB_PLAN")); + assert.deepEqual(r.calls, []); + assert.equal(r.journal.log.length, 0); +}); + +test("live mode executes both legs, less liquid market first, and publishes the trace", async () => { + const r = await run("live"); + assert.deepEqual(r.calls, ["buy:1:Yes", "buy:0:No", "trace"]); + const result = r.events.find((e) => e.event === "ARB_RESULT"); + assert.equal(result?.fields?.status, "completed"); + assert.equal(r.journal.log.at(-1)!.step, "closed"); +}); + +test("an incompatible relation is rejected before anything executes", async () => { + const r = await run("live", relations({ resolution: { a: { mechanism: "price", source: "reflector:XLM" }, b: { mechanism: "signers", source: "committee-1" } } })); + assert.ok(r.events.some((e) => e.event === "ARB_RELATION_REJECTED")); + assert.deepEqual(r.calls, []); +}); diff --git a/oracledesk-stellar/agents/runtime/tasks/arbitrage.ts b/oracledesk-stellar/agents/runtime/tasks/arbitrage.ts new file mode 100644 index 0000000..c189072 --- /dev/null +++ b/oracledesk-stellar/agents/runtime/tasks/arbitrage.ts @@ -0,0 +1,142 @@ +/** + * Cross-market arbitrage as a runtime task (#108), enabled with ARB_ENABLED. + * Each tick (live mode) first finishes any plan a crash left open, then for + * every relation in ARB_RELATIONS_FILE: reads both markets and the treasury's + * positions through the bindings, validates the relation, plans, applies the + * risk checks and executes. Dry-run plans and logs without trading. + */ +import { readFileSync } from "node:fs"; +import type { ArbConfig } from "../../core/config.js"; +import { checkArbPlan } from "../../core/risk.js"; +import { executeArbPlan, FileArbJournal, recoverArbitrage, type ArbChain, type ArbJournal, type ExecutorDeps } from "../../core/strategies/arb-executor.js"; +import { parseRelations, planArbitrage, validateRelation, type Holding, type MarketSnapshot } from "../../core/strategies/arbitrage.js"; +import type { TraceStore } from "../../core/trace.js"; +import type { StellarAdapter } from "../../stellar/adapter.js"; +import type { Task, TaskContext } from "../runtime.js"; + +export type ArbAdapter = Pick< + StellarAdapter, + "mode" | "agent" | "getMarket" | "treasuryPosition" | "riskCaps" | "marketSpent" | "agentBuy" | "agentSell" | "publishTrace" +>; + +export interface ArbDeps { + adapter: ArbAdapter; + cfg: ArbConfig; + traceStore: TraceStore; + nowSecs: () => number; + journal?: ArbJournal; + readFile?: (path: string) => string; +} + +function value(r: { dryRun: boolean; value?: T }, what: string): T { + if (r.dryRun) throw new Error(`${what}: dry-run result has no value`); + return r.value as T; +} + +/** The executor's chain operations, through the real adapter. */ +export function adapterChain(adapter: ArbAdapter): ArbChain { + return { + async market(id) { + const m = await adapter.getMarket(id); + if (!m) throw new Error(`market ${id} not found`); + return { id, reserveYes: m.reserveYes, reserveNo: m.reserveNo, feeBps: m.feeBps, closeTime: m.closeTime, status: m.status, resolutionHash: m.resolutionHash }; + }, + position: (id) => adapter.treasuryPosition(id), + async buy(a) { + const r = await adapter.agentBuy({ market_id: a.marketId, outcome: { tag: a.outcome, values: undefined }, collateral_in: a.collateralIn, min_shares_out: a.minSharesOut }); + return value(r, "agent_buy"); + }, + async sell(a) { + const r = await adapter.agentSell({ market_id: a.marketId, outcome: { tag: a.outcome, values: undefined }, collateral_out: a.collateralOut, max_shares_in: a.maxSharesIn }); + return value(r, "agent_sell"); + }, + async publishTrace(a) { + await adapter.publishTrace({ agent: adapter.agent, market_id: a.marketId, action: "buy", trace_hash: Buffer.from(a.traceHash, "hex"), ipfs_cid: a.cid }); + }, + }; +} + +/** Arbitrage legs submitted but not closed, per the journal. */ +function openLegs(journal: ArbJournal): number { + const open = new Map(); + for (const e of journal.entries()) { + if (e.step === "leg1:intent" || e.step === "leg2:intent") open.set(e.planId, (open.get(e.planId) ?? 0) + 1); + if (e.step === "closed") open.delete(e.planId); + } + return [...open.values()].reduce((a, b) => a + b, 0); +} + +export async function arbitrageTick(d: ArbDeps, ctx: TaskContext): Promise { + const journal = d.journal ?? new FileArbJournal(d.cfg.journalFile); + const live = d.adapter.mode === "live"; + const executor: ExecutorDeps = { + chain: adapterChain(d.adapter), + journal, + traceStore: d.traceStore, + maxLegLoss: d.cfg.maxLegLoss, + maxUnwindRetries: d.cfg.maxUnwindRetries, + now: d.nowSecs, + log: (event, fields) => ctx.log.info(event, fields), + }; + if (live) { + for (const r of await ctx.submit(() => recoverArbitrage(executor), "arb:recover")) ctx.log.info("ARB_RECOVERED", { ...r }); + } + + const graph = parseRelations((d.readFile ?? ((p) => readFileSync(p, "utf8")))(d.cfg.relationsFile!)); + const caps = await d.adapter.riskCaps(); + const dailyRemaining = caps.maxDaily > caps.dailyUsed ? caps.maxDaily - caps.dailyUsed : 0n; + const chain = adapterChain(d.adapter); + const usable = (caps.maxMarket * (10_000n - d.cfg.marketHeadroomBps)) / 10_000n; + + for (const rel of graph.relations) { + if (ctx.signal.aborted) return; + const ids = [BigInt(rel.a), BigInt(rel.b)]; + const markets = new Map(); + for (const id of ids) { + const m = await chain.market(id).catch(() => undefined); + if (m) markets.set(id, m); + } + const problems = validateRelation(rel, markets, d.nowSecs()); + if (problems.length > 0) { + ctx.log.warn("ARB_RELATION_REJECTED", { relation: rel.id, problems }); + continue; + } + const holdings = new Map(); + const spent = new Map(); + for (const id of ids) { + holdings.set(id, await d.adapter.treasuryPosition(id)); + spent.set(id, await d.adapter.marketSpent(id)); + } + const headroom = new Map(ids.map((id) => [id, usable > spent.get(id)! ? usable - spent.get(id)! : 0n])); + const result = planArbitrage({ + relation: rel, + graphHash: graph.hash, + markets, + holdings, + caps: { maxTrade: caps.maxTrade, marketHeadroom: headroom, dailyRemaining, maxNotional: d.cfg.maxNotional }, + thresholds: { minProfit: d.cfg.minProfit, minProfitBps: d.cfg.minProfitBps }, + }); + if (!result.plan) { + ctx.log.info("ARB_NO_PLAN", { relation: rel.id, reason: result.reason }); + continue; + } + const risk = checkArbPlan( + result.plan, + { maxTrade: caps.maxTrade, maxMarket: caps.maxMarket, marketSpent: spent, dailyRemaining, openLegs: openLegs(journal) }, + { maxNotional: d.cfg.maxNotional, maxUnhedged: d.cfg.maxUnhedged, marketHeadroomBps: d.cfg.marketHeadroomBps, maxOpenLegs: d.cfg.maxOpenLegs, maxUnwindRetries: d.cfg.maxUnwindRetries }, + ); + if (risk.length > 0) { + ctx.log.warn("ARB_RISK_REJECTED", { relation: rel.id, problems: risk }); + continue; + } + ctx.log.info("ARB_PLAN", { relation: rel.id, shares: result.plan.shares, cost: result.plan.cost, profit: result.plan.profit, profitBps: result.plan.profitBps }); + if (!live) continue; + const plan = result.plan; + const outcome = await ctx.submit(() => executeArbPlan(plan, executor), `arb:${rel.id}`); + ctx.log.info("ARB_RESULT", { ...outcome }); + } +} + +export function arbitrageTask(d: ArbDeps, opts: { intervalMs: number }): Task { + return { name: "arbitrage", mode: d.adapter.mode, ...opts, run: (ctx) => arbitrageTick(d, ctx) }; +} diff --git a/oracledesk-stellar/agents/runtime/wiring.ts b/oracledesk-stellar/agents/runtime/wiring.ts index 709e08a..f65b7ae 100644 --- a/oracledesk-stellar/agents/runtime/wiring.ts +++ b/oracledesk-stellar/agents/runtime/wiring.ts @@ -1,6 +1,8 @@ /** - * Builds the real task dependencies, state store, lease and runtime options - * from the environment. Shared by bin/agentd.ts and the one-shot CLIs so + * Builds the real task dependencies, state store, lease and runtime options. + * Trading and network settings come from the validated AgentConfig + * (core/config.ts); only runtime knobs (lease, state file, backoff) are still + * read from the environment. Shared by bin/agentd.ts and the one-shot CLIs so * all three behave the same. */ import { dirname, join } from "node:path"; @@ -11,8 +13,9 @@ import { availableCapital, existingQuestionHashes, tradableMarkets } from "../co import { toMarketState } from "../core/state/types.js"; import type { MarketSignal, SignalSource } from "../core/strategy.js"; import { LocalTraceStore } from "../core/trace.js"; -import { agentCredentials, loadDeployments, StellarAdapter, type AgentRole, type Deployments } from "../stellar/adapter.js"; -import { intEnv, type AgentMode } from "./config.js"; +import type { AgentConfig, Tunables } from "../core/config.js"; +import { StellarAdapter, type Deployments } from "../stellar/adapter.js"; +import { assertNever, intEnv, type AgentMode } from "./config.js"; import { FileLease, NoLease, type Lease } from "./lease.js"; import type { RuntimeOptions } from "./runtime.js"; import { FileStateStore, MemoryStateStore, type StateStore } from "./state.js"; @@ -36,33 +39,37 @@ export const demoSignalSource: SignalSource = { }, }; -export function rpcUrl(env: NodeJS.ProcessEnv): string { - return env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org"; -} - -export function buildAdapter(role: AgentRole, mode: AgentMode, env: NodeJS.ProcessEnv, deployments: Deployments): StellarAdapter { - return new StellarAdapter({ - mode, - networkPassphrase: env.STELLAR_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015", - rpcUrl: rpcUrl(env), - ...agentCredentials(role, env, deployments), - deployments, - }); +/** The adapter for a validated config. Only live mode is handed the secret. */ +export function buildAdapter(cfg: AgentConfig): StellarAdapter { + const common = { + networkPassphrase: cfg.networkPassphrase, + rpcUrl: cfg.rpcUrl, + agentAddress: cfg.agentAddress, + deployments: cfg.deployments, + }; + switch (cfg.mode) { + case "live": + return new StellarAdapter({ ...common, mode: "live", agentSecret: cfg.agentSecret }); + case "dry-run": + return new StellarAdapter({ ...common, mode: "dry-run" }); + default: + return assertNever(cfg.mode, "mode"); + } } -function stateReader(env: NodeJS.ProcessEnv, deployments: Deployments) { +function stateReader(rpcUrl: string, deployments: Deployments) { return new ChainStateReader({ marketContractId: deployments.contracts.market_core, portfolioContractId: deployments.contracts.treasury, - server: new Server(rpcUrl(env)), + server: new Server(rpcUrl, { allowHttp: rpcUrl.startsWith("http://") }), }); } /** Chain-state market source: tradable markets the treasury can afford. */ -export function chainCandidates(env: NodeJS.ProcessEnv, deployments: Deployments): () => Promise { - const reader = stateReader(env, deployments); +export function chainCandidates(t: Tunables, deployments: Deployments): () => Promise { + const reader = stateReader(t.rpcUrl, deployments); const cache = new SnapshotCache(); - const tradeSize = BigInt(env.TRADE_SIZE_STROOPS ?? "100000000"); + const tradeSize = t.trading.tradeSizeStroops; return async () => { const snapshot = await cache.get(() => reader.readSnapshot([])); if (availableCapital(snapshot) < tradeSize) return []; @@ -70,8 +77,8 @@ export function chainCandidates(env: NodeJS.ProcessEnv, deployments: Deployments const market = toMarketState(m); return { market, - // No external oracle yet: env override or the market's own price (strategy.ts decides edge). - fairValueBps: Number(env.FAIR_VALUE_BPS ?? String(market.yesBps)), + // No external oracle yet: FAIR_VALUE_BPS or the market's own price (strategy.ts decides edge). + fairValueBps: t.trading.fairValueBps ?? market.yesBps, creator: m.creator, questionHash: m.questionHash, ledger: snapshot.ledgers.max, @@ -80,16 +87,16 @@ export function chainCandidates(env: NodeJS.ProcessEnv, deployments: Deployments }; } -export function makerDeps(env: NodeJS.ProcessEnv, adapter: StellarAdapter, deployments: Deployments): MakerDeps { - const reader = stateReader(env, deployments); +export function makerDeps(t: Tunables, adapter: StellarAdapter, deployments: Deployments): MakerDeps { + const reader = stateReader(t.rpcUrl, deployments); return { adapter, - traceStore: new LocalTraceStore(env.TRACE_STORE_DIR ?? "./.traces"), + traceStore: new LocalTraceStore(t.traceStoreDir), signals: demoSignalSource, knownQuestionHashes: async () => existingQuestionHashes(await reader.readSnapshot([])), - confidenceThresholdBps: Number(env.CONFIDENCE_THRESHOLD_BPS ?? 1000), - defaultSeedStroops: BigInt(env.DEFAULT_SEED_STROOPS ?? "1500000000"), - closeInSecs: Number(env.MARKET_CLOSE_IN_SECS ?? 3600), + confidenceThresholdBps: t.maker.confidenceThresholdBps, + defaultSeedStroops: t.maker.defaultSeedStroops, + closeInSecs: t.maker.closeInSecs, }; } @@ -98,20 +105,20 @@ export function makerDeps(env: NodeJS.ProcessEnv, adapter: StellarAdapter, deplo * (tests, an adapter not bound to a deployment) from the demo fixture. */ export function traderDeps( - env: NodeJS.ProcessEnv, + t: Tunables, adapter: TraderDeps["adapter"], deployments: Deployments | undefined, nowSecs: () => number, ): TraderDeps { return { adapter, - traceStore: new LocalTraceStore(env.TRACE_STORE_DIR ?? "./.traces"), - candidates: deployments ? chainCandidates(env, deployments) : () => fixtureCandidates(nowSecs()), + traceStore: new LocalTraceStore(t.traceStoreDir), + candidates: deployments ? chainCandidates(t, deployments) : () => fixtureCandidates(nowSecs()), treasury: deployments?.contracts.treasury, - minEdgeBps: Number(env.MIN_EDGE_BPS ?? 500), - tradeSizeStroops: BigInt(env.TRADE_SIZE_STROOPS ?? "100000000"), - slippageBps: BigInt(env.SLIPPAGE_BPS ?? 50), - deadlineSecs: Number(env.DEADLINE_SECS ?? 60), + minEdgeBps: t.trading.minEdgeBps, + tradeSizeStroops: t.trading.tradeSizeStroops, + slippageBps: BigInt(t.trading.slippageBps), + deadlineSecs: t.trading.deadlineSecs, nowSecs, }; } @@ -149,4 +156,3 @@ export function runtimeOptions(env: NodeJS.ProcessEnv): Omit { }, /unknown mode/); }); +test("an unknown mode is rejected by the constructor without echoing the secret", () => { + const secret = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7)).secret(); + assert.throws( + () => + new StellarAdapter({ + mode: "Live", + agentSecret: secret, + networkPassphrase: "Test SDF Network ; September 2015", + rpcUrl: "https://soroban-testnet.stellar.org", + deployments, + } as unknown as StellarAdapterOptions), + (err: Error) => /unknown mode "Live"/.test(err.message) && !err.message.includes(secret), + ); +}); + +test("an unknown mode that reaches run() throws before any network access", async () => { + const adapter = new StellarAdapter({ + mode: "dry-run", + networkPassphrase: "Test SDF Network ; September 2015", + rpcUrl: "https://soroban-testnet.stellar.org", + deployments, + }); + // Simulate the options being corrupted after construction. + (adapter as unknown as { opts: { mode: string } }).opts.mode = "yolo"; + await assert.rejects( + adapter.agentBuy({ market_id: 0n, outcome: { tag: "Yes", values: undefined }, collateral_in: 1n, min_shares_out: 0n }), + /unknown mode "yolo"/, + ); +}); + +test("dry-run refuses to be handed an agent secret", () => { + assert.throws( + () => + new StellarAdapter({ + mode: "dry-run", + agentSecret: Keypair.random().secret(), + networkPassphrase: "Test SDF Network ; September 2015", + rpcUrl: "https://soroban-testnet.stellar.org", + deployments, + } as unknown as StellarAdapterOptions), + /dry-run mode must not be given agentSecret/, + ); +}); + test("dry-run agentBuy describes the call without touching the network", async () => { const adapter = new StellarAdapter({ mode: "dry-run", diff --git a/oracledesk-stellar/agents/stellar/adapter.ts b/oracledesk-stellar/agents/stellar/adapter.ts index f348953..fb2286c 100644 --- a/oracledesk-stellar/agents/stellar/adapter.ts +++ b/oracledesk-stellar/agents/stellar/adapter.ts @@ -16,6 +16,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { toTraceAction, type TraceActionType } from "../core/trace.js"; +import { assertNever, type AgentMode } from "../runtime/config.js"; const HERE = dirname(fileURLToPath(import.meta.url)); // HERE is agents/stellar/ — the repo root is two levels up. @@ -64,21 +65,23 @@ export function agentCredentials( }; } -export interface StellarAdapterOptions { - /** "dry-run" (default): builds and logs what would be submitted, never - * signs or sends. "live": actually submits real testnet transactions. */ - mode: "dry-run" | "live"; +interface CommonAdapterOptions { networkPassphrase: string; rpcUrl: string; - /** The agent's secret key (`S...`). Required for `mode: "live"`; ignored - * (and may be omitted) in dry-run. */ - agentSecret?: string; /** The acting agent's address for dry-run descriptions (live mode always * uses the secret key's public key). Defaults to `deployments.agent`. */ agentAddress?: string; deployments: Deployments; } +/** + * "dry-run": builds and logs what would be submitted, never signs or sends, + * and must not be handed a secret at all. "live": submits real transactions + * signed with `agentSecret` (`S...`), which it requires. + */ +export type StellarAdapterOptions = CommonAdapterOptions & + ({ mode: "dry-run"; agentSecret?: undefined } | { mode: "live"; agentSecret?: string }); + export interface DryRunResult { dryRun: true; contractId: string; @@ -114,18 +117,24 @@ export class StellarAdapter { private readonly publicKey?: string; constructor(private readonly opts: StellarAdapterOptions) { - if (opts.mode !== "dry-run" && opts.mode !== "live") { - throw new Error(`unknown mode ${JSON.stringify(opts.mode)}: expected "dry-run" or "live"`); - } - if (opts.mode === "live") { - if (!opts.agentSecret) throw new Error("live mode requires agentSecret"); - const keypair = Keypair.fromSecret(opts.agentSecret); - this.signer = new KeypairSigner(keypair, opts.networkPassphrase); - this.publicKey = keypair.publicKey(); + switch (opts.mode) { + case "dry-run": + if (opts.agentSecret !== undefined) throw new Error("dry-run mode must not be given agentSecret"); + break; + case "live": { + if (!opts.agentSecret) throw new Error("live mode requires agentSecret"); + const keypair = Keypair.fromSecret(opts.agentSecret); + this.signer = new KeypairSigner(keypair, opts.networkPassphrase); + this.publicKey = keypair.publicKey(); + break; + } + default: + // Only the mode is printed: opts may carry a secret. + assertNever(opts, "mode", (opts as { mode?: unknown }).mode); } } - get mode(): "dry-run" | "live" { + get mode(): AgentMode { return this.opts.mode; } @@ -152,6 +161,8 @@ export class StellarAdapter { contractId, networkPassphrase: this.opts.networkPassphrase, rpcUrl: this.opts.rpcUrl, + // A local network (http://localhost:8000/soroban/rpc) has no TLS. + allowHttp: this.opts.rpcUrl.startsWith("http://"), publicKey: this.publicKey, signTransaction: this.signer, }); @@ -170,8 +181,13 @@ export class StellarAdapter { call: (client: any) => Promise<{ signAndSend: () => Promise<{ result: T }> }>, ): Promise> { const contractId = this.opts.deployments.contracts[pkg]; - if (this.opts.mode === "dry-run") { - return { dryRun: true, contractId, method: methodName, args }; + switch (this.opts.mode) { + case "dry-run": + return { dryRun: true, contractId, method: methodName, args }; + case "live": + break; + default: + assertNever(this.opts, "mode", (this.opts as { mode?: unknown }).mode); } let assembled: Awaited>; try { @@ -279,7 +295,8 @@ export class StellarAdapter { }): Promise { const client = await this.client("market_core", "@oracledesk/bindings-market-core"); const { result } = await client.quote_buy(args); - return result as bigint; + // quote_buy returns Result: unwrap it, and surface an Err. + return BigInt(unwrapContractResult("quote_buy", result) as bigint); } /** @@ -314,9 +331,19 @@ export class StellarAdapter { } /** market-core `get_market` (read-only), or `undefined` if it does not exist. */ - async getMarket( - marketId: bigint, - ): Promise<{ creator: string; questionHash: string; resolutionHash: string; closeTime: bigint } | undefined> { + async getMarket(marketId: bigint): Promise< + | { + creator: string; + questionHash: string; + resolutionHash: string; + closeTime: bigint; + status: "Open" | "Resolved" | "Void"; + reserveYes: bigint; + reserveNo: bigint; + feeBps: bigint; + } + | undefined + > { const client = await this.client("market_core", "@oracledesk/bindings-market-core"); const { result } = await client.get_market({ market_id: marketId }); if (result?.isErr?.()) return undefined; @@ -326,9 +353,62 @@ export class StellarAdapter { questionHash: Buffer.from(m.question_hash).toString("hex"), resolutionHash: Buffer.from(m.resolution_hash).toString("hex"), closeTime: BigInt(m.close_time), + status: m.status.tag, + reserveYes: BigInt(m.reserve_yes), + reserveNo: BigInt(m.reserve_no), + feeBps: BigInt(m.fee_bps), + }; + } + + /** + * The treasury caps that bound this agent: the stricter of the global + * config and the agent's own policy, plus what it has spent today. + */ + async riskCaps(): Promise<{ maxTrade: bigint; maxMarket: bigint; maxDaily: bigint; dailyUsed: bigint }> { + const client = await this.client("treasury", "@oracledesk/bindings-treasury"); + const config = unwrapContractResult("get_config", (await client.get_config()).result) as { max_trade: bigint; max_market: bigint; max_daily: bigint }; + const policy = (await client.get_agent({ agent: this.agent })).result as { max_trade: bigint; max_daily: bigint } | undefined; + const usage = unwrapContractResult("agent_daily_usage", (await client.agent_daily_usage({ agent: this.agent })).result) as { amount: bigint }; + const min = (a: bigint, b?: bigint) => (b !== undefined && BigInt(b) < BigInt(a) ? BigInt(b) : BigInt(a)); + return { + maxTrade: min(config.max_trade, policy?.max_trade), + maxMarket: BigInt(config.max_market), + maxDaily: min(config.max_daily, policy?.max_daily), + dailyUsed: BigInt(usage.amount), }; } + /** What the treasury has committed to `marketId` (its cost basis). */ + async marketSpent(marketId: bigint): Promise { + const client = await this.client("treasury", "@oracledesk/bindings-treasury"); + const v = unwrapContractResult("market_valuation", (await client.market_valuation({ market_id: marketId })).result) as { cost_basis: bigint }; + return BigInt(v.cost_basis); + } + + /** An RPC client for this adapter's network (event polling, ledger reads). */ + rpcServer(): rpc.Server { + return new rpc.Server(this.opts.rpcUrl, { allowHttp: this.opts.rpcUrl.startsWith("http://") }); + } + + /** The deployment's contract ids. A copy: callers cannot rebind the adapter. */ + contractIds(): Deployments["contracts"] { + return { ...this.opts.deployments.contracts }; + } + + /** The treasury's YES/NO shares in `marketId` (zero when it holds none). */ + async treasuryPosition(marketId: bigint): Promise<{ yes: bigint; no: bigint }> { + const client = await this.client("market_core", "@oracledesk/bindings-market-core"); + const { result } = await client.get_position({ market_id: marketId, holder: this.opts.deployments.contracts.treasury }); + return { yes: BigInt(result.yes), no: BigInt(result.no) }; + } + + /** The resolver's state tag for `marketId`, e.g. "SignersPending". */ + async resolutionState(marketId: bigint): Promise { + const client = await this.client("resolver", "@oracledesk/bindings-resolver"); + const { result } = await client.state({ market_id: marketId }); + return (result as { tag: string }).tag; + } + /** * Whether a legacy spec is registered for `marketId`. A signer reveal * moves `state()` to SignersPending, but a price reveal leaves it @@ -345,7 +425,7 @@ export class StellarAdapter { durability: xdr.ContractDataDurability.persistent(), }), ); - const { entries } = await new rpc.Server(this.opts.rpcUrl).getLedgerEntries(key); + const { entries } = await new rpc.Server(this.opts.rpcUrl, { allowHttp: this.opts.rpcUrl.startsWith("http://") }).getLedgerEntries(key); return entries.length > 0; } diff --git a/oracledesk-stellar/agents/test/chaos/adapter.chaos.test.ts b/oracledesk-stellar/agents/test/chaos/adapter.chaos.test.ts new file mode 100644 index 0000000..758398d --- /dev/null +++ b/oracledesk-stellar/agents/test/chaos/adapter.chaos.test.ts @@ -0,0 +1,208 @@ +/** + * The real StellarAdapter, live mode, against the fake Soroban RPC (#106). + * Every request goes through the production SDK client: WASM fetched from + * ledger entries, simulation, signing, submission and polling, with real XDR. + * + * Artifact path per contract: market_core and treasury use the first fitting + * WASM (compiled build, else vendored); see test/fake-rpc/artifacts.ts. + */ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { calcBuy } from "../../core/fpmm.js"; +import { NotSubmittedError } from "../../stellar/adapter.js"; +import { FaultPlan, type Fault } from "../fake-rpc/faults.js"; +import { AGENT, blockRealNetwork, startFakeNetwork, type FakeNetwork } from "../fake-rpc/network.js"; +import { LIFECYCLE_ISSUE } from "./helpers.js"; + +const yes = { tag: "Yes" as const, values: undefined as void }; +const SIZE = 100_000_000n; +let unblock: () => void; + +before(() => { + unblock = blockRealNetwork(); +}); +after(() => unblock()); + +async function withNet(plan: FaultPlan, fn: (net: FakeNetwork) => Promise): Promise { + const net = await startFakeNetwork({ plan }); + try { + return await fn(net); + } finally { + await net.stop(); + } +} + +function buy(net: FakeNetwork, overrides: Partial<{ min_shares_out: bigint; deadline: bigint; collateral_in: bigint }> = {}) { + return net.adapter().agentBuyV2({ + market_id: net.market.id, + outcome: yes, + collateral_in: SIZE, + min_shares_out: 0n, + limit_price_bps: 9_000, + deadline: BigInt(net.rpc.clock.closeTime + 600), + ...overrides, + }); +} + +const sendFault = (fault: Fault) => FaultPlan.script([{ method: "sendTransaction", fault }]); + +test("the artifact report names the source used for every contract", async () => { + await withNet(FaultPlan.none(), async (net) => { + for (const a of Object.values(net.artifacts)) { + assert.ok(["compiled", "vendored"].includes(a.source), a.name); + assert.ok(a.wasm.length > 0 && a.errors.size > 0, `${a.name} spec has an Error enum`); + } + assert.equal(net.artifacts.market_core.fits, true); + assert.equal(net.artifacts.treasury.fits, true); + }); +}); + +test("a quote is a read call: simulated, never sent", async () => { + await withNet(FaultPlan.none(), async (net) => { + const shares = await net.adapter().quoteBuy({ market_id: net.market.id, outcome: yes, collateral_in: SIZE }); + const expected = calcBuy(net.market.reserveYes, net.market.reserveNo, SIZE, net.market.feeBps).sharesOut; + assert.equal(shares, expected); + assert.equal(net.rpc.requests.filter((r) => r.method === "sendTransaction").length, 0); + }); +}); + +test("a clean buy is simulated, signed, sent once and decoded from the transaction meta", async () => { + await withNet(FaultPlan.none(), async (net) => { + const before = net.rpc.sequenceOf(AGENT.publicKey()); + const expected = calcBuy(net.market.reserveYes, net.market.reserveNo, SIZE, net.market.feeBps).sharesOut; + const r = await buy(net, { min_shares_out: expected }); + assert.deepEqual(r, { dryRun: false, value: expected }); + assert.equal(net.rpc.applied.length, 1); + assert.equal(net.rpc.sequenceOf(AGENT.publicKey()), before + 1n); + assert.deepEqual(net.rpc.requests.map((r) => r.method).filter((m) => m !== "getLedgerEntries"), [ + "simulateTransaction", + "sendTransaction", + "getTransaction", + ]); + }); +}); + +test("a contract error in simulation is NotSubmittedError and sends nothing", async () => { + await withNet(FaultPlan.script([{ method: "simulateTransaction", fn: "agent_buy_v2", fault: { kind: "contract-error", error: "TradeCapExceeded" } }]), async (net) => { + await assert.rejects(buy(net), (err: Error) => err instanceof NotSubmittedError && /Error\(Contract, #\d+\)/.test(err.message)); + assert.equal(net.rpc.submissions.length, 0); + }); +}); + +test("a real model rejection (slippage) also fails in simulation without a submission", async () => { + await withNet(FaultPlan.none(), async (net) => { + await assert.rejects(buy(net, { min_shares_out: 10n ** 18n }), NotSubmittedError); + assert.equal(net.rpc.submissions.length, 0); + }); +}); + +for (const [label, fault] of [ + ["HTTP 503", { kind: "http", status: 503 }], + ["a JSON-RPC error", { kind: "rpc-error", code: -32603, message: "internal error" }], + ["a dropped connection", { kind: "timeout", ms: 10 }], +] as [string, Fault][]) { + test(`${label} during simulation fails before submission`, async () => { + await withNet(FaultPlan.script([{ method: "simulateTransaction", fault }]), async (net) => { + await assert.rejects(buy(net), NotSubmittedError); + assert.equal(net.rpc.submissions.length, 0); + }); + }); +} + +for (const code of ["txBAD_SEQ", "txTOO_LATE", "txINSUFFICIENT_FEE"] as const) { + test(`${code} is rejected at submission and nothing is applied`, async () => { + await withNet(sendFault({ kind: "tx-error", code }), async (net) => { + const seq = net.rpc.sequenceOf(AGENT.publicKey()); + await assert.rejects(buy(net), /Sending the transaction to the network failed/); + assert.equal(net.rpc.applied.length, 0); + assert.equal(net.rpc.sequenceOf(AGENT.publicKey()), seq, "a rejected transaction consumes no sequence number"); + }); + }); +} + +test("TRY_AGAIN_LATER sends nothing, and a fresh attempt succeeds exactly once", async () => { + await withNet(sendFault({ kind: "try-again-later" }), async (net) => { + await assert.rejects(buy(net), /TRY_AGAIN_LATER/); + assert.equal(net.rpc.applied.length, 0); + await buy(net); + assert.equal(net.rpc.applied.length, 1); + }); +}); + +test("a DUPLICATE answer is reported as a failure and applies nothing", async () => { + await withNet(sendFault({ kind: "duplicate" }), async (net) => { + await assert.rejects(buy(net), /DUPLICATE/); + assert.equal(net.rpc.applied.length, 0); + }); +}); + +test("a transaction that is NOT_FOUND for a while is polled until it lands, and applied once", async () => { + await withNet(sendFault({ kind: "not-found", polls: 2 }), async (net) => { + const r = await buy(net); + assert.equal(r.dryRun, false); + assert.equal(net.rpc.applied.length, 1); + assert.equal(net.rpc.requests.filter((r) => r.method === "getTransaction").length, 3); + }); +}); + +test("latency on every call slows the flow but does not change the outcome", async () => { + const plan = FaultPlan.script( + (["getLedgerEntries", "simulateTransaction", "sendTransaction", "getTransaction"] as const).map((method) => ({ + method, + times: Infinity, + fault: { kind: "latency", ms: 20 } as Fault, + })), + ); + await withNet(plan, async (net) => { + await buy(net); + assert.equal(net.rpc.applied.length, 1); + }); +}); + +test("simulation succeeds but application fails: the fee is spent, the state is not", async () => { + await withNet(sendFault({ kind: "apply-failure", error: "SlippageExceeded" }), async (net) => { + const reserves = [net.rpc.state.markets[0].reserveYes, net.rpc.state.markets[0].reserveNo]; + const seq = net.rpc.sequenceOf(AGENT.publicKey()); + // Today the adapter does not read getTransaction's status, so this + // resolves; the TODO below asserts what it should do. + await buy(net).catch(() => undefined); + assert.equal(net.rpc.submissions.at(-1)?.status, "FAILED"); + assert.equal(net.rpc.sequenceOf(AGENT.publicKey()), seq + 1n, "the failed transaction was included"); + assert.deepEqual([net.rpc.state.markets[0].reserveYes, net.rpc.state.markets[0].reserveNo], reserves); + }); +}); + +test( + "a transaction that fails on chain is reported as a failure, not a success", + { todo: `StellarAdapter.run() must check getTransaction's status: ${LIFECYCLE_ISSUE}` }, + async () => { + await withNet(sendFault({ kind: "apply-failure", error: "SlippageExceeded" }), async (net) => { + await assert.rejects(buy(net)); + }); + }, +); + +test("a reset after acceptance surfaces as an error even though the trade executed", async () => { + await withNet(sendFault({ kind: "reset-after-accept" }), async (net) => { + const err = await buy(net).then( + () => undefined, + (e: unknown) => e, + ); + assert.ok(err instanceof Error); + // The trade is on chain, and the caller cannot tell from the error. + assert.equal(net.rpc.applied.length, 1); + assert.ok(!(err instanceof NotSubmittedError), "an ambiguous outcome must not look like a safe-to-retry failure"); + }); +}); + +test( + "after an ambiguous submission the adapter reconciles instead of letting a retry trade twice", + { todo: `needs idempotent submission and reconciliation: ${LIFECYCLE_ISSUE}` }, + async () => { + await withNet(sendFault({ kind: "reset-after-accept" }), async (net) => { + await buy(net).catch(() => undefined); + await buy(net).catch(() => undefined); // what a naive caller does next + assert.equal(net.rpc.applied.filter((s) => s.fn === "agent_buy_v2").length, 1); + }); + }, +); diff --git a/oracledesk-stellar/agents/test/chaos/faults.test.ts b/oracledesk-stellar/agents/test/chaos/faults.test.ts new file mode 100644 index 0000000..7421c2e --- /dev/null +++ b/oracledesk-stellar/agents/test/chaos/faults.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { FaultPlan } from "../fake-rpc/faults.js"; + +test("scripted rules fire on the nth matching request for `times` requests", () => { + const plan = FaultPlan.script([{ method: "sendTransaction", fn: "agent_buy_v2", nth: 2, times: 2, fault: { kind: "try-again-later" } }]); + const kinds = [1, 2, 3, 4].map(() => plan.next("sendTransaction", "agent_buy_v2").map((f) => f.kind)); + assert.deepEqual(kinds, [[], ["try-again-later"], ["try-again-later"], []]); + assert.deepEqual(plan.next("sendTransaction", "publish_trace"), [], "other functions do not match"); + assert.equal(plan.history.length, 2); +}); + +test("a chaos plan is a pure function of its seed and the request sequence", () => { + const draw = (seed: bigint) => { + const plan = FaultPlan.chaos(seed, { rate: 0.5 }); + for (let i = 0; i < 200; i++) plan.next(i % 2 ? "sendTransaction" : "simulateTransaction"); + return plan.history.map((f) => `${f.seq}:${f.fault.kind}`); + }; + assert.deepEqual(draw(42n), draw(42n)); + assert.notDeepEqual(draw(42n), draw(43n)); + const n = draw(42n).length; + assert.ok(n > 50 && n < 150, `rate 0.5 over 200 requests drew ${n} faults`); +}); + +test("chaos only touches the methods it is given", () => { + const plan = FaultPlan.chaos(1n, { rate: 1, methods: ["getTransaction"] }); + assert.deepEqual(plan.next("sendTransaction"), []); + assert.equal(plan.next("getTransaction").length, 1); +}); diff --git a/oracledesk-stellar/agents/test/chaos/flows.chaos.test.ts b/oracledesk-stellar/agents/test/chaos/flows.chaos.test.ts new file mode 100644 index 0000000..a87648d --- /dev/null +++ b/oracledesk-stellar/agents/test/chaos/flows.chaos.test.ts @@ -0,0 +1,224 @@ +/** + * System-level invariants for the trader and market-maker runtime tasks, + * running the real live adapter against the fake RPC (#106): + * - a transaction is never submitted twice for one intent; + * - every executed trade has a published trace or a journaled pending one; + * - repeated failures halt the agent instead of spending fees forever. + */ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { rpc } from "@stellar/stellar-sdk"; +import { SIGNALS } from "../../runtime/tasks/maker.js"; +import { TRACES, type PendingTrace } from "../../runtime/tasks/traces.js"; +import { FaultPlan } from "../fake-rpc/faults.js"; +import { artifacts, blockRealNetwork, startFakeNetwork, type FakeNetwork } from "../fake-rpc/network.js"; +import { eventValue } from "../fake-rpc/server.js"; +import { LIFECYCLE_ISSUE, makerFor, runtimeFor, tick, tracelessTrades, tradeJournal, traderFor } from "./helpers.js"; + +let unblock: () => void; +before(() => { + unblock = blockRealNetwork(); +}); +after(() => unblock()); + +const registryFits = (await artifacts()).reasoning_registry.fits; +const registryDrift = registryFits + ? false + : `the served reasoning_registry spec does not take publish_trace(action: TraceAction) (${(await artifacts()).reasoning_registry.rejected.join("; ")})`; + +async function withNet(plan: FaultPlan, fn: (net: FakeNetwork) => Promise): Promise { + const net = await startFakeNetwork({ plan }); + try { + return await fn(net); + } finally { + await net.stop(); + } +} + +const buys = (net: FakeNetwork) => net.rpc.applied.filter((s) => s.fn === "agent_buy_v2"); + +test("the trader buys once per tick, journals the trade, and never leaves it traceless", async () => { + await withNet(FaultPlan.none(), async (net) => { + const { runtime, state } = runtimeFor(net, await traderFor(net)); + await tick(runtime); + assert.equal(buys(net).length, 1); + assert.deepEqual(tradeJournal(state).map((t) => t.status), ["done"]); + assert.deepEqual(tracelessTrades(net, state), []); + }); +}); + +test("the trade's trace is published", { todo: registryDrift }, async () => { + await withNet(FaultPlan.none(), async (net) => { + const { runtime } = runtimeFor(net, await traderFor(net)); + await tick(runtime); + assert.equal(net.rpc.applied.filter((s) => s.fn === "publish_trace").length, 1); + }); +}); + +test("a failed trace publication leaves the trace journaled as pending", async () => { + const plan = FaultPlan.script([{ method: "simulateTransaction", fn: "publish_trace", times: Infinity, fault: { kind: "http", status: 503 } }]); + await withNet(plan, async (net) => { + const { runtime, state } = runtimeFor(net, await traderFor(net)); + const err = await tick(runtime); + assert.ok(err, "the tick reports the failed publication"); + assert.equal(buys(net).length, 1); + const pending = state.entries(TRACES).filter(([, t]) => t.status === "pending"); + assert.equal(pending.length, 1); + assert.deepEqual(tracelessTrades(net, state), []); + }); +}); + +test("a journaled pending trace blocks new trades until it is published", async () => { + const plan = FaultPlan.script([{ method: "simulateTransaction", fn: "publish_trace", times: 3, fault: { kind: "http", status: 503 } }]); + await withNet(plan, async (net) => { + const { runtime, state } = runtimeFor(net, await traderFor(net)); + for (let i = 0; i < 3; i++) await tick(runtime); + // Ticks 2 and 3 retried the trace first and stopped; they did not trade again. + assert.equal(buys(net).length, 1); + assert.deepEqual(tracelessTrades(net, state), []); + }); +}); + +test("rejected submissions (txBAD_SEQ) trade nothing and journal only an intent", async () => { + const plan = FaultPlan.script([{ method: "sendTransaction", fn: "agent_buy_v2", fault: { kind: "tx-error", code: "txBAD_SEQ" } }]); + await withNet(plan, async (net) => { + const { runtime, state } = runtimeFor(net, await traderFor(net)); + assert.ok(await tick(runtime)); + assert.equal(buys(net).length, 0); + assert.deepEqual(tradeJournal(state).map((t) => t.status), ["intent"]); + }); +}); + +test("repeated fee-burning failures open the circuit and halt the agent", async () => { + // Simulation passes and every application fails, so each included buy + // costs a fee, and the registry is unreachable. The agent must stop rather + // than keep paying. + const plan = FaultPlan.script([ + { method: "sendTransaction", fn: "agent_buy_v2", times: Infinity, fault: { kind: "apply-failure", error: "SlippageExceeded" } }, + { method: "simulateTransaction", fn: "publish_trace", times: Infinity, fault: { kind: "http", status: 503 } }, + ]); + await withNet(plan, async (net) => { + const { runtime, log } = runtimeFor(net, await traderFor(net), { maxConsecutiveFailures: 3 }); + await runtime.start(); + assert.equal(runtime.exitCode, 1); + assert.ok(log.lines.some((l) => l.event === "CIRCUIT_OPEN")); + const spent = net.rpc.submissions.length; + assert.ok(spent <= 3, `at most one fee-charged submission per failed tick before halting, got ${spent}`); + }); +}); + +test("a submission rejected every time halts the agent without spending fees", async () => { + const plan = FaultPlan.script([{ method: "sendTransaction", times: Infinity, fault: { kind: "try-again-later" } }]); + await withNet(plan, async (net) => { + const { runtime } = runtimeFor(net, await traderFor(net), { maxConsecutiveFailures: 4 }); + await runtime.start(); + assert.equal(runtime.exitCode, 1); + assert.equal(net.rpc.submissions.length, 0); + assert.equal(net.rpc.requests.filter((r) => r.method === "sendTransaction").length, 4); + }); +}); + +test( + "a trade whose response is lost is not repeated on the next tick", + { todo: `trader must reconcile an "intent" before trading again: ${LIFECYCLE_ISSUE}` }, + async () => { + const plan = FaultPlan.script([{ method: "sendTransaction", fn: "agent_buy_v2", fault: { kind: "reset-after-accept" } }]); + await withNet(plan, async (net) => { + const { runtime, state } = runtimeFor(net, await traderFor(net)); + await tick(runtime); + await tick(runtime); + assert.equal(buys(net).length, 1); + assert.deepEqual(tracelessTrades(net, state), []); + }); + }, +); + +test("the maker creates the market once and journals it", async () => { + await withNet(FaultPlan.none(), async (net) => { + const { runtime, state } = runtimeFor(net, await makerFor(net)); + await tick(runtime); + await tick(runtime); + assert.equal(net.rpc.applied.filter((s) => s.fn === "agent_create_market").length, 1); + assert.equal(state.entries<{ status: string }>(SIGNALS)[0]?.[1].status, "created"); + const pending = state.entries(TRACES); + assert.ok(pending.length === 1 && (pending[0][1].status === "pending" || pending[0][1].status === "published")); + }); +}); + +test("a create whose response is lost is never retried blindly", async () => { + const plan = FaultPlan.script([{ method: "sendTransaction", fn: "agent_create_market", fault: { kind: "reset-after-accept" } }]); + await withNet(plan, async (net) => { + const { runtime, state } = runtimeFor(net, await makerFor(net)); + assert.ok(await tick(runtime)); + await tick(runtime); + await tick(runtime); + assert.equal(net.rpc.applied.filter((s) => s.fn === "agent_create_market").length, 1); + assert.equal(state.entries<{ status: string }>(SIGNALS)[0]?.[1].status, "intent"); + }); +}); + +test( + "an ambiguous create is reconciled and its trace published", + { todo: `the maker leaves an ambiguous create as "intent" for an operator: ${LIFECYCLE_ISSUE}` }, + async () => { + const plan = FaultPlan.script([{ method: "sendTransaction", fn: "agent_create_market", fault: { kind: "reset-after-accept" } }]); + await withNet(plan, async (net) => { + const { runtime, state } = runtimeFor(net, await makerFor(net)); + await tick(runtime); + await tick(runtime); + assert.equal(state.entries<{ status: string }>(SIGNALS)[0]?.[1].status, "created"); + }); + }, +); + +test("trade events are served as decodable XDR through getEvents", async () => { + await withNet(FaultPlan.none(), async (net) => { + const { runtime } = runtimeFor(net, await traderFor(net)); + await tick(runtime); + const server = new rpc.Server(net.url, { allowHttp: true }); + const res = await server.getEvents({ startLedger: 1, filters: [{ type: "contract", contractIds: [net.deployments.contracts.treasury] }] }); + assert.equal(res.events.length, 1); + const ev = res.events[0]; + assert.equal(ev.topic[0].sym().toString(), "trade"); + assert.equal((eventValue(ev.value.toXDR("base64")) as { market_id: bigint }).market_id, net.market.id); + }); +}); + +test("the guard refuses any non-loopback request", async () => { + await assert.rejects(fetch("https://soroban-testnet.stellar.org"), /must not reach the real network/); +}); + +// ─── Seeded chaos ──────────────────────────────────────────────────────────── + +async function chaosRun(seed: bigint) { + const plan = FaultPlan.chaos(seed, { rate: 0.3 }); + return withNet(plan, async (net) => { + const { runtime, state } = runtimeFor(net, await traderFor(net), { maxConsecutiveFailures: 100 }); + for (let i = 0; i < 6; i++) await tick(runtime); + return { + faults: plan.history.map((f) => `${f.seq}:${f.method}:${f.fn ?? ""}:${f.fault.kind}`), + submissions: net.rpc.submissions.map((s) => `${s.fn}:${s.status}`), + intents: tradeJournal(state).filter((t) => t.status === "intent").length, + traceless: tracelessTrades(net, state), + buys: buys(net).length, + }; + }); +} + +test("a chaos seed reproduces the same faults and the same outcome", async () => { + const [a, b] = [await chaosRun(7n), await chaosRun(7n)]; + assert.deepEqual(a, b); + assert.ok(a.faults.length > 0, "the seed injected faults"); + const c = await chaosRun(8n); + assert.notDeepEqual(c.faults, a.faults); +}); + +for (const seed of [1n, 2n, 3n, 7n, 9n]) { + test(`chaos seed ${seed}: no trade is left without a trace unless its outcome was ambiguous`, async () => { + const r = await chaosRun(seed); + // Each traceless trade must be explained by an ambiguous "intent" left + // in the journal (a lost response); anything else is an agent bug. + assert.ok(r.traceless.length <= r.intents, `${r.traceless.join("; ")} with ${r.intents} ambiguous intents`); + }); +} + diff --git a/oracledesk-stellar/agents/test/chaos/helpers.ts b/oracledesk-stellar/agents/test/chaos/helpers.ts new file mode 100644 index 0000000..08ad500 --- /dev/null +++ b/oracledesk-stellar/agents/test/chaos/helpers.ts @@ -0,0 +1,131 @@ +/** + * Shared wiring for the chaos suite (#106): runs the real runtime tasks + * (runtime/tasks/*) with the real live StellarAdapter against the fake RPC. + */ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { LocalTraceStore } from "../../core/trace.js"; +import type { MarketSignal } from "../../core/strategy.js"; +import { NoLease } from "../../runtime/lease.js"; +import type { Logger } from "../../runtime/log.js"; +import { Runtime, type Task } from "../../runtime/runtime.js"; +import { MemoryStateStore } from "../../runtime/state.js"; +import { makerTask } from "../../runtime/tasks/maker.js"; +import { TRACES, type PendingTrace } from "../../runtime/tasks/traces.js"; +import { TRADES, traderTask } from "../../runtime/tasks/trader.js"; +import type { FakeNetwork } from "../fake-rpc/network.js"; + +/** + * The separate transaction-lifecycle issue (adapter retries, idempotent + * submission, journaling of ambiguous outcomes). Tests that need its fixes + * are marked TODO with this link. + */ +export const LIFECYCLE_ISSUE = "https://github.com/OracleDesk/OracleDesk-SmartContract/issues?q=is%3Aissue+transaction+lifecycle"; + +export function quietLog(): Logger & { lines: { level: string; event: string; fields?: Record }[] } { + const lines: { level: string; event: string; fields?: Record }[] = []; + const at = (level: string) => (event: string, fields?: Record) => void lines.push({ level, event, fields }); + return { lines, info: at("info"), warn: at("warn"), error: at("error") }; +} + +export async function traceDir(): Promise { + return mkdtemp(join(tmpdir(), "chaos-traces-")); +} + +/** The trader task against the seeded market, priced 40% YES vs a 60% fair value. */ +export async function traderFor(net: FakeNetwork, intervalMs = 1): Promise { + const m = net.market; + return traderTask( + { + adapter: net.adapter(), + traceStore: new LocalTraceStore(await traceDir()), + candidates: async () => [ + { market: { marketId: Number(m.id), question: "chaos", yesBps: 4000, closeTime: Number(m.closeTime) }, fairValueBps: 6000 }, + ], + treasury: net.deployments.contracts.treasury, + minEdgeBps: 500, + tradeSizeStroops: 100_000_000n, + slippageBps: 50n, + deadlineSecs: 60, + nowSecs: () => net.rpc.clock.closeTime, + }, + { intervalMs }, + ); +} + +const SIGNAL: MarketSignal = { + summary: "chaos fixture signal", + proposedQuestion: "Will the chaos suite stay deterministic?", + fairValueBps: 7500, + source: "chaos", + observedAt: 1_700_000_000, +}; + +export async function makerFor(net: FakeNetwork, intervalMs = 1): Promise { + return makerTask( + { + adapter: net.adapter(), + traceStore: new LocalTraceStore(await traceDir()), + signals: { name: "chaos", poll: async () => [SIGNAL] }, + confidenceThresholdBps: 1000, + defaultSeedStroops: 1_500_000_000n, + closeInSecs: 3600, + }, + { intervalMs }, + ); +} + +export function runtimeFor(net: FakeNetwork, task: Task, opts: { maxConsecutiveFailures?: number; state?: MemoryStateStore } = {}) { + const state = opts.state ?? new MemoryStateStore(); + const log = quietLog(); + const runtime = new Runtime({ + tasks: [task], + state, + lease: new NoLease(), + shutdownGraceMs: 5_000, + maxConsecutiveFailures: opts.maxConsecutiveFailures ?? 3, + failureWindowMs: 60_000, + maxFailuresInWindow: 100, + backoffBaseMs: 1, + backoffMaxMs: 1, + log, + // The fake ledger clock is the only clock the agent sees. + now: () => net.rpc.clock.closeTime * 1000, + random: () => 0.5, + }); + return { runtime, state, log }; +} + +/** One `runOnce()` tick; returns its error instead of throwing. */ +export async function tick(runtime: Runtime): Promise { + try { + await runtime.runOnce(); + return undefined; + } catch (err) { + return err; + } +} + +/** + * The trade/trace invariant: every buy that executed on chain has either a + * published trace or a pending trace in the journal. Returns the violations. + */ +export function tracelessTrades(net: FakeNetwork, state: MemoryStateStore): string[] { + const buys = net.rpc.applied.filter((s) => s.fn === "agent_buy_v2" || s.fn === "agent_buy"); + const published = net.rpc.applied.filter((s) => s.fn === "publish_trace").map((s) => s.args.market_id); + const pending = [...state.entries(TRACES)].filter(([, t]) => t.status === "pending").map(([, t]) => t.marketId); + const covered = [...published, ...pending].map(String); + const violations: string[] = []; + let used = 0; + for (const b of buys) { + const idx = covered.indexOf(String(b.args.market_id), used); + if (idx === -1) violations.push(`buy ${b.hash} on market ${String(b.args.market_id)} has no published or journaled trace`); + else used = idx + 1; + } + return violations; +} + +export function tradeJournal(state: MemoryStateStore): { key: string; status: string }[] { + return [...state.entries<{ status: string }>(TRADES)].map(([key, v]) => ({ key, status: v.status })); +} diff --git a/oracledesk-stellar/agents/test/fake-rpc/README.md b/oracledesk-stellar/agents/test/fake-rpc/README.md new file mode 100644 index 0000000..6d0de8b --- /dev/null +++ b/oracledesk-stellar/agents/test/fake-rpc/README.md @@ -0,0 +1,69 @@ +# Fake Soroban RPC and chaos tests (#106) + +`npm run test:chaos` runs `test/chaos/*.test.ts`: the real live-mode +`StellarAdapter` and the runtime trader/maker tasks against a local fake RPC. +No test reaches a real network; `blockRealNetwork()` makes any non-loopback +request fail. + +## What is real and what is modelled + +| Layer | In the tests | +| --- | --- | +| `StellarAdapter`, SDK contract client, signing, polling | Production code, unmodified | +| JSON-RPC 2.0 transport | Real HTTP on 127.0.0.1 | +| Responses | SDK shapes with real base64 XDR (ledger entries, `SorobanTransactionData`, auth entries, `TransactionResult`, `TransactionMeta` v3, contract events) | +| Contract code | Real WASM served from ledger entries; the SDK builds its client from the WASM's spec | +| Contract behaviour | `model.ts`: FPMM math from `core/fpmm.ts`, treasury caps, slippage, limit price, deadline, registry checks. Error codes come from each WASM's own `Error` enum | +| Ledger | `FakeSorobanRpc.clock`: one ledger per accepted transaction; sequence numbers, signatures and time bounds are enforced | + +## Artifact path per contract + +`artifacts.ts` picks, per contract, the first WASM whose spec encodes the +calls the adapter makes: + +1. `compiled`: `target/wasm32v1-none/release/.wasm`. CI's Node job + downloads the Rust job's build here. +2. `vendored`: `contracts/integration-tests/wasm/.wasm`. + +If neither fits, the first one found is served with `fits: false` and the +reasons. The first adapter test asserts the source of every artifact. + +On `develop` at the time of writing the contracts do not compile, so local +runs use the vendored WASM. That copy of `reasoning_registry` predates +`publish_trace(action: TraceAction)`, so the adapter cannot encode a trace +for it. The test "the trade's trace is published" is TODO with that reason +until a fitting registry WASM is served, and the trace invariants are checked +against journaled pending traces instead. + +## Faults + +`FaultPlan.script([...])` fires faults on the nth matching request (method +and, for simulate/send, contract function). `FaultPlan.chaos(seed)` draws them +from a seeded RNG (`sim/rng.ts`), so a seed always reproduces the same run: + +| Fault | Effect | +| --- | --- | +| `latency`, `timeout` | Delayed response; dropped connection | +| `http`, `rpc-error` | HTTP 5xx; JSON-RPC error object | +| `contract-error` | Simulation fails with `Error(Contract, #n)` | +| `try-again-later`, `duplicate` | Submission refused; nothing applied | +| `tx-error` | `txBAD_SEQ`, `txTOO_LATE`, `txINSUFFICIENT_FEE`; nothing applied | +| `not-found` | Applied, but `getTransaction` answers NOT_FOUND `polls` times | +| `apply-failure` | Simulation passed, application fails; fee and sequence spent | +| `reset-after-accept` | Applied, then the connection is reset before the response | + +The SDK polls `getTransaction` with real 1 s / 1.5 s backoff, so +`not-found` faults add real wall time; keep `polls` small. + +## TODOs for the transaction-lifecycle issue + +Tests marked `todo` assert behaviour that needs the separate lifecycle work +(see `LIFECYCLE_ISSUE` in `test/chaos/helpers.ts`). They run and report, but +do not fail the suite: + +- `StellarAdapter.run()` does not check `getTransaction`'s status, so a + transaction that fails on chain resolves as a success (`value: null`). +- After a lost response the trader trades again on the next tick (its journal + key includes the trace hash, which changes every tick). +- The maker leaves an ambiguous create as `intent` for an operator; it is + never retried, but also never reconciled. diff --git a/oracledesk-stellar/agents/test/fake-rpc/artifacts.ts b/oracledesk-stellar/agents/test/fake-rpc/artifacts.ts new file mode 100644 index 0000000..268c2ba --- /dev/null +++ b/oracledesk-stellar/agents/test/fake-rpc/artifacts.ts @@ -0,0 +1,109 @@ +/** + * Contract artifacts for the fake RPC (#106). + * + * The fake serves real compiled WASM over `getLedgerEntries`, so the SDK's + * `Client.from` (the path StellarAdapter uses) downloads it and builds its + * client from the WASM's own contract spec, exactly as against a network. + * Arguments and return values are encoded and decoded with that same spec. + * + * Sources, in order of preference: + * 1. "compiled": target/wasm32v1-none/release/.wasm, a fresh + * `stellar contract build` (CI's Node job downloads it from the Rust job); + * 2. "vendored": contracts/integration-tests/wasm/.wasm, checked in. + * The first source whose spec encodes every call the agents make (`probes`) + * wins. If none does, the best available one is still served with `fits: + * false` and the reasons, so a test can tell an interface drift (e.g. the + * vendored reasoning_registry predates `publish_trace(action: TraceAction)`) + * from an agent bug, and mark itself TODO instead of passing vacuously. + */ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { contract, StrKey, xdr } from "@stellar/stellar-sdk"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const STELLAR_ROOT = join(HERE, "..", "..", ".."); + +export const ARTIFACT_DIRS = { + compiled: join(STELLAR_ROOT, "target", "wasm32v1-none", "release"), + vendored: join(STELLAR_ROOT, "contracts", "integration-tests", "wasm"), +} as const; +export type ArtifactSource = keyof typeof ARTIFACT_DIRS; + +export type ContractName = "market_core" | "treasury" | "reasoning_registry"; +export const CONTRACT_NAMES: ContractName[] = ["market_core", "treasury", "reasoning_registry"]; + +export interface ContractArtifact { + name: ContractName; + source: ArtifactSource; + path: string; + wasm: Buffer; + /** sha256 of the WASM, the contract-code entry's ledger key. */ + hash: Buffer; + spec: contract.Spec; + /** The contract's `Error` enum, name to code, read from the spec. */ + errors: Map; + /** True when every probe call encodes against `spec`. */ + fits: boolean; + /** Sources that were missing or did not fit, and why. */ + rejected: string[]; +} + +/** A call the agents make, used to check that a spec fits the adapter. */ +export interface ProbeCall { + fn: string; + args: Record; +} + +/** A deterministic contract id per contract name. */ +export function contractIdFor(name: ContractName): string { + return StrKey.encodeContract(createHash("sha256").update(`oracledesk-fake:${name}`).digest()); +} + +function errorsOf(spec: contract.Spec): Map { + const errors = new Map(); + for (const entry of spec.entries) { + if (entry.switch() !== xdr.ScSpecEntryKind.scSpecEntryUdtErrorEnumV0()) continue; + const e = entry.udtErrorEnumV0(); + if (e.name().toString() !== "Error") continue; + for (const c of e.cases()) errors.set(c.name().toString(), c.value()); + } + return errors; +} + +/** Why `spec` cannot encode `probes`, or undefined when it can. */ +function misfit(spec: contract.Spec, probes: ProbeCall[]): string | undefined { + for (const p of probes) { + try { + spec.funcArgsToScVals(p.fn, p.args); + } catch (err) { + return `${p.fn}: ${err instanceof Error ? err.message : String(err)}`; + } + } + return undefined; +} + +/** The first source whose WASM fits `probes`, else the first one found (fits: false). */ +export async function loadArtifact(name: ContractName, probes: ProbeCall[]): Promise { + const rejected: string[] = []; + let fallback: ContractArtifact | undefined; + for (const source of Object.keys(ARTIFACT_DIRS) as ArtifactSource[]) { + const path = join(ARTIFACT_DIRS[source], `${name}.wasm`); + if (!existsSync(path)) { + rejected.push(`${source}: ${path} not found`); + continue; + } + const wasm = readFileSync(path); + const spec = await contract.Spec.fromWasm(wasm); + const artifact: ContractArtifact = { + name, source, path, wasm, hash: createHash("sha256").update(wasm).digest(), spec, errors: errorsOf(spec), fits: true, rejected, + }; + const why = misfit(spec, probes); + if (!why) return artifact; + rejected.push(`${source}: spec does not fit the adapter (${why})`); + fallback ??= { ...artifact, fits: false }; + } + if (!fallback) throw new Error(`no WASM for ${name}: ${rejected.join("; ")}`); + return fallback; +} diff --git a/oracledesk-stellar/agents/test/fake-rpc/faults.ts b/oracledesk-stellar/agents/test/fake-rpc/faults.ts new file mode 100644 index 0000000..5db2c06 --- /dev/null +++ b/oracledesk-stellar/agents/test/fake-rpc/faults.ts @@ -0,0 +1,128 @@ +/** + * Deterministic, scriptable faults for the fake Soroban RPC (#106). + * + * A plan is a list of rules. Each RPC request is matched against them in + * order; a rule fires on the nth matching request (1-based, default every + * one) and for `times` requests (default 1). `FaultPlan.chaos(seed)` instead + * draws faults from a seeded RNG, so the same seed and the same request + * sequence always produce the same faults. `history` records what fired. + */ +import { Rng } from "../../sim/rng.js"; + +export type RpcMethod = + | "getHealth" + | "getNetwork" + | "getLatestLedger" + | "getLedgerEntries" + | "simulateTransaction" + | "sendTransaction" + | "getTransaction" + | "getEvents"; + +export type TxErrorCode = "txBAD_SEQ" | "txTOO_LATE" | "txINSUFFICIENT_FEE"; + +export type Fault = + /** Delays the response; the request still succeeds. */ + | { kind: "latency"; ms: number } + /** Holds the request for `ms`, then drops the connection without a response. */ + | { kind: "timeout"; ms: number } + | { kind: "http"; status: number } + | { kind: "rpc-error"; code: number; message: string } + /** sendTransaction: the node refuses the transaction for now. Nothing is applied. */ + | { kind: "try-again-later" } + /** sendTransaction: answers DUPLICATE as if this exact envelope was already seen. Nothing is applied. */ + | { kind: "duplicate" } + /** sendTransaction: rejected before inclusion with this result code. Nothing is applied. */ + | { kind: "tx-error"; code: TxErrorCode } + /** sendTransaction: accepted and applied, but getTransaction answers NOT_FOUND `polls` more times. */ + | { kind: "not-found"; polls: number } + /** simulateTransaction: the contract returns this `Error` variant. */ + | { kind: "contract-error"; error: string } + /** sendTransaction: simulation succeeded, but application fails with this `Error` variant (fee charged). */ + | { kind: "apply-failure"; error: string } + /** sendTransaction: accepted and applied, then the connection is reset before the response. */ + | { kind: "reset-after-accept" }; + +export interface FaultRule { + method: RpcMethod; + /** Contract function the request invokes (simulate/send only). */ + fn?: string; + /** Fire on the nth matching request, 1-based. Omit to start at the first. */ + nth?: number; + /** How many matching requests in a row it fires for (default 1; Infinity for all). */ + times?: number; + fault: Fault; +} + +export interface FiredFault { + seq: number; + method: RpcMethod; + fn?: string; + fault: Fault; +} + +/** Faults a chaos plan may draw, per method. Visibility faults are kept short + * so the SDK's real 1s/1.5s polling backoff stays bounded. */ +const CHAOS_FAULTS: Partial> = { + getLedgerEntries: [{ kind: "latency", ms: 5 }, { kind: "http", status: 503 }], + simulateTransaction: [ + { kind: "latency", ms: 5 }, + { kind: "http", status: 502 }, + { kind: "rpc-error", code: -32603, message: "internal error" }, + ], + sendTransaction: [ + { kind: "try-again-later" }, + { kind: "tx-error", code: "txBAD_SEQ" }, + { kind: "tx-error", code: "txINSUFFICIENT_FEE" }, + { kind: "not-found", polls: 1 }, + { kind: "reset-after-accept" }, + ], + getTransaction: [{ kind: "latency", ms: 5 }], +}; + +export class FaultPlan { + readonly history: FiredFault[] = []; + private readonly seen = new Map(); + private seq = 0; + + private constructor( + private readonly rules: FaultRule[], + private readonly chaos?: { rng: Rng; rate: number; methods: RpcMethod[] }, + ) {} + + static none(): FaultPlan { + return new FaultPlan([]); + } + + static script(rules: FaultRule[]): FaultPlan { + return new FaultPlan(rules); + } + + /** Seeded random faults: each eligible request faults with probability `rate`. */ + static chaos(seed: bigint, opts: { rate?: number; methods?: RpcMethod[] } = {}): FaultPlan { + const methods = opts.methods ?? (Object.keys(CHAOS_FAULTS) as RpcMethod[]); + return new FaultPlan([], { rng: new Rng(seed).fork("fake-rpc-chaos"), rate: opts.rate ?? 0.2, methods }); + } + + /** The faults to apply to this request, in rule order. */ + next(method: RpcMethod, fn?: string): Fault[] { + const seq = ++this.seq; + const fired: Fault[] = []; + for (const rule of this.rules) { + if (rule.method !== method || (rule.fn !== undefined && rule.fn !== fn)) continue; + const n = (this.seen.get(rule) ?? 0) + 1; + this.seen.set(rule, n); + const start = rule.nth ?? 1; + if (n >= start && n < start + (rule.times ?? 1)) fired.push(rule.fault); + } + if (this.chaos && this.chaos.methods.includes(method)) { + const choices = CHAOS_FAULTS[method] ?? []; + // Always draw both numbers so the stream does not depend on outcomes. + const roll = this.chaos.rng.nextU32() / 2 ** 32; + const pick = this.chaos.rng.nextU32() % Math.max(choices.length, 1); + if (choices.length > 0 && roll < this.chaos.rate) fired.push(choices[pick]); + } + for (const fault of fired) this.history.push({ seq, method, fn, fault }); + return fired; + } +} diff --git a/oracledesk-stellar/agents/test/fake-rpc/model.ts b/oracledesk-stellar/agents/test/fake-rpc/model.ts new file mode 100644 index 0000000..8be2d9a --- /dev/null +++ b/oracledesk-stellar/agents/test/fake-rpc/model.ts @@ -0,0 +1,199 @@ +/** + * Behaviour model of the contract calls the agents make (#106). Prices use + * the production integer FPMM (core/fpmm.ts); the treasury and registry + * checks mirror the on-chain validations the agents can trip. Error names are + * the contracts' own `Error` variants, resolved to codes from the WASM spec. + * + * `invoke` is pure with respect to `state`: callers simulate on a clone and + * apply on the real state, which is what lets a test make simulation and + * application diverge. + */ +import { buyWithinLimit, calcBuy, MathError, seedReserves } from "../../core/fpmm.js"; +import type { ContractName } from "./artifacts.js"; + +export interface ModelMarket { + id: bigint; + reserveYes: bigint; + reserveNo: bigint; + feeBps: bigint; + closeTime: bigint; + open: boolean; + creator: string; + questionHash: string; +} + +export interface ModelTrace { + id: bigint; + agent: string; + marketId: bigint; + action: string; + traceHash: string; + cid: string; +} + +export interface ModelState { + markets: ModelMarket[]; + /** Agents the treasury lets trade and create markets. */ + treasuryAgents: string[]; + treasuryBalance: bigint; + maxTrade: bigint; + positions: Record; + /** Agents registered with the reasoning registry. */ + registryAgents: string[]; + traces: ModelTrace[]; +} + +export interface ModelEvent { + contract: ContractName; + topic: string; + data: Record; +} + +export interface CallContext { + /** Ledger close time the call executes at, unix seconds. */ + closeTime: bigint; + treasuryAddress: string; +} + +export type CallOutcome = + | { ok: true; value: unknown; events: ModelEvent[] } + | { ok: false; error: string }; + +/** Functions that never write state or need authorization. */ +const READS = new Set(["quote_buy", "quote_sell", "market_count", "get_market", "get_config", "trace_count", "get_trace", "state"]); + +export function isWrite(fn: string): boolean { + return !READS.has(fn); +} + +export function initialState(opts: { agents: string[]; balance?: bigint; maxTrade?: bigint }): ModelState { + return { + markets: [], + treasuryAgents: [...opts.agents], + treasuryBalance: opts.balance ?? 10_000_000_000_000n, + maxTrade: opts.maxTrade ?? 2_000_000_000n, + positions: {}, + registryAgents: [...opts.agents], + traces: [], + }; +} + +/** Opens a market directly in the model, as a deploy/seed script would. */ +export function seedMarket( + state: ModelState, + m: { seed: bigint; yesBps: bigint; closeTime: bigint; creator: string; feeBps?: bigint; questionHash?: string }, +): ModelMarket { + const r = seedReserves(m.seed, m.yesBps); + const market: ModelMarket = { + id: BigInt(state.markets.length), + reserveYes: r.reserveYes, + reserveNo: r.reserveNo, + feeBps: m.feeBps ?? 100n, + closeTime: m.closeTime, + open: true, + creator: m.creator, + questionHash: m.questionHash ?? "00".repeat(32), + }; + state.markets.push(market); + return market; +} + +type Args = Record; +type Outcome = { tag: "Yes" | "No" }; + +function market(state: ModelState, id: unknown): ModelMarket | undefined { + return state.markets.find((m) => m.id === BigInt(id as bigint)); +} + +function quote(m: ModelMarket, outcome: Outcome, collateralIn: bigint) { + const yes = outcome.tag === "Yes"; + return calcBuy(yes ? m.reserveYes : m.reserveNo, yes ? m.reserveNo : m.reserveYes, collateralIn, m.feeBps); +} + +export function invoke(state: ModelState, contract: ContractName, fn: string, a: Args, ctx: CallContext): CallOutcome { + const fail = (error: string): CallOutcome => ({ ok: false, error }); + try { + switch (`${contract}.${fn}`) { + case "market_core.market_count": + return { ok: true, value: BigInt(state.markets.length), events: [] }; + + case "market_core.quote_buy": { + const m = market(state, a.market_id); + if (!m) return fail("MarketNotFound"); + if (!m.open) return fail("MarketNotOpen"); + if (ctx.closeTime >= m.closeTime) return fail("MarketClosed"); + return { ok: true, value: quote(m, a.outcome as Outcome, a.collateral_in as bigint).sharesOut, events: [] }; + } + + case "treasury.agent_buy": + case "treasury.agent_buy_v2": { + const agent = a.agent as string; + if (!state.treasuryAgents.includes(agent)) return fail("Unauthorized"); + const collateralIn = a.collateral_in as bigint; + if (collateralIn <= 0n) return fail("InvalidAmount"); + if (collateralIn > state.maxTrade) return fail("TradeCapExceeded"); + if (collateralIn > state.treasuryBalance) return fail("InsufficientBalance"); + const m = market(state, a.market_id); + if (!m || !m.open || ctx.closeTime >= m.closeTime) return fail("MarketNotOpen"); + if (fn === "agent_buy_v2" && ctx.closeTime > (a.deadline as bigint)) return fail("MarketCoreCallFailed"); + const q = quote(m, a.outcome as Outcome, collateralIn); + if (q.sharesOut < (a.min_shares_out as bigint)) return fail("SlippageExceeded"); + if (fn === "agent_buy_v2" && !buyWithinLimit(collateralIn, q.sharesOut, BigInt(a.limit_price_bps as number))) { + return fail("MarketCoreCallFailed"); + } + const yes = (a.outcome as Outcome).tag === "Yes"; + if (yes) [m.reserveYes, m.reserveNo] = [q.newReserveBought, q.newReserveOther]; + else [m.reserveNo, m.reserveYes] = [q.newReserveBought, q.newReserveOther]; + state.treasuryBalance -= collateralIn; + const key = `${m.id}:${(a.outcome as Outcome).tag}`; + state.positions[key] = (state.positions[key] ?? 0n) + q.sharesOut; + return { + ok: true, + value: q.sharesOut, + events: [{ contract: "treasury", topic: "trade", data: { market_id: m.id, collateral_in: collateralIn, shares_out: q.sharesOut } }], + }; + } + + case "treasury.agent_create_market": { + const agent = a.agent as string; + if (!state.treasuryAgents.includes(agent)) return fail("Unauthorized"); + const seed = a.seed_amount as bigint; + if (seed <= 0n || seed > state.treasuryBalance) return fail("InsufficientBalance"); + if ((a.close_time as bigint) <= ctx.closeTime) return fail("CloseTimeTooSoon"); + const qh = Buffer.from(a.question_hash as Buffer).toString("hex"); + const m = seedMarket(state, { + seed, + yesBps: BigInt(a.initial_yes_bps as number), + closeTime: a.close_time as bigint, + creator: ctx.treasuryAddress, + questionHash: qh, + }); + state.treasuryBalance -= seed; + return { ok: true, value: m.id, events: [{ contract: "treasury", topic: "market_created", data: { market_id: m.id } }] }; + } + + case "reasoning_registry.publish_trace": { + const agent = a.agent as string; + if (!state.registryAgents.includes(agent)) return fail("AgentNotRegistered"); + if (!(a.action as string)) return fail("EmptyAction"); + if (!(a.ipfs_cid as string)) return fail("EmptyCid"); + const t: ModelTrace = { + id: BigInt(state.traces.length), + agent, + marketId: a.market_id as bigint, + action: String(a.action), + traceHash: Buffer.from(a.trace_hash as Buffer).toString("hex"), + cid: String(a.ipfs_cid), + }; + state.traces.push(t); + return { ok: true, value: t.id, events: [{ contract: "reasoning_registry", topic: "trace", data: { id: t.id, market_id: t.marketId } }] }; + } + + default: + throw new Error(`fake RPC: no model for ${contract}.${fn}`); + } + } catch (err) { + if (err instanceof MathError) return fail("InvalidAmount"); + throw err; + } +} diff --git a/oracledesk-stellar/agents/test/fake-rpc/network.ts b/oracledesk-stellar/agents/test/fake-rpc/network.ts new file mode 100644 index 0000000..35ea684 --- /dev/null +++ b/oracledesk-stellar/agents/test/fake-rpc/network.ts @@ -0,0 +1,149 @@ +/** + * One-call setup of a fake Stellar network for chaos tests (#106): the fake + * RPC with the contract artifacts, a funded agent account, a seeded market, + * and a factory for the real, live-mode StellarAdapter pointed at it. + * + * `blockRealNetwork()` replaces global fetch (the SDK's HTTP transport) with + * one that refuses anything but the loopback fake, so a test can never reach + * a real network by accident. + * + * While a fake network runs, `Date.now()` follows its ledger clock. The SDK + * stamps transaction time bounds and `buildTrace` stamps traces from + * `Date.now()`, so without this a run's hashes, and whether two ticks share a + * second, would depend on the wall clock. + */ +import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import { toTraceAction } from "../../core/trace.js"; +import { StellarAdapter, type Deployments } from "../../stellar/adapter.js"; +import { contractIdFor, CONTRACT_NAMES, loadArtifact, type ContractArtifact, type ContractName, type ProbeCall } from "./artifacts.js"; +import { FaultPlan } from "./faults.js"; +import { initialState, seedMarket, type ModelMarket } from "./model.js"; +import { FakeSorobanRpc } from "./server.js"; + +export const NETWORK_PASSPHRASE = "Test SDF Network ; September 2015"; + +/** The agent's key, fixed so every run signs identical transactions. */ +export const AGENT = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 0x0a)); +const DEPLOYER = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 0x0b)); + +const yes = { tag: "Yes", values: undefined }; + +/** The calls the adapter makes, in the shapes it sends them. */ +function probes(agent: string): Record { + return { + market_core: [ + { fn: "quote_buy", args: { market_id: 0n, outcome: yes, collateral_in: 1n } }, + { fn: "market_count", args: {} }, + ], + treasury: [ + { + fn: "agent_buy_v2", + args: { agent, market_id: 0n, outcome: yes, collateral_in: 1n, min_shares_out: 0n, limit_price_bps: 5000, deadline: 1n }, + }, + { + fn: "agent_create_market", + args: { + agent, + question_hash: Buffer.alloc(32), + resolution_hash: Buffer.alloc(32), + meta_uri: "local://x", + category: { tag: "Macro", values: undefined }, + close_time: 1n, + seed_amount: 1n, + initial_yes_bps: 5000, + }, + }, + ], + reasoning_registry: [ + { + fn: "publish_trace", + args: { agent, market_id: 0n, action: toTraceAction("buy"), trace_hash: Buffer.alloc(32), ipfs_cid: "bafy" }, + }, + ], + }; +} + +let cached: Promise> | undefined; +/** Artifacts are read once per process; they never change during a run. */ +export function artifacts(): Promise> { + cached ??= (async () => { + const p = probes(AGENT.publicKey()); + const out = {} as Record; + for (const n of CONTRACT_NAMES) out[n] = await loadArtifact(n, p[n]); + return out; + })(); + return cached; +} + +export interface FakeNetwork { + rpc: FakeSorobanRpc; + url: string; + deployments: Deployments; + artifacts: Record; + /** The market seeded at startup (id 0, priced at 40% YES). */ + market: ModelMarket; + adapter(): StellarAdapter; + stop(): Promise; +} + +export async function startFakeNetwork(opts: { plan?: FaultPlan; maxTrade?: bigint; startSequence?: bigint } = {}): Promise { + const arts = await artifacts(); + const ids = Object.fromEntries(CONTRACT_NAMES.map((n) => [n, contractIdFor(n)])) as Record; + const deployments: Deployments = { + deployer: DEPLOYER.publicKey(), + agent: AGENT.publicKey(), + contracts: { + usdc: StrKey.encodeContract(Buffer.alloc(32, 0x0c)), + market_core: ids.market_core, + resolver: StrKey.encodeContract(Buffer.alloc(32, 0x0d)), + treasury: ids.treasury, + reasoning_registry: ids.reasoning_registry, + }, + }; + const state = initialState({ agents: [AGENT.publicKey()], maxTrade: opts.maxTrade }); + const rpc = new FakeSorobanRpc({ + networkPassphrase: NETWORK_PASSPHRASE, + contracts: Object.fromEntries(CONTRACT_NAMES.map((n) => [ids[n], arts[n]])), + accounts: { [AGENT.publicKey()]: opts.startSequence ?? 4_294_967_296n }, + state, + treasuryAddress: ids.treasury, + plan: opts.plan, + }); + const market = seedMarket(state, { + seed: 10_000_000_000n, + yesBps: 4000n, + closeTime: BigInt(rpc.clock.closeTime + 86_400), + creator: StrKey.encodeContract(Buffer.alloc(32, 0x0e)), + }); + const url = await rpc.start(); + const realNow = Date.now; + Date.now = () => rpc.clock.closeTime * 1000; + return { + rpc, + url, + deployments, + artifacts: arts, + market, + adapter: () => + new StellarAdapter({ mode: "live", agentSecret: AGENT.secret(), networkPassphrase: NETWORK_PASSPHRASE, rpcUrl: url, deployments }), + stop: async () => { + Date.now = realNow; + await rpc.stop(); + }, + }; +} + +/** Makes any non-loopback HTTP request from this process fail. Returns an undo. */ +export function blockRealNetwork(): () => void { + const real = globalThis.fetch; + globalThis.fetch = (async (input: Parameters[0], init?: Parameters[1]) => { + const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url); + if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") { + throw new Error(`chaos tests must not reach the real network (blocked ${url.href})`); + } + return real(input, init); + }) as typeof fetch; + return () => { + globalThis.fetch = real; + }; +} diff --git a/oracledesk-stellar/agents/test/fake-rpc/server.ts b/oracledesk-stellar/agents/test/fake-rpc/server.ts new file mode 100644 index 0000000..f318b0e --- /dev/null +++ b/oracledesk-stellar/agents/test/fake-rpc/server.ts @@ -0,0 +1,552 @@ +/** + * A deterministic fake Soroban RPC (#106): JSON-RPC 2.0 over HTTP, answering + * with SDK-compatible shapes and real base64 XDR, so the production + * StellarAdapter and the SDK's contract client run unmodified against it. + * + * - Ledger clock: one ledger closes per accepted transaction; tests can + * advance it. Close times feed deadlines and market expiry. + * - Accounts: sequence numbers are enforced (txBAD_SEQ), signatures are + * verified (txBAD_AUTH), time bounds are enforced (txTOO_LATE). + * - Contracts: real WASM served from ledger entries; calls are decoded with + * the WASM's spec and executed by the behaviour model (model.ts). + * - Faults: a FaultPlan injects network, RPC and transaction failures. + * + * Every accepted transaction is recorded in `submissions`; `applied` lists + * the contract calls that actually changed state, which is what the chaos + * invariants (no double trade, trace for every trade) are checked against. + */ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + Address, + Keypair, + scValToNative, + SorobanDataBuilder, + StrKey, + TransactionBuilder, + xdr, + type FeeBumpTransaction, + type Transaction, +} from "@stellar/stellar-sdk"; +import type { ContractArtifact, ContractName } from "./artifacts.js"; +import { FaultPlan, type Fault, type RpcMethod } from "./faults.js"; +import { invoke, isWrite, type ModelEvent, type ModelState } from "./model.js"; + +export interface LedgerClock { + sequence: number; + /** Close time of `sequence`, unix seconds. */ + closeTime: number; + secsPerLedger: number; +} + +export interface Submission { + hash: string; + contract: ContractName; + fn: string; + args: Record; + status: "SUCCESS" | "FAILED"; + ledger: number; + error?: string; +} + +interface TxRecord { + submission: Submission; + envelopeXdr: string; + resultXdr: string; + resultMetaXdr: string; + closeTime: number; + hiddenPolls: number; +} + +interface StoredEvent { + ledger: number; + closeTime: number; + txHash: string; + index: number; + contractId: string; + event: xdr.ContractEvent; +} + +export interface FakeRpcOptions { + networkPassphrase: string; + contracts: Record; + /** Starting sequence number per G... account. */ + accounts: Record; + state: ModelState; + treasuryAddress: string; + plan?: FaultPlan; + clock?: Partial; +} + +const RESOURCE_FEE = 50_000; +const BASE_FEE = 100; + +export class FakeSorobanRpc { + readonly clock: LedgerClock; + plan: FaultPlan; + readonly state: ModelState; + /** Every request, in order, with the contract function it invoked. */ + readonly requests: { method: string; fn?: string }[] = []; + /** Transactions the fake accepted for inclusion, in order. */ + readonly submissions: Submission[] = []; + private readonly txs = new Map(); + private readonly events: StoredEvent[] = []; + private readonly sequences: Map; + private readonly ids: Map; + private server?: Server; + + constructor(private readonly o: FakeRpcOptions) { + this.clock = { sequence: 1_000, closeTime: 1_700_000_000, secsPerLedger: 5, ...o.clock }; + this.plan = o.plan ?? FaultPlan.none(); + this.state = o.state; + this.sequences = new Map(Object.entries(o.accounts)); + this.ids = new Map(Object.entries(o.contracts)); + } + + get url(): string { + const addr = this.server?.address() as AddressInfo | undefined; + if (!addr) throw new Error("fake RPC not started"); + return `http://127.0.0.1:${addr.port}`; + } + + /** Successful contract calls that changed state, in order. */ + get applied(): Submission[] { + return this.submissions.filter((s) => s.status === "SUCCESS"); + } + + sequenceOf(account: string): bigint { + return this.sequences.get(account) ?? 0n; + } + + advance(ledgers = 1): void { + this.clock.sequence += ledgers; + this.clock.closeTime += ledgers * this.clock.secsPerLedger; + } + + async start(): Promise { + this.server = createServer((req, res) => void this.handle(req, res)); + await new Promise((resolve) => this.server!.listen(0, "127.0.0.1", resolve)); + return this.url; + } + + async stop(): Promise { + if (!this.server) return; + this.server.closeAllConnections(); + await new Promise((resolve) => this.server!.close(() => resolve())); + } + + // ─── HTTP / JSON-RPC ─────────────────────────────────────────────────────── + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + let body: { id?: unknown; method?: string; params?: Record }; + try { + body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + return this.reply(res, 200, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }); + } + const method = body.method as RpcMethod; + const params = body.params ?? {}; + const fn = this.functionOf(method, params); + this.requests.push({ method, fn }); + const faults = this.plan.next(method, fn); + for (const f of faults) if (f.kind === "latency") await sleep(f.ms); + const fatal = faults.find((f) => f.kind === "timeout" || f.kind === "http" || f.kind === "rpc-error"); + if (fatal?.kind === "timeout") { + await sleep(fatal.ms); + req.socket.destroy(); + return; + } + if (fatal?.kind === "http") { + res.writeHead(fatal.status, { "content-type": "text/plain" }); + res.end(`fake RPC: injected HTTP ${fatal.status}`); + return; + } + if (fatal?.kind === "rpc-error") { + return this.reply(res, 200, { jsonrpc: "2.0", id: body.id ?? 1, error: { code: fatal.code, message: fatal.message } }); + } + let result: unknown; + try { + result = this.dispatch(method, params, faults); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return this.reply(res, 200, { jsonrpc: "2.0", id: body.id ?? 1, error: { code: -32602, message } }); + } + if (faults.some((f) => f.kind === "reset-after-accept")) { + // The transaction is already applied; the client never hears back. + req.socket.destroy(); + return; + } + this.reply(res, 200, { jsonrpc: "2.0", id: body.id ?? 1, result }); + } + + private reply(res: ServerResponse, status: number, payload: unknown): void { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(payload)); + } + + private dispatch(method: RpcMethod, p: Record, faults: Fault[]): unknown { + switch (method) { + case "getHealth": + return { status: "healthy", latestLedger: this.clock.sequence, oldestLedger: 1, ledgerRetentionWindow: 120_960 }; + case "getNetwork": + return { passphrase: this.o.networkPassphrase, protocolVersion: 23 }; + case "getLatestLedger": + return { id: Buffer.alloc(32, this.clock.sequence % 256).toString("hex"), protocolVersion: 23, sequence: this.clock.sequence }; + case "getLedgerEntries": + return this.getLedgerEntries(p.keys as string[]); + case "simulateTransaction": + return this.simulate(p.transaction as string, faults); + case "sendTransaction": + return this.send(p.transaction as string, faults); + case "getTransaction": + return this.getTransaction(p.hash as string); + case "getEvents": + return this.getEvents(p); + default: + throw new Error(`method ${String(method)} not supported`); + } + } + + // ─── Ledger entries ──────────────────────────────────────────────────────── + + private getLedgerEntries(keys: string[]) { + const entries = []; + for (const raw of keys ?? []) { + const key = xdr.LedgerKey.fromXDR(raw, "base64"); + const data = this.entryFor(key); + if (!data) continue; + entries.push({ + key: raw, + xdr: data.toXDR("base64"), + lastModifiedLedgerSeq: 1, + liveUntilLedgerSeq: this.clock.sequence + 1_000_000, + }); + } + return { entries, latestLedger: this.clock.sequence }; + } + + private entryFor(key: xdr.LedgerKey): xdr.LedgerEntryData | undefined { + switch (key.switch()) { + case xdr.LedgerEntryType.account(): { + const id = StrKey.encodeEd25519PublicKey(key.account().accountId().ed25519()); + const seq = this.sequences.get(id); + if (seq === undefined) return undefined; + return xdr.LedgerEntryData.account( + new xdr.AccountEntry({ + accountId: key.account().accountId(), + balance: xdr.Int64.fromString("100000000000"), + seqNum: xdr.Int64.fromString(seq.toString()), // SequenceNumber is an Int64 typedef + numSubEntries: 0, + inflationDest: null, + flags: 0, + homeDomain: "", + thresholds: Buffer.from([1, 0, 0, 0]), + signers: [], + ext: new xdr.AccountEntryExt(0), + }), + ); + } + case xdr.LedgerEntryType.contractData(): { + const cd = key.contractData(); + if (cd.key().switch() !== xdr.ScValType.scvLedgerKeyContractInstance()) return undefined; + const id = Address.fromScAddress(cd.contract()).toString(); + const artifact = this.ids.get(id); + if (!artifact) return undefined; + return xdr.LedgerEntryData.contractData( + new xdr.ContractDataEntry({ + ext: new xdr.ExtensionPoint(0), + contract: cd.contract(), + key: cd.key(), + durability: xdr.ContractDataDurability.persistent(), + val: xdr.ScVal.scvContractInstance( + new xdr.ScContractInstance({ executable: xdr.ContractExecutable.contractExecutableWasm(artifact.hash), storage: null }), + ), + }), + ); + } + case xdr.LedgerEntryType.contractCode(): { + const hash = key.contractCode().hash(); + const artifact = [...this.ids.values()].find((a) => a.hash.equals(hash)); + if (!artifact) return undefined; + return xdr.LedgerEntryData.contractCode( + new xdr.ContractCodeEntry({ ext: new xdr.ContractCodeEntryExt(0), hash, code: artifact.wasm }), + ); + } + default: + return undefined; + } + } + + // ─── Contract calls ──────────────────────────────────────────────────────── + + private parse(envelope: string): Transaction { + const tx = TransactionBuilder.fromXDR(envelope, this.o.networkPassphrase) as Transaction | FeeBumpTransaction; + if ("innerTransaction" in tx) throw new Error("fee-bump transactions are not modelled"); + return tx; + } + + private callOf(tx: Transaction) { + const op = tx.operations[0]; + if (!op || op.type !== "invokeHostFunction") throw new Error("only invokeHostFunction transactions are modelled"); + const call = op.func.invokeContract(); + const contractId = Address.fromScAddress(call.contractAddress()).toString(); + const artifact = this.ids.get(contractId); + if (!artifact) throw new Error(`unknown contract ${contractId}`); + const fn = call.functionName().toString(); + const inputs = artifact.spec.getFunc(fn).inputs(); + const args: Record = {}; + call.args().forEach((v, i) => { + const input = inputs[i]; + if (input) args[input.name().toString()] = artifact.spec.scValToNative(v, input.type()); + }); + return { artifact, contractId, fn, args, call }; + } + + /** The contract function a simulate/send request invokes, for fault matching. */ + private functionOf(method: string, p: Record): string | undefined { + if (method !== "simulateTransaction" && method !== "sendTransaction") return undefined; + try { + return this.callOf(this.parse(p.transaction as string)).fn; + } catch { + return undefined; + } + } + + private run(state: ModelState, artifact: ContractArtifact, fn: string, args: Record, closeTime: number) { + return invoke(state, artifact.name, fn, args, { closeTime: BigInt(closeTime), treasuryAddress: this.o.treasuryAddress }); + } + + private errorCode(artifact: ContractArtifact, name: string): number { + const code = artifact.errors.get(name); + if (code === undefined) throw new Error(`fake RPC: ${artifact.name} has no Error::${name} in its spec`); + return code; + } + + private returnScVal(artifact: ContractArtifact, fn: string, value: unknown): xdr.ScVal { + const out = artifact.spec.getFunc(fn).outputs()[0]; + if (!out) return xdr.ScVal.scvVoid(); + const type = out.switch() === xdr.ScSpecType.scSpecTypeResult() ? out.result().okType() : out; + return artifact.spec.nativeToScVal(value, type); + } + + private simulate(envelope: string, faults: Fault[]) { + const tx = this.parse(envelope); + const { artifact, contractId, fn, args, call } = this.callOf(tx); + const base = { latestLedger: this.clock.sequence, events: [] as string[] }; + const injected = faults.find((f) => f.kind === "contract-error"); + const outcome = injected?.kind === "contract-error" + ? ({ ok: false, error: injected.error } as const) + : this.run(structuredClone(this.state), artifact, fn, args, this.clock.closeTime + this.clock.secsPerLedger); + if (!outcome.ok) { + return { ...base, error: `HostError: Error(Contract, #${this.errorCode(artifact, outcome.error)})\n\nEvent log (newest first):\n 0: [Diagnostic Event] contract:${contractId}, topics:[error, Error(Contract, #${this.errorCode(artifact, outcome.error)})]` }; + } + const write = isWrite(fn); + const instance = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ contract: call.contractAddress(), key: xdr.ScVal.scvLedgerKeyContractInstance(), durability: xdr.ContractDataDurability.persistent() }), + ); + const data = new SorobanDataBuilder() + .setResources(1_000_000, 10_000, write ? 1_000 : 0) + .setResourceFee(RESOURCE_FEE) + .setReadOnly(write ? [] : [instance]) + .setReadWrite(write ? [instance] : []) + .build(); + const auth = write + ? [ + new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsSourceAccount(), + rootInvocation: new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(call), + subInvocations: [], + }), + }).toXDR("base64"), + ] + : []; + return { + ...base, + transactionData: data.toXDR("base64"), + minResourceFee: String(RESOURCE_FEE), + results: [{ auth, xdr: this.returnScVal(artifact, fn, outcome.value).toXDR("base64") }], + }; + } + + private txResult(code: "txSUCCESS" | "txFAILED" | "txBAD_SEQ" | "txTOO_LATE" | "txINSUFFICIENT_FEE" | "txBAD_AUTH", fee = BASE_FEE): xdr.TransactionResult { + const R = xdr.TransactionResultResult; + const result = + code === "txSUCCESS" + ? R.txSuccess([xdr.OperationResult.opInner(xdr.OperationResultTr.invokeHostFunction(xdr.InvokeHostFunctionResult.invokeHostFunctionSuccess(Buffer.alloc(32))))]) + : code === "txFAILED" + ? R.txFailed([xdr.OperationResult.opInner(xdr.OperationResultTr.invokeHostFunction(xdr.InvokeHostFunctionResult.invokeHostFunctionTrapped()))]) + : code === "txBAD_SEQ" + ? R.txBadSeq() + : code === "txTOO_LATE" + ? R.txTooLate() + : code === "txINSUFFICIENT_FEE" + ? R.txInsufficientFee() + : R.txBadAuth(); + return new xdr.TransactionResult({ feeCharged: xdr.Int64.fromString(String(fee)), result, ext: new xdr.TransactionResultExt(0) }); + } + + private rejected(hash: string, code: Parameters[0]) { + return { + status: "ERROR", + hash, + errorResultXdr: this.txResult(code, 0).toXDR("base64"), + latestLedger: this.clock.sequence, + latestLedgerCloseTime: String(this.clock.closeTime), + }; + } + + private send(envelope: string, faults: Fault[]) { + const tx = this.parse(envelope); + const hash = tx.hash().toString("hex"); + const meta = { hash, latestLedger: this.clock.sequence, latestLedgerCloseTime: String(this.clock.closeTime) }; + if (this.txs.has(hash) || faults.some((f) => f.kind === "duplicate")) return { status: "DUPLICATE", ...meta }; + if (faults.some((f) => f.kind === "try-again-later")) return { status: "TRY_AGAIN_LATER", ...meta }; + const injected = faults.find((f) => f.kind === "tx-error"); + if (injected?.kind === "tx-error") return this.rejected(hash, injected.code); + + const source = tx.source; + const sig = tx.signatures[0]; + if (!sig || !Keypair.fromPublicKey(source).verify(tx.hash(), sig.signature())) return this.rejected(hash, "txBAD_AUTH"); + const current = this.sequences.get(source); + if (current === undefined || BigInt(tx.sequence) !== current + 1n) return this.rejected(hash, "txBAD_SEQ"); + const maxTime = Number(tx.timeBounds?.maxTime ?? 0); + const closeTime = this.clock.closeTime + this.clock.secsPerLedger; + if (maxTime !== 0 && closeTime > maxTime) return this.rejected(hash, "txTOO_LATE"); + + // Accepted: the sequence number and fee are consumed whatever happens next. + this.sequences.set(source, current + 1n); + this.advance(); + const { artifact, contractId, fn, args } = this.callOf(tx); + const divergence = faults.find((f) => f.kind === "apply-failure"); + const work = structuredClone(this.state); + const outcome = divergence?.kind === "apply-failure" + ? ({ ok: false, error: divergence.error } as const) + : this.run(work, artifact, fn, args, this.clock.closeTime); + const submission: Submission = { + hash, + contract: artifact.name, + fn, + args, + status: outcome.ok ? "SUCCESS" : "FAILED", + ledger: this.clock.sequence, + ...(!outcome.ok && { error: outcome.error }), + }; + let returnValue: xdr.ScVal | undefined; + if (outcome.ok) { + Object.assign(this.state, work); + returnValue = this.returnScVal(artifact, fn, outcome.value); + outcome.events.forEach((e, i) => this.recordEvent(e, contractId, hash, i)); + } + this.submissions.push(submission); + const notFound = faults.find((f) => f.kind === "not-found"); + this.txs.set(hash, { + submission, + envelopeXdr: envelope, + resultXdr: this.txResult(outcome.ok ? "txSUCCESS" : "txFAILED", BASE_FEE + RESOURCE_FEE).toXDR("base64"), + // A failed Soroban transaction still has Soroban meta, with a void return value. + resultMetaXdr: this.meta(returnValue ?? xdr.ScVal.scvVoid()).toXDR("base64"), + closeTime: this.clock.closeTime, + hiddenPolls: notFound?.kind === "not-found" ? notFound.polls : 0, + }); + return { status: "PENDING", ...meta }; + } + + private meta(returnValue: xdr.ScVal): xdr.TransactionMeta { + return new xdr.TransactionMeta( + 3, + new xdr.TransactionMetaV3({ + ext: new xdr.ExtensionPoint(0), + txChangesBefore: [], + operations: [new xdr.OperationMeta({ changes: [] })], + txChangesAfter: [], + sorobanMeta: new xdr.SorobanTransactionMeta({ ext: new xdr.SorobanTransactionMetaExt(0), events: [], returnValue, diagnosticEvents: [] }), + }), + ); + } + + private getTransaction(hash: string) { + const window = { + latestLedger: this.clock.sequence, + latestLedgerCloseTime: String(this.clock.closeTime), + oldestLedger: 1, + oldestLedgerCloseTime: String(this.clock.closeTime - this.clock.sequence * this.clock.secsPerLedger), + }; + const tx = this.txs.get(hash); + if (!tx || tx.hiddenPolls > 0) { + if (tx) tx.hiddenPolls--; + return { status: "NOT_FOUND", ...window }; + } + return { + status: tx.submission.status, + ...window, + ledger: tx.submission.ledger, + createdAt: String(tx.closeTime), + applicationOrder: 1, + feeBump: false, + envelopeXdr: tx.envelopeXdr, + resultXdr: tx.resultXdr, + resultMetaXdr: tx.resultMetaXdr, + }; + } + + // ─── Events ──────────────────────────────────────────────────────────────── + + private recordEvent(e: ModelEvent, contractId: string, txHash: string, index: number): void { + const body = new xdr.ContractEventV0({ + topics: [xdr.ScVal.scvSymbol(e.topic)], + data: xdr.ScVal.scvMap( + Object.entries(e.data).map( + ([k, v]) => new xdr.ScMapEntry({ key: xdr.ScVal.scvSymbol(k), val: typeof v === "bigint" ? xdr.ScVal.scvI128(new xdr.Int128Parts({ hi: xdr.Int64.fromString("0"), lo: xdr.Uint64.fromString(v.toString()) })) : xdr.ScVal.scvString(String(v)) }), + ), + ), + }); + const event = new xdr.ContractEvent({ + ext: new xdr.ExtensionPoint(0), + contractId: new Address(contractId).toScAddress().contractId(), + type: xdr.ContractEventType.contract(), + body: new xdr.ContractEventBody(0, body), + }); + this.events.push({ ledger: this.clock.sequence, closeTime: this.clock.closeTime, txHash, index, contractId, event }); + } + + private getEvents(p: Record) { + const start = Number(p.startLedger ?? 1); + const filters = (p.filters as { contractIds?: string[] }[] | undefined) ?? []; + const ids = filters.flatMap((f) => f.contractIds ?? []); + const limit = Number((p.pagination as { limit?: number } | undefined)?.limit ?? 100); + const matched = this.events.filter((e) => e.ledger >= start && (ids.length === 0 || ids.includes(e.contractId))).slice(0, limit); + return { + latestLedger: this.clock.sequence, + oldestLedger: 1, + cursor: matched.length ? `${matched.at(-1)!.ledger}-${matched.at(-1)!.index}` : "", + events: matched.map((e) => { + const v0 = e.event.body().v0(); + return { + type: "contract", + ledger: e.ledger, + ledgerClosedAt: new Date(e.closeTime * 1000).toISOString(), + contractId: e.contractId, + id: `${String(e.ledger).padStart(19, "0")}-${String(e.index).padStart(10, "0")}`, + operationIndex: 0, + transactionIndex: 0, + txHash: e.txHash, + inSuccessfulContractCall: true, + topic: v0.topics().map((t) => t.toXDR("base64")), + value: v0.data().toXDR("base64"), + }; + }), + }; + } +} + +/** Decodes a model value from an event, for test assertions. */ +export function eventValue(b64: string): unknown { + return scValToNative(xdr.ScVal.fromXDR(b64, "base64")); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/oracledesk-stellar/agents/tsconfig.json b/oracledesk-stellar/agents/tsconfig.json index ccf8860..d29058a 100644 --- a/oracledesk-stellar/agents/tsconfig.json +++ b/oracledesk-stellar/agents/tsconfig.json @@ -10,6 +10,5 @@ "forceConsistentCasingInFileNames": true, "noEmit": true }, - "include": ["core/**/*.ts", "stellar/**/*.ts", "bin/**/*.ts", "runtime/**/*.ts"] - "include": ["core/**/*.ts", "stellar/**/*.ts", "bin/**/*.ts", "sim/**/*.ts"] + "include": ["core/**/*.ts", "stellar/**/*.ts", "bin/**/*.ts", "runtime/**/*.ts", "sim/**/*.ts", "test/**/*.ts"] } diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index 1f1110a..9f9a920 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -753,6 +753,102 @@ With `multiformats`: After fetching, `sha256(body) === trace_hash`, as `x402/trace-verification.ts` checks, proves the bytes are the ones the agent committed to. +### Event triggers (agents) vs. the event indexer + +`agents/core/events/` (#105) lets agents react to contract events instead +of waiting for their next scheduled run. It is a **trigger system, not an +indexer**, and the two must not be confused: + +| | Event triggers (`agents/core/events`) | Event indexer (`docs/wave-issues/06-event-indexer.md`) | +| --- | --- | --- | +| Purpose | Wake an agent when something it cares about happens | Durable, queryable history for UIs and analytics | +| Storage | One checkpoint per network and contract set: `{cursor, lastLedger}` plus recently processed event ids, in `EVENTS_STATE_DIR` | Every event, in Postgres | +| History | None. Starts at the latest ledger; never replays | Replayable from any ledger it has stored | +| Retention gap | Detected; triggers a full state resync from chain reads | Is the thing it exists to cover | +| Source of truth | Chain state, re-read before every action | Its own tables | + +Flow: `EventSubscriber` polls `getEvents` (cursor pagination, filters +checked against the RPC's limits, failed contract calls dropped) and hands +each page to the `Dispatcher`. Events are decoded with the binding specs +(`decode.ts`, via the SDK's `Spec.parseEvent`) into a typed +`OracleDeskEvent`, with an `Unknown` variant for anything else. + +- **At-least-once delivery.** A page's checkpoint is written only after + every handler finished (or the event was dead-lettered). A crash in + between redelivers the page, so handlers are idempotent and an LRU of + processed ids, saved with the checkpoint, drops repeats. +- **Ordering.** One market's events run in order; different markets run + concurrently up to `EVENTS_CONCURRENCY`. +- **Failures.** A handler is retried with exponential backoff up to + `EVENTS_MAX_ATTEMPTS`, then the event goes to + `/dead-letter.jsonl` and the page moves on. +- **Idle polls** still advance `lastLedger`, so a quiet stretch is not + mistaken for a retention gap. + +Handlers (`handlers.ts`): `TradeReevaluate` queues a market for the trader +after a trade of at least `EVENTS_TRADE_THRESHOLD` stroops (re-reading the +market first); `ProposalWatch` alerts on a `ResolutionProposed` where the +treasury holds a position and the resolver still reports the proposal as +standing (cancellations emit no event), with the dispute deadline computed +from the proposal's ledger timestamp; `FinalityTracker` marks markets final +on `MarketResolved`, `ResolutionFinalized` or `PriceResolution`; and the +resync handler re-evaluates every tradable market after a gap. + +`npm run trader -- --follow` runs this loop (`runtime/follow.ts`); without +`--follow` the trader behaves exactly as before. + +### Cross-market arbitrage (agents, #108) + +Independent FPMM pools can price related markets inconsistently. With +`ARB_ENABLED=1` the trader runs an `arbitrage` task +(`runtime/tasks/arbitrage.ts`) over the relations in `ARB_RELATIONS_FILE`: + +```json +{ "relations": [ { "id": "btc-100k-implies-90k", "type": "implies", "a": 12, "b": 7, + "resolution": { "a": { "mechanism": "price", "source": "reflector:BTC/USD" }, + "b": { "mechanism": "price", "source": "reflector:BTC/USD" } }, + "voidCorrelation": "joint", "maxCloseSkewSecs": 0 } ] } +``` + +- **Relations** (`core/strategies/arbitrage.ts`): `implies` (P(A) <= P(B)), + `complement` (P(A) + P(B) = 1) and `exclusive` (P(A) + P(B) <= 1). A + relation is rejected unless both markets exist and are open, their close + times are within `maxCloseSkewSecs`, and both declare the *same* + resolution mechanism and source; a declared `resolutionHash` must equal + the market's on-chain commitment. The file's canonical sha256 (the graph + hash) is recorded in every trace. +- **Planning**: for each pair of legs, a payoff table over every outcome the + relation allows, plus void scenarios (`joint`: both void together; + `independent`: either may void alone, paying half per share), gives the + worst-case payout, measured on top of existing treasury holdings. Costs + are exact integer FPMM collateral (`collateralForShares`: fees, price + impact and rounding). The share count is found by integer ternary search + and a +/-64 verification scan, inside `max_trade`, remaining `max_market` + headroom, the rolling `max_daily` and `ARB_MAX_NOTIONAL`, and must clear + `ARB_MIN_PROFIT_STROOPS` and `ARB_MIN_PROFIT_BPS`. +- **Risk** (`core/risk.ts`, `checkArbPlan`): treasury caps and existing + exposure; `ARB_MARKET_HEADROOM_BPS` of each market's `max_market` kept free + for later hedges; `ARB_MAX_UNHEDGED_STROOPS` caps the first leg, which is + the exposure left if the second never fills; `ARB_MAX_OPEN_LEGS` and + `ARB_MAX_UNWIND_RETRIES` bound concurrent legs and unwind attempts. +- **Execution** (`core/strategies/arb-executor.ts`): treasury calls are not + atomic, so the legs are sequential. The less liquid market goes first, + re-quoted and bought with `min_shares_out` = the planned shares. The second + market is then re-read; if its cost rose by more than `ARB_MAX_LEG_LOSS` + the first leg is sold back with `agent_sell`, otherwise the second leg is + bought. Every step is journaled before and after submission in + `ARB_JOURNAL_FILE`, and the next run finishes or unwinds any plan a crash + interrupted, settling ambiguous submissions from the treasury's positions. + A trace with the relation, graph hash, snapshots, plan and leg results is + published when a plan completes or is unwound. Dry-run only plans and logs. + +**Residual risks.** Between the legs the treasury holds one side naked: a +fast move can cost up to `ARB_MAX_LEG_LOSS` plus the unwind's price impact, +and if every unwind attempt fails the leg stays open (`ARB_UNHEDGED` is +logged and the plan closes as `unwind-failed`). The relation file is +trusted: a wrong relation produces confident, wrong trades, which is why +compatibility must be declared and is checked against the chain. + ## Known limitations (see docs/STATUS.md for the full, current list) - Testnet only; unaudited. diff --git a/oracledesk-stellar/model/fpmm_model.py b/oracledesk-stellar/model/fpmm_model.py index 4b9c9f1..ab16d3b 100644 --- a/oracledesk-stellar/model/fpmm_model.py +++ b/oracledesk-stellar/model/fpmm_model.py @@ -174,6 +174,54 @@ def collateral_out_for_shares(r_sold: int, r_other: int, shares_in: int, fee_bps return mul_div_floor(gross, BPS - fee_bps, BPS) +COLLATERAL_SEARCH_STEPS = 256 + + +def collateral_for_shares(r_bought: int, r_other: int, shares_target: int, fee_bps: int) -> int: + """Minimum collateral_in for which calc_buy returns at least shares_target. + + Closed-form estimate of the net amount, grossed up for the fee, then a + bounded binary search against calc_buy itself so integer rounding cannot + make it wrong. Mirrors agents/core/fpmm.ts::collateralForShares (#108). + """ + if shares_target <= 0 or r_bought <= 0 or r_other <= 0 or not 0 <= fee_bps < BPS: + raise MathError("invalid input") + + def shares_for(c: int) -> int: + if c <= 0: + return 0 + try: + return calc_buy(r_bought, r_other, c, fee_bps).shares_out + except MathError: + return 0 + + b = r_bought + r_other - shares_target + net = (isqrt(b * b + 4 * shares_target * r_other) - b) // 2 + estimate = mul_div_ceil(net if net > 0 else 1, BPS, BPS - fee_bps) + hi, steps = estimate, 0 + while shares_for(hi) < shares_target: + hi = hi * 2 + 1 + steps += 1 + if steps > COLLATERAL_SEARCH_STEPS: + raise MathError("target not reachable") + lo = hi - 64 if hi > 64 else 0 + while lo > 0 and shares_for(lo) >= shares_target: + lo //= 2 + steps += 1 + if steps > COLLATERAL_SEARCH_STEPS: + raise MathError("search did not converge") + while hi - lo > 1: + mid = (lo + hi) // 2 + if shares_for(mid) >= shares_target: + hi = mid + else: + lo = mid + steps += 1 + if steps > COLLATERAL_SEARCH_STEPS: + raise MathError("search did not converge") + return hi + + def price_after(outcome_is_yes: bool, r_after: int, r_other_after: int) -> int: """YES price in bps of the pool **after** applying an outcome-relative reserve update (the `new_reserve_*` pair of a BuyQuote/SellQuote). @@ -379,6 +427,56 @@ def _check_shared_vectors(): ) +COLLATERAL_FIXTURES = Path(__file__).resolve().parents[1] / "scripts/fpmm-diff/collateral_for_shares.jsonl" + + +def _shares_or_zero(r_bought: int, r_other: int, c: int, fee_bps: int) -> int: + try: + return calc_buy(r_bought, r_other, c, fee_bps).shares_out if c > 0 else 0 + except MathError: + return 0 + + +def _check_collateral_for_shares_is_minimal(trials: int = 2_000, seed: int = 13): + """collateral_for_shares buys the target, and one stroop less does not.""" + rng = random.Random(seed) + for _ in range(trials): + r_bought = rng.randint(1, 10**13) + r_other = rng.randint(1, 10**13) + fee = rng.choice([0, 1, 30, 100, 250, 9_999]) + target = rng.randint(1, r_bought * 3 + 10) + c = collateral_for_shares(r_bought, r_other, target, fee) + assert _shares_or_zero(r_bought, r_other, c, fee) >= target, (r_bought, r_other, target, fee, c) + assert _shares_or_zero(r_bought, r_other, c - 1, fee) < target, (r_bought, r_other, target, fee, c) + + +def collateral_fixture_cases(seed: int = 108, count: int = 40): + rng = random.Random(seed) + cases = [(1_000, 1_000, 1, 0), (1_000, 1_000, 500, 100), (6_000_000_000, 4_000_000_000, 245_327_043, 100)] + while len(cases) < count: + r_bought, r_other = rng.randint(1, 10**12), rng.randint(1, 10**12) + cases.append((r_bought, r_other, rng.randint(1, r_bought * 2 + 1), rng.choice([0, 30, 100, 300]))) + return cases + + +def write_collateral_fixtures(): + lines = [] + for r_bought, r_other, target, fee in collateral_fixture_cases(): + c = collateral_for_shares(r_bought, r_other, target, fee) + lines.append(json.dumps({"args": [str(r_bought), str(r_other), str(target), str(fee)], "collateral_in": str(c)}, sort_keys=True)) + COLLATERAL_FIXTURES.write_text("\n".join(lines) + "\n") + print(f"wrote {len(lines)} fixtures to {COLLATERAL_FIXTURES}") + + +def _check_collateral_fixtures(): + """Replays the fixtures agents/core/fpmm.test.ts also asserts.""" + for line_no, line in enumerate(COLLATERAL_FIXTURES.read_text().splitlines(), 1): + case = json.loads(line) + a = [int(x) for x in case["args"]] + got = collateral_for_shares(*a) + assert str(got) == case["collateral_in"], f"fixture line {line_no}: {got} != {case['collateral_in']}" + + def run_all(): _check_known_values() _check_shared_vectors() @@ -388,8 +486,15 @@ def run_all(): _check_void_never_overpays() _check_price_after_maps_both_sides() _check_limits_boundary_and_symmetry() + _check_collateral_for_shares_is_minimal() + _check_collateral_fixtures() print("fpmm_model: all property checks passed") if __name__ == "__main__": - run_all() + import sys + + if "--write-collateral-fixtures" in sys.argv: + write_collateral_fixtures() + else: + run_all() diff --git a/oracledesk-stellar/scripts/fpmm-diff/collateral_for_shares.jsonl b/oracledesk-stellar/scripts/fpmm-diff/collateral_for_shares.jsonl new file mode 100644 index 0000000..7bf65ab --- /dev/null +++ b/oracledesk-stellar/scripts/fpmm-diff/collateral_for_shares.jsonl @@ -0,0 +1,40 @@ +{"args": ["1000", "1000", "1", "0"], "collateral_in": "1"} +{"args": ["1000", "1000", "500", "100"], "collateral_in": "284"} +{"args": ["6000000000", "4000000000", "245327043", "100"], "collateral_in": "100588058"} +{"args": ["786539595290", "93697436148", "1455480409812", "300"], "collateral_in": "779076254696"} +{"args": ["303775627184", "216372438664", "90065538109", "100"], "collateral_in": "41755788687"} +{"args": ["198491176560", "782551677333", "175465905864", "30"], "collateral_in": "144957730824"} +{"args": ["206639282277", "113482950537", "178384995093", "300"], "collateral_in": "90808512274"} +{"args": ["688110090492", "763986889269", "124180383565", "300"], "collateral_in": "70067779546"} +{"args": ["378102555759", "798037453367", "220061964831", "300"], "collateral_in": "162556961775"} +{"args": ["528576307827", "991783715866", "973962577763", "0"], "collateral_in": "746898216899"} +{"args": ["446609049161", "259154706876", "484011443332", "0"], "collateral_in": "260240221310"} +{"args": ["869097841966", "853775618534", "498999660906", "100"], "collateral_in": "285626306052"} +{"args": ["195643225038", "249224424813", "321227248634", "300"], "collateral_in": "234844565040"} +{"args": ["540531188519", "116783711883", "706656089821", "30"], "collateral_in": "313943066467"} +{"args": ["803414086920", "678289843140", "298785105236", "100"], "collateral_in": "153369064094"} +{"args": ["31150082854", "867892063416", "55520169842", "100"], "collateral_in": "54247411012"} +{"args": ["430101745714", "50417573953", "779305446759", "300"], "collateral_in": "409901671500"} +{"args": ["860343113768", "804343625925", "1067473865751", "0"], "collateral_in": "674934319217"} +{"args": ["551011388603", "57499100683", "1028165107531", "30"], "collateral_in": "532588691897"} +{"args": ["189769231737", "615012575060", "320551437922", "30"], "collateral_in": "264408133800"} +{"args": ["259433316671", "889187958661", "433448567126", "100"], "collateral_in": "362477706664"} +{"args": ["322445410915", "794019618269", "121874168764", "300"], "collateral_in": "92043425719"} +{"args": ["605894449967", "168431838893", "75221681729", "0"], "collateral_in": "17675880411"} +{"args": ["715370645797", "553151214705", "235612277721", "0"], "collateral_in": "113668029313"} +{"args": ["411554911930", "470137942628", "416402999363", "100"], "collateral_in": "269945022398"} +{"args": ["111184831741", "664873755585", "45941722855", "300"], "collateral_in": "40907097074"} +{"args": ["165241160648", "458098553435", "301130213545", "300"], "collateral_in": "251281895146"} +{"args": ["699898246022", "90589250357", "424776698365", "0"], "collateral_in": "85316746490"} +{"args": ["444144049381", "689504091566", "689813585334", "0"], "collateral_in": "502566378114"} +{"args": ["654572954549", "603061742952", "381946072318", "300"], "collateral_in": "218356048424"} +{"args": ["172280415729", "416926641400", "11623042658", "0"], "collateral_in": "8271587945"} +{"args": ["195677412229", "716790962701", "30753568903", "0"], "collateral_in": "24329795921"} +{"args": ["9782216832", "811142103745", "17397797476", "100"], "collateral_in": "17368421006"} +{"args": ["871826256263", "61769641360", "492166421989", "300"], "collateral_in": "62433834026"} +{"args": ["635813254927", "874856763113", "561390143762", "300"], "collateral_in": "383273675846"} +{"args": ["275726601009", "37832930988", "439039911200", "100"], "collateral_in": "208162309603"} +{"args": ["763574574494", "449350287959", "572667434415", "0"], "collateral_in": "279713490314"} +{"args": ["853416518870", "653598778214", "309251090428", "100"], "collateral_in": "151489189051"} +{"args": ["803711350656", "618505301405", "906961339676", "300"], "collateral_in": "550943843540"} +{"args": ["617865974721", "560434175800", "375291411633", "100"], "collateral_in": "210131101108"}