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
12 changes: 12 additions & 0 deletions oracledesk-stellar/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
STELLAR_NETWORK=testnet
STELLAR_RPC_URL=https://soroban-testnet.stellar.org

# Portion of each trading fee kept by the protocol treasury (bps), set on
# market-core at deploy time. The remainder accrues to the market creator
# and is claimable before resolution (see docs/architecture.md). 10000
# keeps everything protocol-side, matching pre-#8 deployments.
PROTOCOL_FEE_SHARE_BPS=10000

# Identities used by scripts/deploy-testnet.sh, scripts/seed-market.sh, and
# the agents (see agents/stellar/). Generate with:
# stellar keys generate oracledesk-deployer --network testnet --fund
Expand Down Expand Up @@ -40,3 +46,9 @@ AGENT_MODE=dry-run
# Optional: an off-chain reference price source for the trader's fair-value
# estimate. Read-only; never used for execution (see docs/architecture.md).
POLYMARKET_READ_ONLY_API_URL=
# Execution bounds for real trades (agents/bin/trader.ts). The trader quotes
# first, then sends min_shares_out / limit_price_bps derived from the quote:
# SLIPPAGE_BPS is how far below the quoted average price the fill may land,
# DEADLINE_SECS is how long the signed trade stays valid after submission.
SLIPPAGE_BPS=50
DEADLINE_SECS=60
28 changes: 26 additions & 2 deletions oracledesk-stellar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,29 @@ transactions using two throwaway identities (`oracledesk-deployer`,
`oracledesk-agent`) that `scripts/deploy-testnet.sh` generates and funds via
`stellar keys generate --fund`. Nothing here ever touches mainnet.

## Treasury migration

The treasury has no upgrade path or config setters — fixing a bug, changing
caps, or rotating the agent means deploying a **new** treasury. Deploying
one strands nothing: the old treasury's positions and LP ownership can be
handed to a successor market by market:

```bash
make build # treasury wasm must be current
scripts/migrate-treasury.sh # deploys a successor + migrates every market
```

The script reads `deployments/testnet.json` and the old treasury's live
config, deploys a new treasury with the same agent/collateral/caps, records
it as the old treasury's successor (`set_successor`, admin), then calls
`migrate_market(i)` for every `i` in `0..market_count`. Both steps are
admin-gated — the agent cannot invoke them — and the destination is fixed
to the recorded successor, so migration cannot send value anywhere else.
Re-running is safe. It deliberately does **not** rewire market-core
(`set_treasury`); that cutover changes the fee recipient for future markets
and stays a separate operator step (the script prints the exact command).
See `docs/architecture.md` for the full auth model.

## Repository layout

```
Expand All @@ -96,8 +119,9 @@ x402/ the paid trace API (Express + @x402/*)
agents/ core/ (chain-agnostic strategy + trace building),
stellar/ (signing/submission adapter), bin/ (CLIs)
packages/bindings/ generated TypeScript contract clients
scripts/ deploy-testnet.sh, seed-market.sh, demo.sh,
gen-bindings.sh, spec-hash/ (a small dev-tool crate)
scripts/ deploy-testnet.sh, migrate-treasury.sh,
seed-market.sh, demo.sh, gen-bindings.sh,
spec-hash/ (a small dev-tool crate)
deployments/ testnet.json — the current live testnet addresses
docs/ architecture, ADRs, frontend-integration, STATUS
```
Expand Down
55 changes: 55 additions & 0 deletions oracledesk-stellar/agents/bin/trader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { StellarAdapter } from "../stellar/adapter.js";
import { main } from "./trader.js";

/** Placeholder market/fair-value pair the trader reads (yesBps 4000 vs
* fair 6000 => a "buy Yes" decision at the default 500bps edge). */
const FAIR_VALUE_BPS = 6000;
const QUOTE_SHARES = 123_456n;

function fakeAdapter(quoteShares: bigint, calls: Record<string, unknown>[]): StellarAdapter {
return {
mode: "dry-run" as const,
quoteBuy: async () => quoteShares,
agentBuyV2: async (args: Record<string, unknown>) => {
calls.push(args);
return { dryRun: true as const, contractId: "CTREASURY", method: "agent_buy_v2", args };
},
} as unknown as StellarAdapter;
}

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), env, () => 1_000_000);

assert.equal(calls.length, 1);
const args = calls[0];
// Default SLIPPAGE_BPS = 50: quote * (10000 - 50) / 10000, floored.
const minSharesOut = (QUOTE_SHARES * 9_950n) / 10_000n;
assert.equal(args.min_shares_out, minSharesOut);
assert.ok((args.min_shares_out as bigint) > 0n);
// Never pay more than fair for the outcome being bought: this is a YES
// buy, so the limit is the fair YES value itself.
assert.equal(args.limit_price_bps, FAIR_VALUE_BPS);
// Default DEADLINE_SECS = 60, measured from the injected clock.
assert.equal(args.deadline, 1_000_060n);
});

test("trader honours SLIPPAGE_BPS and DEADLINE_SECS overrides", async () => {
const calls: Record<string, unknown>[] = [];
const env = {
TRACE_STORE_DIR: await mkdtemp(join(tmpdir(), "trader-test-")),
SLIPPAGE_BPS: "100",
DEADLINE_SECS: "30",
};
await main(fakeAdapter(QUOTE_SHARES, calls), env, () => 2_000_000);

const args = calls[0];
assert.equal(args.min_shares_out, (QUOTE_SHARES * 9_900n) / 10_000n);
assert.equal(args.deadline, 2_000_030n);
});
96 changes: 72 additions & 24 deletions oracledesk-stellar/agents/bin/trader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@
* fair-value estimate, and trades the mispricing if the edge clears the
* configured threshold. Every trade is preceded by a recorded trace.
*
* Bounds are never zero: the trade size comes from the strategy, then a
* fresh `quote_buy` immediately before submission turns into
* `min_shares_out` (quote minus SLIPPAGE_BPS) and a `limit_price_bps`
* backstop, plus a `deadline` — so a stale or sandwiched transaction
* reverts on-chain instead of executing at an arbitrary price.
*
* Defaults to dry-run. Set AGENT_MODE=live and AGENT_SECRET_KEY to submit
* real testnet transactions — see .env.example.
*/
import "dotenv/config";
import { pathToFileURL } from "node:url";
import { buildTrace, LocalTraceStore } from "../core/trace.js";
import { BPS } from "../core/fpmm.js";
import { decideTrade, type MarketState } from "../core/strategy.js";
import { loadDeployments, StellarAdapter } from "../stellar/adapter.js";

Expand All @@ -22,23 +30,22 @@ async function fetchMarketAndFairValue(): Promise<{ market: MarketState; fairVal
};
}

async function main() {
const mode = (process.env.AGENT_MODE as "dry-run" | "live") ?? "dry-run";
const deployments = loadDeployments();
const adapter = new StellarAdapter({
mode,
networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015",
rpcUrl: process.env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org",
agentSecret: process.env.AGENT_SECRET_KEY,
deployments,
});
const traceStore = new LocalTraceStore(process.env.TRACE_STORE_DIR ?? "./.traces");
/**
* Exported (and injectable) so tests can drive the whole trade flow with a
* fake adapter and clock. `now` returns unix seconds.
*/
export async function main(
adapter: StellarAdapter,
env: NodeJS.ProcessEnv = process.env,
now: () => number = () => Math.floor(Date.now() / 1000),
): Promise<void> {
const traceStore = new LocalTraceStore(env.TRACE_STORE_DIR ?? "./.traces");

console.log(`[trader] mode=${mode}`);
console.log(`[trader] mode=${adapter.mode}`);
const { market, fairValueBps } = await fetchMarketAndFairValue();
const decision = decideTrade(market, fairValueBps, {
minEdgeBps: Number(process.env.MIN_EDGE_BPS ?? 500),
tradeSizeStroops: BigInt(process.env.TRADE_SIZE_STROOPS ?? "100000000"),
minEdgeBps: Number(env.MIN_EDGE_BPS ?? 500),
tradeSizeStroops: BigInt(env.TRADE_SIZE_STROOPS ?? "100000000"),
});
console.log(`[trader] ${decision.action}: ${decision.reasoning}`);
if (decision.action === "skip") return;
Expand All @@ -53,17 +60,45 @@ async function main() {
const { cid } = await traceStore.put(built.hash, built.json);
console.log(`[trader] trace ${built.hash} -> ${cid}`);

const result = await adapter.agentBuy({
const outcome = { tag: decision.outcome!, values: undefined } as {
tag: "Yes" | "No";
values: void;
};
const collateralInStroops = decision.collateralInStroops!;

// Fresh quote -> non-zero bounds. The quote is a read-only simulation,
// so this runs in dry-run mode too and the same code path is exercised
// before any real submission.
const quotedShares = await adapter.quoteBuy({
market_id: BigInt(market.marketId),
outcome: { tag: decision.outcome!, values: undefined },
collateral_in: decision.collateralInStroops!,
min_shares_out: 0n,
outcome,
collateral_in: collateralInStroops,
});
console.log("[trader] agent_buy:", result);
const slippageBps = BigInt(env.SLIPPAGE_BPS ?? 50);
const minSharesOut = (quotedShares * (BPS - slippageBps)) / BPS;
// Never pay more than fair for the outcome being bought: for a YES buy
// that is `fairValueBps`; for a NO buy it is the complement, since the
// contract compares the limit against the average price of the outcome
// being bought (the NO price).
const limitPriceBps = decision.outcome === "Yes" ? fairValueBps : Number(BPS) - fairValueBps;
const deadline = BigInt(now() + Number(env.DEADLINE_SECS ?? 60));
console.log(
`[trader] quote ${quotedShares} shares -> min_shares_out=${minSharesOut} limit_price_bps=${limitPriceBps} deadline=${deadline}`,
);

const result = await adapter.agentBuyV2({
market_id: BigInt(market.marketId),
outcome,
collateral_in: collateralInStroops,
min_shares_out: minSharesOut,
limit_price_bps: limitPriceBps,
deadline,
});
console.log("[trader] agent_buy_v2:", result);

if (!result.dryRun) {
const traceResult = await adapter.publishTrace({
agent: deployments.agent,
agent: loadDeployments().agent,
market_id: BigInt(market.marketId),
action: "buy",
trace_hash: Buffer.from(built.hash, "hex"),
Expand All @@ -73,7 +108,20 @@ async function main() {
}
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
const isMain =
process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
const mode = (process.env.AGENT_MODE as "dry-run" | "live") ?? "dry-run";
const deployments = loadDeployments();
const adapter = new StellarAdapter({
mode,
networkPassphrase: process.env.STELLAR_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015",
rpcUrl: process.env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org",
agentSecret: process.env.AGENT_SECRET_KEY,
deployments,
});
main(adapter).catch((err) => {
console.error(err);
process.exit(1);
});
}
40 changes: 40 additions & 0 deletions oracledesk-stellar/agents/core/fpmm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
BPS,
buyWithinLimit,
calcBuy,
calcSell,
collateralOutForShares,
priceAfter,
priceAfterBuy,
priceAfterSell,
priceYesBps,
seedReserves,
sellWithinLimit,
settleValue,
} from "./fpmm.js";

Expand Down Expand Up @@ -108,3 +113,38 @@ test("collateralOutForShares round-trips through calcSell within one unit of rou
test("BPS matches the contract's basis-point scale", () => {
assert.equal(BPS, 10_000n);
});

test("priceAfter maps both outcomes to the same YES price", () => {
assert.equal(priceAfter(true, 1000n, 1000n), 5000n);
assert.equal(priceAfter(false, 1000n, 1000n), 5000n);
assert.equal(priceAfter(true, 910n, 1100n), priceYesBps(910n, 1100n));
assert.equal(priceAfter(false, 910n, 1100n), priceYesBps(1100n, 910n));
});

test("priceAfterBuy matches the reference example shared with math.rs and the Python model", () => {
// Buying 10 USDC of YES in a 100-sets pool at 50% with a 1% fee moves
// YES from 5000 to 5470 bps.
const q = calcBuy(1_000_000_000n, 1_000_000_000n, 100_000_000n, 100n);
assert.equal(priceYesBps(1_000_000_000n, 1_000_000_000n), 5000n);
assert.equal(priceAfterBuy(true, q), 5470n);
// Selling right back must land below the pre-trade price (two fees).
const s = calcSell(q.newReserveBought, q.newReserveOther, 100_000_000n, 100n);
assert.ok(priceAfterSell(true, s) < 5000n);
});

test("buyWithinLimit and sellWithinLimit mirror the on-chain boundaries", () => {
// Exact equality passes, one unit over fails, in both directions.
assert.equal(buyWithinLimit(100n, 100n, 10_000n), true);
assert.equal(buyWithinLimit(100n, 101n, 10_000n), true);
assert.equal(buyWithinLimit(101n, 100n, 10_000n), false);
assert.equal(buyWithinLimit(100n, 200n, 5_000n), true);
assert.equal(buyWithinLimit(101n, 200n, 5_000n), false);
assert.equal(sellWithinLimit(100n, 100n, 10_000n), true);
assert.equal(sellWithinLimit(99n, 100n, 10_000n), false);
assert.equal(sellWithinLimit(100n, 200n, 5_000n), true);
assert.equal(sellWithinLimit(100n, 201n, 5_000n), false);
// A zero limit is trivially satisfied for sells and binds a buy only at
// a zero average price.
assert.equal(sellWithinLimit(0n, 1n, 0n), true);
assert.equal(buyWithinLimit(1n, 1n, 0n), false);
});
50 changes: 50 additions & 0 deletions oracledesk-stellar/agents/core/fpmm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,53 @@ export function collateralOutForShares(
const gross = grossForShares(rSold, rOther, sharesIn, feeBps);
return mulDivFloor(gross, BPS - feeBps, BPS);
}

/**
* YES price in bps of the pool **after** applying an outcome-relative
* reserve update (the `newReserve*` pair of a BuyQuote/SellQuote). Mirrors
* math.rs::price_after: pass `outcomeIsYes = false` for a NO trade, where
* `rAfter` is the NO reserve.
*/
export function priceAfter(outcomeIsYes: boolean, rAfter: bigint, rOtherAfter: bigint): bigint {
return outcomeIsYes ? priceYesBps(rAfter, rOtherAfter) : priceYesBps(rOtherAfter, rAfter);
}

/**
* Post-trade YES price of a `calcBuy` quote for the outcome the buyer
* receives (`takenIsYes = false` for a NO buy). This is what the per-market
* impact breaker measures against the pre-trade price.
*/
export function priceAfterBuy(takenIsYes: boolean, quote: BuyQuote): bigint {
return priceAfter(takenIsYes, quote.newReserveBought, quote.newReserveOther);
}

/**
* Post-trade YES price of a `calcSell` quote for the outcome the seller
* gave up (`takenIsYes = false` for a NO sell).
*/
export function priceAfterSell(takenIsYes: boolean, quote: SellQuote): bigint {
return priceAfter(takenIsYes, quote.newReserveSold, quote.newReserveOther);
}

/**
* True when a buy's average execution price `collateralIn / sharesOut`
* (fees included) is at most `limitBps` per share. Compared by
* cross-multiplication so rounding cannot flip the decision; exact
* equality passes. Mirrors math.rs::buyWithinLimit / the on-chain
* `buy_v2` limit check.
*/
export function buyWithinLimit(collateralIn: bigint, sharesOut: bigint, limitBps: bigint): boolean {
if (collateralIn < 0n || sharesOut <= 0n) throw new MathError("invalid input");
return collateralIn * BPS <= sharesOut * limitBps;
}

/**
* True when a sell's average execution price `collateralOut / sharesIn`
* is at least `limitBps` per share. Exact equality passes; a zero limit is
* trivially satisfied, which buys the caller no protection. Mirrors
* math.rs::sellWithinLimit / the on-chain `sell_v2` limit check.
*/
export function sellWithinLimit(collateralOut: bigint, sharesIn: bigint, limitBps: bigint): boolean {
if (collateralOut < 0n || sharesIn <= 0n) throw new MathError("invalid input");
return collateralOut * BPS >= sharesIn * limitBps;
}
Loading