diff --git a/oracledesk-stellar/.env.example b/oracledesk-stellar/.env.example index 0d0f9ee..af5575a 100644 --- a/oracledesk-stellar/.env.example +++ b/oracledesk-stellar/.env.example @@ -115,11 +115,18 @@ TRADER_SECRET_KEY= # 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; +# the i128 maximum (default 100000000 = 10 USDC), now a hard sizing cap. +# 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= +# Impact-aware fractional Kelly sizing. KELLY_FRACTION must be >0 and <=1 +# (default 0.25). The other values are basis points or stroops. +KELLY_FRACTION=0.25 +SIZING_BUFFER_BPS=25 +MIN_TRADE_STROOPS=1000000 +FAIR_UNCERTAINTY_BPS=500 # 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, diff --git a/oracledesk-stellar/.gitignore b/oracledesk-stellar/.gitignore index 2209f34..32c3060 100644 --- a/oracledesk-stellar/.gitignore +++ b/oracledesk-stellar/.gitignore @@ -28,6 +28,8 @@ yarn-error.log* .events/ # Arbitrage execution journal (ARB_JOURNAL_FILE, #108) .arb/ +.risk/ +.journal/ # Stellar / Soroban CLI local state (funded testnet identities, RPC caches) .soroban/ diff --git a/oracledesk-stellar/agents/bin/agentd.ts b/oracledesk-stellar/agents/bin/agentd.ts index 46fadf5..b2d6072 100644 --- a/oracledesk-stellar/agents/bin/agentd.ts +++ b/oracledesk-stellar/agents/bin/agentd.ts @@ -16,6 +16,7 @@ import "dotenv/config"; import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; +import { redactString } from "../core/redact.js"; import { ConfigError, EXIT_CONFIG, EXIT_TEMPFAIL, intEnv, parseAgentMode } from "../runtime/config.js"; import { LeaseHeldError, type Lease } from "../runtime/lease.js"; import { jsonLogger, type Logger } from "../runtime/log.js"; @@ -155,7 +156,7 @@ if (isMain) { process.exitCode = code; }, (err) => { - console.error(err); + console.error(redactString(err instanceof Error ? err.message : String(err))); process.exitCode = 1; }, ); diff --git a/oracledesk-stellar/agents/bin/audit.ts b/oracledesk-stellar/agents/bin/audit.ts index 3a1ded0..6b5c202 100755 --- a/oracledesk-stellar/agents/bin/audit.ts +++ b/oracledesk-stellar/agents/bin/audit.ts @@ -16,6 +16,7 @@ import "dotenv/config"; import { createHash } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; +import { redactString } from "../core/redact.js"; import type { TraceRepository } from "../../x402/trace-api.js"; export interface AuditEntry { @@ -286,6 +287,6 @@ async function main(): Promise { } main().catch((err) => { - console.error("audit failed:", err); + console.error("audit failed:", redactString(err instanceof Error ? err.message : String(err))); process.exit(1); }); diff --git a/oracledesk-stellar/agents/bin/market-maker.ts b/oracledesk-stellar/agents/bin/market-maker.ts index f3c68e6..681b92a 100644 --- a/oracledesk-stellar/agents/bin/market-maker.ts +++ b/oracledesk-stellar/agents/bin/market-maker.ts @@ -15,6 +15,7 @@ * exits, `--offline` skips the network probes in dry-run. */ import "dotenv/config"; +import { redactString } from "../core/redact.js"; import { describeConfig, loadAgentConfig } from "../core/config.js"; import { ConfigError, EXIT_CONFIG, EXIT_TEMPFAIL } from "../runtime/config.js"; import { LeaseHeldError } from "../runtime/lease.js"; @@ -40,6 +41,6 @@ async function main() { } main().catch((err) => { - console.error(err instanceof ConfigError ? err.message : err); + console.error(redactString(err instanceof Error ? err.message : String(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 e2ea39f..148bd1d 100644 --- a/oracledesk-stellar/agents/bin/trader.test.ts +++ b/oracledesk-stellar/agents/bin/trader.test.ts @@ -12,6 +12,19 @@ import { main } from "./trader.js"; const FAIR_VALUE_BPS = 6000; const QUOTE_SHARES = 123_456n; +const approved = async (marketId: bigint) => ({ + outcome: "allow" as const, + reasons: [], + commitments: "verified" as const, + metadata: "verified" as const, + resolutionPath: "verified" as const, + oracleTrust: "not_applicable" as const, + signerTrust: "not_applicable" as const, + marketId: marketId.toString(), + stateFingerprint: "fixture-market-state", + checkedAt: Date.now(), +}); + function fakeAdapter(quoteShares: bigint, calls: Record[]): StellarAdapter { return { mode: "dry-run" as const, @@ -27,7 +40,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), parseTunables(env), () => 1_000_000); + await main(fakeAdapter(QUOTE_SHARES, calls), parseTunables(env), () => 1_000_000, approved); assert.equal(calls.length, 1); const args = calls[0]; @@ -49,7 +62,7 @@ test("trader honours SLIPPAGE_BPS and DEADLINE_SECS overrides", async () => { SLIPPAGE_BPS: "100", DEADLINE_SECS: "30", }; - await main(fakeAdapter(QUOTE_SHARES, calls), parseTunables(env), () => 2_000_000); + await main(fakeAdapter(QUOTE_SHARES, calls), parseTunables(env), () => 2_000_000, approved); 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 b6bb5b2..014ed20 100644 --- a/oracledesk-stellar/agents/bin/trader.ts +++ b/oracledesk-stellar/agents/bin/trader.ts @@ -21,7 +21,9 @@ */ import "dotenv/config"; import { pathToFileURL } from "node:url"; +import { redactString } from "../core/redact.js"; import { describeConfig, loadAgentConfig, type AgentConfig, type Tunables } from "../core/config.js"; +import type { DueDiligenceVerdict } from "../core/due-diligence.js"; import { LocalTraceStore } from "../core/trace.js"; import { followTask } from "../runtime/follow.js"; import { arbitrageTask } from "../runtime/tasks/arbitrage.js"; @@ -44,10 +46,11 @@ export async function main( adapter: StellarAdapter, run: Tunables, now: () => number = () => Math.floor(Date.now() / 1000), + dueDiligence?: (marketId: bigint) => Promise, ): Promise { const deployments = (adapter as Partial).deployments; const runtime = new Runtime({ - tasks: [traderTask(traderDeps(run, adapter, deployments, now), { intervalMs: 0 }), ...arbTasks(run, adapter, now, 0)], + tasks: [traderTask(traderDeps(run, adapter, deployments, now, dueDiligence), { 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), @@ -107,7 +110,7 @@ if (isMain) { return main(buildAdapter(cfg), cfg); }) .catch((err) => { - console.error(err instanceof ConfigError ? err.message : err); + console.error(redactString(err instanceof Error ? err.message : String(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 index f8ba3a7..144c36e 100644 --- a/oracledesk-stellar/agents/core/config.test.ts +++ b/oracledesk-stellar/agents/core/config.test.ts @@ -94,9 +94,13 @@ test("an unset AGENT_MODE defaults to dry-run with documented defaults", async ( assert.equal(cfg.rpcUrl, NETWORKS.testnet.rpcUrl); assert.equal(cfg.trading.minEdgeBps, 500); assert.equal(cfg.trading.tradeSizeStroops, 100_000_000n); + assert.deepEqual(cfg.trading.kellyFraction, { numerator: 1n, denominator: 4n }); + assert.equal(cfg.trading.sizingBufferBps, 25); + assert.equal(cfg.trading.minTradeStroops, 1_000_000n); + assert.equal(cfg.trading.fairUncertaintyBps, 500); assert.equal(cfg.maker.defaultSeedStroops, 1_500_000_000n); assert.equal(cfg.agentSecret, undefined); - assert.match(cfg.deploymentsFile, /deployments\/testnet\.json$/); + assert.match(cfg.deploymentsFile, /deployments[\\/]testnet\.json$/); }); test("unknown and empty agent modes are rejected", async () => { @@ -315,4 +319,10 @@ test("live startup runs both probes", async () => { 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); + assert.deepEqual(parseTunables({ KELLY_FRACTION: "0.125" }).trading.kellyFraction, { + numerator: 125n, + denominator: 1000n, + }); + assert.throws(() => parseTunables({ KELLY_FRACTION: "0" }), AgentConfigError); + assert.throws(() => parseTunables({ KELLY_FRACTION: "1.1" }), AgentConfigError); }); diff --git a/oracledesk-stellar/agents/core/config.ts b/oracledesk-stellar/agents/core/config.ts index b349523..1e32185 100644 --- a/oracledesk-stellar/agents/core/config.ts +++ b/oracledesk-stellar/agents/core/config.ts @@ -97,6 +97,24 @@ function stroopsField(fallback: bigint, min = 1n) { }); } +function fractionField() { + return z.string().optional().transform((raw, ctx): { numerator: bigint; denominator: bigint } => { + if (raw === undefined) return { numerator: 1n, denominator: 4n }; + if (!/^(?:0|1)(?:\.\d{1,6})?$/.test(raw)) { + ctx.addIssue({ code: "custom", message: `must be a decimal fraction greater than 0 and at most 1, got ${JSON.stringify(raw)}` }); + return z.NEVER; + } + const [whole, fractional = ""] = raw.split("."); + const denominator = 10n ** BigInt(fractional.length); + const numerator = BigInt(whole) * denominator + BigInt(fractional || "0"); + if (numerator <= 0n || numerator > denominator) { + ctx.addIssue({ code: "custom", message: `must be greater than 0 and at most 1, got ${JSON.stringify(raw)}` }); + return z.NEVER; + } + return { numerator, denominator }; + }); +} + /** 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); @@ -124,6 +142,10 @@ export const EnvSchema = z.object({ MIN_EDGE_BPS: bpsField(500), CONFIDENCE_THRESHOLD_BPS: bpsField(1000), SLIPPAGE_BPS: bpsField(50), + KELLY_FRACTION: fractionField(), + SIZING_BUFFER_BPS: bpsField(25), + MIN_TRADE_STROOPS: stroopsField(1_000_000n, 1n), + FAIR_UNCERTAINTY_BPS: bpsField(500), FAIR_VALUE_BPS: bpsField(undefined), TRADE_SIZE_STROOPS: stroopsField(100_000_000n), DEFAULT_SEED_STROOPS: stroopsField(1_500_000_000n, MIN_SEED_STROOPS), @@ -254,6 +276,10 @@ export interface TradingConfig { deadlineSecs: number; /** Fixed fair-value override; unset means each market's own price. */ fairValueBps?: number; + kellyFraction: { numerator: bigint; denominator: bigint }; + sizingBufferBps: number; + minTradeStroops: bigint; + fairUncertaintyBps: number; } /** Event-driven triggers (#105), used by `trader --follow`. */ @@ -350,6 +376,10 @@ function tunablesFrom(e: ParsedEnv, env: NodeJS.ProcessEnv): Tunables { slippageBps: e.SLIPPAGE_BPS!, deadlineSecs: e.DEADLINE_SECS!, fairValueBps: e.FAIR_VALUE_BPS, + kellyFraction: e.KELLY_FRACTION!, + sizingBufferBps: e.SIZING_BUFFER_BPS!, + minTradeStroops: e.MIN_TRADE_STROOPS, + fairUncertaintyBps: e.FAIR_UNCERTAINTY_BPS!, }, maker: { confidenceThresholdBps: e.CONFIDENCE_THRESHOLD_BPS!, @@ -538,7 +568,15 @@ export function describeConfig(cfg: AgentConfig): Record { 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() }, + trading: { + ...trading, + tradeSizeStroops: trading.tradeSizeStroops.toString(), + minTradeStroops: trading.minTradeStroops.toString(), + kellyFraction: { + numerator: trading.kellyFraction.numerator.toString(), + denominator: trading.kellyFraction.denominator.toString(), + }, + }, maker: { ...maker, defaultSeedStroops: maker.defaultSeedStroops.toString() }, }; } diff --git a/oracledesk-stellar/agents/core/execution-algorithms.test.ts b/oracledesk-stellar/agents/core/execution-algorithms.test.ts index be0d668..34a2e46 100644 --- a/oracledesk-stellar/agents/core/execution-algorithms.test.ts +++ b/oracledesk-stellar/agents/core/execution-algorithms.test.ts @@ -15,7 +15,7 @@ async function createMockQuote() { } test("TWAPExecutor creates parent order", async () => { - const executor = new TWAPExecutor(createMockQuote, 1000, 100); + const executor = new TWAPExecutor(createMockQuote, 1000, 1000); const parentOrder: ParentOrder = { id: "parent-1", @@ -33,11 +33,11 @@ test("TWAPExecutor creates parent order", async () => { const executionId = await executor.executeParentOrder(parentOrder, "market-1"); assert.ok(executionId); - assert.equal(parentOrder.status, "completed" || "failed"); + assert.equal(parentOrder.status, "completed"); }); test("TWAPExecutor generates child orders", async () => { - const executor = new TWAPExecutor(createMockQuote, 1000, 100); + const executor = new TWAPExecutor(createMockQuote, 1000, 1000); const parentOrder: ParentOrder = { id: "parent-2", diff --git a/oracledesk-stellar/agents/core/execution-algorithms.ts b/oracledesk-stellar/agents/core/execution-algorithms.ts index 69d0134..6c0a994 100644 --- a/oracledesk-stellar/agents/core/execution-algorithms.ts +++ b/oracledesk-stellar/agents/core/execution-algorithms.ts @@ -79,17 +79,17 @@ export class TWAPExecutor { parentOrder.status = "executing"; const startTime = Date.now(); const endTime = startTime + this.sliceDurationMs; + let slicesRemaining = Math.max(1, Math.ceil(this.sliceDurationMs / this.orderIntervalMs)); const childIds: string[] = []; - let currentTime = startTime; - - while (currentTime < endTime && parentOrder.executedShares < parentOrder.targetShares) { + while (Date.now() < endTime && parentOrder.executedShares < parentOrder.targetShares) { + const sliceStartedAt = Date.now(); const remainingShares = parentOrder.targetShares - parentOrder.executedShares; - const numberOfSlices = Math.ceil( - (endTime - currentTime) / this.orderIntervalMs, - ); - const sliceSize = remainingShares / BigInt(numberOfSlices); + const sliceSize = slicesRemaining <= 1 + ? remainingShares + : remainingShares / BigInt(slicesRemaining); + slicesRemaining = Math.max(0, slicesRemaining - 1); const childOrder = await this.createAndExecuteChild( parentOrder, @@ -102,11 +102,7 @@ export class TWAPExecutor { parentOrder.childOrders.push(childOrder.id); } - currentTime = Date.now(); - const waitTime = Math.max( - 0, - this.orderIntervalMs - (currentTime - (currentTime - startTime)), - ); + const waitTime = Math.max(0, this.orderIntervalMs - (Date.now() - sliceStartedAt)); if (waitTime > 0) { await new Promise((resolve) => setTimeout(resolve, waitTime)); } @@ -214,7 +210,7 @@ export class ParticipationLimitedExecutor { while (parentOrder.executedShares < parentOrder.targetShares) { const quote = await this.quote({ market, shares: 1000000n }); - this.state.lastQuote = { timestamp: Date.now(), ...quote }; + this.state.lastQuote = { ...quote }; // Check if edge disappeared if (quote.price > parentOrder.limitPrice) { diff --git a/oracledesk-stellar/agents/core/redact.test.ts b/oracledesk-stellar/agents/core/redact.test.ts new file mode 100644 index 0000000..47502dd --- /dev/null +++ b/oracledesk-stellar/agents/core/redact.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { redact, redactString, REDACTED } from "./redact.js"; + +test("redacts sensitive fields recursively without mutating the input", () => { + const source = { + agent: "trader", + privateKey: "private-value", + nested: { api_token: "token-value", count: 2 }, + messages: [{ text: "safe" }], + }; + const result = redact(source); + assert.deepEqual(result, { + agent: "trader", + privateKey: REDACTED, + nested: { api_token: REDACTED, count: 2 }, + messages: [{ text: "safe" }], + }); + assert.equal(source.privateKey, "private-value"); +}); + +test("redacts Stellar secret seeds, bearer credentials, and URL query secrets in text", () => { + const secret = `S${"A".repeat(55)}`; + assert.equal( + redactString(`seed=${secret} Bearer abc.def token?api_key=topsecret`), + `seed=${REDACTED} Bearer ${REDACTED} token?api_key=${REDACTED}`, + ); +}); + +test("handles cyclic values", () => { + const value: { self?: unknown; password: string } = { password: "hidden" }; + value.self = value; + const result = redact(value) as typeof value; + assert.equal(result.password, REDACTED); + assert.equal(result.self, result); +}); diff --git a/oracledesk-stellar/agents/core/redact.ts b/oracledesk-stellar/agents/core/redact.ts new file mode 100644 index 0000000..39ef431 --- /dev/null +++ b/oracledesk-stellar/agents/core/redact.ts @@ -0,0 +1,41 @@ +const SENSITIVE_KEY = + /(?:secret|private.?key|seed|password|passphrase|token|authorization|credential|api.?key|access.?key)/i; +const STELLAR_SECRET = /\bS[A-Z2-7]{55}\b/g; +const PEM_PRIVATE_KEY = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g; +const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi; +const QUERY_SECRET = /([?&](?:token|secret|key|api[_-]?key|password|signature)=)[^&#\s]+/gi; + +export const REDACTED = "[REDACTED]"; + +export function redactString(value: string): string { + return value + .replace(STELLAR_SECRET, REDACTED) + .replace(PEM_PRIVATE_KEY, REDACTED) + .replace(BEARER, "Bearer [REDACTED]") + .replace(QUERY_SECRET, `$1${REDACTED}`); +} + +export function redact(value: T): T { + const visited = new WeakMap(); + const visit = (current: unknown, key?: string): unknown => { + if (key && SENSITIVE_KEY.test(key)) return REDACTED; + if (typeof current === "string") return redactString(current); + if (current === null || typeof current !== "object") return current; + if (Buffer.isBuffer(current)) return REDACTED; + const cached = visited.get(current); + if (cached !== undefined) return cached; + if (Array.isArray(current)) { + const copy: unknown[] = []; + visited.set(current, copy); + for (const item of current) copy.push(visit(item)); + return copy; + } + const copy: Record = {}; + visited.set(current, copy); + for (const [childKey, child] of Object.entries(current)) { + copy[childKey] = visit(child, childKey); + } + return copy; + }; + return visit(value) as T; +} diff --git a/oracledesk-stellar/agents/core/risk.test.ts b/oracledesk-stellar/agents/core/risk.test.ts new file mode 100644 index 0000000..4267f94 --- /dev/null +++ b/oracledesk-stellar/agents/core/risk.test.ts @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + evaluateAgentRisk, + ROLE_CREATE, + ROLE_TRADE, + type AgentRiskState, + type AgentUsageWindow, +} from "./risk.js"; + +function window(hour = 10n, headIdx = 0): AgentUsageWindow { + return { headHour: hour, headIdx, amounts: Array(25).fill(0n) }; +} + +function state(overrides: Partial = {}): AgentRiskState { + return { + now: 10n * 3_600n, + global: { maxTrade: 100n, maxMarket: 500n, maxDaily: 1_000n, maxNetExposure: 1_000n }, + agent: { active: true, roles: ROLE_TRADE | ROLE_CREATE, maxTrade: 100n, maxDaily: 800n }, + usage: { global: window(), agent: window() }, + idleCapital: 1_000n, + queueReservedCapital: 0n, + netExposure: 0n, + marketExposure: 0n, + marketNetPosition: { collateralIn: 0n, collateralOut: 0n }, + yieldLiquidity: { enabled: false, available: null }, + price: { + maxBuyPriceBps: 9_500n, + minSellPriceBps: 500n, + maxImpactBps: 500n, + maxSlippageBps: 100n, + }, + ...overrides, + }; +} + +function buy(overrides: Partial[0], { kind: "buy" }>> = {}) { + return { + kind: "buy" as const, + amount: 50n, + priceBeforeBps: 6_000n, + priceAfterBps: 6_100n, + quotedShares: 100n, + minSharesOut: 90n, + ...overrides, + }; +} + +function create(overrides: Partial[0], { kind: "create" }>> = {}) { + return { + kind: "create" as const, + seedAmount: 50n, + closeTime: 10n * 3_600n + 100n, + category: "Crypto" as const, + initialYesBps: 6_000n, + creationPolicy: { + maxSeed: 100n, + maxMarketsPerWindow: 3, + creationWindowSecs: 24n * 3_600n, + minCloseHorizon: 60n, + maxCloseHorizon: 30n * 24n * 3_600n, + allowedCategories: 0x3f, + }, + creationBuckets: [], + ...overrides, + }; +} + +test("allows a buy within role, price, capital, usage, market, and net exposure caps", () => { + assert.deepEqual(evaluateAgentRisk(buy(), state()), { violations: [], unverifiable: [] }); +}); + +test("checks the agent state and each buy constraint in treasury order", () => { + const s = state({ + agent: { active: true, roles: ROLE_TRADE, maxTrade: 1n, maxDaily: 1n }, + global: { maxTrade: 10n, maxMarket: 20n, maxDaily: 10n, maxNetExposure: 10n }, + idleCapital: 60n, + queueReservedCapital: 20n, + price: { maxBuyPriceBps: 5_000n, minSellPriceBps: 500n, maxImpactBps: 10n, maxSlippageBps: 100n }, + marketExposure: 0n, + netExposure: 0n, + }); + const result = evaluateAgentRisk(buy({ amount: 50n, priceBeforeBps: 6_000n, priceAfterBps: 6_100n }), s); + assert.deepEqual(result.violations.slice(0, 4), [ + "trade amount exceeds max_trade", + "spend would use queue-reserved capital", + "buy price is above max_buy_price_bps", + "price impact exceeds max_impact_bps", + ]); + assert.ok(result.violations.includes("global rolling 24-hour usage exceeds max_daily")); + assert.ok(result.violations.includes("agent rolling 24-hour usage exceeds max_daily")); +}); + +test("rolling usage expires buckets as the contract's 25-hour ring advances", () => { + const amounts = Array(25).fill(0n); + amounts[0] = 80n; + const agedWindow = { headHour: 10n, headIdx: 0, amounts }; + const stillRetained = state({ + now: 11n * 3_600n, + usage: { global: agedWindow, agent: agedWindow }, + global: { maxTrade: 100n, maxMarket: 500n, maxDaily: 50n, maxNetExposure: 1_000n }, + agent: { active: true, roles: ROLE_TRADE, maxTrade: 100n, maxDaily: 50n }, + }); + assert.ok(evaluateAgentRisk(buy({ amount: 10n }), stillRetained).violations.includes("global rolling 24-hour usage exceeds max_daily")); + + const expired = state({ + now: 35n * 3_600n, + usage: { global: agedWindow, agent: agedWindow }, + global: { maxTrade: 100n, maxMarket: 500n, maxDaily: 50n, maxNetExposure: 1_000n }, + agent: { active: true, roles: ROLE_TRADE, maxTrade: 100n, maxDaily: 50n }, + }); + assert.deepEqual(evaluateAgentRisk(buy({ amount: 10n }), expired).violations, []); +}); + +test("applies create role, seed, horizon, category, price and rolling creation limits", () => { + const good = create(); + assert.deepEqual(evaluateAgentRisk(good, state()), { violations: [], unverifiable: [] }); + + const policy = { ...good.creationPolicy, maxSeed: 40n, minCloseHorizon: 200n, allowedCategories: 1 << 1 }; + const result = evaluateAgentRisk(create({ + seedAmount: 50n, + closeTime: state().now + 100n, + category: "Crypto", + initialYesBps: 9_600n, + creationPolicy: policy, + creationBuckets: [{ bucketStart: state().now - 100n, count: 3 }], + }), state({ agent: { active: true, roles: ROLE_TRADE, maxTrade: 100n, maxDaily: 800n } })); + assert.deepEqual(result.violations, [ + "agent is inactive or lacks ROLE_CREATE", + ]); + + const createResult = evaluateAgentRisk(create({ + seedAmount: 50n, + closeTime: state().now + 100n, + category: "Crypto", + initialYesBps: 9_600n, + creationPolicy: policy, + creationBuckets: [{ bucketStart: state().now - 100n, count: 3 }], + }), state()); + assert.deepEqual(createResult.violations.slice(0, 5), [ + "seed amount exceeds max_seed", + "close time is too soon", + "market category is not allowed", + "initial price is outside the price-policy band", + "creation rate limit exceeded", + ]); +}); + +test("applies post-market-core market cap and net-exposure checks", () => { + const result = evaluateAgentRisk(buy({ amount: 50n }), state({ + marketExposure: 480n, + marketNetPosition: { collateralIn: 80n, collateralOut: 30n }, + netExposure: 990n, + global: { maxTrade: 100n, maxMarket: 500n, maxDaily: 1_000n, maxNetExposure: 1_000n }, + })); + assert.ok(result.violations.includes("market exposure exceeds max_market")); + assert.ok(result.violations.includes("net exposure exceeds max_net_exposure")); +}); + +test("distinguishes unavailable liquid capital from uncertain yield recall", () => { + const noVenue = evaluateAgentRisk(buy({ amount: 100n }), state({ idleCapital: 20n })); + assert.ok(noVenue.violations.includes("insufficient liquid capital")); + + const unknownVenue = evaluateAgentRisk(buy({ amount: 100n }), state({ + idleCapital: 20n, + yieldLiquidity: { enabled: true, available: null }, + })); + assert.deepEqual(unknownVenue.violations, []); + assert.ok(unknownVenue.unverifiable.some((item) => item.includes("Blend state"))); + + const insufficientVenue = evaluateAgentRisk(buy({ amount: 100n }), state({ + idleCapital: 20n, + yieldLiquidity: { enabled: true, available: 30n }, + })); + assert.ok(insufficientVenue.violations.includes("insufficient liquid capital after yield recall")); +}); diff --git a/oracledesk-stellar/agents/core/risk.ts b/oracledesk-stellar/agents/core/risk.ts index 44b7c71..12c2ffe 100644 --- a/oracledesk-stellar/agents/core/risk.ts +++ b/oracledesk-stellar/agents/core/risk.ts @@ -58,3 +58,277 @@ export function checkArbPlan(plan: ArbPlan, state: TreasuryRiskState, limits: Ar } return problems; } + +export const ROLE_CREATE = 1 << 0; +export const ROLE_TRADE = 1 << 1; +const USAGE_BUCKET_SECONDS = 3_600n; +const USAGE_BUCKETS = 25; +const CREATION_BUCKETS = 24; +const BPS_SCALE = 10_000n; + +export interface AgentRiskPolicy { + active: boolean; + roles: number; + maxTrade: bigint; + maxDaily: bigint; +} + +/** Ring buffer layout mirrors the treasury's on-chain UsageWindow. */ +export interface AgentUsageWindow { + headHour: bigint; + headIdx: number; + amounts: readonly bigint[]; +} + +export interface CreationRateBucket { + bucketStart: bigint; + count: number; +} + +export interface AgentRiskState { + now: bigint; + global: { + maxTrade: bigint; + maxMarket: bigint; + maxDaily: bigint; + maxNetExposure: bigint; + }; + agent: AgentRiskPolicy; + usage: { + global: AgentUsageWindow; + agent: AgentUsageWindow; + }; + /** Treasury's current token balance before a possible yield withdrawal. */ + idleCapital: bigint; + queueReservedCapital: bigint; + /** Current cost-basis exposure across all markets. */ + netExposure: bigint; + /** Lifetime/cap exposure for the target market. Missing markets use zero. */ + marketExposure: bigint; + /** In/out counters for the target market used to calculate its net increase. */ + marketNetPosition: { collateralIn: bigint; collateralOut: bigint }; + /** Whether a yield venue is enabled and the amount that can safely be recalled. */ + yieldLiquidity: { enabled: boolean; available: bigint | null }; + price: { + maxBuyPriceBps: bigint; + minSellPriceBps: bigint; + maxImpactBps: bigint; + maxSlippageBps: bigint; + }; +} + +export interface AgentBuyRiskAction { + kind: "buy"; + amount: bigint; + priceBeforeBps: bigint; + priceAfterBps: bigint; + quotedShares: bigint; + minSharesOut: bigint; +} + +export interface AgentCreateRiskAction { + kind: "create"; + seedAmount: bigint; + closeTime: bigint; + category: "Crypto" | "Macro" | "Geopolitics" | "Sports" | "Culture" | "Other"; + initialYesBps: bigint; + creationPolicy: { + maxSeed: bigint; + maxMarketsPerWindow: number; + creationWindowSecs: bigint; + minCloseHorizon: bigint; + maxCloseHorizon: bigint; + allowedCategories: number; + }; + creationBuckets: readonly CreationRateBucket[]; +} + +export type AgentRiskAction = AgentBuyRiskAction | AgentCreateRiskAction; + +export interface AgentRiskEvaluation { + /** Checks which would reject the invocation, in contract execution order. */ + violations: string[]; + /** Checks dependent on external runtime outcomes or unavailable inputs. */ + unverifiable: string[]; +} + +const CATEGORY_BITS: Record = { + Crypto: 1 << 0, + Macro: 1 << 1, + Geopolitics: 1 << 2, + Sports: 1 << 3, + Culture: 1 << 4, + Other: 1 << 5, +}; + +function windowState(window: AgentUsageWindow, now: bigint): { total: bigint; headAmount: bigint } | undefined { + if ( + window.amounts.length !== USAGE_BUCKETS || + !Number.isInteger(window.headIdx) || + window.headIdx < 0 || + window.headIdx >= USAGE_BUCKETS + ) return undefined; + const currentHour = now / USAGE_BUCKET_SECONDS; + const steps = currentHour > window.headHour + ? Number((currentHour - window.headHour) > BigInt(USAGE_BUCKETS) + ? BigInt(USAGE_BUCKETS) + : currentHour - window.headHour) + : 0; + const headIdx = (window.headIdx + steps) % USAGE_BUCKETS; + let total = 0n; + for (let slot = 0; slot < USAGE_BUCKETS; slot += 1) { + const amount = window.amounts[slot]; + if (amount === undefined || amount < 0n) return undefined; + const age = (window.headIdx - slot + USAGE_BUCKETS) % USAGE_BUCKETS; + if (age + steps < USAGE_BUCKETS) total += amount; + } + return { total, headAmount: window.amounts[headIdx] ?? 0n }; +} + +function marketNetIncrease(position: AgentRiskState["marketNetPosition"], amount: bigint): bigint { + const current = position.collateralIn > position.collateralOut + ? position.collateralIn - position.collateralOut + : 0n; + const nextIn = position.collateralIn + amount; + const next = nextIn > position.collateralOut ? nextIn - position.collateralOut : 0n; + return next - current; +} + +function creationWindowTotal( + buckets: readonly CreationRateBucket[], + now: bigint, + windowSecs: bigint, +): number { + return buckets.reduce( + (sum, bucket) => bucket.bucketStart + windowSecs > now ? sum + bucket.count : sum, + 0, + ); +} + +/** + * Mirrors the treasury's create/buy gates. For buys and creates, capacity, + * daily usage, and net exposure are evaluated after the nested market-core + * action, matching Soroban's transaction rollback semantics. + */ +export function evaluateAgentRisk(action: AgentRiskAction, state: AgentRiskState): AgentRiskEvaluation { + const violations: string[] = []; + const unverifiable: string[] = []; + const role = action.kind === "buy" ? ROLE_TRADE : ROLE_CREATE; + const amount = action.kind === "buy" ? action.amount : action.seedAmount; + if (!state.agent.active || (state.agent.roles & role) === 0) { + violations.push(`agent is inactive or lacks ${action.kind === "buy" ? "ROLE_TRADE" : "ROLE_CREATE"}`); + return { violations, unverifiable }; + } + + if (action.kind === "buy") { + if (amount <= 0n) violations.push("trade amount must be positive"); + else if (amount > state.agent.maxTrade || amount > state.global.maxTrade) { + violations.push("trade amount exceeds max_trade"); + } + if (maxBigInt(0n, state.idleCapital - amount) < state.queueReservedCapital) { + violations.push("spend would use queue-reserved capital"); + } + if (action.priceBeforeBps > state.price.maxBuyPriceBps) { + violations.push("buy price is above max_buy_price_bps"); + } + const slippageFloor = (action.quotedShares * (BPS_SCALE - state.price.maxSlippageBps)) / BPS_SCALE; + const effectiveMinimum = action.minSharesOut > slippageFloor + ? action.minSharesOut + : slippageFloor > 1n ? slippageFloor : 1n; + if (action.quotedShares < effectiveMinimum) { + violations.push("quoted shares do not satisfy effective min_shares_out"); + } + checkLiquidity(amount, state, violations, unverifiable); + if (absDiff(action.priceAfterBps, action.priceBeforeBps) > state.price.maxImpactBps) { + violations.push("price impact exceeds max_impact_bps"); + } + } else { + const policy = action.creationPolicy; + if (amount <= 0n) violations.push("seed amount must be positive"); + else if (amount > policy.maxSeed) violations.push("seed amount exceeds max_seed"); + const horizon = action.closeTime - state.now; + if (horizon < 0n || horizon < policy.minCloseHorizon) violations.push("close time is too soon"); + if (horizon > policy.maxCloseHorizon) violations.push("close time is too far"); + if ((CATEGORY_BITS[action.category] & policy.allowedCategories) === 0) { + violations.push("market category is not allowed"); + } + if (action.initialYesBps < state.price.minSellPriceBps || action.initialYesBps > state.price.maxBuyPriceBps) { + violations.push("initial price is outside the price-policy band"); + } + if (creationWindowTotal(action.creationBuckets, state.now, policy.creationWindowSecs) >= policy.maxMarketsPerWindow) { + violations.push("creation rate limit exceeded"); + } + checkLiquidity(amount, state, violations, unverifiable); + const effectiveIdle = state.idleCapital < amount && state.yieldLiquidity.enabled && state.yieldLiquidity.available !== null + ? state.idleCapital + state.yieldLiquidity.available + : state.idleCapital; + if (state.idleCapital < amount && state.yieldLiquidity.enabled && state.yieldLiquidity.available === null) { + unverifiable.push("reserved-capital check after yield recall depends on withdrawal outcome"); + } else if (maxBigInt(0n, effectiveIdle - amount) < state.queueReservedCapital) { + violations.push("spend would use queue-reserved capital"); + } + } + + // reserve_capacity runs after market-core succeeds: lifetime market cap, + // global rolling window, then the acting agent's rolling window. + const currentMarketExposure = action.kind === "create" ? 0n : state.marketExposure; + const currentMarketNet = action.kind === "create" + ? { collateralIn: 0n, collateralOut: 0n } + : state.marketNetPosition; + if (currentMarketExposure + amount > state.global.maxMarket) { + violations.push("market exposure exceeds max_market"); + } + checkUsageWindow("global", state.usage.global, amount, state.global.maxDaily, state.now, violations, unverifiable); + checkUsageWindow("agent", state.usage.agent, amount, state.agent.maxDaily, state.now, violations, unverifiable); + + // reserve_net follows reserve_capacity and enforces cost-basis exposure. + if (state.netExposure + marketNetIncrease(currentMarketNet, amount) > state.global.maxNetExposure) { + violations.push("net exposure exceeds max_net_exposure"); + } + return { violations, unverifiable }; +} + +function checkLiquidity( + amount: bigint, + state: AgentRiskState, + violations: string[], + unverifiable: string[], +): void { + if (state.idleCapital >= amount) return; + if (!state.yieldLiquidity.enabled) { + violations.push("insufficient liquid capital"); + return; + } + if (state.yieldLiquidity.available === null) { + unverifiable.push("yield withdrawal availability and success depend on Blend state and invocation outcome"); + } else if (state.idleCapital + state.yieldLiquidity.available < amount) { + violations.push("insufficient liquid capital after yield recall"); + } else { + unverifiable.push("yield withdrawal execution can fail even when sufficient liquidity is reported"); + } +} + +function checkUsageWindow( + label: string, + window: AgentUsageWindow, + amount: bigint, + cap: bigint, + now: bigint, + violations: string[], + unverifiable: string[], +): void { + const current = windowState(window, now); + if (!current) { + unverifiable.push(`${label} UsageWindow input is malformed`); + return; + } + if (current.total + amount > cap) violations.push(`${label} rolling 24-hour usage exceeds max_daily`); +} + +function absDiff(a: bigint, b: bigint): bigint { + return a >= b ? a - b : b - a; +} + +function maxBigInt(a: bigint, b: bigint): bigint { + return a >= b ? a : b; +} diff --git a/oracledesk-stellar/agents/core/sizing.test.ts b/oracledesk-stellar/agents/core/sizing.test.ts new file mode 100644 index 0000000..beb1726 --- /dev/null +++ b/oracledesk-stellar/agents/core/sizing.test.ts @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { BPS, MAX_SETS, calcBuy, calcSell, collateralOutForShares, MathError, priceYesBps } from "./fpmm.js"; +import { impactLimit, sizeBuy, sizeBuyDetailed, sizeSell, type CapsView, type PoolView, type SizingParams } from "./sizing.js"; + +const params: SizingParams = { + kellyFractionNumerator: 1n, + kellyFractionDenominator: 4n, + bufferBps: 25n, + slippageBps: 50n, + minTradeStroops: 1n, +}; +const pool: PoolView = { + reserveYes: 1_000_000_000n, + reserveNo: 1_000_000_000n, + feeBps: 100n, + setsMinted: 0n, +}; +const caps: CapsView = { + maxTrade: 100_000_000n, + maxMarket: 500_000_000n, + marketExposure: 0n, + maxDaily: 1_000_000_000n, + dailyUsed: 0n, + availableCapital: 200_000_000n, +}; + +test("impactLimit returns the largest feasible collateral at the floored target price", () => { + const target = 5_400n; + const size = impactLimit(pool, "Yes", target, 100_000_000n); + const q = calcBuy(pool.reserveYes, pool.reserveNo, size, pool.feeBps); + const post = (q.newReserveOther * BPS) / (q.newReserveBought + q.newReserveOther); + assert.ok(post <= target); + const next = calcBuy(pool.reserveYes, pool.reserveNo, size + 1n, pool.feeBps); + const nextPost = (next.newReserveOther * BPS) / (next.newReserveBought + next.newReserveOther); + assert.ok(nextPost > target); +}); + +test("impactLimit returns zero when the first executable trade crosses the target", () => { + const thin = { ...pool, reserveYes: 100n, reserveNo: 100n }; + assert.equal(impactLimit(thin, "Yes", 5_001n, 100n), 0n); +}); + +test("sizeBuy clamps to the target, applies fractional Kelly and calculates slippage", () => { + const buy = sizeBuy(pool, { yes: 0n, no: 0n }, caps, { fairYesBps: 6_000n, uncertaintyBps: 100n }, params); + assert.ok(buy); + assert.equal(buy.outcome, "Yes"); + assert.ok(buy.collateralIn > 0n && buy.collateralIn <= caps.maxTrade); + assert.ok(buy.minSharesOut <= buy.expectedShares); + const quote = calcBuy(pool.reserveYes, pool.reserveNo, buy.collateralIn, pool.feeBps); + assert.equal(quote.sharesOut, buy.expectedShares); + assert.ok(buy.priceAfterBps <= buy.report.inputs.targetBps); + assert.equal(buy.report.size, buy.collateralIn); +}); + +test("belief uncertainty that erases the edge skips sizing", () => { + assert.equal( + sizeBuy(pool, { yes: 0n, no: 0n }, caps, { fairYesBps: 5_100n, uncertaintyBps: 100n }, params), + undefined, + ); +}); + +test("caps report the limiting max-trade constraint", () => { + const buy = sizeBuy( + pool, + { yes: 0n, no: 0n }, + { ...caps, maxTrade: 100_000n, maxMarket: 1_000_000_000n }, + { fairYesBps: 8_000n, uncertaintyBps: 0n }, + { ...params, kellyFractionNumerator: 1n, kellyFractionDenominator: 1n }, + ); + assert.equal(buy?.collateralIn, 100_000n); + assert.equal(buy?.binding, "max_trade"); +}); + +test("market, daily, capital and MAX_SETS headroom each bind sizing", () => { + const fullKelly = { ...params, kellyFractionNumerator: 1n, kellyFractionDenominator: 1n }; + const belief = { fairYesBps: 8_000n, uncertaintyBps: 0n }; + const cases: Array<[string, CapsView, PoolView, bigint, string]> = [ + ["max_market", { ...caps, maxMarket: 100_000n }, pool, 8_000n, "max_market"], + ["max_daily", { ...caps, dailyHeadroom: 100_000n }, pool, 8_000n, "max_daily"], + ["capital", { ...caps, availableCapital: 100_000n }, pool, 10_000n, "capital"], + ["max_sets", caps, { ...pool, setsMinted: MAX_SETS - 10_000_000n }, 8_000n, "max_sets"], + ]; + for (const [, candidateCaps, candidatePool, fairYesBps, expectedBinding] of cases) { + const result = sizeBuyDetailed( + candidatePool, + { yes: 0n, no: 0n }, + candidateCaps, + { fairYesBps, uncertaintyBps: 0n }, + fullKelly, + ); + assert.ok(result.buy, expectedBinding); + assert.equal(result.report.binding, expectedBinding); + } +}); + +test("fractional Kelly that falls below the dust floor skips with a dust report", () => { + const result = sizeBuyDetailed( + pool, + { yes: 0n, no: 0n }, + caps, + { fairYesBps: 8_000n, uncertaintyBps: 0n }, + { ...params, kellyFractionNumerator: 1n, kellyFractionDenominator: 1_000_000n, minTradeStroops: 1_000_000n }, + ); + assert.equal(result.buy, undefined); + assert.equal(result.report.binding, "dust"); +}); + +test("No-side sizing mirrors Yes-side sizing on swapped reserves", () => { + const noPool = { ...pool, reserveYes: 700_000_000n, reserveNo: 1_300_000_000n }; + const currentNo = BPS - priceYesBps(noPool.reserveYes, noPool.reserveNo); + const noTarget = 4_500n; + const maxNoSize = impactLimit(noPool, "No", noTarget, 100_000_000n); + const noQuote = calcBuy(noPool.reserveNo, noPool.reserveYes, maxNoSize, noPool.feeBps); + const noPostPrice = (noQuote.newReserveOther * BPS) / (noQuote.newReserveBought + noQuote.newReserveOther); + assert.ok(currentNo < noPostPrice && noPostPrice <= noTarget); + const upperNoQuote = calcBuy(noPool.reserveNo, noPool.reserveYes, 1_000_000_000n, noPool.feeBps); + const upperNoPrice = (upperNoQuote.newReserveOther * BPS) / (upperNoQuote.newReserveBought + upperNoQuote.newReserveOther); + assert.ok(upperNoPrice > noTarget); + + const noBuy = sizeBuy( + noPool, + { yes: 0n, no: 0n }, + caps, + { fairYesBps: 4_000n, uncertaintyBps: 0n }, + params, + ); + assert.equal(noBuy?.outcome, "No"); + assert.ok(noBuy && noBuy.priceAfterBps <= noBuy.report.inputs.targetBps); +}); + +test("sell sizing round-trips its returned collateral and share limit", () => { + const sell = sizeSell( + pool, + "Yes", + { yes: 100_000_000n, no: 0n }, + { fairYesBps: 3_000n, uncertaintyBps: 0n }, + params, + ); + assert.ok(sell); + const quote = calcSell(pool.reserveYes, pool.reserveNo, sell.collateralOut, pool.feeBps); + assert.ok(quote.sharesIn <= sell.maxSharesIn); + assert.ok(quote.sharesIn <= 100_000_000n); + assert.equal(sell.collateralOut, collateralOutForShares(pool.reserveYes, pool.reserveNo, quote.sharesIn, pool.feeBps)); + assert.ok(sell.priceAfterBps >= 3_025n); +}); + +test("No-side sell prices and targets are expressed in No probability", () => { + const noPool = { ...pool, reserveYes: 700_000_000n, reserveNo: 1_300_000_000n }; + const sell = sizeSell( + noPool, + "No", + { yes: 0n, no: 100_000_000n }, + { fairYesBps: 7_000n, uncertaintyBps: 0n }, + params, + ); + assert.ok(sell); + const quote = calcSell(noPool.reserveNo, noPool.reserveYes, sell.collateralOut, noPool.feeBps); + const noPrice = (quote.newReserveOther * BPS) / (quote.newReserveSold + quote.newReserveOther); + assert.equal(sell.priceAfterBps, noPrice); + assert.ok(sell.priceAfterBps >= 3_025n); +}); + +test("price impact is monotone over deterministic pool/fee samples", () => { + let seed = 0x12345678; + const next = () => { + seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0; + return seed; + }; + for (let i = 0; i < 5_000; i += 1) { + const rYes = BigInt(next() % 1_000_000 + 100); + const rNo = BigInt(next() % 1_000_000 + 100); + const fee = BigInt(next() % 1_001); + let previous = priceYesBps(rYes, rNo); + for (let collateral = 1; collateral <= 20; collateral += 1) { + try { + const q = calcBuy(rYes, rNo, BigInt(collateral), fee); + const current = (q.newReserveOther * BPS) / (q.newReserveBought + q.newReserveOther); + assert.ok(current >= previous, `sample ${i}, collateral ${collateral}`); + previous = current; + } catch (error) { + if (!(error instanceof MathError)) throw error; + // A zero-net/zero-share buy is a non-tradable point at the curve origin. + } + } + } +}); + +test("impactLimit never selects a buy beyond its floored target", () => { + let seed = 0x9e3779b9; + const next = () => { + seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0; + return seed; + }; + for (let i = 0; i < 500; i += 1) { + const rYes = BigInt(next() % 10_000_000 + 10_000); + const rNo = BigInt(next() % 10_000_000 + 10_000); + const feeBps = BigInt(next() % 1_001); + const candidate = { ...pool, reserveYes: rYes, reserveNo: rNo, feeBps }; + const rawTarget = priceYesBps(rYes, rNo) + BigInt(next() % 2_000); + const target = rawTarget > BPS ? BPS : rawTarget; + const size = impactLimit(candidate, "Yes", target, 10_000_000n); + if (size === 0n) continue; + const quote = calcBuy(rYes, rNo, size, feeBps); + const post = (quote.newReserveOther * BPS) / (quote.newReserveBought + quote.newReserveOther); + assert.ok(post <= target, `sample ${i}: post=${post}, target=${target}`); + } +}); diff --git a/oracledesk-stellar/agents/core/sizing.ts b/oracledesk-stellar/agents/core/sizing.ts new file mode 100644 index 0000000..03949ee --- /dev/null +++ b/oracledesk-stellar/agents/core/sizing.ts @@ -0,0 +1,443 @@ +import { + BPS, + MAX_SETS, + MathError, + calcBuy, + calcSell, + collateralOutForShares, + priceYesBps, +} from "./fpmm.js"; + +const MAX_COLLATERAL = (1n << 127n) - 1n; + +export interface PoolView { + reserveYes: bigint; + reserveNo: bigint; + feeBps: bigint; + setsMinted: bigint; +} + +export interface Holdings { + yes: bigint; + no: bigint; +} + +export interface CapsView { + maxTrade: bigint; + maxMarket: bigint; + marketExposure: bigint; + maxDaily: bigint; + dailyUsed: bigint; + dailyHeadroom?: bigint; + availableCapital: bigint; +} + +export interface Belief { + fairYesBps: bigint; + uncertaintyBps: bigint; +} + +export interface SizingParams { + kellyFractionNumerator: bigint; + kellyFractionDenominator: bigint; + bufferBps: bigint; + slippageBps: bigint; + minTradeStroops: bigint; +} + +export type Binding = + | "impact" + | "kelly" + | "max_trade" + | "max_market" + | "max_daily" + | "capital" + | "max_sets" + | "dust"; + +export interface SizingReport { + inputs: { + pool: PoolView; + holdings: Holdings; + caps: CapsView; + belief: Belief; + params: SizingParams; + effectiveProbabilityBps: bigint; + targetBps: bigint; + bankroll: bigint; + }; + impactC: bigint; + kellyC: bigint; + limits: Record, bigint>; + size: bigint; + binding: Binding; +} + +export interface SizedBuy { + outcome: "Yes" | "No"; + collateralIn: bigint; + minSharesOut: bigint; + expectedShares: bigint; + priceAfterBps: bigint; + binding: Binding; + report: SizingReport; +} + +export interface SizeBuyResult { + buy?: SizedBuy; + report: SizingReport; +} + +export interface SizedSell { + outcome: "Yes" | "No"; + collateralOut: bigint; + maxSharesIn: bigint; + expectedSharesIn: bigint; + priceAfterBps: bigint; +} + +function orient(pool: PoolView, outcome: "Yes" | "No"): [bigint, bigint] { + return outcome === "Yes" + ? [pool.reserveYes, pool.reserveNo] + : [pool.reserveNo, pool.reserveYes]; +} + +function validateParams(params: SizingParams): void { + if ( + params.kellyFractionDenominator <= 0n || + params.kellyFractionNumerator <= 0n || + params.kellyFractionNumerator > params.kellyFractionDenominator || + params.bufferBps < 0n || + params.slippageBps < 0n || + params.slippageBps > BPS || + params.minTradeStroops < 0n + ) { + throw new RangeError("invalid sizing parameters"); + } +} + +function boundedBelief( + pool: PoolView, + belief: Belief, + outcome: "Yes" | "No", +): { probability: bigint; currentPrice: bigint } { + if (belief.fairYesBps < 0n || belief.fairYesBps > BPS || belief.uncertaintyBps < 0n) { + throw new RangeError("belief must be within basis-point bounds"); + } + const currentYes = priceYesBps(pool.reserveYes, pool.reserveNo); + const currentPrice = outcome === "Yes" ? currentYes : BPS - currentYes; + const fair = outcome === "Yes" ? belief.fairYesBps : BPS - belief.fairYesBps; + const direction = fair >= currentPrice ? 1n : -1n; + let probability = fair - direction * belief.uncertaintyBps; + probability = direction > 0n + ? (probability < currentPrice ? currentPrice : probability) + : (probability > currentPrice ? currentPrice : probability); + if (probability < 0n) probability = 0n; + if (probability > BPS) probability = BPS; + return { probability, currentPrice }; +} + +function priceAfterBuy(pool: PoolView, outcome: "Yes" | "No", collateral: bigint): bigint | null { + if (collateral === 0n) { + const yesPrice = priceYesBps(pool.reserveYes, pool.reserveNo); + return outcome === "Yes" ? yesPrice : BPS - yesPrice; + } + const [bought, other] = orient(pool, outcome); + try { + const quote = calcBuy(bought, other, collateral, pool.feeBps); + return (quote.newReserveOther * BPS) / (quote.newReserveBought + quote.newReserveOther); + } catch (error) { + if (error instanceof MathError) return null; + throw error; + } +} + +/** + * For increasing collateral, the rounded-up fee grows by at most one per + * stroop, so net collateral is non-decreasing. The other reserve therefore + * increases, the bought reserve (rounded up) never increases, and the + * floored marginal price is monotone non-decreasing. + */ +export function impactLimit( + pool: PoolView, + outcome: "Yes" | "No", + targetBps: bigint, + hi: bigint, +): bigint { + if (targetBps < 0n || targetBps > BPS || hi < 0n) throw new RangeError("invalid impact search bounds"); + let low = 0n; + let high = hi; + while (low < high) { + const mid = (low + high) / 2n; + if (netAt(pool, outcome, mid) > 0n) high = mid; + else low = mid + 1n; + } + const firstTradable = low; + if (firstTradable > hi) return 0n; + const firstPrice = priceAfterBuy(pool, outcome, firstTradable); + if (firstPrice === null) return 0n; + if (firstPrice > targetBps) return 0n; + low = firstTradable; + high = hi; + while (low < high) { + const mid = (low + high + 1n) / 2n; + const yesPrice = priceAfterBuy(pool, outcome, mid); + if (yesPrice !== null && yesPrice <= targetBps) low = mid; + else high = mid - 1n; + } + return low; +} + +function netAt(pool: PoolView, outcome: "Yes" | "No", collateral: bigint): bigint { + if (collateral <= 0n) return 0n; + const [bought, other] = orient(pool, outcome); + try { + return calcBuy(bought, other, collateral, pool.feeBps).net; + } catch (error) { + if (error instanceof MathError) return 0n; + throw error; + } +} + +function maxSetsLimit(pool: PoolView, outcome: "Yes" | "No", hi: bigint): bigint { + if (pool.setsMinted >= MAX_SETS) return 0n; + const remaining = MAX_SETS - pool.setsMinted; + let low = 0n; + let high = hi; + while (low < high) { + const mid = (low + high + 1n) / 2n; + if (netAt(pool, outcome, mid) <= remaining) low = mid; + else high = mid - 1n; + } + return low; +} + +function expectedLogWealth( + pool: PoolView, + outcome: "Yes" | "No", + holdings: Holdings, + probabilityBps: bigint, + bankroll: bigint, + collateral: bigint, +): number { + const [bought, other] = orient(pool, outcome); + const heldBought = outcome === "Yes" ? holdings.yes : holdings.no; + const heldOther = outcome === "Yes" ? holdings.no : holdings.yes; + let shares = 0n; + if (collateral > 0n) { + try { + shares = calcBuy(bought, other, collateral, pool.feeBps).sharesOut; + } catch (error) { + if (error instanceof MathError) return Number.NEGATIVE_INFINITY; + throw error; + } + } + const wealthBought = bankroll - collateral + heldBought + shares; + const wealthOther = bankroll - collateral + heldOther; + if (bankroll <= 0n) return Number.NEGATIVE_INFINITY; + const q = Number(probabilityBps) / Number(BPS); + let value = 0; + if (probabilityBps > 0n) { + if (wealthBought <= 0n) return Number.NEGATIVE_INFINITY; + value += q * Math.log(Number(wealthBought) / Number(bankroll)); + } + if (probabilityBps < BPS) { + if (wealthOther <= 0n) return Number.NEGATIVE_INFINITY; + value += (1 - q) * Math.log(Number(wealthOther) / Number(bankroll)); + } + return value; +} + +function kellyOptimum( + pool: PoolView, + outcome: "Yes" | "No", + holdings: Holdings, + probabilityBps: bigint, + bankroll: bigint, + hi: bigint, +): bigint { + if (hi <= 0n || bankroll <= 0n) return 0n; + let low = 0n; + let high = hi; + while (high - low > 8n) { + const third = (high - low) / 3n; + const m1 = low + third; + const m2 = high - third; + const g1 = expectedLogWealth(pool, outcome, holdings, probabilityBps, bankroll, m1); + const g2 = expectedLogWealth(pool, outcome, holdings, probabilityBps, bankroll, m2); + if (g1 < g2) low = m1 + 1n; + else high = m2 - 1n; + } + let best = low; + let bestValue = expectedLogWealth(pool, outcome, holdings, probabilityBps, bankroll, low); + for (let candidate = low + 1n; candidate <= high; candidate += 1n) { + const value = expectedLogWealth(pool, outcome, holdings, probabilityBps, bankroll, candidate); + if (value > bestValue) { + best = candidate; + bestValue = value; + } + } + return best; +} + +export function sizeBuyDetailed( + pool: PoolView, + holdings: Holdings, + caps: CapsView, + belief: Belief, + params: SizingParams, +): SizeBuyResult { + validateParams(params); + const edge = belief.fairYesBps - priceYesBps(pool.reserveYes, pool.reserveNo); + const outcome = edge >= 0n ? "Yes" : "No"; + const { probability, currentPrice } = boundedBelief(pool, belief, outcome); + const targetBps = probability > params.bufferBps ? probability - params.bufferBps : 0n; + const headrooms = { + max_trade: max0(caps.maxTrade), + max_market: max0(caps.maxMarket - caps.marketExposure), + max_daily: max0(caps.dailyHeadroom ?? (caps.maxDaily - caps.dailyUsed)), + capital: max0(caps.availableCapital), + }; + if (edge === 0n || probability <= currentPrice) { + const limits = { impact: 0n, ...headrooms, max_sets: 0n, kelly: 0n }; + return { + report: { + inputs: { pool, holdings, caps, belief, params, effectiveProbabilityBps: probability, targetBps, bankroll: caps.availableCapital }, + impactC: 0n, + kellyC: 0n, + limits, + size: 0n, + binding: "kelly", + }, + }; + } + const impactC = impactLimit(pool, outcome, targetBps, MAX_COLLATERAL); + const maxSets = maxSetsLimit(pool, outcome, impactC); + const kellyUpper = minBigInt(impactC, maxSets, ...Object.values(headrooms)); + const bankroll = caps.availableCapital; + const optimum = kellyOptimum(pool, outcome, holdings, probability, bankroll, kellyUpper); + const kellyC = (optimum * params.kellyFractionNumerator) / params.kellyFractionDenominator; + const limits = { + impact: impactC, + ...headrooms, + max_sets: maxSets, + kelly: kellyC, + }; + const [binding, size] = (Object.entries(limits) as [Exclude, bigint][]) + .reduce(([bestName, bestSize], [name, value]) => value < bestSize ? [name, value] : [bestName, bestSize]); + const report: SizingReport = { + inputs: { pool, holdings, caps, belief, params, effectiveProbabilityBps: probability, targetBps, bankroll }, + impactC, + kellyC, + limits, + size, + binding: size < params.minTradeStroops ? "dust" : binding, + }; + if (size < params.minTradeStroops) return { report }; + const [bought, other] = orient(pool, outcome); + const quote = calcBuy(bought, other, size, pool.feeBps); + const priceAfterBps = priceAfterBuy(pool, outcome, size); + if (priceAfterBps === null) return { report }; + const slippageFloor = (quote.sharesOut * (BPS - params.slippageBps)) / BPS; + const buy: SizedBuy = { + outcome, + collateralIn: size, + expectedShares: quote.sharesOut, + minSharesOut: slippageFloor > 0n ? slippageFloor : 1n, + priceAfterBps, + binding: report.binding, + report, + }; + return { buy, report }; +} + +export function sizeBuy( + pool: PoolView, + holdings: Holdings, + caps: CapsView, + belief: Belief, + params: SizingParams, +): SizedBuy | undefined { + return sizeBuyDetailed(pool, holdings, caps, belief, params).buy; +} + +export function sizeSell( + pool: PoolView, + outcome: "Yes" | "No", + holdings: Holdings, + belief: Belief, + params: SizingParams, +): SizedSell | undefined { + validateParams(params); + const { probability, currentPrice } = boundedBelief(pool, belief, outcome); + const target = probability + params.bufferBps > BPS ? BPS : probability + params.bufferBps; + const held = outcome === "Yes" ? holdings.yes : holdings.no; + if (currentPrice <= target || held <= 0n) return undefined; + const [sold, other] = orient(pool, outcome); + const quoteAt = (shares: bigint) => { + const collateral = collateralOutForShares(sold, other, shares, pool.feeBps); + const quote = calcSell(sold, other, collateral, pool.feeBps); + return { + collateral, + quote, + price: (quote.newReserveOther * BPS) / (quote.newReserveSold + quote.newReserveOther), + }; + }; + let low = 0n; + let high = held; + while (low < high) { + const mid = (low + high) / 2n; + try { + quoteAt(mid); + high = mid; + } catch (error) { + if (!(error instanceof MathError)) throw error; + low = mid + 1n; + } + } + const firstTradable = low; + if (firstTradable > held) return undefined; + let first; + try { + first = quoteAt(firstTradable); + } catch (error) { + if (error instanceof MathError) return undefined; + throw error; + } + if (first.price < target) return undefined; + low = firstTradable; + high = held; + while (low < high) { + const shares = (low + high + 1n) / 2n; + let price: bigint | null; + try { + price = quoteAt(shares).price; + } catch (error) { + if (!(error instanceof MathError)) throw error; + price = null; + } + if (price !== null && price >= target) low = shares; + else high = shares - 1n; + } + if (low === 0n) return undefined; + const { collateral: collateralOut, quote: verified } = quoteAt(low); + if (verified.sharesIn > low || verified.sharesIn > held) { + throw new Error("sell quote exceeded its share bound"); + } + const priceAfterBps = (verified.newReserveOther * BPS) / (verified.newReserveSold + verified.newReserveOther); + return { + outcome, + collateralOut, + maxSharesIn: verified.sharesIn, + expectedSharesIn: verified.sharesIn, + priceAfterBps, + }; +} + +function max0(value: bigint): bigint { + return value > 0n ? value : 0n; +} + +function minBigInt(...values: bigint[]): bigint { + return values.reduce((min, value) => value < min ? value : min); +} diff --git a/oracledesk-stellar/agents/core/strategy.test.ts b/oracledesk-stellar/agents/core/strategy.test.ts index 6016f0e..91684a7 100644 --- a/oracledesk-stellar/agents/core/strategy.test.ts +++ b/oracledesk-stellar/agents/core/strategy.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { calcBuy, priceAfterBuy } from "./fpmm.js"; -import { decideMarketProposal, decideTrade, twapFromObservations } from "./strategy.js"; +import { decideMarketProposal, decideTrade, decideTradeSized, twapFromObservations } from "./strategy.js"; +import type { CapsView } from "./sizing.js"; test("decideMarketProposal skips a signal with no actionable estimate", () => { const d = decideMarketProposal( @@ -203,3 +204,41 @@ test("decideTrade skips when every size would trip the impact cap", () => { ); assert.equal(d.action, "skip"); }); + +test("decideTradeSized buys less than a fixed order on a thin pool", () => { + const market = { + marketId: 3, + question: "thin", + yesBps: 5_000, + closeTime: 0, + reserveYesStroops: 100_000n, + reserveNoStroops: 100_000n, + feeBps: 100, + }; + const caps: CapsView = { + maxTrade: 50_000n, + maxMarket: 100_000n, + marketExposure: 0n, + maxDaily: 100_000n, + dailyUsed: 0n, + availableCapital: 50_000n, + }; + const decision = decideTradeSized( + market, + { reserveYes: 100_000n, reserveNo: 100_000n, feeBps: 100n, setsMinted: 0n }, + { yes: 0n, no: 0n }, + caps, + { fairYesBps: 7_000n, uncertaintyBps: 0n }, + { + kellyFractionNumerator: 1n, + kellyFractionDenominator: 4n, + bufferBps: 25n, + slippageBps: 50n, + minTradeStroops: 1n, + }, + 100, + ); + assert.equal(decision.action, "buy"); + assert.ok(decision.collateralInStroops! < 50_000n); + assert.ok(decision.sizing); +}); diff --git a/oracledesk-stellar/agents/core/strategy.ts b/oracledesk-stellar/agents/core/strategy.ts index 880b67e..217895c 100644 --- a/oracledesk-stellar/agents/core/strategy.ts +++ b/oracledesk-stellar/agents/core/strategy.ts @@ -11,6 +11,7 @@ */ import { calcBuy, priceAfterBuy, priceYesBps } from "./fpmm.js"; +import { sizeBuyDetailed, type Belief, type CapsView, type Holdings, type PoolView, type SizedBuy, type SizingParams, type SizingReport } from "./sizing.js"; export interface MarketSignal { /** Free-text description of what was observed (a news event, an oracle @@ -114,6 +115,44 @@ export interface TraderDecision { collateralInStroops?: bigint; } +export type SizedTraderDecision = TraderDecision & { sizing?: SizingReport; sizedBuy?: SizedBuy }; + +/** A cap-aware strategy path; the legacy fixed-size decision remains available. */ +export function decideTradeSized( + market: MarketState, + pool: PoolView, + holdings: Holdings, + caps: CapsView, + belief: Belief, + params: SizingParams, + minEdgeBps = 0, +): SizedTraderDecision { + const edge = Number(belief.fairYesBps) - market.yesBps; + if (Math.abs(edge) < minEdgeBps) { + return { + action: "skip", + reasoning: `edge ${edge}bps on market ${market.marketId} is below the ${minEdgeBps}bps threshold`, + }; + } + const result = sizeBuyDetailed(pool, holdings, caps, belief, params); + const sizedBuy = result.buy; + if (!sizedBuy) { + return { + action: "skip", + reasoning: `sizing found no executable trade for market ${market.marketId}`, + sizing: result.report, + }; + } + return { + action: "buy", + reasoning: `market ${market.marketId} sized ${sizedBuy.collateralIn} stroops on the ${sizedBuy.outcome} FPMM curve (binding: ${sizedBuy.binding})`, + outcome: sizedBuy.outcome, + collateralInStroops: sizedBuy.collateralIn, + sizing: result.report, + sizedBuy, + }; +} + /** * Largest `size <= maxSize` whose estimated price impact stays at or below * `maxImpactBps`, found by binary search over `calcBuy` (impact is diff --git a/oracledesk-stellar/agents/runtime/follow.test.ts b/oracledesk-stellar/agents/runtime/follow.test.ts index 08beed5..f7e10f7 100644 --- a/oracledesk-stellar/agents/runtime/follow.test.ts +++ b/oracledesk-stellar/agents/runtime/follow.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import type { DueDiligenceVerdict } from "../core/due-diligence.js"; import { LocalTraceStore } from "../core/trace.js"; import { FakeEventsRpc, IDS, testDecoder, trade } from "../core/events/testing.js"; import { followTask, type FollowAdapter } from "./follow.js"; @@ -12,6 +13,18 @@ import { MemoryStateStore } from "./state.js"; import type { TraderCandidate } from "./tasks/trader.js"; const NOW = 1_700_000_000; +const approved = (marketId: bigint): DueDiligenceVerdict => ({ + outcome: "allow", + reasons: [], + commitments: "verified", + metadata: "verified", + resolutionPath: "verified", + oracleTrust: "not_applicable", + signerTrust: "not_applicable", + marketId: marketId.toString(), + stateFingerprint: "fixture-state", + checkedAt: Date.now(), +}); function fakeAdapter(buys: bigint[], exposure: Record): FollowAdapter { return { @@ -56,6 +69,7 @@ async function setup(exposure: Record = {}) { slippageBps: 50n, deadlineSecs: 60, nowSecs: () => NOW, + dueDiligence: async (marketId) => approved(marketId), }, rpc, decoder: await testDecoder(), diff --git a/oracledesk-stellar/agents/runtime/log.test.ts b/oracledesk-stellar/agents/runtime/log.test.ts new file mode 100644 index 0000000..20c4fe4 --- /dev/null +++ b/oracledesk-stellar/agents/runtime/log.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { jsonLogger } from "./log.js"; + +test("jsonLogger redacts secrets in fields, errors, and text", () => { + const lines: string[] = []; + const logger = jsonLogger((line) => lines.push(line)); + logger.error("request_failed", { + authorization: "Bearer abc.def", + detail: new Error("request contained Bearer abc.def"), + url: "https://example.test/?token=private-value", + }); + assert.equal(lines.length, 1); + assert.ok(!lines[0]!.includes("abc.def")); + assert.ok(!lines[0]!.includes("private-value")); + assert.ok(!lines[0]!.includes("Bearer abc.def")); + assert.match(lines[0]!, /"\[REDACTED\]"/); +}); diff --git a/oracledesk-stellar/agents/runtime/log.ts b/oracledesk-stellar/agents/runtime/log.ts index 8f417c3..412eb6d 100644 --- a/oracledesk-stellar/agents/runtime/log.ts +++ b/oracledesk-stellar/agents/runtime/log.ts @@ -1,5 +1,7 @@ /** One structured JSON log line per event (`{ts, level, event, ...fields}`). */ +import { redact } from "../core/redact.js"; + export type LogFields = Record; export interface Logger { @@ -10,10 +12,14 @@ export interface Logger { const replacer = (_k: string, v: unknown) => typeof v === "bigint" ? v.toString() : v instanceof Error ? { name: v.name, message: v.message } : v; +const safeReplacer = (key: string, value: unknown) => { + const normalized = replacer(key, value); + return redact({ [key]: normalized })[key]; +}; export function jsonLogger(write: (line: string) => void = (l) => console.log(l), base: LogFields = {}): Logger { const emit = (level: string) => (event: string, fields: LogFields = {}) => - write(JSON.stringify({ ts: new Date().toISOString(), level, event, ...base, ...fields }, replacer)); + write(JSON.stringify({ ts: new Date().toISOString(), level, event, ...base, ...fields }, safeReplacer)); return { info: emit("info"), warn: emit("warn"), error: emit("error") }; } diff --git a/oracledesk-stellar/agents/runtime/runtime.test.ts b/oracledesk-stellar/agents/runtime/runtime.test.ts index 5fa7ecf..b6ad647 100644 --- a/oracledesk-stellar/agents/runtime/runtime.test.ts +++ b/oracledesk-stellar/agents/runtime/runtime.test.ts @@ -58,7 +58,7 @@ test("task never overlaps itself even when run exceeds interval", async () => { await rt.stop("test"); await started; assert.equal(maxActive, 1); - assert.ok(runs >= 3, `runs: ${runs}`); + assert.ok(runs >= 2, `runs: ${runs}`); }); test("async mutex is FIFO", async () => { diff --git a/oracledesk-stellar/agents/runtime/tasks.test.ts b/oracledesk-stellar/agents/runtime/tasks.test.ts index 8004161..4684d7b 100644 --- a/oracledesk-stellar/agents/runtime/tasks.test.ts +++ b/oracledesk-stellar/agents/runtime/tasks.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import type { DueDiligenceVerdict } from "../core/due-diligence.js"; import type { MarketSignal } from "../core/strategy.js"; import type { TraceStore } from "../core/trace.js"; import { NoLease } from "./lease.js"; @@ -7,12 +8,24 @@ import { MarketLocks } from "./locks.js"; import { memoryLogger } from "./log.js"; import { Runtime, type Task } from "./runtime.js"; import { MemoryStateStore } from "./state.js"; -import { makerTask, SIGNALS, type MakerDeps } from "./tasks/maker.js"; +import { makerTask, signalId, SIGNALS, type MakerDeps } from "./tasks/maker.js"; import { TRACES, type PendingTrace } from "./tasks/traces.js"; import { traderTask, type TraderCandidate, type TraderDeps } from "./tasks/trader.js"; const TREASURY = "CTREASURY"; const traceStore: TraceStore = { put: async (hash) => ({ cid: `local://${hash}` }) }; +const approved = (marketId: string): DueDiligenceVerdict => ({ + outcome: "allow", + reasons: [], + commitments: "verified", + metadata: "verified", + resolutionPath: "verified", + oracleTrust: "not_applicable", + signerTrust: "not_applicable", + marketId, + stateFingerprint: "fixture-state", + checkedAt: Date.now(), +}); async function once(state: MemoryStateStore, task: Task) { const rt = new Runtime({ @@ -46,6 +59,7 @@ function traderDeps(adapter: TraderDeps["adapter"], candidates: TraderCandidate[ adapter, traceStore, candidates: async () => candidates, + dueDiligence: async (marketId) => approved(marketId.toString()), treasury: TREASURY, minEdgeBps: 500, tradeSizeStroops: 100_000_000n, @@ -111,6 +125,8 @@ function makerDeps(calls: string[], state: MemoryStateStore, mode: "live" | "dry traceStore, signals: { name: "fixture", poll: async () => [signal] }, knownQuestionHashes: async () => new Set(), + validateProposal: async (signal, question) => + approved(signalId(signal.source, signal.observedAt, question)), reveal: async (id) => void calls.push(`reveal(${id})`), confidenceThresholdBps: 1_000, defaultSeedStroops: 1_500_000_000n, diff --git a/oracledesk-stellar/agents/runtime/tasks/trader.ts b/oracledesk-stellar/agents/runtime/tasks/trader.ts index 9e91f72..3dd1996 100644 --- a/oracledesk-stellar/agents/runtime/tasks/trader.ts +++ b/oracledesk-stellar/agents/runtime/tasks/trader.ts @@ -8,7 +8,8 @@ * trace through the pending-trace queue. */ import { BPS } from "../../core/fpmm.js"; -import { decideTrade, type MarketState } from "../../core/strategy.js"; +import { decideTrade, decideTradeSized, type MarketState } from "../../core/strategy.js"; +import type { CapsView, Holdings, SizingParams } from "../../core/sizing.js"; import { buildTrace, type TraceStore } from "../../core/trace.js"; import { isDueDiligenceApproved, @@ -28,10 +29,14 @@ export interface TraderCandidate { questionHash?: string; /** Ledger the market state was read at, if known. */ ledger?: number; + holdings?: Holdings; + caps?: CapsView; + setsMinted?: bigint; } export interface TraderDeps { - adapter: Pick; + adapter: Pick + & Partial>; traceStore: TraceStore; candidates: () => Promise; /** This deployment's treasury contract id (creator of our own markets). */ @@ -39,6 +44,8 @@ export interface TraderDeps { minEdgeBps: number; tradeSizeStroops: bigint; slippageBps: bigint; + sizingParams?: SizingParams; + uncertaintyBps?: bigint; deadlineSecs: number; /** Unix seconds. */ nowSecs: () => number; @@ -79,15 +86,40 @@ export async function traderTick(deps: TraderDeps, ctx: TaskContext): Promise { ctx.state.set(EVALUATED, String(market.marketId), { at: ctx.now(), ledger: c.ledger, yesBps: market.yesBps }); - const decision = decideTrade(market, fairValueBps, { minEdgeBps: deps.minEdgeBps, tradeSizeStroops: deps.tradeSizeStroops }); + const pool = market.reserveYesStroops !== undefined && market.reserveNoStroops !== undefined && market.feeBps !== undefined + ? { + reserveYes: market.reserveYesStroops, + reserveNo: market.reserveNoStroops, + feeBps: BigInt(market.feeBps), + setsMinted: c.setsMinted ?? 0n, + } + : undefined; + const sized = pool && c.holdings && c.caps && deps.sizingParams + ? decideTradeSized( + market, + pool, + c.holdings, + c.caps, + { fairYesBps: BigInt(fairValueBps), uncertaintyBps: deps.uncertaintyBps ?? 0n }, + deps.sizingParams, + deps.minEdgeBps, + ) + : undefined; + const decision = sized ?? decideTrade(market, fairValueBps, { + minEdgeBps: deps.minEdgeBps, + tradeSizeStroops: deps.tradeSizeStroops, + }); ctx.log.info("TRADER_DECISION", { market: market.marketId, action: decision.action, reasoning: decision.reasoning }); if (decision.action === "skip") return; const outcome = { tag: decision.outcome!, values: undefined as void }; const collateralIn = decision.collateralInStroops!; const quoted = await deps.adapter.quoteBuy({ market_id: BigInt(market.marketId), outcome, collateral_in: collateralIn }); - const minSharesOut = (quoted * (BPS - deps.slippageBps)) / BPS; - const limitPriceBps = decision.outcome === "Yes" ? fairValueBps : Number(BPS) - fairValueBps; + const minSharesOut = sized?.sizedBuy?.minSharesOut + ?? (quoted * (BPS - deps.slippageBps)) / BPS; + const limitPriceBps = sized?.sizing + ? Number(sized.sizing.inputs.targetBps) + : decision.outcome === "Yes" ? fairValueBps : Number(BPS) - fairValueBps; const deadline = BigInt(deps.nowSecs() + deps.deadlineSecs); ctx.log.info("TRADER_QUOTE", { quoted, minSharesOut, limitPriceBps, deadline }); if (ctx.signal.aborted) return; @@ -102,7 +134,7 @@ export async function traderTick(deps: TraderDeps, ctx: TaskContext): Promise reason.code).join(", ")}`, - inputs: { market, fairValueBps, decision, dueDiligence: verdict }, + inputs: { market, fairValueBps, decision, sizing: sized?.sizing, dueDiligence: verdict }, }); const { cid } = await deps.traceStore.put(built.hash, built.json); ctx.log.info("TRADER_TRACE", { trace: built.hash, cid }); diff --git a/oracledesk-stellar/agents/runtime/wiring.ts b/oracledesk-stellar/agents/runtime/wiring.ts index f65b7ae..59bd1e2 100644 --- a/oracledesk-stellar/agents/runtime/wiring.ts +++ b/oracledesk-stellar/agents/runtime/wiring.ts @@ -9,11 +9,12 @@ import { dirname, join } from "node:path"; import { Server } from "@stellar/stellar-sdk/rpc"; import { SnapshotCache } from "../core/state/state_cache.js"; import { ChainStateReader } from "../core/state/reader.js"; -import { availableCapital, existingQuestionHashes, tradableMarkets } from "../core/state/select.js"; +import { existingQuestionHashes, tradableMarkets } from "../core/state/select.js"; import { toMarketState } from "../core/state/types.js"; import type { MarketSignal, SignalSource } from "../core/strategy.js"; import { LocalTraceStore } from "../core/trace.js"; import type { AgentConfig, Tunables } from "../core/config.js"; +import type { CapsView, SizingParams } from "../core/sizing.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"; @@ -66,15 +67,43 @@ function stateReader(rpcUrl: string, deployments: Deployments) { } /** Chain-state market source: tradable markets the treasury can afford. */ -export function chainCandidates(t: Tunables, deployments: Deployments): () => Promise { +export function chainCandidates( + t: Tunables, + deployments: Deployments, + adapter: TraderDeps["adapter"], +): () => Promise { const reader = stateReader(t.rpcUrl, deployments); const cache = new SnapshotCache(); - const tradeSize = t.trading.tradeSizeStroops; return async () => { const snapshot = await cache.get(() => reader.readSnapshot([])); - if (availableCapital(snapshot) < tradeSize) return []; - return tradableMarkets(snapshot).map((m) => { + const risk = await adapter.riskCaps?.(); + const canSize = risk !== undefined && adapter.marketExposure !== undefined && adapter.treasuryPosition !== undefined; + const dailyHeadroom = risk?.dailyHeadroom ?? (risk ? risk.maxDaily - risk.dailyUsed : 0n); + if (canSize && (snapshot.portfolio.availableCapital <= 0n || dailyHeadroom <= 0n)) return []; + const markets = tradableMarkets(snapshot); + return Promise.all(markets.map(async (m) => { const market = toMarketState(m); + let holdings; + let caps: CapsView | undefined; + if (canSize && risk !== undefined) { + const [position, exposure] = await Promise.all([ + adapter.treasuryPosition!(m.marketId), + adapter.marketExposure!(m.marketId), + ]); + holdings = position; + const available = snapshot.portfolio.availableCapital < dailyHeadroom + ? snapshot.portfolio.availableCapital + : dailyHeadroom; + caps = { + maxTrade: risk.maxTrade < t.trading.tradeSizeStroops ? risk.maxTrade : t.trading.tradeSizeStroops, + maxMarket: risk.maxMarket, + marketExposure: exposure, + maxDaily: risk.maxDaily, + dailyUsed: risk.dailyUsed, + dailyHeadroom, + availableCapital: available, + }; + } return { market, // No external oracle yet: FAIR_VALUE_BPS or the market's own price (strategy.ts decides edge). @@ -82,8 +111,11 @@ export function chainCandidates(t: Tunables, deployments: Deployments): () => Pr creator: m.creator, questionHash: m.questionHash, ledger: snapshot.ledgers.max, + holdings, + caps, + setsMinted: m.setsMinted, }; - }); + })); }; } @@ -109,17 +141,27 @@ export function traderDeps( adapter: TraderDeps["adapter"], deployments: Deployments | undefined, nowSecs: () => number, + dueDiligence?: TraderDeps["dueDiligence"], ): TraderDeps { return { adapter, traceStore: new LocalTraceStore(t.traceStoreDir), - candidates: deployments ? chainCandidates(t, deployments) : () => fixtureCandidates(nowSecs()), + candidates: deployments ? chainCandidates(t, deployments, adapter) : () => fixtureCandidates(nowSecs()), treasury: deployments?.contracts.treasury, minEdgeBps: t.trading.minEdgeBps, tradeSizeStroops: t.trading.tradeSizeStroops, slippageBps: BigInt(t.trading.slippageBps), + sizingParams: { + kellyFractionNumerator: t.trading.kellyFraction.numerator, + kellyFractionDenominator: t.trading.kellyFraction.denominator, + bufferBps: BigInt(t.trading.sizingBufferBps), + slippageBps: BigInt(t.trading.slippageBps), + minTradeStroops: t.trading.minTradeStroops, + } satisfies SizingParams, + uncertaintyBps: BigInt(t.trading.fairUncertaintyBps), deadlineSecs: t.trading.deadlineSecs, nowSecs, + dueDiligence, }; } @@ -155,4 +197,3 @@ export function runtimeOptions(env: NodeJS.ProcessEnv): Omit { rpcUrl: "https://soroban-testnet.stellar.org", deployments, }); - }, /agentSecret/); + }, /signer or agentSecret/); +}); + +test("live mode accepts a policy-backed signer with the configured agent identity", () => { + const keypair = Keypair.random(); + const signer = new KeypairSigner(keypair, "Test SDF Network ; September 2015"); + const adapter = new StellarAdapter({ + mode: "live", + signer, + agentAddress: keypair.publicKey(), + networkPassphrase: "Test SDF Network ; September 2015", + rpcUrl: "https://soroban-testnet.stellar.org", + deployments, + }); + assert.equal(adapter.agent, keypair.publicKey()); +}); + +test("live mode rejects an injected signer that does not match the configured agent", () => { + const keypair = Keypair.random(); + assert.throws(() => new StellarAdapter({ + mode: "live", + signer: new KeypairSigner(keypair, "Test SDF Network ; September 2015"), + agentAddress: Keypair.random().publicKey(), + networkPassphrase: "Test SDF Network ; September 2015", + rpcUrl: "https://soroban-testnet.stellar.org", + deployments, + }), /does not match/); }); test("adapter rejects unknown mode", () => { @@ -97,7 +124,7 @@ test("dry-run refuses to be handed an agent secret", () => { rpcUrl: "https://soroban-testnet.stellar.org", deployments, } as unknown as StellarAdapterOptions), - /dry-run mode must not be given agentSecret/, + /dry-run mode must not be given a signer or agentSecret/, ); }); diff --git a/oracledesk-stellar/agents/stellar/adapter.ts b/oracledesk-stellar/agents/stellar/adapter.ts index 6aed090..42f1b1e 100644 --- a/oracledesk-stellar/agents/stellar/adapter.ts +++ b/oracledesk-stellar/agents/stellar/adapter.ts @@ -17,6 +17,7 @@ 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"; +import type { StellarSigner } from "./signers/types.js"; const HERE = dirname(fileURLToPath(import.meta.url)); // HERE is agents/stellar/ — the repo root is two levels up. @@ -80,7 +81,10 @@ interface CommonAdapterOptions { * signed with `agentSecret` (`S...`), which it requires. */ export type StellarAdapterOptions = CommonAdapterOptions & - ({ mode: "dry-run"; agentSecret?: undefined } | { mode: "live"; agentSecret?: string }); + ( + | { mode: "dry-run"; agentSecret?: undefined; signer?: undefined } + | { mode: "live"; agentSecret?: string; signer?: StellarSigner } + ); export interface DryRunResult { dryRun: true; @@ -129,19 +133,34 @@ export class NotSubmittedError extends Error { * misconfigured agent can never accidentally submit a real transaction. */ export class StellarAdapter { - private readonly signer?: KeypairSigner; + private readonly signer?: StellarSigner; private readonly publicKey?: string; constructor(private readonly opts: StellarAdapterOptions) { switch (opts.mode) { case "dry-run": - if (opts.agentSecret !== undefined) throw new Error("dry-run mode must not be given agentSecret"); + if (opts.agentSecret !== undefined || opts.signer !== undefined) { + throw new Error("dry-run mode must not be given a signer or 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(); + if (opts.signer && opts.agentSecret) { + throw new Error("live mode accepts either signer or agentSecret, not both"); + } + if (opts.signer) { + this.signer = opts.signer; + this.publicKey = opts.signer.address; + } else if (opts.agentSecret) { + const keypair = Keypair.fromSecret(opts.agentSecret); + this.signer = new KeypairSigner(keypair, opts.networkPassphrase); + this.publicKey = keypair.publicKey(); + } else { + throw new Error("live mode requires signer or agentSecret"); + } + const expectedAddress = opts.agentAddress ?? opts.deployments.agent; + if (this.publicKey !== expectedAddress) { + throw new Error("signer address does not match the configured agent address"); + } break; } default: @@ -380,20 +399,51 @@ export class StellarAdapter { * 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 }> { + async riskCaps(): Promise<{ + maxTrade: bigint; + maxMarket: bigint; + maxDaily: bigint; + dailyUsed: bigint; + dailyHeadroom?: 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 [usageResult, globalHeadroomResult] = await Promise.all([ + client.agent_daily_usage({ agent: this.agent }), + client.daily_headroom(), + ]); + const usage = unwrapContractResult("agent_daily_usage", usageResult.result) as { amount: bigint }; const min = (a: bigint, b?: bigint) => (b !== undefined && BigInt(b) < BigInt(a) ? BigInt(b) : BigInt(a)); + const maxDaily = min(config.max_daily, policy?.max_daily); + const agentHeadroom = max0(maxDaily - BigInt(usage.amount)); return { maxTrade: min(config.max_trade, policy?.max_trade), maxMarket: BigInt(config.max_market), - maxDaily: min(config.max_daily, policy?.max_daily), + maxDaily, dailyUsed: BigInt(usage.amount), + dailyHeadroom: min(BigInt(unwrapContractResult("daily_headroom", globalHeadroomResult.result) as bigint), agentHeadroom), }; } + /** Cumulative treasury spend for this market; sells and close_trade do not erase it. */ + async marketExposure(marketId: bigint): Promise { + const key = xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Address(this.opts.deployments.contracts.treasury).toScAddress(), + key: xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("Market"), xdr.ScVal.scvU64(xdr.Uint64.fromString(String(marketId)))]), + durability: xdr.ContractDataDurability.persistent(), + }), + ); + const { entries } = await this.rpcServer().getLedgerEntries(key); + const entry = entries[0]; + if (!entry) return 0n; + const data = entry.val.contractData()?.val(); + const amount = data?.map()?.find((item) => item.key().sym?.().toString() === "amount")?.val()?.i128()?.toString(); + if (amount === undefined) throw new Error(`treasury market exposure ${marketId} has an invalid ledger entry`); + return BigInt(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"); @@ -571,6 +621,10 @@ export class StellarAdapter { } } +function max0(value: bigint): bigint { + return value > 0n ? value : 0n; +} + /** * Generated clients return `Result` for fallible contract methods: an * `Err` becomes a thrown error carrying the contract error name (e.g. diff --git a/oracledesk-stellar/agents/stellar/errors.test.ts b/oracledesk-stellar/agents/stellar/errors.test.ts new file mode 100644 index 0000000..cdf6ba9 --- /dev/null +++ b/oracledesk-stellar/agents/stellar/errors.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { classifySorobanError, SorobanPendingError, SorobanTerminalError } from "./errors.js"; + +test("contract and authorization failures are terminal", () => { + assert.deepEqual(classifySorobanError("HostError: contract invocation failed"), { + kind: "contract", + terminal: true, + message: "HostError: contract invocation failed", + }); + assert.equal(classifySorobanError("authorization failed: missing signature").terminal, true); + assert.equal(classifySorobanError("txBadAuth").kind, "auth"); +}); + +test("transport failures are not treated as terminal", () => { + assert.equal(classifySorobanError(new Error("connection reset by peer")).terminal, false); + assert.equal(classifySorobanError(new Error("connection reset by peer")).kind, "transient"); +}); + +test("submission error classes preserve the hash and error classification", () => { + const terminal = new SorobanTerminalError("contract error: invalid state", { transactionHash: "abc" }); + assert.equal(terminal.classification.terminal, true); + assert.equal(terminal.transactionHash, "abc"); + + const pending = new SorobanPendingError("still unresolved", "def"); + assert.equal(pending.classification.terminal, false); + assert.equal(pending.transactionHash, "def"); +}); diff --git a/oracledesk-stellar/agents/stellar/errors.ts b/oracledesk-stellar/agents/stellar/errors.ts new file mode 100644 index 0000000..3d0fe58 --- /dev/null +++ b/oracledesk-stellar/agents/stellar/errors.ts @@ -0,0 +1,94 @@ +export type SorobanErrorKind = "contract" | "auth" | "transient" | "unknown"; + +export interface SorobanErrorClassification { + kind: SorobanErrorKind; + terminal: boolean; + message: string; +} + +function collectText(value: unknown, seen = new Set()): string[] { + if (typeof value === "string") return [value]; + if (value === null || typeof value !== "object" || seen.has(value)) return []; + seen.add(value); + const entries: unknown[] = []; + if (Array.isArray(value)) entries.push(...value); + else { + const object = value as Record; + for (const [key, child] of Object.entries(object)) { + entries.push(key, child); + } + // Stellar SDK XDR objects expose discriminant values through switch(). + const switchMethod = (value as { switch?: () => unknown }).switch; + if (typeof switchMethod === "function") { + try { + entries.push(switchMethod.call(value)); + } catch { + // Some XDR union values are not initialized; their fields still help. + } + } + } + return entries.flatMap((entry) => collectText(entry, seen)); +} + +/** + * Classify explicit Soroban contract/auth failures as terminal. Generic RPC + * and transport failures remain unknown, because they may have reached a node. + */ +export function classifySorobanError(value: unknown): SorobanErrorClassification { + const message = collectText(value).join(" ").replace(/\s+/g, " ").trim() || String(value); + const normalized = message.toLowerCase(); + + if ( + /\b(tx_bad_auth|txbadauth|bad_auth|badauth)\b/.test(normalized) || + (/\b(auth|authorization|signature)\b/.test(normalized) && + /\b(error|failed|invalid|missing|bad|denied|unauthorized)\b/.test(normalized)) || + /\bauth[_ -](error|failed|invalid|missing|denied)\b/.test(normalized) + ) { + return { kind: "auth", terminal: true, message }; + } + if ( + /\b(contract error|contract_error|contracterror|contract trap|hosterror|scerror|contract invocation failed|invokehostfunctionfailed)\b/.test( + normalized, + ) + ) { + return { kind: "contract", terminal: true, message }; + } + if (/\b(try_again_later|timeout|timed out|temporar|rate limit|unavailable|network|connection reset)\b/.test(normalized)) { + return { kind: "transient", terminal: false, message }; + } + return { kind: "unknown", terminal: false, message }; +} + +export class SorobanSubmissionError extends Error { + readonly classification: SorobanErrorClassification; + readonly transactionHash?: string; + + constructor( + message: string, + classification: SorobanErrorClassification, + options: { cause?: unknown; transactionHash?: string } = {}, + ) { + super(message, { cause: options.cause }); + this.name = "SorobanSubmissionError"; + this.classification = classification; + this.transactionHash = options.transactionHash; + } +} + +export class SorobanTerminalError extends SorobanSubmissionError { + constructor(message: string, options: { cause?: unknown; transactionHash?: string } = {}) { + super(message, { ...classifySorobanError(message), terminal: true }, options); + this.name = "SorobanTerminalError"; + } +} + +/** The signed envelope may still land; callers must not create a replacement. */ +export class SorobanPendingError extends SorobanSubmissionError { + constructor(message: string, transactionHash: string, options: { cause?: unknown } = {}) { + super(message, { kind: "unknown", terminal: false, message }, { + ...options, + transactionHash, + }); + this.name = "SorobanPendingError"; + } +} diff --git a/oracledesk-stellar/agents/stellar/journal.test.ts b/oracledesk-stellar/agents/stellar/journal.test.ts new file mode 100644 index 0000000..3f515e5 --- /dev/null +++ b/oracledesk-stellar/agents/stellar/journal.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { createJournalRecord, TransactionJournal } from "./journal.js"; + +function journalPath(): string { + return join(process.cwd(), `.transaction-journal-${randomUUID()}.jsonl`); +} + +const record = (id: string, stage: "signed" | "submitted" = "signed") => + createJournalRecord({ + id, + stage, + hash: "ab".repeat(32), + envelopeXdr: "signed-envelope-xdr", + }); + +test("journal appends durable JSONL records and returns latest state by id", async () => { + const path = journalPath(); + const journal = new TransactionJournal(path); + try { + await journal.append(record("trade-1")); + await journal.append(record("trade-1", "submitted")); + await journal.append(record("trade-2")); + + const records = await journal.readAll(); + assert.deepEqual(records.map(({ id, stage }) => [id, stage]), [ + ["trade-1", "signed"], + ["trade-1", "submitted"], + ["trade-2", "signed"], + ]); + assert.equal((await journal.latestById()).get("trade-1")?.stage, "submitted"); + assert.equal((await readFile(path, "utf8")).endsWith("\n"), true); + } finally { + await rm(path, { force: true }); + } +}); + +test("journal warns and ignores only a torn final record", async () => { + const path = journalPath(); + const warnings: string[] = []; + const journal = new TransactionJournal(path, { warn: (message) => warnings.push(message) }); + try { + await writeFile(path, `${JSON.stringify(record("trade-1"))}\n{"version":1,"id":"torn"`, "utf8"); + assert.equal((await journal.readAll()).length, 1); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /torn final/); + } finally { + await rm(path, { force: true }); + } +}); + +test("journal rejects malformed complete records and repairs a torn tail before appending", async () => { + const path = journalPath(); + const warnings: string[] = []; + const journal = new TransactionJournal(path, { warn: (message) => warnings.push(message) }); + try { + await writeFile(path, `${JSON.stringify(record("trade-1"))}\nnot-json\n`, "utf8"); + await assert.rejects(journal.readAll(), /invalid transaction journal record at line 2/); + + await writeFile(path, `${JSON.stringify(record("trade-1"))}\n{"broken":`, "utf8"); + await journal.append(record("trade-2")); + assert.deepEqual((await journal.readAll()).map(({ id }) => id), ["trade-1", "trade-2"]); + assert.equal(warnings.length, 1); + } finally { + await rm(path, { force: true }); + } +}); diff --git a/oracledesk-stellar/agents/stellar/journal.ts b/oracledesk-stellar/agents/stellar/journal.ts new file mode 100644 index 0000000..3cd59ca --- /dev/null +++ b/oracledesk-stellar/agents/stellar/journal.ts @@ -0,0 +1,167 @@ +import { open, readFile, mkdir, truncate } from "node:fs/promises"; +import { dirname } from "node:path"; + +export type JournalStage = "signed" | "submitting" | "submitted" | "confirmed" | "failed" | "uncertain"; + +export interface TransactionJournalRecord { + version: 1; + id: string; + stage: JournalStage; + hash: string; + envelopeXdr: string; + timestamp: string; + attempt?: number; + detail?: string; +} + +export interface TransactionJournalOptions { + /** Called when an incomplete final JSONL record is ignored. */ + warn?: (message: string) => void; +} + +const stages = new Set([ + "signed", + "submitting", + "submitted", + "confirmed", + "failed", + "uncertain", +]); + +function isJournalRecord(value: unknown): value is TransactionJournalRecord { + if (value === null || typeof value !== "object") return false; + const record = value as Partial; + return ( + record.version === 1 && + typeof record.id === "string" && + record.id.length > 0 && + typeof record.stage === "string" && + stages.has(record.stage as JournalStage) && + typeof record.hash === "string" && + typeof record.envelopeXdr === "string" && + typeof record.timestamp === "string" && + (record.attempt === undefined || (Number.isInteger(record.attempt) && record.attempt > 0)) && + (record.detail === undefined || typeof record.detail === "string") + ); +} + +function validateRecord(record: TransactionJournalRecord): void { + if (!isJournalRecord(record)) throw new TypeError("invalid transaction journal record"); +} + +/** + * Append-only JSONL journal. Each complete record is synced before append() + * resolves, so callers can safely proceed to the next externally visible stage. + */ +export class TransactionJournal { + private queue: Promise = Promise.resolve(); + private readonly warn: (message: string) => void; + + constructor(readonly path: string, options: TransactionJournalOptions = {}) { + this.warn = options.warn ?? ((message) => console.warn(message)); + } + + append(record: TransactionJournalRecord): Promise { + validateRecord(record); + const operation = this.queue.then(() => this.appendSerialized(record)); + this.queue = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + async readAll(): Promise { + await this.queue; + let content: string; + try { + content = await readFile(this.path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + if (content.length === 0) return []; + + const hasFinalNewline = content.endsWith("\n"); + const lines = content.split("\n"); + if (hasFinalNewline) lines.pop(); + + const records: TransactionJournalRecord[] = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!line) throw new Error(`empty transaction journal record at line ${index + 1}`); + try { + const value: unknown = JSON.parse(line); + if (!isJournalRecord(value)) throw new Error("invalid record fields"); + records.push(value); + } catch (error) { + const isIncompleteTail = !hasFinalNewline && index === lines.length - 1; + if (!isIncompleteTail) { + throw new Error(`invalid transaction journal record at line ${index + 1}`, { cause: error }); + } + this.warn(`Ignoring torn final transaction journal record in ${this.path}`); + } + } + return records; + } + + async latestById(): Promise> { + const latest = new Map(); + for (const record of await this.readAll()) latest.set(record.id, record); + return latest; + } + + private async appendSerialized(record: TransactionJournalRecord): Promise { + await mkdir(dirname(this.path), { recursive: true }); + let existing = ""; + try { + existing = await readFile(this.path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const lastCompleteRecordEnd = existing.endsWith("\n") + ? existing.length - 1 + : existing.lastIndexOf("\n"); + if (lastCompleteRecordEnd >= 0) { + const completeRecords = existing.slice(0, lastCompleteRecordEnd).split("\n"); + for (let index = 0; index < completeRecords.length; index += 1) { + try { + const value: unknown = JSON.parse(completeRecords[index]); + if (!isJournalRecord(value)) throw new Error("invalid record fields"); + } catch (error) { + throw new Error(`invalid transaction journal record at line ${index + 1}`, { cause: error }); + } + } + } + let separator = ""; + if (existing.length > 0 && !existing.endsWith("\n")) { + const lastLineStart = existing.lastIndexOf("\n") + 1; + const tail = existing.slice(lastLineStart); + try { + const value: unknown = JSON.parse(tail); + if (!isJournalRecord(value)) throw new Error("invalid record fields"); + separator = "\n"; + } catch { + this.warn(`Discarding torn final transaction journal record in ${this.path}`); + await truncate(this.path, Buffer.byteLength(existing.slice(0, lastLineStart), "utf8")); + } + } + const handle = await open(this.path, "a"); + try { + await handle.writeFile(`${separator}${JSON.stringify(record)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + } +} + +export function createJournalRecord( + fields: Omit & { timestamp?: string }, +): TransactionJournalRecord { + return { + ...fields, + version: 1, + timestamp: fields.timestamp ?? new Date().toISOString(), + }; +} diff --git a/oracledesk-stellar/agents/stellar/signers/auth-entry.ts b/oracledesk-stellar/agents/stellar/signers/auth-entry.ts new file mode 100644 index 0000000..252ae7f --- /dev/null +++ b/oracledesk-stellar/agents/stellar/signers/auth-entry.ts @@ -0,0 +1,165 @@ +import { Address, authorizeEntry, buildAuthorizationEntryPreimage, xdr } from "@stellar/stellar-sdk"; +import type { Keypair } from "@stellar/stellar-sdk"; +import type { InvocationPolicy, SignerPolicy } from "./types.js"; +import { hashInvocationArgs } from "./policy.js"; + +export interface ValidatedAuthPreimage { + preimage: xdr.HashIdPreimage; + entry: xdr.SorobanAuthorizationEntry; + validUntilLedgerSeq: number; +} + +function sameXdr(left: xdr.HashIdPreimage, right: xdr.HashIdPreimage): boolean { + return left.toXDR().equals(right.toXDR()); +} + +function assertAllowedInvocation( + invocation: xdr.SorobanAuthorizedInvocation, + allowed: readonly InvocationPolicy[], +): void { + if (invocation.subInvocations().length !== 0) { + throw new Error("nested Soroban authorization invocations are unsupported"); + } + const fn = invocation.function(); + if (fn.switch() !== xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeContractFn()) { + throw new Error("contract creation authorization entries are unsupported"); + } + const contractFn = fn.contractFn(); + const rawName = contractFn.functionName(); + const functionName = Buffer.isBuffer(rawName) ? rawName.toString("utf8") : rawName; + if ( + typeof functionName !== "string" || + !/^[A-Za-z_][A-Za-z0-9_]*$/.test(functionName) || + (Buffer.isBuffer(rawName) && !Buffer.from(functionName, "utf8").equals(rawName)) + ) { + throw new Error("authorized contract function name has an unsupported shape"); + } + const contractId = Address.fromScAddress(contractFn.contractAddress()).toString(); + const argsHash = hashInvocationArgs(contractFn.args()); + if ( + !allowed.some( + (item) => + item.contractId === contractId && + item.functionName === functionName && + item.argsHash === argsHash, + ) + ) { + throw new Error("Soroban authorization invocation is not explicitly allowed by signer policy"); + } +} + +export function validateAuthEntryPreimage( + authEntryXdr: string, + signerAddress: string, + policy: SignerPolicy, +): ValidatedAuthPreimage { + let preimage: xdr.HashIdPreimage; + try { + const bytes = Buffer.from(authEntryXdr, "base64"); + if (bytes.length === 0 || bytes.toString("base64") !== authEntryXdr) { + throw new Error("non-canonical base64"); + } + preimage = xdr.HashIdPreimage.fromXDR(bytes); + } catch { + throw new Error("auth signing input must be a valid base64 HashIdPreimage XDR"); + } + + if ( + preimage.switch() !== xdr.EnvelopeType.envelopeTypeSorobanAuthorizationWithAddress() + ) { + throw new Error("only address-bound Soroban authorization preimages are supported"); + } + const addressPreimage = preimage.sorobanAuthorizationWithAddress(); + const address = Address.fromScAddress(addressPreimage.address()).toString(); + if (address !== signerAddress) { + throw new Error("Soroban authorization preimage is bound to a different address"); + } + const validUntilLedgerSeq = addressPreimage.signatureExpirationLedger(); + if (!Number.isSafeInteger(validUntilLedgerSeq) || validUntilLedgerSeq <= 0) { + throw new Error("Soroban authorization expiration ledger is invalid"); + } + const invocation = addressPreimage.invocation(); + assertAllowedInvocation(invocation, policy.allowedInvocations); + + const entry = new xdr.SorobanAuthorizationEntry({ + rootInvocation: invocation, + credentials: xdr.SorobanCredentials.sorobanCredentialsAddressV2( + new xdr.SorobanAddressCredentials({ + address: addressPreimage.address(), + nonce: addressPreimage.nonce(), + signatureExpirationLedger: 0, + signature: xdr.ScVal.scvVec([]), + }), + ), + }); + + let rebuilt: xdr.HashIdPreimage; + try { + rebuilt = buildAuthorizationEntryPreimage(entry, validUntilLedgerSeq, policy.networkPassphrase); + } catch { + throw new Error("Soroban authorization preimage cannot be safely reconstructed"); + } + if (!sameXdr(preimage, rebuilt)) { + throw new Error("Soroban authorization preimage does not match the configured network"); + } + + return { preimage, entry, validUntilLedgerSeq }; +} + +export async function signValidatedAuthPreimage( + validated: ValidatedAuthPreimage, + keypair: Keypair, + networkPassphrase: string, +): Promise { + let signature: Buffer | undefined; + await authorizeEntry( + validated.entry, + async (preimage, payload) => { + if (!sameXdr(validated.preimage, preimage)) { + throw new Error("SDK-generated authorization preimage differs from validated input"); + } + signature = keypair.sign(payload); + return signature; + }, + validated.validUntilLedgerSeq, + networkPassphrase, + ); + if (!signature) throw new Error("SDK did not request an authorization signature"); + return signature.toString("base64"); +} + +export async function verifyAuthEntrySignature( + authEntryXdr: string, + signedAuthEntry: string, + signerAddress: string, + policy: SignerPolicy, +): Promise { + const validated = validateAuthEntryPreimage(authEntryXdr, signerAddress, policy); + let signature: Buffer; + try { + signature = Buffer.from(signedAuthEntry, "base64"); + if (signature.length !== 64 || signature.toString("base64") !== signedAuthEntry) { + throw new Error("invalid signature encoding"); + } + } catch { + throw new Error("auth signer returned an invalid signature"); + } + let callbackUsed = false; + try { + await authorizeEntry( + validated.entry, + async (preimage, payload) => { + if (!sameXdr(validated.preimage, preimage)) { + throw new Error("SDK-generated authorization preimage differs from validated input"); + } + callbackUsed = true; + return signature; + }, + validated.validUntilLedgerSeq, + policy.networkPassphrase, + ); + } catch { + throw new Error("auth signer returned an invalid or mismatched signature"); + } + if (!callbackUsed) throw new Error("SDK did not validate an authorization signature"); +} diff --git a/oracledesk-stellar/agents/stellar/signers/local.ts b/oracledesk-stellar/agents/stellar/signers/local.ts new file mode 100644 index 0000000..0554c83 --- /dev/null +++ b/oracledesk-stellar/agents/stellar/signers/local.ts @@ -0,0 +1,79 @@ +import { Keypair, Transaction, TransactionBuilder, type xdr } from "@stellar/stellar-sdk"; +import { KeypairSigner } from "@stellar/stellar-sdk/contract"; +import type { StellarSigner, SignerPolicy } from "./types.js"; +import { assertSigningRequest, validateTransaction } from "./policy.js"; +import { signValidatedAuthPreimage, validateAuthEntryPreimage } from "./auth-entry.js"; + +export interface LocalSignerOptions { + keypair: Keypair; + policy: SignerPolicy; +} + +export function createLocalSigner({ keypair, policy }: LocalSignerOptions): StellarSigner { + const sdkSigner = new KeypairSigner(keypair, policy.networkPassphrase); + return { + address: sdkSigner.address, + async signTransaction(transactionXdr, options) { + assertSigningRequest(options ?? {}, sdkSigner.address, policy); + validateTransaction(transactionXdr, policy.networkPassphrase, policy); + return sdkSigner.signTransaction(transactionXdr, { + networkPassphrase: policy.networkPassphrase, + address: sdkSigner.address, + }); + }, + async signAuthEntry(authEntryXdr, options) { + if (options?.address && options.address !== sdkSigner.address) { + throw new Error("requested signer address does not match signer identity"); + } + const validated = validateAuthEntryPreimage(authEntryXdr, sdkSigner.address, policy); + return { + signedAuthEntry: await signValidatedAuthPreimage( + validated, + keypair, + policy.networkPassphrase, + ), + signerAddress: sdkSigner.address, + }; + }, + }; +} + +export function verifySignedTransaction( + transactionXdr: string, + expectedTransactionHash: string, + signerAddress: string, + networkPassphrase: string, +): Transaction { + let tx: ReturnType; + try { + tx = TransactionBuilder.fromXDR(transactionXdr, networkPassphrase); + } catch { + throw new Error("signed transaction XDR is invalid or unsupported"); + } + if (!(tx instanceof Transaction)) throw new Error("signed fee-bump transactions are unsupported"); + if (tx.hash().toString("hex") !== expectedTransactionHash) { + throw new Error("signer changed the approved transaction"); + } + + const signature = findValidSignature(tx, signerAddress); + if (!signature) throw new Error("signed transaction has no valid signature from the signer"); + return tx; +} + +export function findValidSignature( + transaction: Transaction, + signerAddress: string, +): xdr.DecoratedSignature | undefined { + let publicKey: Keypair; + try { + publicKey = Keypair.fromPublicKey(signerAddress); + } catch { + throw new Error("signer address is not a valid Ed25519 account"); + } + const hint = publicKey.signatureHint(); + return transaction.signatures.find( + (decorated) => + decorated.hint().equals(hint) && + publicKey.verify(transaction.hash(), decorated.signature()), + ); +} diff --git a/oracledesk-stellar/agents/stellar/signers/multisig.ts b/oracledesk-stellar/agents/stellar/signers/multisig.ts new file mode 100644 index 0000000..010f477 --- /dev/null +++ b/oracledesk-stellar/agents/stellar/signers/multisig.ts @@ -0,0 +1,81 @@ +import { Transaction, xdr } from "@stellar/stellar-sdk"; +import type { SignerPolicy, StellarSigner, TransactionSigningProvider } from "./types.js"; +import { assertSigningRequest, validateTransaction } from "./policy.js"; +import { findValidSignature, verifySignedTransaction } from "./local.js"; + +export interface MultisigSignerOptions { + /** Address used as the transaction source and exposed to the SDK client. */ + address: string; + participants: readonly TransactionSigningProvider[]; + threshold: number; + policy: SignerPolicy; +} + +export function createMultisigSigner({ + address, + participants, + threshold, + policy, +}: MultisigSignerOptions): StellarSigner { + if (!Number.isInteger(threshold) || threshold < 1 || threshold > participants.length) { + throw new Error("multisig threshold must be between one and the participant count"); + } + const participantAddresses = participants.map((participant) => participant.address); + if (new Set(participantAddresses).size !== participantAddresses.length) { + throw new Error("multisig participants must have distinct addresses"); + } + + return { + address, + async signTransaction(transactionXdr, options) { + assertSigningRequest(options ?? {}, address, policy); + const approved = validateTransaction(transactionXdr, policy.networkPassphrase, policy); + const accepted = new Map(); + + for (const participant of participants) { + try { + const result = await participant.signTransaction(transactionXdr, { + networkPassphrase: policy.networkPassphrase, + address: participant.address, + }); + if (result.error || result.signerAddress !== participant.address) continue; + const signed = verifySignedTransaction( + result.signedTxXdr, + approved.transactionHash, + participant.address, + policy.networkPassphrase, + ); + const signature = findValidSignature(signed, participant.address); + if (signature) accepted.set(participant.address, signature); + } catch { + // A failed participant does not prevent other configured signers from contributing. + } + if (accepted.size >= threshold) break; + } + + if (accepted.size < threshold) { + throw new Error(`multisig threshold not met (${accepted.size}/${threshold})`); + } + + const merged = approved.transaction; + const signatures = new Map(); + for (const signature of merged.signatures) { + signatures.set(signature.toXDR().toString("base64"), signature); + } + for (const signature of accepted.values()) { + signatures.set(signature.toXDR().toString("base64"), signature); + } + const envelope = merged.toEnvelope(); + if (envelope.switch() !== xdr.EnvelopeType.envelopeTypeTx()) { + throw new Error("multisig requires a classic transaction envelope"); + } + envelope.v1().signatures([...signatures.values()]); + return { signedTxXdr: envelope.toXDR("base64"), signerAddress: address }; + }, + async signAuthEntry() { + throw new Error( + "multisig auth-entry signing is unsupported: the SDK helper accepts one Ed25519 signature and cannot enforce this threshold", + ); + }, + }; +} diff --git a/oracledesk-stellar/agents/stellar/signers/policy-server.test.ts b/oracledesk-stellar/agents/stellar/signers/policy-server.test.ts new file mode 100644 index 0000000..b54638c --- /dev/null +++ b/oracledesk-stellar/agents/stellar/signers/policy-server.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { Account, Keypair, Operation, TransactionBuilder, xdr } from "@stellar/stellar-sdk"; +import { createLocalSigner } from "./local.js"; +import { createReferencePolicyServer, MemoryAuditLog } from "./policy-server.js"; +import { hashInvocationArgs } from "./policy.js"; +import type { SignerPolicy } from "./types.js"; + +const NETWORK = "Test SDF Network ; September 2015"; +const CONTRACT = "CC3KU5TWAATOBXLWIV4ZTXNEM44VIOY3ZTR7KTJTBOOBP7YIXMCDSWDJ"; + +test("reference policy server signs approved calls and records no transaction XDR", async (t) => { + const keypair = Keypair.random(); + const args = [xdr.ScVal.scvU32(7)]; + const policy: SignerPolicy = { + networkPassphrase: NETWORK, + allowedSourceAddresses: [keypair.publicKey()], + allowedInvocations: [{ + contractId: CONTRACT, + functionName: "commit", + argsHash: hashInvocationArgs(args), + }], + }; + const signer = createLocalSigner({ keypair, policy }); + const auditLog = new MemoryAuditLog(); + const reference = createReferencePolicyServer({ signer, policy, auditLog }); + await new Promise((resolve) => reference.server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve, reject) => { + reference.server.close((error) => error ? reject(error) : resolve()); + })); + + const transactionXdr = new TransactionBuilder(new Account(keypair.publicKey(), "1"), { + fee: "100", + networkPassphrase: NETWORK, + }) + .addOperation(Operation.invokeContractFunction({ contract: CONTRACT, function: "commit", args })) + .setTimeout(60) + .build() + .toXDR(); + const address = reference.server.address(); + assert.ok(address && typeof address !== "string"); + const endpoint = `http://127.0.0.1:${address.port}/sign`; + + const allowed = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ xdr: transactionXdr, networkPassphrase: NETWORK, address: keypair.publicKey() }), + }); + assert.equal(allowed.status, 200); + assert.deepEqual(auditLog.entries().map((event) => event.action), ["allow"]); + assert.equal(JSON.stringify(auditLog.entries()).includes(transactionXdr), false); + + const rejected = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ xdr: transactionXdr, networkPassphrase: "wrong", address: keypair.publicKey() }), + }); + assert.equal(rejected.status, 403); + assert.deepEqual(auditLog.entries().map((event) => event.action), ["allow", "deny"]); +}); diff --git a/oracledesk-stellar/agents/stellar/signers/policy-server.ts b/oracledesk-stellar/agents/stellar/signers/policy-server.ts new file mode 100644 index 0000000..5b2fc86 --- /dev/null +++ b/oracledesk-stellar/agents/stellar/signers/policy-server.ts @@ -0,0 +1,102 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { redact } from "../../core/redact.js"; +import { validateTransaction } from "./policy.js"; +import type { AuditEvent, AuditLog, SignerPolicy, StellarSigner } from "./types.js"; + +const MAX_BODY_BYTES = 1_000_000; + +export class MemoryAuditLog implements AuditLog { + private readonly records: AuditEvent[] = []; + + append(event: AuditEvent): void { + this.records.push(redact(event)); + } + + entries(): readonly AuditEvent[] { + return this.records.map((event) => ({ ...event })); + } +} + +export interface PolicyServerOptions { + signer: StellarSigner; + policy: SignerPolicy; + auditLog?: AuditLog; +} + +export interface ReferencePolicyServer { + server: Server; + auditLog: AuditLog; +} + +async function readJson(request: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = Buffer.from(chunk); + size += buffer.length; + if (size > MAX_BODY_BYTES) throw new Error("request body exceeds limit"); + chunks.push(buffer); + } + const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("request body must be a JSON object"); + } + return parsed as Record; +} + +function send(response: ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(redact(body))); +} + +export function createReferencePolicyServer({ + signer, + policy, + auditLog = new MemoryAuditLog(), +}: PolicyServerOptions): ReferencePolicyServer { + const server = createServer(async (request, response) => { + if (request.method !== "POST" || request.url !== "/sign") { + send(response, 404, { error: "not found" }); + return; + } + + let txHash: string | undefined; + try { + const body = await readJson(request); + if ( + typeof body.xdr !== "string" || + typeof body.networkPassphrase !== "string" || + typeof body.address !== "string" || + body.submit !== undefined || + body.submitUrl !== undefined + ) { + throw new Error("request must include xdr, networkPassphrase, and address only"); + } + const approved = validateTransaction(body.xdr, body.networkPassphrase, policy); + txHash = approved.transactionHash; + const signed = await signer.signTransaction(body.xdr, { + networkPassphrase: body.networkPassphrase, + address: body.address, + }); + if (signed.error) throw new Error(signed.error.message); + auditLog.append({ + timestamp: new Date().toISOString(), + action: "allow", + signerAddress: signed.signerAddress ?? signer.address, + transactionHash: txHash, + }); + send(response, 200, signed); + } catch (error) { + const reason = error instanceof Error ? error.message : "signing request rejected"; + auditLog.append({ + timestamp: new Date().toISOString(), + action: "deny", + signerAddress: signer.address, + transactionHash: txHash, + reason, + }); + send(response, 403, { error: reason }); + } + }); + return { server, auditLog }; +} diff --git a/oracledesk-stellar/agents/stellar/signers/policy.ts b/oracledesk-stellar/agents/stellar/signers/policy.ts new file mode 100644 index 0000000..5420243 --- /dev/null +++ b/oracledesk-stellar/agents/stellar/signers/policy.ts @@ -0,0 +1,112 @@ +import { createHash } from "node:crypto"; +import { Address, Operation, Transaction, TransactionBuilder, xdr } from "@stellar/stellar-sdk"; +import type { SignerPolicy } from "./types.js"; + +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; +const HASH = /^[0-9a-f]{64}$/; + +export function hashInvocationArgs(args: readonly xdr.ScVal[]): string { + const length = Buffer.alloc(4); + length.writeUInt32BE(args.length); + const serialized = args.map((arg) => arg.toXDR()); + return createHash("sha256").update(length).update(Buffer.concat(serialized)).digest("hex"); +} + +export interface ValidatedInvocation { + transaction: Transaction; + transactionHash: string; + contractId: string; + functionName: string; + argsHash: string; +} + +export function validateTransaction( + transactionXdr: string, + networkPassphrase: string, + policy: SignerPolicy, +): ValidatedInvocation { + if (networkPassphrase !== policy.networkPassphrase) { + throw new Error("network passphrase does not match signer policy"); + } + + let parsed: ReturnType; + try { + parsed = TransactionBuilder.fromXDR(transactionXdr, networkPassphrase); + } catch { + throw new Error("transaction XDR is invalid or unsupported"); + } + if (!(parsed instanceof Transaction)) { + throw new Error("fee-bump and non-classic transaction envelopes are unsupported"); + } + + const tx = parsed; + if (!policy.allowedSourceAddresses.includes(tx.source)) { + throw new Error("transaction source is not allowed by signer policy"); + } + if (tx.operations.length !== 1) { + throw new Error("only single-operation transactions are supported"); + } + + const operation = tx.operations[0]; + if (operation.type !== "invokeHostFunction") { + throw new Error("only Soroban contract invocation transactions are supported"); + } + if (operation.source && operation.source !== tx.source) { + throw new Error("operation-level source overrides are unsupported"); + } + if (operation.auth && operation.auth.length > 0) { + throw new Error("Soroban authorization entries are unsupported"); + } + + const hostFunction = operation.func; + if (hostFunction.switch() !== xdr.HostFunctionType.hostFunctionTypeInvokeContract()) { + throw new Error("contract creation, WASM upload, and other host functions are unsupported"); + } + + const invoke = hostFunction.invokeContract(); + const contractId = Address.fromScVal(xdr.ScVal.scvAddress(invoke.contractAddress())).toString(); + const rawFunctionName = invoke.functionName(); + const functionName = Buffer.isBuffer(rawFunctionName) ? rawFunctionName.toString("utf8") : rawFunctionName; + if ( + typeof functionName !== "string" || + !IDENTIFIER.test(functionName) || + (Buffer.isBuffer(rawFunctionName) && !Buffer.from(functionName, "utf8").equals(rawFunctionName)) + ) { + throw new Error("contract function name has an unsupported shape"); + } + const argsHash = hashInvocationArgs(invoke.args()); + const invocationAllowed = policy.allowedInvocations.some( + (entry) => + entry.contractId === contractId && + entry.functionName === functionName && + HASH.test(entry.argsHash) && + entry.argsHash === argsHash, + ); + if (!invocationAllowed) { + throw new Error("contract invocation is not explicitly allowed by signer policy"); + } + + return { + transaction: tx, + transactionHash: tx.hash().toString("hex"), + contractId, + functionName, + argsHash, + }; +} + +export function assertSigningRequest( + request: { networkPassphrase?: string; address?: string; submit?: boolean; submitUrl?: string }, + signerAddress: string, + policy: SignerPolicy, +): void { + if (request.networkPassphrase && request.networkPassphrase !== policy.networkPassphrase) { + throw new Error("requested network passphrase does not match signer policy"); + } + if (request.address && request.address !== signerAddress) { + throw new Error("requested signer address does not match signer identity"); + } + if (request.submit === true || request.submitUrl !== undefined) { + throw new Error("signer callbacks must not submit transactions"); + } +} diff --git a/oracledesk-stellar/agents/stellar/signers/remote.ts b/oracledesk-stellar/agents/stellar/signers/remote.ts new file mode 100644 index 0000000..c69158d --- /dev/null +++ b/oracledesk-stellar/agents/stellar/signers/remote.ts @@ -0,0 +1,62 @@ +import type { + AuthEntrySigningProvider, + SignerPolicy, + StellarSigner, + SigningRequest, + SignedTransaction, +} from "./types.js"; +import { assertSigningRequest, validateTransaction } from "./policy.js"; +import { verifySignedTransaction } from "./local.js"; +import { validateAuthEntryPreimage, verifyAuthEntrySignature } from "./auth-entry.js"; + +export interface RemoteSignerOptions { + address: string; + policy: SignerPolicy; + sign: (request: SigningRequest) => Promise; + signAuthEntry?: AuthEntrySigningProvider["signAuthEntry"]; +} + +export function createRemoteSigner({ + address, + policy, + sign, + signAuthEntry, +}: RemoteSignerOptions): StellarSigner { + return { + address, + async signTransaction(transactionXdr, options) { + assertSigningRequest(options ?? {}, address, policy); + const approved = validateTransaction(transactionXdr, policy.networkPassphrase, policy); + const response = await sign({ + xdr: transactionXdr, + networkPassphrase: policy.networkPassphrase, + address, + }); + if (response.error) throw new Error(`remote signer rejected request: ${response.error.message}`); + if (response.signerAddress !== address) { + throw new Error("remote signer returned a different signer identity"); + } + verifySignedTransaction( + response.signedTxXdr, + approved.transactionHash, + address, + policy.networkPassphrase, + ); + return { signedTxXdr: response.signedTxXdr, signerAddress: address }; + }, + async signAuthEntry(preimageXdr, options) { + if (options?.address && options.address !== address) { + throw new Error("requested signer address does not match signer identity"); + } + validateAuthEntryPreimage(preimageXdr, address, policy); + if (!signAuthEntry) throw new Error("remote auth-entry signing is not configured"); + const response = await signAuthEntry(preimageXdr, { address }); + if (response.error) throw new Error(`remote signer rejected auth entry: ${response.error.message}`); + if (response.signerAddress !== address) { + throw new Error("remote signer returned a different signer identity"); + } + await verifyAuthEntrySignature(preimageXdr, response.signedAuthEntry, address, policy); + return { signedAuthEntry: response.signedAuthEntry, signerAddress: address }; + }, + }; +} diff --git a/oracledesk-stellar/agents/stellar/signers/signers.test.ts b/oracledesk-stellar/agents/stellar/signers/signers.test.ts new file mode 100644 index 0000000..5851458 --- /dev/null +++ b/oracledesk-stellar/agents/stellar/signers/signers.test.ts @@ -0,0 +1,294 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + Account, + Address, + authorizeEntry, + buildAuthorizationEntryPreimage, + Keypair, + Operation, + TransactionBuilder, + xdr, +} from "@stellar/stellar-sdk"; +import { createLocalSigner, verifySignedTransaction } from "./local.js"; +import { createMultisigSigner } from "./multisig.js"; +import { createRemoteSigner } from "./remote.js"; +import { hashInvocationArgs, validateTransaction } from "./policy.js"; +import type { InvocationPolicy, SignerPolicy } from "./types.js"; + +const NETWORK = "Test SDF Network ; September 2015"; +const CONTRACT = "CC3KU5TWAATOBXLWIV4ZTXNEM44VIOY3ZTR7KTJTBOOBP7YIXMCDSWDJ"; +const args = [xdr.ScVal.scvU32(17), xdr.ScVal.scvString("ok")]; + +function makeTransaction(source: Keypair, functionName = "commit", txArgs = args): string { + return new TransactionBuilder(new Account(source.publicKey(), "1"), { + fee: "100", + networkPassphrase: NETWORK, + }) + .addOperation(Operation.invokeContractFunction({ contract: CONTRACT, function: functionName, args: txArgs })) + .setTimeout(60) + .build() + .toXDR(); +} + +function rule(functionName = "commit", txArgs = args): InvocationPolicy { + return { contractId: CONTRACT, functionName, argsHash: hashInvocationArgs(txArgs) }; +} + +function policy(source: string, ...rules: InvocationPolicy[]): SignerPolicy { + return { + networkPassphrase: NETWORK, + allowedSourceAddresses: [source], + allowedInvocations: rules, + }; +} + +test("local signer signs only the exact allowlisted invocation", async () => { + const account = Keypair.random(); + const signer = createLocalSigner({ keypair: account, policy: policy(account.publicKey(), rule()) }); + const approved = makeTransaction(account); + const signed = await signer.signTransaction(approved); + assert.equal(signed.signerAddress, account.publicKey()); + const txHash = validateTransaction(approved, NETWORK, policy(account.publicKey(), rule())).transactionHash; + assert.equal(verifySignedTransaction(signed.signedTxXdr, txHash, account.publicKey(), NETWORK).signatures.length, 1); + assert.ok(signed.signedTxXdr.length > approved.length); + await assert.rejects(() => signer.signTransaction(makeTransaction(account, "withdraw")), /not explicitly allowed/); + await assert.rejects( + () => signer.signTransaction(makeTransaction(account, "commit", [xdr.ScVal.scvU32(18), args[1]])), + /not explicitly allowed/, + ); + await assert.rejects( + () => signer.signTransaction(approved, { submit: true }), + /must not submit/, + ); +}); + +test("transaction validation rejects non-contract and multi-operation envelopes", () => { + const account = Keypair.random(); + const configured = policy(account.publicKey(), rule()); + const operation = Operation.invokeContractFunction({ + contract: CONTRACT, + function: "commit", + args, + }); + const second = new TransactionBuilder(new Account(account.publicKey(), "1"), { + fee: "100", + networkPassphrase: NETWORK, + }) + .addOperation(operation) + .addOperation(operation) + .setTimeout(60) + .build() + .toXDR(); + + assert.throws(() => validateTransaction(second, NETWORK, configured), /single-operation/); +}); + +test("remote signer must return the same transaction with its valid signature", async () => { + const source = Keypair.random(); + const remoteKey = Keypair.random(); + const changedArgs = [xdr.ScVal.scvU32(17), xdr.ScVal.scvString("changed")]; + const configured = policy(source.publicKey(), rule(), rule("commit", changedArgs)); + const localRemote = createLocalSigner({ keypair: remoteKey, policy: configured }); + const remote = createRemoteSigner({ + address: remoteKey.publicKey(), + policy: configured, + async sign(request) { + return localRemote.signTransaction(request.xdr, { + address: remoteKey.publicKey(), + networkPassphrase: request.networkPassphrase, + }); + }, + }); + + const response = await remote.signTransaction(makeTransaction(source)); + assert.equal(response.signerAddress, remoteKey.publicKey()); + + const altered = createRemoteSigner({ + address: remoteKey.publicKey(), + policy: configured, + async sign() { + const changed = makeTransaction(source, "commit", changedArgs); + return localRemote.signTransaction(changed); + }, + }); + await assert.rejects(() => altered.signTransaction(makeTransaction(source)), /signer changed/); +}); + +test("multisig merges distinct verified participant signatures to threshold", async () => { + const source = Keypair.random(); + const configured = policy(source.publicKey(), rule()); + const keys = [Keypair.random(), Keypair.random(), Keypair.random()]; + const participants = keys.map((keypair) => createLocalSigner({ keypair, policy: configured })); + const multisig = createMultisigSigner({ + address: source.publicKey(), + participants, + threshold: 2, + policy: configured, + }); + + const unsigned = makeTransaction(source); + const expectedHash = validateTransaction(unsigned, NETWORK, configured).transactionHash; + for (const participant of participants.slice(0, 2)) { + const participantResult = await participant.signTransaction(unsigned, { + address: participant.address, + networkPassphrase: NETWORK, + }); + assert.equal(participantResult.signerAddress, participant.address); + assert.doesNotThrow(() => + verifySignedTransaction(participantResult.signedTxXdr, expectedHash, participant.address, NETWORK), + ); + } + const result = await multisig.signTransaction(unsigned); + assert.equal(result.signerAddress, source.publicKey()); + const signed = TransactionBuilder.fromXDR(result.signedTxXdr, NETWORK); + assert.equal(signed.signatures.length, 2); + assert.throws( + () => createMultisigSigner({ address: source.publicKey(), participants, threshold: 4, policy: configured }), + /threshold/, + ); +}); + +test("local signer exposes the policy-checked Soroban auth-entry callback", () => { + const account = Keypair.random(); + const signer = createLocalSigner({ keypair: account, policy: policy(account.publicKey(), rule()) }); + assert.equal(typeof signer.signAuthEntry, "function"); +}); + +function makeAuthEntry(address: string, functionName = "commit", txArgs = args) { + const invocation = new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: Address.fromString(CONTRACT).toScAddress(), + functionName, + args: txArgs, + }), + ), + subInvocations: [], + }); + return new xdr.SorobanAuthorizationEntry({ + rootInvocation: invocation, + credentials: xdr.SorobanCredentials.sorobanCredentialsAddressV2( + new xdr.SorobanAddressCredentials({ + address: Address.fromString(address).toScAddress(), + nonce: new xdr.Int64(9n), + signatureExpirationLedger: 0, + signature: xdr.ScVal.scvVec([]), + }), + ), + }); +} + +test("local auth-entry signer signs the SDK-generated, address-bound authorization preimage", async () => { + const keypair = Keypair.random(); + const configured = policy(keypair.publicKey(), rule()); + const entry = makeAuthEntry(keypair.publicKey()); + const expiration = 12345; + const preimage = buildAuthorizationEntryPreimage(entry, expiration, NETWORK); + const preimageXdr = preimage.toXDR("base64"); + const signer = createLocalSigner({ keypair, policy: configured }); + const result = await signer.signAuthEntry!(preimageXdr, { address: keypair.publicKey() }); + assert.equal(result.signerAddress, keypair.publicKey()); + + const signed = await authorizeEntry( + entry, + async (sdkPreimage, payload) => { + assert.deepEqual(sdkPreimage.toXDR(), preimage.toXDR()); + return keypair.verify(payload, Buffer.from(result.signedAuthEntry, "base64")) + ? Buffer.from(result.signedAuthEntry, "base64") + : Buffer.alloc(64); + }, + expiration, + NETWORK, + ); + assert.equal(signed.credentials().addressV2().signatureExpirationLedger(), expiration); + assert.equal( + signed.credentials().addressV2().signature().vec()?.length, + 1, + ); + + await assert.rejects( + () => signer.signAuthEntry!(entry.toXDR("base64"), { address: keypair.publicKey() }), + /HashIdPreimage/, + ); + await assert.rejects( + () => + signer.signAuthEntry!( + buildAuthorizationEntryPreimage(entry, expiration, "Public Global Stellar Network ; September 2015") + .toXDR("base64"), + { address: keypair.publicKey() }, + ), + /configured network/, + ); +}); + +test("remote auth-entry signer verifies its response with the SDK authorization helper", async () => { + const remoteKey = Keypair.random(); + const configured = policy(remoteKey.publicKey(), rule()); + const localSigner = createLocalSigner({ keypair: remoteKey, policy: configured }); + const remote = createRemoteSigner({ + address: remoteKey.publicKey(), + policy: configured, + async sign(request) { + return { signedTxXdr: request.xdr, signerAddress: remoteKey.publicKey() }; + }, + signAuthEntry: (preimageXdr, options) => localSigner.signAuthEntry!(preimageXdr, options), + }); + const entry = makeAuthEntry(remoteKey.publicKey()); + const preimageXdr = buildAuthorizationEntryPreimage(entry, 12345, NETWORK).toXDR("base64"); + const result = await remote.signAuthEntry!(preimageXdr, { address: remoteKey.publicKey() }); + assert.equal(result.signerAddress, remoteKey.publicKey()); + await assert.rejects( + () => + createRemoteSigner({ + address: remoteKey.publicKey(), + policy: configured, + async sign(request) { + return { signedTxXdr: request.xdr, signerAddress: remoteKey.publicKey() }; + }, + async signAuthEntry() { + return { signedAuthEntry: Buffer.alloc(64).toString("base64"), signerAddress: remoteKey.publicKey() }; + }, + }).signAuthEntry!(preimageXdr), + /invalid or mismatched signature/, + ); +}); + +test("auth-entry signer rejects address mismatch, nested invocations, and unallowlisted calls", async () => { + const keypair = Keypair.random(); + const other = Keypair.random(); + const configured = policy(keypair.publicKey(), rule()); + const signer = createLocalSigner({ keypair, policy: configured }); + const expiration = 12345; + + const wrongAddress = makeAuthEntry(other.publicKey()); + const wrongAddressPreimage = buildAuthorizationEntryPreimage(wrongAddress, expiration, NETWORK); + await assert.rejects( + () => signer.signAuthEntry!(wrongAddressPreimage.toXDR("base64")), + /different address/, + ); + + const nested = makeAuthEntry(keypair.publicKey()); + nested.rootInvocation().subInvocations([ + new xdr.SorobanAuthorizedInvocation({ + function: nested.rootInvocation().function(), + subInvocations: [], + }), + ]); + await assert.rejects( + () => + signer.signAuthEntry!( + buildAuthorizationEntryPreimage(nested, expiration, NETWORK).toXDR("base64"), + ), + /nested/, + ); + + const unapproved = makeAuthEntry(keypair.publicKey(), "withdraw"); + await assert.rejects( + () => + signer.signAuthEntry!( + buildAuthorizationEntryPreimage(unapproved, expiration, NETWORK).toXDR("base64"), + ), + /not explicitly allowed/, + ); +}); diff --git a/oracledesk-stellar/agents/stellar/signers/types.ts b/oracledesk-stellar/agents/stellar/signers/types.ts new file mode 100644 index 0000000..8fbfbf6 --- /dev/null +++ b/oracledesk-stellar/agents/stellar/signers/types.ts @@ -0,0 +1,64 @@ +import type { Signer } from "@stellar/stellar-sdk/contract"; + +export interface InvocationPolicy { + contractId: string; + functionName: string; + /** SHA-256 of the length-prefixed concatenation of each argument's XDR. */ + argsHash: string; +} + +export interface SignerPolicy { + networkPassphrase: string; + allowedSourceAddresses: readonly string[]; + allowedInvocations: readonly InvocationPolicy[]; +} + +export interface SigningRequest { + xdr: string; + networkPassphrase: string; + address: string; + submit?: boolean; + submitUrl?: string; +} + +export interface SignedTransaction { + signedTxXdr: string; + signerAddress?: string; + error?: { message: string; code: number; ext?: string[] }; +} + +export interface TransactionSigningProvider { + readonly address: string; + signTransaction( + transactionXdr: string, + options?: { + networkPassphrase?: string; + address?: string; + submit?: boolean; + submitUrl?: string; + }, + ): Promise; +} + +export interface AuthEntrySigningProvider { + readonly address: string; + signAuthEntry( + preimageXdr: string, + options?: { address?: string }, + ): Promise<{ signedAuthEntry: string; signerAddress?: string; error?: { message: string; code: number } }>; +} + +export type StellarSigner = Signer; + +export interface AuditEvent { + timestamp: string; + action: "allow" | "deny"; + signerAddress: string; + transactionHash?: string; + reason?: string; +} + +export interface AuditLog { + append(event: AuditEvent): void; + entries(): readonly AuditEvent[]; +} diff --git a/oracledesk-stellar/agents/stellar/submit.test.ts b/oracledesk-stellar/agents/stellar/submit.test.ts new file mode 100644 index 0000000..2a2412d --- /dev/null +++ b/oracledesk-stellar/agents/stellar/submit.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Account, Asset, Keypair, Operation, TransactionBuilder, type Transaction } from "@stellar/stellar-sdk"; +import { TransactionJournal } from "./journal.js"; +import { submitSorobanTransaction, transactionFromJournal } from "./submit.js"; +import { SorobanPendingError, SorobanSubmissionError } from "./errors.js"; + +function makeTransaction(): Transaction { + return { + toXDR: () => "signed-envelope-xdr", + hash: () => Buffer.from("ab".repeat(32), "hex"), + } as unknown as Transaction; +} + +function makeSuccess(hash: string) { + return { status: "SUCCESS", txHash: hash } as never; +} + +function makeNotFound(hash: string) { + return { status: "NOT_FOUND", txHash: hash } as never; +} + +function makeJournalPath(): string { + return join(process.cwd(), `.transaction-submit-${randomUUID()}.jsonl`); +} + +test("bounded retries resend the exact transaction and eventually confirm it", async () => { + const path = makeJournalPath(); + const journal = new TransactionJournal(path); + const transaction = makeTransaction(); + const sent: Transaction[] = []; + let polls = 0; + const server = { + async sendTransaction(tx: Transaction) { + sent.push(tx); + return { status: sent.length === 1 ? "TRY_AGAIN_LATER" : "PENDING", hash: "ab".repeat(32) } as never; + }, + async getTransaction() { + polls += 1; + return polls === 1 ? makeNotFound("ab".repeat(32)) : makeSuccess("ab".repeat(32)); + }, + }; + try { + const result = await submitSorobanTransaction({ + id: "trade-1", + transaction, + server, + journal, + maxAttempts: 3, + maxPollsPerAttempt: 2, + retryDelayMs: 0, + sleep: async () => undefined, + }); + assert.equal(result.status, "SUCCESS"); + assert.equal(sent.length, 2); + assert.ok(sent.every((tx) => tx === transaction)); + assert.deepEqual((await journal.readAll()).map(({ stage }) => stage), [ + "signed", + "submitting", + "submitting", + "submitted", + "confirmed", + ]); + } finally { + await rm(path, { force: true }); + } +}); + +test("submission exhaustion reports that the signed hash may still land", async () => { + const path = makeJournalPath(); + const journal = new TransactionJournal(path); + const tx = makeTransaction(); + const sent: Transaction[] = []; + const server = { + async sendTransaction(transaction: Transaction) { + sent.push(transaction); + throw new Error("connection reset"); + }, + async getTransaction() { + return makeNotFound("ab".repeat(32)); + }, + }; + try { + await assert.rejects( + submitSorobanTransaction({ + id: "trade-2", + transaction: tx, + server, + journal, + maxAttempts: 2, + retryDelayMs: 0, + sleep: async () => undefined, + }), + (error: unknown) => + error instanceof SorobanPendingError && + error.transactionHash === "ab".repeat(32) && + /may still land/.test(error.message), + ); + assert.equal(sent.length, 2); + assert.ok(sent.every((sentTx) => sentTx === tx)); + assert.equal((await journal.readAll()).at(-1)?.stage, "uncertain"); + } finally { + await rm(path, { force: true }); + } +}); + +test("finalized failures are not retried", async () => { + const path = makeJournalPath(); + const journal = new TransactionJournal(path); + let sends = 0; + const server = { + async sendTransaction() { + sends += 1; + return { status: "PENDING", hash: "ab".repeat(32) } as never; + }, + async getTransaction() { + return { status: "FAILED", txHash: "ab".repeat(32) } as never; + }, + }; + try { + await assert.rejects( + submitSorobanTransaction({ + id: "trade-3", + transaction: makeTransaction(), + server, + journal, + maxAttempts: 3, + maxPollsPerAttempt: 1, + retryDelayMs: 0, + }), + (error: unknown) => + error instanceof SorobanSubmissionError && + error.transactionHash === "ab".repeat(32) && + error.classification.terminal, + ); + assert.equal(sends, 1); + assert.equal((await journal.readAll()).at(-1)?.stage, "failed"); + } finally { + await rm(path, { force: true }); + } +}); + +test("contract failures reported during send are terminal and are not retried", async () => { + const path = makeJournalPath(); + const journal = new TransactionJournal(path); + let sends = 0; + const server = { + async sendTransaction() { + sends += 1; + return { status: "ERROR", errorResult: "HostError: contract invocation failed" } as never; + }, + async getTransaction() { + return makeNotFound("ab".repeat(32)); + }, + }; + try { + await assert.rejects( + submitSorobanTransaction({ + id: "trade-contract-failure", + transaction: makeTransaction(), + server, + journal, + maxAttempts: 3, + retryDelayMs: 0, + }), + (error: unknown) => + error instanceof SorobanSubmissionError && + error.classification.kind === "contract" && + error.classification.terminal, + ); + assert.equal(sends, 1); + assert.equal((await journal.readAll()).at(-1)?.stage, "failed"); + } finally { + await rm(path, { force: true }); + } +}); + +test("journal recovery restores the exact signed XDR", () => { + const passphrase = "Test SDF Network ; September 2015"; + const keypair = Keypair.random(); + const transaction = new TransactionBuilder(new Account(keypair.publicKey(), "1"), { + fee: "100", + networkPassphrase: passphrase, + }) + .addOperation( + Operation.payment({ + destination: keypair.publicKey(), + asset: Asset.native(), + amount: "1", + }), + ) + .setTimeout(30) + .build(); + transaction.sign(keypair); + const envelopeXdr = transaction.toXDR(); + assert.equal(transactionFromJournal(envelopeXdr, passphrase).toXDR(), envelopeXdr); +}); diff --git a/oracledesk-stellar/agents/stellar/submit.ts b/oracledesk-stellar/agents/stellar/submit.ts new file mode 100644 index 0000000..9f4f5f0 --- /dev/null +++ b/oracledesk-stellar/agents/stellar/submit.ts @@ -0,0 +1,157 @@ +import { rpc, Transaction } from "@stellar/stellar-sdk"; +import { createJournalRecord, type TransactionJournal } from "./journal.js"; +import { classifySorobanError, SorobanPendingError, SorobanSubmissionError } from "./errors.js"; + +export interface SorobanSubmitOptions { + id: string; + transaction: Transaction; + server: Pick; + journal: TransactionJournal; + /** Maximum number of broadcasts of this exact signed envelope. */ + maxAttempts?: number; + /** Number of status polls after an accepted broadcast. */ + maxPollsPerAttempt?: number; + retryDelayMs?: number; + sleep?: (milliseconds: number) => Promise; +} + +export type SorobanSubmitResult = Extract>, { status: "SUCCESS" }>; + +const defaultSleep = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +function errorMessage(value: unknown): string { + if (value instanceof Error) return value.message; + return typeof value === "string" ? value : JSON.stringify(value); +} + +function makeError(value: unknown, hash: string): SorobanSubmissionError { + const classification = classifySorobanError(value); + return new SorobanSubmissionError(classification.message, { ...classification, terminal: true }, { + cause: value, + transactionHash: hash, + }); +} + +/** + * Broadcasts a transaction that is already signed. Every retry serializes the + * same immutable envelope; this function never rebuilds or re-signs it. + */ +export async function submitSorobanTransaction(options: SorobanSubmitOptions): Promise { + const { + id, + transaction, + server, + journal, + maxAttempts = 4, + maxPollsPerAttempt = 4, + retryDelayMs = 250, + sleep = defaultSleep, + } = options; + if (!id) throw new TypeError("transaction journal id is required"); + if (!Number.isInteger(maxAttempts) || maxAttempts < 1) throw new RangeError("maxAttempts must be a positive integer"); + if (!Number.isInteger(maxPollsPerAttempt) || maxPollsPerAttempt < 0) { + throw new RangeError("maxPollsPerAttempt must be a non-negative integer"); + } + + const envelopeXdr = transaction.toXDR(); + const hash = transaction.hash().toString("hex"); + const log = (stage: Parameters[0]["stage"], attempt?: number, detail?: string) => + journal.append(createJournalRecord({ id, stage, hash, envelopeXdr, attempt, detail })); + + await log("signed"); + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + await log("submitting", attempt); + let sendResponse: Awaited> | undefined; + try { + // A fresh SDK wrapper is not constructed here: the signed Transaction + // supplied by the caller is reused for every broadcast attempt. + sendResponse = await server.sendTransaction(transaction); + } catch (error) { + lastError = error; + const final = await lookupFinalized(server, hash); + if (final?.status === "SUCCESS") { + await log("confirmed", attempt); + return final; + } + if (final?.status === "FAILED") { + await log("failed", attempt, "transaction finalized with FAILED status"); + throw makeError(final, hash); + } + if (attempt < maxAttempts) { + await sleep(retryDelayMs); + continue; + } + break; + } + + if (sendResponse.status === "ERROR") { + lastError = sendResponse.errorResult ?? sendResponse; + const classification = classifySorobanError(lastError); + await log("failed", attempt, classification.message); + throw new SorobanSubmissionError(`Soroban RPC rejected transaction ${hash}: ${classification.message}`, classification, { + cause: lastError, + transactionHash: hash, + }); + } + + if (sendResponse.status === "PENDING" || sendResponse.status === "DUPLICATE") { + await log("submitted", attempt, sendResponse.status); + for (let poll = 0; poll < maxPollsPerAttempt; poll += 1) { + if (poll > 0 || retryDelayMs > 0) await sleep(retryDelayMs); + try { + const result = await server.getTransaction(hash); + if (result.status === "SUCCESS") { + await log("confirmed", attempt); + return result; + } + if (result.status === "FAILED") { + await log("failed", attempt, "transaction finalized with FAILED status"); + throw makeError(result, hash); + } + } catch (error) { + if (error instanceof SorobanSubmissionError) throw error; + lastError = error; + } + } + } else { + // TRY_AGAIN_LATER means this send was not accepted. Repeating the same + // signed envelope is safe and preserves its transaction hash. + lastError = new Error(`Soroban RPC returned ${sendResponse.status}`); + } + if (attempt < maxAttempts) await sleep(retryDelayMs); + } + + await log("uncertain", maxAttempts, lastError === undefined ? undefined : errorMessage(lastError)); + throw new SorobanPendingError( + `Transaction ${hash} was not confirmed after ${maxAttempts} bounded submission attempts; its signed envelope may still land. Resume by resending the same envelope or checking this hash.`, + hash, + { cause: lastError }, + ); +} + +/** + * Resumes a journaled envelope after restart. It restores the exact signed + * XDR, rather than rebuilding an equivalent transaction with a new sequence or + * hash. + */ +export function transactionFromJournal( + envelopeXdr: string, + networkPassphrase: string, +): Transaction { + return new Transaction(envelopeXdr, networkPassphrase); +} + +async function lookupFinalized( + server: Pick, + hash: string, +): Promise> | undefined> { + try { + const result = await server.getTransaction(hash); + return result.status === "NOT_FOUND" ? undefined : result; + } catch { + return undefined; + } +} diff --git a/oracledesk-stellar/contracts/oracledesk-types/src/lib.rs b/oracledesk-stellar/contracts/oracledesk-types/src/lib.rs index 4662608..6ee8830 100644 --- a/oracledesk-stellar/contracts/oracledesk-types/src/lib.rs +++ b/oracledesk-stellar/contracts/oracledesk-types/src/lib.rs @@ -24,7 +24,7 @@ //! `contracts/resolver`'s `shared_types_are_byte_identical_to_imported` test //! pins that byte-for-byte. -use soroban_sdk::{Address, BytesN, Symbol, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, Symbol, Vec}; // --------------------------------------------------------------------------- // market-core outcome surface diff --git a/oracledesk-stellar/contracts/resolver/build.rs b/oracledesk-stellar/contracts/resolver/build.rs index b2eb122..f2d5225 100644 --- a/oracledesk-stellar/contracts/resolver/build.rs +++ b/oracledesk-stellar/contracts/resolver/build.rs @@ -1,8 +1,9 @@ // Pins the vendored cross-contract wasm to its committed sha256 (issue #58), // so a stale copy is a compile error rather than a silent old interface. -include!("../../scripts/verify-wasm-hash.rs"); +#[path = "../../scripts/verify-wasm-hash.rs"] +mod verify_wasm_hash; fn main() { - verify_wasm_hash("market_core.wasm", "market_core.wasm.sha256"); - verify_wasm_hash("reasoning_registry.wasm", "reasoning_registry.wasm.sha256"); + verify_wasm_hash::verify_wasm_hash("market_core.wasm", "market_core.wasm.sha256"); + verify_wasm_hash::verify_wasm_hash("reasoning_registry.wasm", "reasoning_registry.wasm.sha256"); } diff --git a/oracledesk-stellar/contracts/treasury/build.rs b/oracledesk-stellar/contracts/treasury/build.rs index 5c7129b..b379df3 100644 --- a/oracledesk-stellar/contracts/treasury/build.rs +++ b/oracledesk-stellar/contracts/treasury/build.rs @@ -1,7 +1,8 @@ // Pins the vendored cross-contract wasm to its committed sha256 (issue #58), // so a stale copy is a compile error rather than a silent old interface. -include!("../../scripts/verify-wasm-hash.rs"); +#[path = "../../scripts/verify-wasm-hash.rs"] +mod verify_wasm_hash; fn main() { - verify_wasm_hash("market_core.wasm", "market_core.wasm.sha256"); + verify_wasm_hash::verify_wasm_hash("market_core.wasm", "market_core.wasm.sha256"); } diff --git a/oracledesk-stellar/contracts/treasury/src/lib.rs b/oracledesk-stellar/contracts/treasury/src/lib.rs index 5fcb763..207bdb2 100644 --- a/oracledesk-stellar/contracts/treasury/src/lib.rs +++ b/oracledesk-stellar/contracts/treasury/src/lib.rs @@ -553,16 +553,16 @@ pub enum Error { YieldWithdrawFailed = 57, VenueHasPrincipal = 58, /// Category exposure would exceed its correlation cap (#49). - CategoryCapExceeded = 44, + CategoryCapExceeded = 59, /// Close-date bucket exposure would exceed its cap (#49). - BucketCapExceeded = 45, + BucketCapExceeded = 60, /// `accept_admin`/`cancel_admin_transfer` called with nothing proposed. - NoPendingAdmin = 46, + NoPendingAdmin = 61, /// A proposed admin handover was not accepted within its `expires_at`. - PendingAdminExpired = 47, + PendingAdminExpired = 62, /// `agent_create_market_and_reveal` was given a malformed resolution /// envelope or a rule outside the two primary modes (#64). - InvalidResolution = 48, + InvalidResolution = 63, } #[contractevent] diff --git a/oracledesk-stellar/docs/agent-signing.md b/oracledesk-stellar/docs/agent-signing.md new file mode 100644 index 0000000..e05c67c --- /dev/null +++ b/oracledesk-stellar/docs/agent-signing.md @@ -0,0 +1,81 @@ +# Agent signer primitives + +The signer factories in `agents/stellar/signers/` implement the Stellar SDK +16 `contract.Signer` callback shape. `StellarAdapter` accepts one of these +signers through its `signer` option in live mode, and uses `signer.address` as +the acting agent. The signer address must match `agentAddress` (or the +deployment's default agent). The existing CLI wiring still selects local +`agentSecret` keys; remote or multisig signers must currently be supplied by a +programmatic adapter caller. The signers are deliberately narrower than a +general-purpose wallet: + +- A `SignerPolicy` must enumerate allowed transaction source addresses and + exact allowed Soroban contract/function/argument combinations. Each + `argsHash` is the SHA-256 hash of a four-byte big-endian argument count + followed by each argument's `ScVal` XDR bytes; use `hashInvocationArgs` to + calculate it. +- Only a classic transaction with exactly one `invokeContract` host-function + operation and no Soroban authorization entries is accepted. Fee bumps, + contract creation, WASM upload, other operation types, and unsupported + invocation shapes are rejected. +- Local signers use the SDK's `KeypairSigner` for transaction signatures; remote signers require an + injected signing transport and verify that its response preserves the + approved transaction and contains a valid signature from the expected + Ed25519 address. Multisig gathers and merges participant transaction + signatures until its configured threshold is met. +- Local and remote signers support SDK auth-entry callbacks only for + address-bound Soroban authorization preimages. They parse the supplied + `HashIdPreimage`, require the preimage address to match the signer, rebuild + it with `buildAuthorizationEntryPreimage`, and sign/verify through the SDK's + `authorizeEntry` helper. Legacy address-less auth preimages, nested + invocations, contract-creation authorization, other networks, and calls not + in the invocation allowlist are rejected. Multisig explicitly rejects + auth-entry signing because the SDK helper accepts a single Ed25519 + signature, not a verifiable multisig threshold. +- `createReferencePolicyServer` is a minimal HTTP example (`POST /sign`) for + trusted environments, not a production security boundary. Put it behind + authenticated, encrypted transport and add durable audit storage, rate + limits, and operational controls before deployment. The sample audit log + records decision metadata and transaction hashes, never raw transaction + XDR. + +## Adapter callback integration + +Pass a signer to `new StellarAdapter({ mode: "live", signer, ... })`. The +adapter sets the generated-client `publicKey` from `signer.address` and passes +the `contract.Signer` object to `Client.from`. The signer callback receives +base64 transaction-envelope XDR plus optional +`networkPassphrase`/`address`/`submit`/`submitUrl`, and returns +`{ signedTxXdr, signerAddress? }` (or an SDK wallet error). This implementation +requires the supplied network and address to match policy/identity, forbids +submission options, and validates before signing. + +`signAuthEntry` is a separate callback used for non-invoker Soroban +authorization. Its input is the SDK-generated base64 `HashIdPreimage` XDR, +not a raw `SorobanAuthorizationEntry`; it returns the base64 Ed25519 +signature over the SDK-generated network-bound payload. Adapter integration +should keep the existing dry-run behavior and avoid handing secrets to +dry-run mode. + +## Exported signer APIs + +The signer package currently exports factory functions (not concrete signer +classes): + +```ts +createLocalSigner(options: LocalSignerOptions): StellarSigner +createRemoteSigner(options: RemoteSignerOptions): StellarSigner +createMultisigSigner(options: MultisigSignerOptions): StellarSigner +createReferencePolicyServer(options: PolicyServerOptions): ReferencePolicyServer +``` + +`LocalSignerOptions` is `{ keypair: Keypair; policy: SignerPolicy }`. +`RemoteSignerOptions` is `{ address: string; policy: SignerPolicy; +sign: (request: SigningRequest) => Promise; +signAuthEntry?: AuthEntrySigningProvider["signAuthEntry"] }`. +`MultisigSignerOptions` is `{ address: string; +participants: readonly TransactionSigningProvider[]; threshold: number; +policy: SignerPolicy }`. `StellarSigner` aliases SDK 16's `contract.Signer`. +`TransactionSigningProvider` exposes `address` and +`signTransaction(transactionXdr, options?)`; `AuthEntrySigningProvider` +exposes `address` and `signAuthEntry(preimageXdr, options?)`. diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index d6d88d6..79d6938 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -872,6 +872,23 @@ no admin. `min_shares_out` / `limit_price_bps` bounds plus a deadline from it. - **packages/bindings/**: generated TypeScript clients (`scripts/gen-bindings.sh`). +### Trader sizing + +The trader's optional sized path computes an impact limit by binary-searching +the integer `calcBuy` curve, so it respects the contract's fee and +pool-favoring rounding rather than a floating-point reserve approximation. +It shrinks the fair-value probability by `FAIR_UNCERTAINTY_BPS`, maximizes +expected binary-settlement log wealth with existing YES/NO holdings, and +applies the rational `KELLY_FRACTION` (default 0.25). The result is further +bounded by `TRADE_SIZE_STROOPS`, treasury market/daily/capital headroom and +the market's `MAX_SETS` capacity. A report containing the inputs, intermediate +limits and binding constraint is included in the reasoning trace. `SLIPPAGE_BPS` +sets the minimum share output from the exact integer quote, while the trader +also re-quotes immediately before submission and applies a deadline. + +The limit protects the estimated post-trade marginal price from crossing +`effective fair value - SIZING_BUFFER_BPS`; it cannot guarantee a fill if +another transaction changes the pool between the snapshot and execution. **Trade bounds and circuit breakers.** Two layers of protection beyond the share-count slippage parameters: diff --git a/oracledesk-stellar/packages/bindings/treasury/src/index.ts b/oracledesk-stellar/packages/bindings/treasury/src/index.ts index a26a953..2c293a9 100644 --- a/oracledesk-stellar/packages/bindings/treasury/src/index.ts +++ b/oracledesk-stellar/packages/bindings/treasury/src/index.ts @@ -84,24 +84,24 @@ export const Errors = { /** * Category exposure would exceed its correlation cap (#49). */ - 44: {message:"CategoryCapExceeded"}, + 59: {message:"CategoryCapExceeded"}, /** * Close-date bucket exposure would exceed its cap (#49). */ - 45: {message:"BucketCapExceeded"}, + 60: {message:"BucketCapExceeded"}, /** * `accept_admin`/`cancel_admin_transfer` called with nothing proposed. */ - 46: {message:"NoPendingAdmin"}, + 61: {message:"NoPendingAdmin"}, /** * A proposed admin handover was not accepted within its `expires_at`. */ - 47: {message:"PendingAdminExpired"}, + 62: {message:"PendingAdminExpired"}, /** * `agent_create_market_and_reveal` was given a malformed resolution * envelope or a rule outside the two primary modes (#64). */ - 48: {message:"InvalidResolution"} + 63: {message:"InvalidResolution"} }