Skip to content

Commit 2696c78

Browse files
committed
Balances doc-sync and test/comment fixes
1 parent 8308de9 commit 2696c78

7 files changed

Lines changed: 80 additions & 29 deletions

File tree

crates/application/src/lib.rs

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5108,8 +5108,9 @@ mod get_market_balances_use_case_tests {
51085108
// holding of 12.5 NACKL is `12_500_000_000` atoms and must render as ~12.5,
51095109
// not 125_000_000. Scaling atoms by `quantity_precision` (2) instead of the
51105110
// full `decimals` over-reports by 10^(9-2).
5111-
// `happy_path_sums_three_pools_per_outcome` can't catch that — its small
5112-
// synthetic inputs sit far below atom scale — so this feeds realistic ones.
5111+
// A small-synthetic-input happy path can't catch that — its inputs sit far
5112+
// below atom scale, where the 10^(9-2) error is invisible — so this feeds
5113+
// realistic atom-scale values.
51135114
#[tokio::test]
51145115
async fn market_balances_free_not_overscaled_for_real_atoms() {
51155116
let stake = PnStake {
@@ -5131,12 +5132,8 @@ mod get_market_balances_use_case_tests {
51315132
})
51325133
.await
51335134
.expect("ok");
5134-
let free = &out.balances[0].free;
5135-
let val: f64 = free.parse().expect("free parses as a number");
5136-
assert!(
5137-
(val - 12.5).abs() < 1e-6,
5138-
"outcome free over-scaled: got {free} (expected ~12.5 tokens for 12.5e9 atoms at decimals=9)"
5139-
);
5135+
// Exact: 12_500_000_000 atoms / 10^9 → "12.500000000" (not 125_000_000).
5136+
assert_eq!(out.balances[0].free, "12.500000000");
51405137
}
51415138

51425139
// Same over-scale on the `locked_in_orders` column, which is summed from
@@ -5162,17 +5159,13 @@ mod get_market_balances_use_case_tests {
51625159
})
51635160
.await
51645161
.expect("ok");
5165-
let locked = &out.balances[0].locked_in_orders;
5166-
let val: f64 = locked.parse().expect("locked parses as a number");
5167-
assert!(
5168-
(val - 20.0).abs() < 1e-6,
5169-
"outcome locked over-scaled: got {locked} (expected ~20 tokens for 20e9 atoms at decimals=9)"
5170-
);
5162+
// Exact: 20_000_000_000 atoms / 10^9 → "20.000000000" (not 200_000_000).
5163+
assert_eq!(out.balances[0].locked_in_orders, "20.000000000");
51715164
}
51725165

51735166
// Golden-fixture regression (captured live on the local stack): a
51745167
// `buyFullSet` at the market price splits collateral into outcome amounts
5175-
// of full-`decimals` precision, OFF the quantity_precision grid — 25 NACKL
5168+
// of full-`decimals` precision, OFF the quantity_precision grid — ~25 NACKL
51765169
// → 11_567_164_168 / 13_432_835_808 atoms. Scaling by `quantity_precision`
51775170
// would over-report (→ "115671641.68"); a grid-strict descale to the qp
51785171
// lattice would have to drop non-zero low digits. Scaling by the full

crates/chain/src/client.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,8 @@ impl Dex {
104104
/// Buy a full set of outcome tokens by depositing `collateral` of
105105
/// the market's quote asset into the PMP. On a market sitting in
106106
/// `AWAITING_FREEZE`, the first successful call also activates the
107-
/// OrderBook for everyone else. See
108-
/// `docs/tech-specs/write-api.md §POST /api/v1/buyFullSet`.
107+
/// OrderBook, after which it stays active for all subsequent callers.
108+
/// See `docs/tech-specs/write-api.md §POST /api/v1/buyFullSet`.
109109
pub async fn split_full_set(
110110
&self,
111111
pn_address: &str,

crates/infrastructure/src/postgres_repo.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,8 +1064,17 @@ impl MarketReadRepository for PostgresReadModelRepository {
10641064
);
10651065
anyhow!(DomainError::MarketInconsistent)
10661066
})?;
1067-
// <= MAX_DECIMAL_PRECISION (38) always fits u8.
1068-
let decimals = decimals_scale as u8;
1067+
// Total conversion: validate_decimal_scale already capped this at
1068+
// MAX_DECIMAL_PRECISION (38). Checked rather than `as u8` so a future
1069+
// cap raised above 255 can't silently truncate.
1070+
let decimals = u8::try_from(decimals_scale).map_err(|_| {
1071+
tracing::warn!(
1072+
pmp = %market_address.0,
1073+
raw = decimals_scale,
1074+
"decimals exceeds u8 after scale validation — read-model corruption"
1075+
);
1076+
anyhow!(DomainError::MarketInconsistent)
1077+
})?;
10691078

10701079
// `oracle_list_hash` is nullable at the schema level (pre-reconcile),
10711080
// but we already gated on last_reconciled_at IS NOT NULL — a NULL here

crates/infrastructure/tests/balances.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,55 @@ async fn resolve_market_for_balances_negative_token_type_fails_closed() {
451451
assert!(matches!(dom, dodex_domain::DomainError::MarketInconsistent));
452452
}
453453

454+
#[tokio::test]
455+
async fn resolve_market_for_balances_decimals_above_max_fails_closed() {
456+
// The resolve path validates the joined `ref_tokens.decimals` via
457+
// validate_decimal_scale (cap MAX_DECIMAL_PRECISION = 38), NOT a bare
458+
// u8::try_from (which would admit up to 255). 39 sits in that gap, so it
459+
// distinguishes the two validators: it must lift to MarketInconsistent
460+
// rather than be handed to scale_decimal, where the over-cap scale would
461+
// blow up the allocation.
462+
let Some(pool) = setup().await else { return };
463+
let pmp = "0:dec-overmax-bal-pmp";
464+
let ob = "0:dec-overmax-bal-ob";
465+
sqlx::query("delete from markets where pmp_address = $1")
466+
.bind(pmp)
467+
.execute(&pool)
468+
.await
469+
.unwrap();
470+
// Sentinel ref_tokens row with an out-of-cap decimals (39). All `not null`
471+
// columns filled; only `decimals` is read by the code under test.
472+
sqlx::query(
473+
r#"insert into ref_tokens (
474+
token_type, token_code, decimals,
475+
min_notional, lot_size, tick_size_bps,
476+
price_precision, quantity_precision)
477+
values (39039, '__DEC39__', 39,
478+
0::numeric, 0::numeric, 0::numeric, 0, 0)
479+
on conflict (token_type) do nothing"#,
480+
)
481+
.execute(&pool)
482+
.await
483+
.unwrap();
484+
sqlx::query(
485+
r#"insert into markets (
486+
pmp_address, name, token_type, token_code, event_id, oracle_list_hash,
487+
orderbook_address, num_outcomes, last_reconciled_at)
488+
values ($1, 'dec-overmax', 39039, '__DEC39__', 42::numeric, 24::numeric,
489+
$2, 1, now())"#,
490+
)
491+
.bind(pmp)
492+
.bind(ob)
493+
.execute(&pool)
494+
.await
495+
.unwrap();
496+
497+
let repo = PostgresReadModelRepository::new(pool.clone());
498+
let err = repo.resolve_market_for_balances(&MarketAddress(pmp.to_string())).await.unwrap_err();
499+
let dom = err.downcast_ref::<dodex_domain::DomainError>().expect("DomainError");
500+
assert!(matches!(dom, dodex_domain::DomainError::MarketInconsistent));
501+
}
502+
454503
#[tokio::test]
455504
async fn resolve_for_new_order_negative_outcome_id_fails_closed() {
456505
// market_outcomes.outcome_id has no CHECK constraint enforcing non-negative.

docs/api-spec.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -628,14 +628,14 @@ Response:
628628
{
629629
"outcomeId": 0,
630630
"symbol": "PM-2026-ELECTION-NO",
631-
"free": "10.00",
632-
"lockedInOrders": "0.00"
631+
"free": "10.000000000",
632+
"lockedInOrders": "0.000000000"
633633
},
634634
{
635635
"outcomeId": 1,
636636
"symbol": "PM-2026-ELECTION-YES",
637-
"free": "5.50",
638-
"lockedInOrders": "1000.00"
637+
"free": "5.500000000",
638+
"lockedInOrders": "1000.000000000"
639639
}
640640
]
641641
}
@@ -650,8 +650,8 @@ Response fields:
650650
| `balances` | ARRAY | One entry per outcome of the market, sorted by `outcomeId` ascending. Length equals the market's `outcomes[]` length in `/api/v1/markets`. |
651651
| `balances[].outcomeId` | INT | Stable outcome id; matches `outcomes[].outcomeId` from `/api/v1/markets`. |
652652
| `balances[].symbol` | STRING | Outcome-token symbol; matches `outcomes[].symbol` from `/api/v1/markets`. |
653-
| `balances[].free` | DECIMAL | Outcome tokens currently held by the trading PrivateNote across clean, debt, and coupon stake pools, scaled by the outcome's `quantityPrecision`. |
654-
| `balances[].lockedInOrders` | DECIMAL | Outcome tokens locked in resting SELL orders on this outcome, scaled by the outcome's `quantityPrecision`. |
653+
| `balances[].free` | DECIMAL | Outcome tokens currently held by the trading PrivateNote across clean, debt, and coupon stake pools, scaled by the quote asset's on-chain `decimals` (same scaling as `/api/v1/account`). |
654+
| `balances[].lockedInOrders` | DECIMAL | Outcome tokens locked in resting SELL orders on this outcome, scaled by the quote asset's on-chain `decimals`. |
655655

656656
Errors:
657657

docs/tech-specs/read-api.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ Returns the caller's outcome-token holdings for one market. `free` comes from a
414414

415415
Three inputs feed one response:
416416

417-
1. **Market resolution.** Two SELECTs (one on [`markets`](data-schema.md#markets), one on [`market_outcomes`](data-schema.md#market_outcomes)) return `(event_id, oracle_list_hash, token_type, orderbook_address, num_outcomes, [(outcome_id, symbol, quantity_precision) …])`. The first is gated on `last_reconciled_at IS NOT NULL`; pre-reconcile markets are hidden symmetrically with `/api/v1/markets`. The market lifecycle status is NOT a gate — terminal markets still serve balances so holders can see what they own until they claim or settle. Splitting into two SELECTs keeps the row types simple at the cost of one extra round trip; the per-request volume is low enough that the JOIN form is not worth the type-erasure pain.
417+
1. **Market resolution.** Two SELECTs (one on [`markets`](data-schema.md#markets) INNER-joined to [`ref_tokens`](data-schema.md#ref_tokens) for the quote-asset `decimals`, one on [`market_outcomes`](data-schema.md#market_outcomes)) return `(event_id, oracle_list_hash, token_type, orderbook_address, num_outcomes, decimals, [(outcome_id, symbol, quantity_precision) …])`. The first is gated on `last_reconciled_at IS NOT NULL`; pre-reconcile markets are hidden symmetrically with `/api/v1/markets`. The `ref_tokens` join cannot hide a market: `markets.token_type` is `NOT NULL` and FK-references the statically seeded `ref_tokens` PK, so it is a strict 1:1. The market lifecycle status is NOT a gate — terminal markets still serve balances so holders can see what they own until they claim or settle. Keeping the per-outcome rows in a separate SELECT keeps the row types simple at the cost of one extra round trip.
418418
2. **PN stake state.** The chain-side accessor is the auto-generated getter for the public mapping `PrivateNote._stakes`. TVM Solidity auto-getters for public mappings take no arguments and return the entire `map(uint256 → StakeInfo)` — see the PN ABI under `contracts/abi/dex/PrivateNote.abi.json`. The API computes the per-market key `stake_hash = tvm.hash(abi.encode(event_id, oracle_list_hash, token_type))` — the same hash the PN itself uses internally — and looks it up on the returned map. The hash is built off-chain in Rust via a thin wrapper around `tvm_types`. Each `StakeInfo` value carries three parallel `uint128[]` arrays (`amount`, `debtAmount`, `couponsAmount`) indexed by `outcome_id`, plus housekeeping fields the API ignores. A missing key on the returned map (caller never staked on this market) is treated as "all outcomes at zero", not as an error.
419419

420420
Returning the whole mapping in one call costs the same as one keyed lookup would on EVM (the ABI shape is fixed by TVM Solidity), so this is an opportunity, not a tax: a future "all my outcomes" view across markets needs no additional chain calls.
@@ -442,8 +442,8 @@ Three inputs feed one response:
442442
- Fetch the PN BOC and run the `_stakes` getter through `tvm_runner` (returns the full `map(uint256 → StakeInfo)`); the API then looks up `map[stake_hash]`.
443443
- Run the `live_orders` aggregation SELECT.
444444
6. Build `balances[]` in `outcome_id` ASC order. For each outcome:
445-
- `free = scale(amount[outcome_id] + debtAmount[outcome_id] + couponsAmount[outcome_id], quantity_precision)`. The three pools are summed because the public surface is "what the user owns" — clean, debt-bound, and coupon-bound stakes are all the user's tokens; the distinction is internal accounting that the UI does not need at this layer.
446-
- `lockedInOrders = scale(coalesce(SUM, 0), quantity_precision)` from the aggregation map; outcomes without a row default to 0.
445+
- `free = scale(amount[outcome_id] + debtAmount[outcome_id] + couponsAmount[outcome_id], decimals)`. Scaled by the quote asset's on-chain `decimals` (not `quantity_precision`) — the `_stakes` amounts are chain atoms, the same scale `/api/v1/account` uses; scaling by `quantity_precision` would over-report by `10^(decimals − quantity_precision)`. The three pools are summed because the public surface is "what the user owns" — clean, debt-bound, and coupon-bound stakes are all the user's tokens; the distinction is internal accounting that the UI does not need at this layer.
446+
- `lockedInOrders = scale(coalesce(SUM, 0), decimals)` from the aggregation map (the `live_orders` amounts are chain atoms too); outcomes without a row default to 0.
447447
7. Capture `now_ms` once in the handler before executing the use case — surfaces as `updateTime`.
448448

449449
### Locked source split

docs/tech-specs/write-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -681,7 +681,7 @@ _Implementation tech spec to be filled in._
681681

682682
## `POST /api/v1/buyFullSet`
683683

684-
Buys a full set of outcome tokens for one market by depositing `collateral` of the market's quote asset into the PMP. The chain entry point is `PrivateNote.splitFullSet`; on a market sitting in `AWAITING_FREEZE` the first successful call also activates the OrderBook for everyone else. From the caller's standpoint the request and response are identical to any later call against the same market.
684+
Buys a full set of outcome tokens for one market by depositing `collateral` of the market's quote asset into the PMP. The chain entry point is `PrivateNote.splitFullSet`; on a market sitting in `AWAITING_FREEZE` the first successful call also activates the OrderBook, after which it stays active for all subsequent callers. From the caller's standpoint the request and response are identical to any later call against the same market.
685685

686686
The handler runs three phases: request parsing → market resolution + status gate → collateral validation + chain submission. Each phase fails closed with its own error code (see [Error mapping](#error-mapping-3)).
687687

0 commit comments

Comments
 (0)