Skip to content

fix(market): treat rate/volume as decimal strings (human-units throughout) - #27

Merged
vrogojin merged 1 commit into
masterfrom
refactor/decimal-rates-volumes
Jun 12, 2026
Merged

fix(market): treat rate/volume as decimal strings (human-units throughout)#27
vrogojin merged 1 commit into
masterfrom
refactor/decimal-rates-volumes

Conversation

@vrogojin

Copy link
Copy Markdown
Owner

Summary

The trader was serialising `rate_min/rate_max/volume_min/volume_max` as bigint smallest-units in the description and search query. For 18-decimal quote assets like ETH the rate `0.08-0.12` became the string `80000000000000000-120000000000000000` — and the semantic-search engine on the deployed market-api server returns HTTP 500 (HTML body) when it encounters large-number-dash-large-number patterns in the query string.

It was never tested live: the trader's e2e suite mocks `MarketAdapter`, so the bigint values never round-tripped through the real server.

Why this is the right shape

Existing test fixtures show the original design: rates were small dimensionless ratios, volumes were in BASE whole units, and the math `rate × volume = 475 × 1000 = 475000` worked at small numbers:

```js
// test/e2e/trader-intent-lifecycle.e2e.test.ts:190
rate_min: '450', rate_max: '500', volume_min: '100', volume_max: '1000'
```

The bigint typing came from a later misinterpretation that conflated rate/volume with token amounts. This PR restores the original intent.

Changes

What Before After
`TradingIntent.rate_min/rate_max/volume_min/volume_max/volume_filled` `bigint` `string` (decimal, e.g. `'0.08'`, `'50'`)
`DealTerms.rate`, `DealTerms.volume` `bigint` `string`
`parseDescription` regex `\d+` `\d+(?:\.\d+)?`
`validateIntentParams` / `validateDealTerms` `BigInt()` coercion, `<= 0n` etc. accept decimal strings, compare via `Number`
`MAX_RATE` / `MAX_VOLUME` `2n ** 128n` `Number.MAX_SAFE_INTEGER`
`intent-engine` matching, fan-out volume allocation, midpoint calc bigint arithmetic `Number` arithmetic
`PaymentsAdapter` balance bigints only added `getDecimals(coinId)` backed by sphere-sdk `TokenRegistry.getTokenDecimals`
`trader-main.ts onDealAccepted` reservation direct bigint pass-through converts whole-units volume × rate to smallest-units bigint via `toSmallestUnitsBigInt()`
`swap-executor.ts buildSwapDealInput` bigint volume / rate × volume optional `getDecimals` lookup converts to smallest-units integer string before `sphere.swap.proposeSwap` (which requires `/^[1-9][0-9]*$/` per SwapModule.ts:1186)
Tests 56 sites of `123n` → `'123'` on intent/deal/match assertions ledger amount assertions (`getAvailable`) left as bigint since that IS smallest-units

Where decimals lookup happens

Only at the smallest-unit boundary. Description / search query / parseDescription / encodeDescription / matching all work in human decimal strings. Smallest-unit conversion lives in two places:

  1. `trader-main.ts onDealAccepted`: when reserving from the wallet's bigint balance ledger
  2. `swap-executor.ts buildSwapDealInput`: when calling `sphere.swap.proposeSwap`

Both call the SDK's `getTokenDecimals(coinId)` via the new `PaymentsAdapter.getDecimals`.

The four-stop chain that landed today on soak §6

# PR Issue Status
1 #25 precision: drop `Number()` cast already merged + v0.3 image
2 unicity-sphere/sphere-sdk#483 type alignment: SDK `price` → `string` already merged
3 #26 drop structured `price` field (server rejected it) already merged + v0.4 image
4 this PR rate/volume in human-units throughout (server rejected bigint-in-text query too) this PR

Each was a real bug. The first three didn't unblock the soak alone; this fourth completes the chain.

Test plan

Related

…hout)

The trader was serialising rate_min/rate_max/volume_min/volume_max as
bigint smallest-units in the description and search query. For 18-decimal
quote assets like ETH the rate 0.08-0.12 became the string
"80000000000000000-120000000000000000", which the semantic-search engine
on the deployed market-api server choked on (large-number-dash-large-number
patterns return HTTP 500 with HTML body). It was never tested live —
the trader's e2e suite mocks MarketAdapter so the bigint values never
round-tripped through the real server.

Existing test fixtures (e.g. rate_min: '450', volume_max: '1000') and
internal math (rate × volume = 475000) reveal the original design: rates
are dimensionless ratios, volumes are in BASE whole units. The bigint
typing came from a later misinterpretation that conflated them with
token amounts.

This change makes that explicit:

* TradingIntent.rate_min/rate_max/volume_min/volume_max/volume_filled
  bigint → string (decimal strings, e.g. "0.08", "50")
* DealTerms.rate, DealTerms.volume bigint → string
* parseDescription regex: \d+ → \d+(?:\.\d+)? to accept decimals
* validateIntentParams / validateDealTerms: accept decimal strings,
  compare via Number (2^53 ceiling is plenty for trading ratios + volumes)
* MAX_RATE / MAX_VOLUME: 2^128 → Number.MAX_SAFE_INTEGER
* intent-engine matching, fan-out volume allocation, midpoint calc:
  switched to Number arithmetic
* PaymentsAdapter gets `getDecimals(coinId)` backed by sphere-sdk's
  TokenRegistry.getTokenDecimals (the one place we DO need decimals —
  at the smallest-unit boundary)
* trader-main.ts onDealAccepted: converts whole-unit volume × rate to
  smallest-units bigint via toSmallestUnitsBigInt() at the wallet
  reservation boundary
* swap-executor.ts buildSwapDealInput: optional getDecimals lookup
  converts whole-unit terms to smallest-units integer strings before
  calling sphere.swap.proposeSwap (which requires positive-integer-string
  per SwapModule.ts:1186)
* Tests sweep: 56 sites of `123n` → `'123'` across intent/deal/match
  assertions; ledger amount assertions (getAvailable) left as bigint
  since that side IS smallest-units

The four-stop fix chain that landed today on the soak's §6 was:
1. trader-service #25  — drop Number() cast (precision)
2. sphere-sdk    #483  — align SDK types to bigint-string
3. trader-service #26  — drop structured price field (server rejected it)
4. this PR             — rate/volume in human-units throughout (server
                          rejected the bigint-in-text-query too)

Verified:
- npx tsc --noEmit: clean
- npm test: 698/698 pass (added Number arithmetic tests on top of
  existing string-fixture coverage)
- npm run build: tsup clean

Surfaced by: unicity-sphere/sphere-sdk#475 §6/§8 (trader-roundtrip
soak) which produced HTTP 500 against /api/intents and /api/search
with the bigint-in-text formats.
@vrogojin
vrogojin force-pushed the refactor/decimal-rates-volumes branch from 9ad721d to 5093791 Compare June 12, 2026 13:54
@vrogojin
vrogojin merged commit b2aa445 into master Jun 12, 2026
1 check passed
@vrogojin
vrogojin deleted the refactor/decimal-rates-volumes branch June 12, 2026 14:23
vrogojin added a commit that referenced this pull request Jun 13, 2026
…ler (#28)

PR #27 changed TradingIntent.rate_min/rate_max/volume_min/volume_max
from bigint to string but missed the trader-command-handler.ts gate at
the wire boundary. The handler still ran safeParseBigint() first, which
rejected '0.08' with "rate_min must be a non-negative integer string".

The shape downstream is decimal string everywhere (validateIntentParams,
TradingIntent record, encodeDescription) — the handler just needs to
assert the input IS a string and pass it through. Detailed validation
remains in validateIntentParams (called immediately below).

Verified end-to-end in unicity-sphere/sphere-sdk#475 trader-roundtrip
soak: alice's `sphere trader create-intent --rate-min 0.08 ...` now
succeeds at the trader gate (previously failed before reaching the
intent engine).

698 / 698 tests pass.
@vrogojin
vrogojin restored the refactor/decimal-rates-volumes branch July 15, 2026 14:13
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.

1 participant