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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion oracledesk-stellar/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions oracledesk-stellar/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
3 changes: 2 additions & 1 deletion oracledesk-stellar/agents/bin/agentd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
},
);
Expand Down
3 changes: 2 additions & 1 deletion oracledesk-stellar/agents/bin/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -286,6 +287,6 @@ async function main(): Promise<void> {
}

main().catch((err) => {
console.error("audit failed:", err);
console.error("audit failed:", redactString(err instanceof Error ? err.message : String(err)));
process.exit(1);
});
3 changes: 2 additions & 1 deletion oracledesk-stellar/agents/bin/market-maker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
});
17 changes: 15 additions & 2 deletions oracledesk-stellar/agents/bin/trader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[]): StellarAdapter {
return {
mode: "dry-run" as const,
Expand All @@ -27,7 +40,7 @@ function fakeAdapter(quoteShares: bigint, calls: Record<string, unknown>[]): Ste
test("trader computes non-zero min_shares_out and limit_price_bps from a fresh quote", async () => {
const calls: Record<string, unknown>[] = [];
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];
Expand All @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions oracledesk-stellar/agents/bin/trader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -44,10 +46,11 @@ export async function main(
adapter: StellarAdapter,
run: Tunables,
now: () => number = () => Math.floor(Date.now() / 1000),
dueDiligence?: (marketId: bigint) => Promise<DueDiligenceVerdict>,
): Promise<void> {
const deployments = (adapter as Partial<StellarAdapter>).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),
Expand Down Expand Up @@ -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);
});
}
12 changes: 11 additions & 1 deletion oracledesk-stellar/agents/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
40 changes: 39 additions & 1 deletion oracledesk-stellar/agents/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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`. */
Expand Down Expand Up @@ -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!,
Expand Down Expand Up @@ -538,7 +568,15 @@ export function describeConfig(cfg: AgentConfig): Record<string, unknown> {
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() },
};
}
6 changes: 3 additions & 3 deletions oracledesk-stellar/agents/core/execution-algorithms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
22 changes: 9 additions & 13 deletions oracledesk-stellar/agents/core/execution-algorithms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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));
}
Expand Down Expand Up @@ -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) {
Expand Down
36 changes: 36 additions & 0 deletions oracledesk-stellar/agents/core/redact.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
41 changes: 41 additions & 0 deletions oracledesk-stellar/agents/core/redact.ts
Original file line number Diff line number Diff line change
@@ -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<T>(value: T): T {
const visited = new WeakMap<object, unknown>();
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<string, unknown> = {};
visited.set(current, copy);
for (const [childKey, child] of Object.entries(current)) {
copy[childKey] = visit(child, childKey);
}
return copy;
};
return visit(value) as T;
}
Loading
Loading