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
28 changes: 24 additions & 4 deletions modules/market/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
45 changes: 32 additions & 13 deletions tests/unit/modules/MarketModule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
Expand Down Expand Up @@ -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,
});

Expand All @@ -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<string, string>;
Expand Down Expand Up @@ -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();
Expand All @@ -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',
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
Loading