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
147 changes: 137 additions & 10 deletions oracledesk-stellar/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ emits `LpTransferred`. Both preserve the solvency invariant by
construction; the position mapping is unchanged down to the "remove
zero entries" rule.

**Runtime enforcement.** The invariant is no longer just a test
property. Every collateral movement in market-core goes through a
checked transfer helper that measures the actual balance delta, and a
global locked-collateral counter is updated on every inflow and outflow.
Every market shares one token balance held at the market-core address, so
the contract no longer trusts that every `token.transfer` moved exactly
the requested amount.

#### Runtime solvency accounting

**Economics.** LP worst-case loss, the rounding argument and the no-arbitrage
theorem for this exact integer math are in [fpmm-economics.md](fpmm-economics.md)
(executable checks: `model/fpmm_bounds.py`).
Expand All @@ -100,7 +110,17 @@ holder, `claim_pool_remainder` for the creator/LP, `sweep_fees`
ownership can change hands at any point via `transfer_position` /
`transfer_lp`.

### resolver
The contract maintains a global counter and a per-market figure:

- `DataKey::TotalLocked` (instance, `i128`, initialised to `0` in the
constructor): the sum over all markets of collateral the contract
still owes (unredeemed claims plus unswept fees).
- `DataKey::Locked(market_id)` (persistent, `ii128`, same TT\ policy as
`Market`): the per-market share of that obligation. It is kept in a
separate entry so the `Market` XDR layout does not change.

Every collateral movement goes through `transfer_in` / `transfer_out`,
which measure the actual balance delta around the transfer:

Independent resolution modes, all ultimately calling
`market_core.resolve(market_id, outcome)`:
Expand Down Expand Up @@ -146,6 +166,15 @@ Independent resolution modes, all ultimately calling
the fallback dispute window closes, `resolve_price` immediately supersedes the proposal
and resolves the market.

- *Inbound* transfers must increase the contract balance by exactly the
requested amount. Any mismatch reverts with `Error::CollateralMismatch`.
- *Outbound* transfers must decrease it by exactly the amount (else
`CollateralMismatch`), and after the decrement of `TotalLocked` the
global guard checks `token.balance(contract) >= TotalLocked`.
Otherwise the call reverts with `Error::Insolvent`.

The accounting rules are exactly:

The resolution lifecycle (both modes, the guardian, expiry and market-core's
status) is model-checked in TLA+; see [formal/README.md](formal/README.md) for
the properties, the findings and the conformance tests that replay the traces.
Expand Down Expand Up @@ -254,7 +283,16 @@ The resolver never depends on `market-core`'s Rust crate β€” see
[ADR 0002](adr/0002-cross-contract-calls.md) for why, and what that trades
off.

### treasury
| Site | Direction | `Locked` / `TotalLocked` delta |
|--------------------------|------------------|------------------------------|
| `create_market` | creator -> contract | `+= seed_amount` |
| `buy` | trader -> contract | `+= collateral_in` (net + fee) |
| `sell` | contract -> trader | `-= collateral_out` |
| `redeem` | contract -> holder | `-= payout` |
| `claim_pool_remainder` | contract -> creator| `-= payout` |
| `sweep_fees` | contract -> recipient| `-= amount` |

Notes on the rules:

Holds the agents' pooled USDC and is the *only* path that spends it: an
agent calls `agent_create_market` / `agent_buy` / `agent_sell` /
Expand Down Expand Up @@ -431,6 +469,21 @@ implemented: it would record provenance only, and the events plus the
successor's own exposure records are enough for an off-chain dashboard
([wave-issue 08](wave-issues/08-treasury-exposure-dashboard.md)).

- `buy` locks `collateral_in = net + fee`, because `net` mints sets and
`fee` accrues to the protocol/LP shares. The fee remains inside
`locked` until `sweep_fees` / `claim_lp_fees` / `claim_pool_remainder`
subtract it β€” it is never double-counted.
- `sell` burns gross sets and pays out `collateral_out`; the fee
`gross - out` stays in the market's fee accrual, and the locked delta is
only the cash actually leaving.
- `claim_pool_remainder` zeroes the reserves and subtracts the payout
from `locked`. After this claim, `sets_minted`+`$fees_accrued` is no
longer the correct figure for what the market still owes; the
solvency report uses `locked` alone in that state.
- `sweep_fees` subtracts the swept amount from `locked`.
- Any negative result returns `Error::Overflow` (with a `debug_assert`
in debug builds).

**Permissionless settlement & exposure release (#45).** Settle market capital
without depending on hot agent keys: `settle_market(caller, market_id)` is
permissionless and idempotent. It redeems the treasury's outcome-share position if
Expand Down Expand Up @@ -476,6 +529,12 @@ is documented at length in `contracts/treasury/src/lib.rs` (see
in the same file's test module β€” a test that's been verified to fail
without the fix and pass with it (see the commit history / docs/STATUS.md).

The global guard is the only check that can be made against the shared
ledger balance: a per-market figure cannot be verified on-chain because
the balance is shared across markets. Donations (anyone sending tokens
directly to the contract) make `balance > TotalLocked`, which is
normal and must not revert.

#### Treasury cap guarantees

What the caps actually promise, as checked by
Expand Down Expand Up @@ -587,13 +646,45 @@ is 9, the same as the treasury's `InsufficientBalance`).

### reasoning-registry

An admin allow-list of agent addresses; a registered agent calls
`publish_trace(agent, market_id, action, trace_hash, cid)` to record that a
trace exists, returning a `trace_id`. `verify_trace(trace_id, candidate_hash)`
is the check a paying subscriber (via `x402/`) or a frontend runs before
trusting fetched trace content. Traces are also indexed per market
(`market_trace_count` / `market_trace_at`) via a counter rather than an
unbounded `Vec`, so the index never has an unbounded-growth storage cost.
**Views.** `solvency_report(market_id)` returns a `SolvencyReport`
with the market's locked collateral, fees accrued, sets minted, and
outstanding-claims upper bound (`sets_minted + fees_accrued` while
open or unclaimed, derived from `locked` alone after the LP claim),
plus `lp_claimed`. `global_solvency()` returns `(token.balance(contract),
TotalLocked)`. The per-market figure is an audit aid, not an enforced
check; the global guard is the enforced one.

**Misbehaving collateral.** A fee-on-transfer or rebasing SEP-41 token, a
Classic asset whose issuer has `AUTH_CLAWBACK_ENABLED` and claws back
from the contract, or a buggy custom collateral each make the contract hold
less than it owes. The balance-delta checks reject fee-on-transfer
collateral at the first inflow (`CollateralMismatch`). If the issuer claws
back between transactions, the next outbound transfer trips `Insolvent`.
That freezes payouts globally β€” intentional fail-safe behaviour, so the
shortfall is visible instead of silently draining other markets.

The contract has no upgrade path today (see `scripts/deploy-testnet.sh`), so
this is a new-deployment-only change: a redeployed market-core starts from
`TotalLocked = 0`. There is deliberately no admin setter to backfill the
counter for pre-existing markets, because that would be an obvious
privileged backdoor.

**Fee split and recipient snapshot.** `create_market` snapshots a
`FeeTerms { recipient, protocol_share_bps }` side entry per market, so
`set_treasury` / `set_protocol_fee_share_bps` only affect markets created
after them. Each trade's fee is split once at accrual time
(`math::split_fee`, protocol shares round up so `protocol + lp == fee`
exactly): the protocol portion accumulates in `Market.fees_accrued` and is
swept to the snapshotted recipient by the permissionless `sweep_fees`(old `FeesSwept` event kept for indexers, plus `FeesSweptTo`); the creator
portion accumulates in a `LpFees(market_id)` side entry, claimable any time
(before finality too) via `claim_lp_fees`, and also drained by
`claim_pool_remainder`. Markets created before this change have no
`FeeTerms` entry and fall back to the old behavior: recipient = whatever
`DataKey::Treasury` Holds at sweep time, `protocol_share_bps = 10_000` β€”
fully backward compatible, no migration transaction needed. The separate
LP balance (rather than adding fees to the reserves) is deliberate: it does
not move `k` or the price and maps directly onto the per-LP fee accumulator
of the future multi-LP design ([wave-issue 02](wave-issues/02-multi-lp-liquidity.md)).

#### Agent Lifecycle and Trace Annotations (#56)

Expand Down Expand Up @@ -712,6 +803,42 @@ no admin.
`min_shares_out` / `limit_price_bps` bounds plus a deadline from it.
- **packages/bindings/**: generated TypeScript clients (`scripts/gen-bindings.sh`).

**Trade bounds and circuit breakers.** Two layers of protection beyond the
share-count slippage parameters:

- *Deadline + limit price* (#12): `buy_v2` / `sell_v2` take a
`deadline` (ledger **timestamp**, unix seconds) checked before anything
else, and a `limit_price_bps` bounding the **average execution price**
(`collateral_in / shares_out` for buys, `collateral_out / shares_in` for
sells, fee included, compared by cross-multiplication). `0` means "no
limit" for buys; for sells it is trivially satisfied and buys no
protection. The original `buy` / `sell` remain as thin wrappers for
backward compatibility (same body, no bounds).
- *Per-market breakers* (#10): `BreakerConfig { max_impact_bps, band_bps }`
is snapshotted per market from an admin default (`set_default_breaker`,
which rejects `band_bps < max_impact_bps` because that makes one trade
cover the whole band). `max_impact_bps` bounds a single trade's
|post βˆ’ pre| YES-price move (`>` β€” a trade moving exactly the cap
passes); `band_bps` bounds |post βˆ’ ledger-open price|, with the reference
stored in short-TTL **temporary** storage keyed by ledger sequence, so it
resets by itself every ledger. 0 disables either check; legacy markets
have no config and are unaffected. Breaker trips revert with
`PriceImpactTooHigh` / `PriceBandExceeded` and β€” because Soroban rolls
back an invocation's events on `Err` β€” emit **no** event (the error codes
and the `would_trip`` / `breaker_config` / `breaker_state` views are the
observability path; see the comment on `check_breakers`).

*Worst-case lock cost.* The band cannot be ratcheted: the reference is
the ledger-open price, so an attacker who pushes the price to the band
edge only blocks further trades for the rest of that ledger (~5 s) β€”
and pays the trading fee plus price impact to do it, which are both
retained by the pool.

**Resolution and payouts.** `resolve(market_id, outcome)` sets the
final outcome once. `redeem` pays `payout` for a winning position and
burns it; `lp_claimed` gates `claim_pool_remainder`, which pays the
creator the residual value of the reserves and zeroes them. Both payouts
are subject to the checked transfer and global solvency guard.

### IPFS trace storage

Expand Down Expand Up @@ -816,4 +943,4 @@ and is disabled if unset. It returns purchase state and transaction/payer fields
with raw trace bytes, response content and requirement keys redacted. The SQLite
file is permissioned to its owner and git-ignored. Back it up as sensitive
purchase history. This design supports one service process; shared multi-instance
locking, refunds and automatic indeterminate reconciliation remain separate work.
locking, refunds and automatic indeterminate reconciliation remain separate work.
11 changes: 5 additions & 6 deletions oracledesk-stellar/packages/bindings/market-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,15 @@ export const Errors = {
* Cumulative same-ledger movement would exceed `band_bps`.
*/
22: {message:"PriceBandExceeded"},
23: {message:"NoPendingWiring"},
24: {message:"InvalidWiring"},
/**
* `accept_admin`/`cancel_admin_transfer` called with nothing proposed.
* A transfer moved a different amount than requested (fee-on-transfer,
* rebasing, or a buggy collateral token).
*/
25: {message:"NoPendingAdmin"},
23: {message:"CollateralMismatch"},
/**
* A proposed admin handover was not accepted within its `expires_at`.
* The contract's collateral balance is below its outstanding obligations.
*/
26: {message:"PendingAdminExpired"}
24: {message:"Insolvent"}
}


Expand Down