Skip to content

feat(market-core,treasury,resolver): refundable creation bond and integrator fees (#23, #24) - #248

Open
anitahhhh wants to merge 11 commits into
OracleDesk:developfrom
anitahhhh:feat/creation-bond-and-integrator-fees-23-24
Open

anitahhhh wants to merge 11 commits into
OracleDesk:developfrom
anitahhhh:feat/creation-bond-and-integrator-fees-23-24

Conversation

@anitahhhh

@anitahhhh anitahhhh commented Oct 22, 2026 •

Copy link
Copy Markdown

Summary

Two protocol-economics features across the three money-moving contracts, both
delivered as storage sidecars with appended DataKey tails and new
entrypoints
, so no existing XDR shape changes:

  • Add a refundable creation bond forfeited when a market is guardian-voided #23 — refundable creation bond. The creator posts bond on top of
    seed_amount in a single atomic transfer. It is refunded on normal
    resolution and on a NoFault void, and forfeited to the fee recipient when
    the guardian voids the market for cause. claim_bond(market_id) is
    permissionless once final so a keeper can drive it.
  • Add integrator (frontend) fees on buy and sell with per-market caps #24 — integrator (frontend) fees. buy_via / sell_via /
    quote_buy_via / quote_sell_via charge an integrator cut bounded by a
    per-market cap snapshotted at creation, accrue it to
    DataKey::IntegratorFees(Address), and pay it out through
    claim_integrator_fees. buy / sell keep their exact prior behaviour as
    the zero-integrator path.

Branch is 11 commits on develop@f1900f4, head 1820f98. This PR was resumed from a
pushed-but-unopened branch
; two defects in the inherited code were found and
fixed here (see Inherited code).

The model/fpmm_model.py mirror is the one part of this PR that ran on this
host and passed
— see The one gate that executed. Nothing else did.

Closes #23
Closes #24


What changed, per contract

File +/- Contents
contracts/market-core/src/lib.rs 1502 / 6 bond config + sidecar, void_market_with_reason, claim_bond, integrator caps + accrual + claim, *_via entrypoints, 4 new errors/events, 33 tests
contracts/market-core/src/math.rs 196 / 12 calc_buy_with / calc_sell_with; calc_buy / calc_sell re-expressed as the zero-integrator calls, 6 tests
contracts/treasury/src/lib.rs 218 / 4 agent_create_market funds and caps the bond, 3 tests
contracts/resolver/src/lib.rs 281 / 1 guardian_void_market_v2, VoidReason mirror, name-dispatch void, GuardianVoided, 4 tests
docs/adr/0010-bond-and-fee-sidecars.md 185 / 0 new ADR
docs/architecture.md 35 / 0 global solvency invariant restated
docs/frontend-integration.md 87 / 0 how to pass an integrator; what to show at creation
model/fpmm_model.py 221 / 0 Python mirror of calc_buy_with / calc_sell_with + 6 checks

No *.wasm, no *.wasm.sha256, no packages/bindings file is touched
(git diff --name-only develop...HEAD | grep -c wasm → 0).

market-core

  • DataKey::CreationBond (instance, default 0) and the per-market snapshot
    DataKey::Bond(u64) -> MarketBond { amount, state, void_reason, forfeit_to }.
    BondState { None, Held, Refunded, Forfeited }.
  • create_market pulls seed_amount + creation_bond in one
    token.transfer (checked_add → Overflow), because treasury's nested-transfer
    pre-authorisation matches exactly one (from, to, amount) triple.
  • void_market(market_id) keeps its pre-Add a refundable creation bond forfeited when a market is guardian-voided #23 semantics by delegating to
    void_inner(.., NoFault); void_market_with_reason(market_id, reason) is the
    new resolver-only entrypoint.
  • claim_bond applies CEI: state flips to Refunded/Forfeited before the
    transfer, so a replay pays nothing; a second call is AlreadyClaimed (17).
    Forfeiture happens only on MarketStatus::Void && void_reason == GuardianFault.
    forfeit_to is snapshotted at void time (falling back to DataKey::Treasury)
    so a later propose_treasury/accept_treasury cannot redirect the forfeit between the void and the
    claim — the issue's edge-case recommendation.
  • Legacy markets have no Bond entry: claim_bond → NoBond (28), and the
    solvency helper reads absent as 0. The upgrade is migration-free.
  • set_max_integrator_fee_bps / get_max_integrator_fee_bps (global, default
    0) and the per-market snapshot DataKey::MarketMaxIntegratorFee(u64), with
    default_fee_bps + bps ≤ MAX_FEE_BPS enforced at the setter and per trade
    (validate_integrator_fee), because the market snapshot is authoritative.
  • accrue_integrator_fee adds to IntegratorFees(integrator) and emits the new
    IntegratorFeeAccrued event rather than changing Trade's field list
    (indexers decode Trade).
  • claim_integrator_fees requires the integrator's auth, removes the key
    before the transfer, and returns Ok(0) on an empty balance.

math.rs — rounding

Buy: both fees are mul_div_ceil, so net = collateral_in − fee − ifee never
exceeds the pool-credit and the trader (who opted into the integrator) eats the
rounding. Sell: gross is ceiled on the combined bps, then
ifee = mul_div_floor(total_fee, int_fee_bps, fee_bps + int_fee_bps) and
fee = total_fee − ifee, so the protocol's leg is never reduced below a
protocol-only computation. The zero-denominator case (fee_bps + int_fee_bps == 0)
returns ifee = 0 instead of dividing.

treasury

agent_create_market reads the bond through a new core_creation_bond name
dispatch, then uses total_outlay = seed_amount + bond for check_trade_size,
ensure_liquid, ensure_unreserved, authorize_token_pull and
reserve_capacity — a single combined authorisation, exactly the amount the
nested transfer pulls. The bond-aware check_trade_size is gated on bond > 0
so the existing agent_create_market_rejects_seed_above_trade_cap (which expects
SeedCapExceeded for seed 151 > max_trade 150) keeps its contract.
reserve_net / record_cost deliberately stay on seed_amount: the bond is not
position cost, and treating a refund as position profit would corrupt NAV.

resolver

guardian_void_market(market_id, reason, at_fault) is added as
guardian_void_market_v2, leaving v1's signature intact, and emits
GuardianVoided { market_id, at_fault }. It reaches market-core through
env.try_invoke_contract::<(), soroban_sdk::Error>(core, &Symbol::new(env, "void_market_with_reason"), args).

Why name dispatch instead of generated clients

Two hard walls, both recorded in docs/adr/0010-bond-and-fee-sidecars.md:

  1. The vendored market_core.wasm blobs are pinned by .sha256 and verified at
    build time by resolver/build.rs and treasury/build.rs; a stale blob is a
    compile error, and rebuilding them requires stellar-cli 27.1.0 while this
    host has 22.0.0, plus a rustc newer than the 1.90.0 pinned by issue Replace vendored market_core.wasm with a deterministic build and hash-pinned import #58.
  2. So a regenerated client is not available, and adding a positional parameter to
    an existing entrypoint would break the three decoders that still hold the old
    spec.

Name dispatch sidesteps both: it needs no client and no blob refresh. The
encoding it relies on is not assumed — #[contracttype] enums whose variants are
all unit with no explicit discriminants encode as ScVal::Symbol("Variant")
(soroban-sdk-macros-27.0.6/derive_enum.rs:370), and
void_reason_encodes_as_plain_symbol_name asserts it directly:

assert_eq!(
    VoidReason::GuardianFault.into_val(&env),
    Symbol::new(&env, "GuardianFault").into_val(&env)
);

The fail-closed direction is deliberate: core_void_with_reason returns
MarketCoreCallFailed (44) rather than silently degrading to a no-fault void, and
core_creation_bond treats a dispatch failure as 0 (the pre-#23 core genuinely
has no bond). Both behaviours are tested.


Acceptance criteria — #23

  • set_creation_bond / get_creation_bond exist; setter is admin-gated with
    a wrong-caller test — wrong_caller_cannot_set_creation_bond.
  • create_market pulls seed + bond atomically and records Bond(id)
    without changing the Market XDR — create_market_pulls_seed_plus_bond;
    Market is byte-for-byte unmodified (verified by reading the struct).
  • void_market_with_reason exists and void_market keeps NoFault —
    bond_refunded_on_plain_void_keeps_no_fault_semantics,
    wrong_caller_cannot_void_with_reason.
  • claim_bond handles every terminal path and cannot run twice —
    bond_refunded_on_resolution, bond_refunded_on_explicit_no_fault_void,
    bond_forfeited_on_guardian_fault_void, claim_bond_twice_fails,
    legacy_market_has_no_bond, claim_pool_remainder_does_not_touch_bond.
  • The resolver guardian void passes the at-fault flag and emits
    GuardianVoided — guardian_void_v2_no_fault_keeps_plain_void_semantics,
    guardian_void_v2_at_fault_forwards_guardian_fault (native
    market-core), guardian_void_v2_shares_validation_and_auth,
    guardian_void_v2_at_fault_fails_closed_on_stale_wasm.
    Deviation: the issue proposed changing guardian_void_market's
    signature. That is a breaking change to a live entrypoint that three
    vendored decoders still hold, so the flag ships as a new v2 entrypoint
    instead, which is this repo's established pattern (buy → buy_v2).
  • Treasury counts the bond toward caps and authorises the combined pull,
    with tests — agent_create_market_counts_bond_toward_caps,
    agent_create_market_rejects_seed_plus_bond_above_trade_cap,
    agent_create_market_funds_no_bond_against_pre_23_core.
    Not done: the optional treasury claim_bond wrapper. The market-core
    entrypoint is permissionless (the issue explicitly allows relying on it),
    so a wrapper would add an auth path with no capability.
  • Multi-market solvency test including held bonds —
    global_solvency_includes_bonds_multi_market over
    test::assert_global_solvency, which sums
    sets_minted + fees_accrued + DataKey::LpFees(id) + held bonds + unclaimed integrator balances across 0..market_count().
  • Vendored market_core.wasm, bindings and docs updated, make test /
    make lint pass — not met, and not achievable on this host. Docs are
    updated (architecture.md, frontend-integration.md, ADR 0010). The wasm
    blobs and generated bindings are deliberately absent; see Refusals and
    Zero execution. Consequence: against the currently vendored blob the
    resolver's at-fault path fails closed instead of voiding. The behaviour is
    correct and tested in both directions, but it is inert until a maintainer
    runs make build and re-pins the hashes.

Acceptance criteria — #24

  • buy_via / sell_via / quote_buy_via / quote_sell_via exist and
    buy / sell behaviour is unchanged — buy and sell are thin
    delegations to do_buy / do_sell with None, and
    buy_via_with_zero_integrator_fee_matches_buy /
    sell_via_with_zero_integrator_fee_matches_sell pin the equivalence.
    "Bindings signatures unchanged" holds trivially because no bindings were
    regenerated.
  • Integrator fees capped by a per-market snapshot; protocol + integrator
    never exceed MAX_FEE_BPS — buy_via_fee_at_exact_market_cap_is_accepted,
    combined_take_at_exact_max_fee_bps_is_accepted,
    sell_via_rejects_fee_above_market_cap,
    max_integrator_fee_snapshot_not_affected_by_admin_change.
    Deviation: the issue proposed appending max_integrator_fee_bps to
    Market. A new Market field changes the XDR that get_market returns
    and that the resolver's stale vendored decoders read, so the cap lives in
    the MarketMaxIntegratorFee(u64) sidecar with absent-means-zero, following
    the existing FeeTerms sidecar precedent. The semantics are identical;
    get_market_max_integrator_fee_bps is the accessor.
  • claim_integrator_fees is integrator-authorised, zeroes before transfer,
    emits an event — claim_integrator_fees_pays_and_zeroes,
    wrong_caller_cannot_claim_integrator_fees,
    claim_integrator_fees_second_call_is_a_noop,
    integrator_fees_accumulate_across_markets.
  • Property tests that the protocol fee is never reduced by rounding and that
    round trips never profit — calc_sell_with_split_never_shortchanges_protocol,
    round_trip_with_integrator_never_profits, and
    calc_buy_with_zero_integrator_matches_calc_buy over 2000 LCG iterations.
  • [~] Python and TypeScript mirrors produce identical known values to Rust —
    Python half done and executed on this host (model/fpmm_model.py gains
    calc_buy_with / calc_sell_with, the Rust literals pinned in
    _check_integrator_known_values, and five sweeps driven by the same LCG
    stream
    math.rs uses, so the two languages cover identical inputs rather
    than independent random ones). python3 model/fpmm_model.py passes — the
    only real execution in this PR, see The one gate that executed.
    TypeScript not done (agents/core/fpmm.ts cannot be type-stripped by
    this host's Node). Also note what "identical" means here: the expected
    values are transcribed from the Rust source, not produced by a differential
    run, because Rust could not execute. scripts/fpmm-diff/ (oracle,
    diff.py dispatch table, vectors.jsonl) is not extended for the new
    functions either — see Refusals.
  • A solvency test includes unclaimed integrator balances —
    solvency_includes_integrator_balances.
  • docs/frontend-integration.md documents how to pass an integrator —
    new "Integrator (frontend) fees" and "Creation bond" sections, including
    the warning that min_shares_out must be derived from quote_buy_via, not
    quote_buy.
  • make test and make lint pass — not run. See Zero execution.

Zero execution — read this before reviewing

No Rust in this PR was compiled, type-checked, clippy'd, or run. Not one Rust
test executed.
The sole exception is the Python mirror (see The one gate that
executed
). This is not an oversight; on this host it is impossible:

  • cargo build / cargo test / cargo check cannot link: the vendored
    cross-contract .wasm blobs are hash-pinned by build.rs and the toolchain
    needed to rebuild them (stellar-cli 27.1.0, rustc ≥ 1.91 per soroban-sdk
    27.0.6's MSRV) is not installed — rust-toolchain.toml pins 1.90.0 per issue
    Replace vendored market_core.wasm with a deterministic build and hash-pinned import #58.
  • make build, make test, make lint, make bindings all depend on that
    toolchain.
  • Node here cannot strip TypeScript types, so agents/'s test runner cannot
    execute even the tests that already exist. (python3 does work, hence the
    mirror.)

So the 46 new #[test] functions in this PR are authored and statically
reviewed, never executed
(the 6 new Python checks were run). They are written against helpers I read in full and
whose signatures I checked at the definition site, but their correctness is
argued, not demonstrated. Treat every test name below as an unverified claim
until a machine with the pinned toolchain runs:

cd oracledesk-stellar && make build && cargo test -p market-core && cargo test -p resolver && cargo test -p treasury

Three specific things I could not verify and would check first:

  1. The two #[should_panic(expected = "Error(Contract, #27)")] expectations —
    the number is my reading of the enum, not compiler output. Every other new
    error assertion compares the named variant, so it cannot drift.
  2. void_reason_encodes_as_plain_symbol_name — it asserts the
    derive_enum.rs:370 encoding I built the resolver dispatch on. If it fails,
    the resolver's at-fault path is wrong too, not just the test.
  3. treasury's with_market_core(McImpl::Native, ..) usage in the two new
    bond-cap tests — the fixtures are shared with the #111/#112 lanes and I
    could not confirm the dual-registration helper's exact signature by compiling.

The one gate that executed

Python model/fpmm_model.py runs on this host, so the #24 mirror is verified,
not merely authored.

$ cd oracledesk-stellar && python3 model/fpmm_model.py
fpmm_model: all property checks passed      # exit 0

run_all() now includes the six new checks alongside the pre-existing ones, and
each was also invoked individually so a silent skip is impossible:

$ python3 -c '<import fpmm_model as m; ...>'
RAN-OK  _check_integrator_known_values
RAN-OK  _check_integrator_accounting_is_exact
RAN-OK  _check_calc_buy_with_zero_integrator_matches_calc_buy
RAN-OK  _check_calc_sell_with_zero_integrator_matches_calc_sell
RAN-OK  _check_sell_split_never_shortchanges_protocol
RAN-OK  _check_round_trip_with_integrator_never_profits
NEGATIVE-CONTROL-OK calc_buy_with(1000,1000,100,100,50).shares_out is 187 not 188
ITERATION-COUNT calc_buy zero-integrator equivalence held on 2000 distinct inputs

Two things that run proves and that review alone could not settle:

  1. The rounding contract. calc_sell_with(10_000, 20_000, 1000, 100, 50) gives
    gross = 1016, total_fee = 16, ifee = floor(16*50/150) = 5, fee = 11 —
    matching the assert!(q_with.fee >= q_solo.fee) claim in
    calc_sell_with_split_never_shortchanges_protocol on 2000 shared LCG inputs.
  2. _check_integrator_accounting_is_exact runs the identities the contract
    asserts on chain (collateral_in == net + fee + ifee,
    gross == collateral_out + fee + ifee) across the same domain, so the
    solvency formula in docs/architecture.md is checked rather than asserted.

This does not transfer to the Rust side. Python mirroring Rust's literals
only shows the two sources agree as written; cargo test -p market-core still
has to run the Rust copies of these five sweeps.

Gate differentials (before / after)

Two gates are available here. python3 model/fpmm_model.py genuinely executes,
and its result is in the first row below. cargo +stable fmt -p <crate> proves
rustfmt parses the file and nothing more — it is not a type check. Both are
reported as a differential against the same command at the merge base
f1900f4
, never as "clean":

Gate at base f1900f4 at HEAD Delta
python3 model/fpmm_model.py (executes) exit 0, pre-existing checks only exit 0, +6 new checks, each also RAN-OK individually with a negative control additions pass
cargo +stable fmt -p market-core -- --check exit 0, 0 hunks exit 0, 0 hunks none
cargo +stable fmt -p resolver -- --check exit 1, 13 hunks / 80 changed lines exit 1, 13 hunks / 80 changed lines none (see below)
cargo +stable fmt -p treasury -- --check exit 1, 2 hunks / 27 changed lines exit 1, 2 hunks / 27 changed lines none (see below)
cargo build/test/check unavailable unavailable no Rust gate exists on this host

The resolver and treasury non-zero exits are pre-existing on develop
and are not this PR's work; they belong to the code those branches' earlier
merges left unformatted. They are not merely equal in count: the flagged content
is byte-identical. Reproduce with (ANSI escapes stripped first — cargo colourises,
and a naive grep comparison silently returns zero lines and looks green):

strip='s/\e\[[0-9;]*[a-zA-Z]//g; s/\e\(B//g'
capture() { cargo +stable fmt -p "$1" -- --check 2>&1 | perl -pe "$strip" | grep '^[-+]' ; }
git worktree add /tmp/base f1900f4
for c in resolver treasury; do
  (cd /tmp/base/oracledesk-stellar && capture $c) > /tmp/${c}_base.txt
  (cd oracledesk-stellar                     && capture $c) > /tmp/${c}_head.txt
  diff /tmp/${c}_base.txt /tmp/${c}_head.txt   # empty
done
git worktree remove /tmp/base

My own additions did regress fmt twice, both found and fixed by hand rather
than by cargo fmt write-mode (which would have reformatted the 13 + 2
pre-existing hunks and inflated this PR's diff): five line-width hunks in
treasury, two in resolver.

Manual audit, since no compiler could do it: no duplicate Error discriminants
in market-core, no duplicate DataKey variant names, no todo! /
unimplemented!, balanced braces in all four edited .rs files, and
treasury::map_market_core_error's catch-all _ => arm means market-core's two
new variants cannot break treasury's exhaustive match.

Inherited code — two defects found and fixed

This branch was resumed from 210758c9f (pushed by an earlier lane that died
before opening a PR). Static review of inherited code caught two things that
would have failed at compile time — the exact class of bug a zero-execution
environment hides:

  1. resolver: three new #23 tests had been appended inside mod golden, not
    mod test. mod golden's use super::* resolves against the crate root, so
    every fixture helper they call is out of scope: a compile error. Moved
    into mod test.
  2. resolver: the at-fault success test asserted that a void happened through
    the default mc_impl(), which is McImpl::Wasm — the vendored blob that does
    not contain void_market_with_reason. It would have failed on a real
    toolchain. Wrapped in with_market_core(McImpl::Native, ..) (precedent:
    native_and_wasm_market_core_agree) and paired with
    guardian_void_v2_at_fault_fails_closed_on_stale_wasm, which asserts the
    wasm-mode behaviour is Err(MarketCoreCallFailed), status still Open, state
    still Disputed, and no GuardianVoided event published.

Tail collisions — expected, and the merge order matters

Two Error tails are extended, and both are already claimed by open sibling
PRs. Verified by reading each PR's patch, not guessed.

market-core ends at PendingAdminExpired = 26 on develop; this PR appends
IntegratorFeeTooHigh = 27 and NoBond = 28:

PR also claims at market-core
#239 ModeRestricted = 27
#240 DirectoryEntryArchived = 27
#242 AlreadyHalted = 27, HaltTooOld = 28
#237 MigrationBatchTooLarge = 30

resolver gains MarketCoreCallFailed = 44; three PRs claim that number too:

PR also claims at resolver
#241 QuorumNotReached = 44, FeedsDisagree = 45
#242 EventTimeMismatch = 44 … CoreUnavailable = 48

Duplicate discriminants are a hard compile error in Soroban (E0081), so
whichever merges last must re-append its variants one past whatever is then at
the tail. Blast radius of a renumber, measured rather than assumed: #27 appears
as a literal in exactly two #[should_panic(expected = "Error(Contract, #27)")]
expectations (lib.rs:4009, lib.rs:4361) and in one prose mention in
docs/frontend-integration.md. #28 and #44 need no chase at all — the tests
that depend on them compare against the named variant
(Err(Ok(Error::NoBond)), Err(Ok(Error::MarketCoreCallFailed))), so changing
the discriminant moves the number without touching an assertion. That is the
argument for keeping new tests off numeric literals, and these two variants are
the only ones here that use one. An in-code note at the collision point says so. The
same applies to the DataKey append point. Nothing in this PR can resolve the
ordering from here, and I have not "helpfully" renumbered to a gap that may be
filled by an earlier merge.

Refusals — what is deliberately not in this PR

  • No .wasm and no .wasm.sha256. Regenerating them requires a toolchain
    this host does not have; committing a blob I cannot build from source is
    strictly worse than committing none, because the .sha256 pin would then
    attest to a binary nobody can reproduce. make build is the first step of
    merge, not of this PR.
  • No packages/bindings. make bindings cannot run, and hand-editing
    generated files would produce a diff the generator contradicts.
  • No agents/core/fpmm.ts mirror. The acceptance criterion is identical
    known values
    , which requires running Rust against the mirror and diffing. Rust
    cannot be run here, so a hand-transcribed mirror would be an unverifiable claim
    about arithmetic in the hottest money path in the repo — and this host's Node
    cannot even execute the TS tests that already exist. model/fpmm_model.py is
    the exception, not the rule: its port does execute (see The one gate that
    executed
    ), which is why it was worth writing and the TS one is not.
    The remaining refusal, in order: (1) agents/core/fpmm.ts gains
    calcBuyWith / calcSellWith with collateralOutForShares taking an optional
    integratorFeeBps; (2) scripts/fpmm-diff/oracle (a cargo binary) exposes the
    two new functions, and diff.py's dispatch table gains calc_buy_with /
    calc_sell_with rows; (3) vectors.jsonl is regenerated by the differential
    run itself, so all three languages assert the same boundary inputs without
    anyone transcribing a literal.
  • No neighbouring-issue fixes. Breakers, directories, quorum resolution and
    the migration schema are other lanes' tails.
  • No reformatting of the pre-existing resolver / treasury fmt debt.
    It would bury the reviewable diff in noise.

Notes for whoever merges

  1. Rebase first: develop moved from 5ede00a to f1900f4 during this work and
    this branch was rebased onto it (--force-with-lease, diff verified unchanged).
  2. Resolve the Error / DataKey tail per Tail collisions.
  3. make build, then re-pin the resolver and treasury blob hashes, then
    make bindings and check the committed bindings are not stale (CI enforces).
  4. cargo build --manifest-path scripts/fpmm-diff/oracle/Cargo.toml --release
    then python3 scripts/fpmm-diff/diff.py --seed 1 --count 50000 is the real
    test of the Add integrator (frontend) fees on buy and sell with per-market caps #24 math: it runs Rust against the Python mirror already in this
    PR. Add the TS mirror after that passes, not before.
  5. The resolver's GuardianVoided is a new event and guardian_void_market (v1)
    is unchanged, so backend sync scripts do not need a coordinated redeploy —
    but the bindings for buy_via/sell_via/claim_bond do need regenerating
    before any UI can call them.

anitahhhh and others added 8 commits October 22, 2026 09:40
…iture (OracleDesk#23)

Adds an admin-set creation bond (`set_creation_bond` / `get_creation_bond`,
instance key `DataKey::CreationBond`, absent = 0 so fresh and legacy
instances keep today's behaviour). Every new market snapshots the bond
into a `DataKey::Bond(u64)` sidecar — `Market`'s XDR shape is untouched,
same pattern as `FeeTerms(u64)` — and the creator's seed and bond leave
in ONE transfer so the treasury's `(from, to, amount)` pre-authorisation
triple keeps matching.

Settlement is a new permissionless `claim_bond(market_id)` entrypoint,
callable only after finality: on a plain void or a resolution the bond
refunds to the creator; on a guardian-fault void
(`void_market_with_reason(id, VoidReason::GuardianFault)`) it is
forfeited to the fee-recipient snapshot taken at void time, so a later
treasury migration cannot move the penalty. Existing `void_market` is
unchanged and stays NoFault. Legacy markets without a sidecar entry
reject the claim with `NoBond`.

The append-only `DataKey`/`Error` tails also reserve OracleDesk#24's entries
(`MaxIntegratorFeeBps`, `MarketMaxIntegratorFee(u64)`,
`IntegratorFees(Address)`, `IntegratorFeeTooHigh = 27`) so the enums are
appended once across this PR.

14 bond tests authored (refund, forfeiture, forfeit-recipient snapshot,
double-claim, pre-finality, legacy markets, auth negatives, multi-market
solvency invariant extended with held bonds). Not executed here: this
checkout's gate is `cargo +stable fmt -p market-core -- --check` (exit 0,
clean baseline at tip).
…racleDesk#24)

Adds `buy_via` / `sell_via` (and `quote_buy_via` / `quote_sell_via`) —
additive entrypoints that route an extra `integrator_fee_bps` cut to an
agent/integrator. Buys charge the integrator on top of the protocol fee
(both round up, so rounding keeps favouring the pool); sells widen the
gross spread by the integrator bps and carve the integrator portion out
with floor division, so the protocol never collects less than plain
`sell` would have (`fee + integrator_fee == gross - collateral_out` is
exact). `calc_buy`/`calc_sell` are now `calc_buy_with`/`calc_sell_with`
with bps = 0 and produce byte-identical quotes — the shared-vectors test
and existing fill accounting are untouched.

Caps (OracleDesk#24): an admin opts the platform in with
`set_max_integrator_fee_bps`, validated against the current default fee
so `fee + cap <= MAX_FEE_BPS`; `create_market` snapshots the cap into a
`MarketMaxIntegratorFee(u64)` sidecar (absent = 0, integrator routing
disabled), so later admin changes can neither widen nor narrow what an
existing market honours. `Market`'s XDR shape again stays frozen.

`claim_integrator_fees(integrator)` pays the cross-market balance
(CEI: balance removed before transfer, zero-balance claim is a no-op
returning 0) and is auth'd by the integrator only; accruals are credited
without integrator consent so routing needs no registration.

17 tests authored here (11 entrypoint/accrual/cap/claim/auth/solvency
tests in lib.rs, 6 math tests including zero-equivalence and
split-never-shortchanges-property LCG sweeps; the solvency invariant
helper from the bond commit already carries the integrator term).
Gate on this checkout: `cargo +stable fmt -p market-core -- --check`
exit 0; tests are not executed (repo does not compile at develop tip).
…acleDesk#23)

Adds `guardian_void_market_v2(market_id, reason, at_fault)` — the same
validation as `guardian_void_market` (shared `guardian_void_inner`) plus
an explicit at-fault flag for the creator's refundable bond. With
`at_fault = true` the void is forwarded to market-core's
`void_market_with_reason(id, GuardianFault)` via **name dispatch**
(`env.try_invoke_contract` + `Symbol`), because `contractimport!` binds
this crate to the vendored `market_core.wasm`, whose generated client
does not know the new function until `make build` regenerates it.
`VoidReason` is an all-unit-variant `#[contracttype]` enum and encodes as
a plain `ScVal::Symbol`, mirrored locally so the call is wire-compatible
across that boundary.

`guardian_void_market` itself keeps its exact signature, call path
(generated `void_market` client = NoFault) and event set: existing
callers, tests and the Invalid-verdict, expire_dispute and
finalize_signers void paths are untouched, per the frozen-entrypoint
rule. A new `GuardianVoided { market_id, at_fault }` event is emitted
only on the v2 entrypoint, alongside the existing `GuardianAction`.
Appends `Error::MarketCoreCallFailed = 44` (append-only tail; OracleDesk#241 also
claims 44 on its branch — last merger re-appends).

3 resolver tests authored (NoFault parity + event, at-fault forwarding +
event, shared validation and admin auth). The at-fault test passes only
after `market_core.wasm` regeneration; with the committed pre-OracleDesk#23 blob
the dispatch reverts the whole call with `MarketCoreCallFailed` and no
state is touched. Tests not executed here (resolver does not compile at
develop tip: `core_call` is used but defined nowhere — pre-existing,
out of scope; my additions are self-contained and avoid it).

Gate: `cargo +stable fmt -p resolver -- --check` flags exactly the same
12 pre-existing hunks as the untouched-tip baseline (bodies verified
identical, only line numbers shifted); zero new formatting diffs in
added ranges.
… that owns their helpers

The three `guardian_void_v2_*` tests were appended inside `mod golden`, whose
`use super::*` pulls from the crate root. `dispute_fixture`, `open`, `status`,
`signer_fixture`, `DEADLINE`, `McImpl`, `with_market_core`, `MockAuth` and
`MockAuthInvoke` are all declared inside `mod test`, so the block referenced
helpers that are not in scope there and `cargo test -p resolver` would not build.

Relocated into `mod test`, directly after the `both_modes!` invocation.

Also pinned the vendored-blob reality rather than asserting past it: the
at-fault route name-dispatches `void_market_with_reason`, which the vendored
`market_core.wasm` does not export, so

- `guardian_void_v2_at_fault_forwards_guardian_fault` now runs under an
  explicit `with_market_core(McImpl::Native, ..)` (the existing idiom in
  `native_and_wasm_market_core_agree`) instead of defaulting to the wasm
  registration where the dispatch cannot resolve; and
- a new `guardian_void_v2_at_fault_fails_closed_on_stale_wasm` asserts the
  default path fails closed with `MarketCoreCallFailed`, leaves the market
  `Open`/`Disputed` and publishes no `GuardianVoided` event.

Co-Authored-By: anitahhhh <ajalamodupe@outlook.com>
OracleDesk#23/OracleDesk#24

Closes the coverage gaps against the issue's money invariants:

- `buy_via_fee_at_exact_market_cap_is_accepted` / `combined_take_at_exact_max_fee_bps_is_accepted`
  pin both `>` boundaries (`bps > cap`, `bps + fee_bps > MAX_FEE_BPS`), which the
  rejecting-side tests alone leave unfalsifiable.
- `sell_via_rejects_fee_above_market_cap` — the cap was only exercised on buy.
- `buy_via_with_zero_integrator_fee_matches_buy` /
  `sell_via_with_zero_integrator_fee_matches_sell` prove OracleDesk#24's compatibility
  criterion at contract level on two identically seeded markets: equal shares,
  reserves, sets_minted, fees_accrued, and no integrator balance opened.
- `claim_integrator_fees_second_call_is_a_noop` and
  `integrator_fees_accumulate_across_markets` pin zero-before-transfer and the
  per-integrator-across-markets accrual shape.
- `void_reason_encodes_as_plain_symbol_name` asserts the single encoding claim
  the whole OracleDesk#23 resolver route rests on, instead of only asserting it in a
  comment.

Co-Authored-By: anitahhhh <ajalamodupe@outlook.com>
…OracleDesk#23)

market-core now pulls `seed_amount + creation_bond` in one transfer, so
treasury-created markets would have failed their nested-transfer
pre-authorisation: `authorize_transfer` matches a single exact
`(from, to, amount)` triple, and the treasury was authorizing `seed` only.

- `core_creation_bond` reads `get_creation_bond` by name via
  `env.try_invoke_contract`, because `contractimport!` generates
  `MarketCoreClient` from the vendored `market_core.wasm`, which predates the
  view; a client-based call would not compile until `make build` regenerates
  the blob. Same mechanism `core_trade` already uses for trades.
- **Absent means zero.** A pre-OracleDesk#23 core has no such function, and every
  dispatch failure degrades to `0` rather than to `MarketCoreCallFailed`, so
  treasury keeps funding legacy cores. Fail-closed in the dangerous
  direction: under-reporting a required bond leaves the nested transfer
  unauthorised and the whole creation reverts.
- `total_outlay = seed + bond` is what `ensure_liquid`, `ensure_unreserved`,
  `authorize_token_pull` and `reserve_capacity` (market + daily caps) now see.
- Net position exposure and P&L cost basis stay on the seed: the bond is
  refundable collateral, not a position, so it is not double-counted against
  `DataKey::Net(id)`/`Pnl(id)`. A refund or forfeit back into this treasury
  lands in `idle()` and so reaches `nav()` exactly once, with no receivable
  tracked anywhere.
- `check_trade_size(total)` runs only when `bond > 0`. `agent_create_market`
  has never checked `max_trade` on the seed itself (it checks
  `creation_policy.max_seed`); applying the check unconditionally would
  silently tighten that pre-existing, unrelated gap and would preempt
  `SeedCapExceeded` in `agent_create_market_rejects_seed_above_trade_cap`.

Tests: `agent_create_market_counts_bond_toward_caps`,
`agent_create_market_rejects_seed_plus_bond_above_trade_cap` (both under an
explicit `McImpl::Native` registration, dispatched by name for the same reason)
and `agent_create_market_funds_no_bond_against_pre_23_core`, which pins the
default wasm registration degrading to a seed-only pull.

Co-Authored-By: anitahhhh <ajalamodupe@outlook.com>
…ntegrator fees (OracleDesk#23, OracleDesk#24)

ADR 0010 records the two decisions every OracleDesk#23/OracleDesk#24 consumer depends on: new
per-market state goes in appended `DataKey` sidecars with absent-means-default
(never a new `Market` field, which would break the three vendored-blob
decoders), and new cross-contract entrypoints are reached by name
(`env.try_invoke_contract` + the `ScVal::Symbol("Variant")` encoding of an
all-unit `#[contracttype]` enum), so nothing here waits on `make build`.

Also documents the two properties that are easy to get wrong later: why a
forfeited bond is not a treasury receivable (kept out of `Net(id)`/`Pnl(id)`
so a refund is not read as position profit), and why `core_creation_bond`
treats a dispatch failure as `0` rather than as an error.

`docs/architecture.md`: the solvency invariant is stated in its global form
(`+ Σ held bonds + Σ unclaimed integrator balances`), matching
`test::assert_global_solvency`, plus lifecycle notes for the bond and the
integrator-fee paths.

Lists as required follow-up what this host cannot do: `make build` (stellar-cli
22.0.0 vs 27.1.0, rustc pinned to 1.90.0 by OracleDesk#58), `make bindings`, and the
Python/TS reference mirrors.

Co-Authored-By: anitahhhh <ajalamodupe@outlook.com>
…nd (OracleDesk#23, OracleDesk#24)

The two issues' acceptance criteria include the front-end contract: a UI
that displays only `seed_amount` at creation produces transactions that
fail on balance, and an integrator that derives `min_shares_out` from
`quote_buy` over-states its own trade by the fee it is charging.

Documents the three contract-enforced fee rules, which quote to use for
the bound, that `Trade` is unchanged in favour of `IntegratorFeeAccrued`,
the bond settlement table, and the real gap that `buy_via`/`sell_via`
carry neither deadline nor limit price.

Co-Authored-By: Claude <noreply@anthropic.com>
@drips-wave

drips-wave Bot commented Oct 22, 2026

Copy link
Copy Markdown

@anitahhhh Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

anitahhhh and others added 3 commits October 22, 2026 10:19
…esk#24)

Port of the OracleDesk#24 integrator-fee math to model/fpmm_model.py, with the
known-value literals transcribed from math.rs's
buy_with_integrator_known_values / sell_with_integrator_known_values and the
property sweeps driven by the same LCG stream math.rs uses, so the two
implementations sweep identical inputs instead of independent random ones.

Adds the collateral-identity sweep the contract asserts on chain
(collateral_in == net + fee + ifee, gross == collateral_out + fee + ifee),
because a break there is a break in the solvency invariant.

This is the only gate on this host that actually executes: python3
model/fpmm_model.py passes, each new check runs individually, and a mutated
expectation fails. BuyQuote/SellQuote deliberately keep their shape and the
"with" variants get their own dataclasses, since _check_shared_vectors
compares __dict__ against the committed vectors.jsonl and a new field would
invalidate every calc_buy line; math.rs can carry the field because its
shared_vectors_match enumerates the compared keys explicitly.

Co-Authored-By: Claude <noreply@anthropic.com>
…ils precisely (OracleDesk#24)

ADR 0010 listed the Python mirror as follow-up work it could not do. That was
wrong about the constraint: model/fpmm_model.py runs here, so its checks are
executed rather than merely authored. Only the TypeScript mirror stays out,
because Node on this host cannot strip types.

Also drops the claim that resolver's Error 44 collides with OracleDesk#239/OracleDesk#240/OracleDesk#241: it
is OracleDesk#241 (QuorumNotReached) and OracleDesk#242 (EventTimeMismatch) that reach 44, verified
from each PR's own patch.

Co-Authored-By: Claude <noreply@anthropic.com>
…riant rule (OracleDesk#23, OracleDesk#24)

`develop`'s resolver enum ends at 43, so `MarketCoreCallFailed = 44` reaches for
the same number as OracleDesk#241's `QuorumNotReached` and OracleDesk#242's `EventTimeMismatch`. The
renumber rule is worth writing down where the next contributor will hit it:
asserting the named variant survives the renumber, asserting the numeric literal
does not.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add integrator (frontend) fees on buy and sell with per-market caps Add a refundable creation bond forfeited when a market is guardian-voided

1 participant