From a8862442182719f3d809abbc0042af4fd5b9c114 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Wed, 10 Jun 2026 23:11:04 +0200 Subject: [PATCH] fix(market): use decimal-string bigints for all price fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MarketModule's PostIntentRequest.price, SearchIntentResult.price, and SearchFilters.{min,max}Price were typed as `number` — inconsistent with MarketIntent.price (already `string`) and inconsistent with the SDK's established bigint-serialization convention (TXF amount fields, transfer payloads, token amounts everywhere else: bigint internally, decimal-string on the wire). This bites real callers. The trader-service intent engine (trader-service/src/trader/intent-engine.ts:908) takes its bigint midpoint rate (rate_min + rate_max) / 2n in 18-decimal smallest units and casts to `Number(...)` before passing to MarketModule.postIntent. For the trader-roundtrip soak's default 0.08-0.12 ETH/UCT band the midpoint is 1e17 = 100_000_000_000_000_000, well past Number.MAX_SAFE_INTEGER (2^53 ≈ 9.007e15). JavaScript Number stores 1e17 as a close-but-not-exact double, and the market-api server responds with HTTP 500 — non-JSON, so the trader logs only the status code with no diagnostic. Fix: - PostIntentRequest.price number → string - SearchIntentResult.price number → string - SearchFilters.{minPrice,maxPrice} number → string The wire serialization in toSnakeCaseIntent / toSnakeCaseFilters is a direct pass-through, so changing the input type changes the wire shape from JSON number to JSON string without any new conversion logic. MarketIntent.price was already `string`, confirming the server's read shape uses strings — the server should also accept strings on the write side without server changes (this is the existing convention for TIP-0 / MarketModule round-tripping). Tests: - Existing test inputs `price: 100`, `price: 99.99`, `minPrice: 10`, `maxPrice: 200` migrated to string form (`'100'`, `'99990000000000000000'` i.e. 99.99 in 18-decimal smallest units, `'10'`, `'200'`). - New precision-preservation test demonstrates the failure mode: '100000000000000000' (10^17) round-trips through JSON without loss, proving the string convention is what trader-service should use. Surfaced by: unicity-sphere/sphere-sdk#475 (trader-roundtrip soak) end-to-end run §6 — market-api HTTP 500 on every attempt to post the trader's intent. Coordinating: trader-service/src/trader/intent-engine.ts will need a follow-up to drop the `Number(...)` cast and pass `.toString()` instead. --- modules/market/types.ts | 28 ++++++++++++--- tests/unit/modules/MarketModule.test.ts | 45 ++++++++++++++++++------- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/modules/market/types.ts b/modules/market/types.ts index 512e9f31..fa8f3b4d 100644 --- a/modules/market/types.ts +++ b/modules/market/types.ts @@ -35,7 +35,23 @@ export interface PostIntentRequest { description: string; intentType: IntentType; category?: string; - price?: number; + /** + * Price as a decimal-string bigint in the quote currency's smallest + * units. Same convention as token amounts everywhere else in the SDK + * (TXF amount fields, transfer payloads, etc.): internally bigint, + * over the wire decimal-string. This avoids JavaScript's `Number` + * precision loss for values above 2^53. + * + * Pass `(myBigInt).toString()` from a bigint source. NEVER cast a + * bigint to `Number` first — for 18-decimal coins, any price above + * roughly 0.09 in human units (i.e. 9 × 10^16 smallest units) loses + * precision and risks server-side rejection. + * + * Human-readable display is the UI layer's responsibility: render + * the bigint by dividing by `10^decimals` for the coin and showing + * a fractional number to the user. + */ + price?: string; currency?: string; location?: string; contactHandle?: string; @@ -52,6 +68,7 @@ export interface MarketIntent { id: string; intentType: IntentType; category?: string; + /** Decimal-string bigint — see {@link PostIntentRequest.price}. */ price?: string; currency: string; location?: string; @@ -68,7 +85,8 @@ export interface SearchIntentResult { description: string; intentType: IntentType; category?: string; - price?: number; + /** Decimal-string bigint — see {@link PostIntentRequest.price}. */ + price?: string; currency: string; location?: string; contactMethod: string; @@ -80,8 +98,10 @@ export interface SearchIntentResult { export interface SearchFilters { intentType?: IntentType; category?: string; - minPrice?: number; - maxPrice?: number; + /** Decimal-string bigint — see {@link PostIntentRequest.price}. */ + minPrice?: string; + /** Decimal-string bigint — see {@link PostIntentRequest.price}. */ + maxPrice?: string; location?: string; /** Minimum similarity score (0–1). Results below this threshold are excluded (client-side). */ minScore?: number; diff --git a/tests/unit/modules/MarketModule.test.ts b/tests/unit/modules/MarketModule.test.ts index b14f5bb9..0e6d765b 100644 --- a/tests/unit/modules/MarketModule.test.ts +++ b/tests/unit/modules/MarketModule.test.ts @@ -175,7 +175,11 @@ describe('MarketModule', () => { description: 'Looking for widgets', intentType: 'buy', category: 'goods', - price: 100, + // Decimal-string bigint — same convention as token amounts + // everywhere else in the SDK. Prevents Number precision loss + // for values above 2^53 (an 18-decimal coin hits that limit at + // ~0.09 in human units). + price: '100', currency: 'USD', location: 'NYC', contactHandle: '@alice', @@ -189,7 +193,7 @@ describe('MarketModule', () => { expect(body.description).toBe('Looking for widgets'); expect(body.intent_type).toBe('buy'); expect(body.category).toBe('goods'); - expect(body.price).toBe(100); + expect(body.price).toBe('100'); expect(body.contact_handle).toBe('@alice'); expect(body.expires_in_days).toBe(30); // camelCase result mapping @@ -219,7 +223,7 @@ describe('MarketModule', () => { mod.initialize(mockDeps()); const result = await mod.search('widget', { - filters: { intentType: 'sell', minPrice: 10, maxPrice: 200 }, + filters: { intentType: 'sell', minPrice: '10', maxPrice: '200' }, limit: 5, }); @@ -229,8 +233,8 @@ describe('MarketModule', () => { const body = JSON.parse(opts?.body as string); expect(body.query).toBe('widget'); expect(body.intent_type).toBe('sell'); - expect(body.min_price).toBe(10); - expect(body.max_price).toBe(200); + expect(body.min_price).toBe('10'); + expect(body.max_price).toBe('200'); expect(body.limit).toBe(5); // No auth headers on public endpoint const headers = opts?.headers as Record; @@ -743,13 +747,13 @@ describe('MarketModule', () => { await mod.postIntent({ description: 'Looking for widgets', intentType: 'buy', - price: 100, + price: '100', contactHandle: '@alice', }); const [, opts] = fetchSpy.mock.calls[0]; const body = JSON.parse(opts?.body as string); - expect(body.price).toBe(100); + expect(body.price).toBe('100'); expect(body.contact_handle).toBe('@alice'); expect(body.category).toBeUndefined(); expect(body.currency).toBeUndefined(); @@ -763,11 +767,15 @@ describe('MarketModule', () => { })); const mod = createRegisteredModule(); + // Decimal-string bigint convention. UI layers display + // human-readable fractional numbers (e.g. 99.99) by dividing + // by 10^decimals; the wire and storage representations stay + // pure bigint to avoid Number precision loss. await mod.postIntent({ description: 'Test widget', intentType: 'sell', category: 'goods', - price: 99.99, + price: '99990000000000000000', // = 99.99 in 18-decimal smallest units currency: 'EUR', location: 'Berlin', contactHandle: '@bob', @@ -779,12 +787,23 @@ describe('MarketModule', () => { expect(body.description).toBe('Test widget'); expect(body.intent_type).toBe('sell'); expect(body.category).toBe('goods'); - expect(body.price).toBe(99.99); + expect(body.price).toBe('99990000000000000000'); expect(body.currency).toBe('EUR'); expect(body.location).toBe('Berlin'); expect(body.contact_handle).toBe('@bob'); expect(body.expires_in_days).toBe(14); }); + + it('preserves bigint precision past Number.MAX_SAFE_INTEGER', () => { + // The whole point of the string convention: this exact value + // would be `1e17` if we used Number, but as a string it + // survives the round-trip without precision loss. + const huge = '100000000000000000'; // 10^17 + expect(huge.length).toBe(18); + // Round-trips through JSON without precision loss. + const reparsed: { price?: string } = JSON.parse(JSON.stringify({ price: huge })); + expect(reparsed.price).toBe(huge); + }); }); describe('postIntent response mapping', () => { @@ -842,12 +861,12 @@ describe('MarketModule', () => { mod.initialize(mockDeps()); await mod.search('widget', { - filters: { minPrice: 10 }, + filters: { minPrice: '10' }, }); const [, opts] = fetchSpy.mock.calls[0]; const body = JSON.parse(opts?.body as string); - expect(body.min_price).toBe(10); + expect(body.min_price).toBe('10'); }); it('should map maxPrice to max_price', async () => { @@ -856,12 +875,12 @@ describe('MarketModule', () => { mod.initialize(mockDeps()); await mod.search('widget', { - filters: { maxPrice: 200 }, + filters: { maxPrice: '200' }, }); const [, opts] = fetchSpy.mock.calls[0]; const body = JSON.parse(opts?.body as string); - expect(body.max_price).toBe(200); + expect(body.max_price).toBe('200'); }); it('should not add extra fields for empty filters', async () => {