From 50937915014d68a7cf890d8403285d3b13d13e37 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Fri, 12 Jun 2026 10:04:03 +0200 Subject: [PATCH] fix(market): treat rate/volume as decimal strings (human-units throughout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/trader/intent-engine.test.ts | 36 ++--- src/trader/intent-engine.ts | 66 +++++----- src/trader/main.ts | 17 ++- src/trader/negotiation-handler.test.ts | 46 +++---- src/trader/negotiation-handler.ts | 57 ++++---- src/trader/swap-executor.test.ts | 6 +- src/trader/swap-executor.ts | 43 +++++- src/trader/trader-main.ts | 67 +++++++--- src/trader/trader-state-store.test.ts | 22 ++-- src/trader/types.test.ts | 47 ++++--- src/trader/types.ts | 36 ++++- src/trader/utils.ts | 103 +++++++++------ test/e2e-live/helpers/contracts.ts | 8 +- test/e2e-live/helpers/scenario-helpers.ts | 8 +- test/e2e-live/helpers/tenant-fixture.test.ts | 24 ++-- test/e2e/trader-matching.e2e.test.ts | 130 +++++++++---------- test/e2e/trader-multi-agent.e2e.test.ts | 104 +++++++-------- test/e2e/trader-negotiation.e2e.test.ts | 54 ++++---- test/e2e/trader-swap.e2e.test.ts | 16 +-- test/mocks/mock-payments-module.ts | 13 ++ 20 files changed, 523 insertions(+), 380 deletions(-) diff --git a/src/trader/intent-engine.test.ts b/src/trader/intent-engine.test.ts index 028cf40..273851b 100644 --- a/src/trader/intent-engine.test.ts +++ b/src/trader/intent-engine.test.ts @@ -99,10 +99,10 @@ function buildSearchResult(overrides?: { direction?: 'buy' | 'sell'; base_asset?: string; quote_asset?: string; - rate_min?: bigint; - rate_max?: bigint; - volume_min?: bigint; - volume_max?: bigint; + rate_min?: string; + rate_max?: string; + volume_min?: string; + volume_max?: string; escrow_address?: string; deposit_timeout_sec?: number; agentPublicKey?: string; @@ -114,10 +114,10 @@ function buildSearchResult(overrides?: { const dir = overrides?.direction ?? 'sell'; const base = overrides?.base_asset ?? 'ALPHA'; const quote = overrides?.quote_asset ?? 'USD'; - const rateMin = overrides?.rate_min ?? 100n; - const rateMax = overrides?.rate_max ?? 110n; - const volMin = overrides?.volume_min ?? 10n; - const volMax = overrides?.volume_max ?? 100n; + const rateMin = overrides?.rate_min ?? '100'; + const rateMax = overrides?.rate_max ?? '110'; + const volMin = overrides?.volume_min ?? '10'; + const volMax = overrides?.volume_max ?? '100'; const escrow = overrides?.escrow_address ?? 'any'; const timeout = overrides?.deposit_timeout_sec ?? 300; const expiryMs = overrides?.expiry_ms ?? Date.now() + 3_600_000; @@ -135,7 +135,7 @@ function buildSearchResult(overrides?: { rate_max: rateMax, volume_min: volMin, volume_max: volMax, - volume_filled: 0n, + volume_filled: '0', escrow_address: escrow, deposit_timeout_sec: timeout, expiry_ms: expiryMs, @@ -188,11 +188,11 @@ describe('IntentEngine', () => { expect(record.intent.direction).toBe('buy'); expect(record.intent.base_asset).toBe('ALPHA'); expect(record.intent.quote_asset).toBe('USD'); - expect(record.intent.rate_min).toBe(100n); - expect(record.intent.rate_max).toBe(110n); - expect(record.intent.volume_min).toBe(10n); - expect(record.intent.volume_max).toBe(100n); - expect(record.intent.volume_filled).toBe(0n); + expect(record.intent.rate_min).toBe('100'); + expect(record.intent.rate_max).toBe('110'); + expect(record.intent.volume_min).toBe('10'); + expect(record.intent.volume_max).toBe('100'); + expect(record.intent.volume_filled).toBe('0'); expect(record.intent.escrow_address).toBe('any'); expect(record.intent.deposit_timeout_sec).toBe(300); expect(record.intent.expiry_ms).toBeGreaterThan(Date.now()); @@ -470,8 +470,8 @@ describe('IntentEngine', () => { // Counterparty sells at 200-210 (no overlap with 100-110) const result = buildSearchResult({ direction: 'sell', - rate_min: 200n, - rate_max: 210n, + rate_min: '200', + rate_max: '210', agentPublicKey: 'b'.repeat(64), }); market.setSearchResults([result]); @@ -499,8 +499,8 @@ describe('IntentEngine', () => { // Counterparty sells only volume_max=5 (insufficient) const result = buildSearchResult({ direction: 'sell', - volume_min: 1n, - volume_max: 5n, + volume_min: '1', + volume_max: '5', agentPublicKey: 'b'.repeat(64), }); market.setSearchResults([result]); diff --git a/src/trader/intent-engine.ts b/src/trader/intent-engine.ts index 1bfc8fd..ec1a0cf 100644 --- a/src/trader/intent-engine.ts +++ b/src/trader/intent-engine.ts @@ -49,7 +49,7 @@ export interface IntentEngine { /** Mark a counterparty as failed for a given intent so it won't be matched again. */ markCounterpartyFailed(intentId: string, counterpartyPubkey: string): void; /** Record a partial or full fill after a successful swap. */ - recordFill(intentId: string, filledVolume: bigint): void; + recordFill(intentId: string, filledVolume: string): void; /** Look up an intent by its MarketModule ID (UUID). */ getIntentByMarketId?(marketIntentId: string): IntentRecord | null; /** Update the strategy (e.g., after SET_STRATEGY command). */ @@ -284,15 +284,18 @@ export function createIntentEngine(deps: IntentEngineDeps): IntentEngine { if (own.base_asset !== parsed.base_asset) return false; if (own.quote_asset !== parsed.quote_asset) return false; - // 3. Overlapping rate ranges - if (own.rate_min > parsed.rate_max) return false; - if (parsed.rate_min > own.rate_max) return false; - - // 4. Sufficient volume - const ownAvailable = own.volume_max - own.volume_filled; - const otherAvailable = parsed.volume_max - 0n; // search results show max volume; filled unknown, assume 0 - const minRequired = - own.volume_min > parsed.volume_min ? own.volume_min : parsed.volume_min; + // 3. Overlapping rate ranges (decimal-string comparison via Number; + // rates are dimensionless ratios, well within Number precision) + if (Number(own.rate_min) > Number(parsed.rate_max)) return false; + if (Number(parsed.rate_min) > Number(own.rate_max)) return false; + + // 4. Sufficient volume (volumes are decimal strings in BASE whole + // units; Number precision is fine for any realistic trading volume) + const ownAvailable = Number(own.volume_max) - Number(own.volume_filled); + const otherAvailable = Number(parsed.volume_max); // search results show max volume; filled unknown, assume 0 + const ownMin = Number(own.volume_min); + const parsedMin = Number(parsed.volume_min); + const minRequired = ownMin > parsedMin ? ownMin : parsedMin; const availableForTrade = ownAvailable < otherAvailable ? ownAvailable : otherAvailable; if (availableForTrade < minRequired) return false; @@ -510,10 +513,10 @@ export function createIntentEngine(deps: IntentEngineDeps): IntentEngine { const fanOutLimit = Math.max(1, strategy.max_concurrent_swaps * 3); const candidates = proposingMatches.slice(0, fanOutLimit); - const remainingVolume = current.intent.volume_max - current.intent.volume_filled; + const remainingVolume = Number(current.intent.volume_max) - Number(current.intent.volume_filled); // If remaining volume is zero, skip fan-out and restore to ACTIVE. - if (remainingVolume <= 0n) { + if (remainingVolume <= 0) { logger.info('fan_out_skipped_zero_remaining_volume', { intent_id: own.intent_id, remaining_volume: remainingVolume.toString(), @@ -555,15 +558,15 @@ export function createIntentEngine(deps: IntentEngineDeps): IntentEngine { // therefore deterministic AND aligned with spec — no rotation // needed because the most-likely-best candidate gets first claim // by design. - const candidatesWithVolume: Array = []; + const candidatesWithVolume: Array = []; let remainingToAllocate = remainingVolume; - const ownMin = current.intent.volume_min; + const ownMin = Number(current.intent.volume_min); for (const entry of candidates) { - if (remainingToAllocate <= 0n) break; + if (remainingToAllocate <= 0) break; const parsedCp = parseDescription(entry.description); if (!parsedCp) continue; - const cpMax = parsedCp.volume_max; - const cpMin = parsedCp.volume_min; + const cpMax = Number(parsedCp.volume_max); + const cpMin = Number(parsedCp.volume_min); const minRequired = ownMin > cpMin ? ownMin : cpMin; const share = remainingToAllocate < cpMax ? remainingToAllocate : cpMax; if (share < minRequired) continue; @@ -828,11 +831,15 @@ export function createIntentEngine(deps: IntentEngineDeps): IntentEngine { // Compute expiry_ms from expiry_sec const expiryMs = nowMs() + params.expiry_sec * 1000; - // Build intent fields for ID computation - const rateMin = BigInt(params.rate_min); - const rateMax = BigInt(params.rate_max); - const volumeMin = BigInt(params.volume_min); - const volumeMax = BigInt(params.volume_max); + // Build intent fields for ID computation. Rates are + // dimensionless ratios; volumes are in BASE whole units. Both + // stay as decimal strings throughout — no smallest-unit conversion + // at the trader layer. The SDK converts to smallest units at + // transfer time via TokenRegistry, where decimals are known. + const rateMin = params.rate_min; + const rateMax = params.rate_max; + const volumeMin = params.volume_min; + const volumeMax = params.volume_max; const escrowAddress = params.escrow_address ?? DEFAULT_ESCROW; const depositTimeoutSec = params.deposit_timeout_sec ?? DEFAULT_DEPOSIT_TIMEOUT_SEC; @@ -874,7 +881,7 @@ export function createIntentEngine(deps: IntentEngineDeps): IntentEngine { rate_max: rateMax, volume_min: volumeMin, volume_max: volumeMax, - volume_filled: 0n, + volume_filled: '0', escrow_address: escrowAddress, deposit_timeout_sec: depositTimeoutSec, expiry_ms: expiryMs, @@ -1066,20 +1073,21 @@ export function createIntentEngine(deps: IntentEngineDeps): IntentEngine { logger.info('counterparty_marked_failed', { intent_id: localId, counterparty: counterpartyPubkey }); }, - recordFill(intentId: string, filledVolume: bigint): void { + recordFill(intentId: string, filledVolume: string): void { const record = resolveIntentByEitherId(intentId); if (!record) return; - const newFilled = record.intent.volume_filled + filledVolume; - const remaining = record.intent.volume_max - newFilled; + const newFilledNum = Number(record.intent.volume_filled) + Number(filledVolume); + const newFilled = String(newFilledNum); + const remaining = Number(record.intent.volume_max) - newFilledNum; const targetState: IntentState = - (remaining <= 0n || remaining < record.intent.volume_min) + (remaining <= 0 || remaining < Number(record.intent.volume_min)) ? 'FILLED' : 'PARTIALLY_FILLED'; transitionIntent(record, targetState, { volume_filled: newFilled }); logger.info('intent_fill_recorded', { intent_id: intentId, - filled_volume: filledVolume.toString(), - total_filled: newFilled.toString(), + filled_volume: filledVolume, + total_filled: newFilled, new_state: targetState, }); diff --git a/src/trader/main.ts b/src/trader/main.ts index f21d4cf..af45b54 100644 --- a/src/trader/main.ts +++ b/src/trader/main.ts @@ -11,7 +11,12 @@ * Shutdown: SIGTERM → agent.stop() → sphere.destroy() */ -import { Sphere, verifySignedMessage } from '@unicitylabs/sphere-sdk'; +import { + Sphere, + verifySignedMessage, + getTokenDecimals, + getCoinIdBySymbol, +} from '@unicitylabs/sphere-sdk'; import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs'; import type { DirectMessage, SphereEventType } from '@unicitylabs/sphere-sdk'; import * as fs from 'node:fs'; @@ -438,6 +443,16 @@ export async function startTrader(): Promise { const asset = assets.find(a => a.coinId === coinId || a.symbol === coinId); return asset ? BigInt(asset.confirmedAmount) : 0n; }, + getDecimals(coinId: string): number { + // Accept either a hex coinId or a symbol. Resolve via the SDK's + // TokenRegistry singleton. Falls back to 18 (the convention for + // most 18-decimal testnet coins) when the registry has no entry. + const resolvedCoinId = /^[0-9a-fA-F]{64}$/.test(coinId) + ? coinId + : (getCoinIdBySymbol(coinId) ?? coinId); + const decimals = getTokenDecimals(resolvedCoinId); + return decimals > 0 ? decimals : 18; + }, getAllBalances() { const assets = sphere.payments.getBalance(); return assets.map((a) => ({ diff --git a/src/trader/negotiation-handler.test.ts b/src/trader/negotiation-handler.test.ts index fdab403..bc969e2 100644 --- a/src/trader/negotiation-handler.test.ts +++ b/src/trader/negotiation-handler.test.ts @@ -64,11 +64,11 @@ function makeOwnIntent(): IntentRecord { direction: 'sell', base_asset: 'ALPHA', quote_asset: 'BRAVO', - rate_min: 100n, - rate_max: 200n, - volume_min: 10n, - volume_max: 100n, - volume_filled: 0n, + rate_min: '100', + rate_max: '200', + volume_min: '10', + volume_max: '100', + volume_filled: '0', escrow_address: 'escrow-addr-1', deposit_timeout_sec: 60, expiry_ms: Date.now() + 3_600_000, @@ -149,8 +149,8 @@ function buildProposeDealTerms(overrides: Partial = {}): DealTerms { acceptor_address: AGENT_ADDRESS, base_asset: 'ALPHA', quote_asset: 'BRAVO', - rate: 150n, - volume: 50n, + rate: '150', + volume: '50', proposer_direction: 'sell', escrow_address: 'escrow-addr-1', deposit_timeout_sec: 60, @@ -198,7 +198,7 @@ describe('NegotiationHandler', () => { it('creates a deal with correct DealTerms and content-addressed deal_id', async () => { const ownIntent = makeOwnIntent(); const counterparty = makeCounterparty(); - const deal = await handler.proposeDeal(ownIntent, counterparty, 150n, 50n, 'escrow-1'); + const deal = await handler.proposeDeal(ownIntent, counterparty, '150', '50', 'escrow-1'); expect(deal.terms.proposer_pubkey).toBe(AGENT_PUBKEY); expect(deal.terms.acceptor_pubkey).toBe(COUNTERPARTY_PUBKEY); @@ -210,8 +210,8 @@ describe('NegotiationHandler', () => { expect(deal.terms.acceptor_intent_id).toBe(counterparty.id); expect(deal.terms.base_asset).toBe('ALPHA'); expect(deal.terms.quote_asset).toBe('BRAVO'); - expect(deal.terms.rate).toBe(150n); - expect(deal.terms.volume).toBe(50n); + expect(deal.terms.rate).toBe('150'); + expect(deal.terms.volume).toBe('50'); expect(deal.terms.escrow_address).toBe('escrow-1'); // deal_id is content-addressed @@ -222,7 +222,7 @@ describe('NegotiationHandler', () => { it('sends np.propose_deal DM with correct NpMessage envelope', async () => { const deal = await handler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); expect(deps.sendDm).toHaveBeenCalledTimes(1); @@ -240,7 +240,7 @@ describe('NegotiationHandler', () => { it('transitions to PROPOSED state', async () => { const deal = await handler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); expect(deal.state).toBe('PROPOSED'); @@ -254,7 +254,7 @@ describe('NegotiationHandler', () => { try { const localHandler = createNegotiationHandler(createDeps()); const deal = await localHandler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); // Before timeout: still PROPOSED @@ -458,7 +458,7 @@ describe('NegotiationHandler', () => { it('validates sender is acceptor and transitions PROPOSED -> ACCEPTED', async () => { // First, propose a deal (our agent is proposer) const deal = await handler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); expect(deal.state).toBe('PROPOSED'); @@ -480,7 +480,7 @@ describe('NegotiationHandler', () => { it('calls onDealAccepted callback', async () => { const deal = await handler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); const acceptMsg = buildNpMessage( @@ -499,7 +499,7 @@ describe('NegotiationHandler', () => { it('rejects accept from non-acceptor pubkey', async () => { const deal = await handler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); const acceptMsg = buildNpMessage( @@ -522,7 +522,7 @@ describe('NegotiationHandler', () => { describe('handleIncomingDm() — np.reject_deal', () => { it('transitions PROPOSED deal to CANCELLED', async () => { const deal = await handler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); const rejectMsg = buildNpMessage( @@ -670,7 +670,7 @@ describe('NegotiationHandler', () => { const localDeps = createDeps(); const localHandler = createNegotiationHandler(localDeps); const deal = await localHandler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); expect(localHandler.getDeal(deal.terms.deal_id)!.state).toBe('PROPOSED'); @@ -714,7 +714,7 @@ describe('NegotiationHandler', () => { it('cancelPending() cancels all non-terminal deals', async () => { // Create a PROPOSED deal const deal1 = await handler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); // Create an ACCEPTED deal (via incoming propose) @@ -734,7 +734,7 @@ describe('NegotiationHandler', () => { it('cancelPending() does not touch terminal deals', async () => { // Create and then reject a deal so it becomes CANCELLED const deal = await handler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); const rejectMsg = buildNpMessage( deal.terms.deal_id, @@ -757,7 +757,7 @@ describe('NegotiationHandler', () => { const localHandler = createNegotiationHandler(localDeps); await localHandler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); // stop() should clear the timer — advancing time should not cause transition @@ -887,7 +887,7 @@ describe('NegotiationHandler', () => { it('handleAcceptDeal attaches received np.accept_deal as counterparty_envelope', async () => { const deal = await handler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); const acceptMsg = buildNpMessage( deal.terms.deal_id, @@ -911,7 +911,7 @@ describe('NegotiationHandler', () => { // envelope until np.accept_deal arrives. hydrateDeal will refuse to // trust this record on restart — reconciliation must skip the reject. const deal = await handler.proposeDeal( - makeOwnIntent(), makeCounterparty(), 150n, 50n, 'escrow-1', + makeOwnIntent(), makeCounterparty(), '150', '50', 'escrow-1', ); expect(deal.counterparty_envelope).toBeUndefined(); }); diff --git a/src/trader/negotiation-handler.ts b/src/trader/negotiation-handler.ts index 4cc81f7..dedaae0 100644 --- a/src/trader/negotiation-handler.ts +++ b/src/trader/negotiation-handler.ts @@ -154,8 +154,8 @@ export interface NegotiationHandler { proposeDeal( ownIntent: IntentRecord, counterparty: MarketSearchResult, - agreedRate: bigint, - agreedVolume: bigint, + agreedRate: string, + agreedVolume: string, escrowAddress: string, ): Promise; @@ -287,7 +287,7 @@ export interface NegotiationHandlerDeps { agentAddress: string; logger: Logger; /** Look up the acceptor's own intent by ID for proposal validation. */ - getIntent?: (intentId: string) => { direction: 'buy' | 'sell'; base_asset: string; quote_asset: string; rate_min: bigint; rate_max: bigint; volume_min: bigint; volume_max: bigint } | null; + getIntent?: (intentId: string) => { direction: 'buy' | 'sell'; base_asset: string; quote_asset: string; rate_min: string; rate_max: string; volume_min: string; volume_max: string } | null; /** Return the strategy's trusted escrow list for proposal validation. */ getTrustedEscrows?: () => readonly string[]; /** @@ -857,17 +857,16 @@ export function createNegotiationHandler(deps: NegotiationHandlerDeps): Negotiat return; } - // Convert rate/volume to bigint if they arrive as strings (wire format) - let rate: bigint; - let volume: bigint; - try { - rate = BigInt(String(termsRaw['rate'] ?? '0')); - volume = BigInt(String(termsRaw['volume'] ?? '0')); - } catch { - logger.warn('np_propose_deal_invalid_bigint', { + // Wire format already carries rate/volume as decimal strings; + // validate they're non-negative decimal strings before accepting. + const NUM_RE = /^\d+(?:\.\d+)?$/; + const rate = String(termsRaw['rate'] ?? '0'); + const volume = String(termsRaw['volume'] ?? '0'); + if (!NUM_RE.test(rate) || !NUM_RE.test(volume)) { + logger.warn('np_propose_deal_invalid_decimal', { deal_id: msg.deal_id, - rate: String(termsRaw['rate'] ?? ''), - volume: String(termsRaw['volume'] ?? ''), + rate, + volume, }); return; } @@ -1041,23 +1040,23 @@ export function createNegotiationHandler(deps: NegotiationHandlerDeps): Negotiat } // Verify rate is within acceptor's range - if (terms.rate < acceptorIntent.rate_min || terms.rate > acceptorIntent.rate_max) { + if (Number(terms.rate) < Number(acceptorIntent.rate_min) || Number(terms.rate) > Number(acceptorIntent.rate_max)) { logger.warn('np_propose_deal_rate_out_of_range', { deal_id: msg.deal_id, - rate: terms.rate.toString(), - rate_min: acceptorIntent.rate_min.toString(), - rate_max: acceptorIntent.rate_max.toString(), + rate: terms.rate, + rate_min: acceptorIntent.rate_min, + rate_max: acceptorIntent.rate_max, }); return; } // Verify volume is within acceptor's range - if (terms.volume < acceptorIntent.volume_min || terms.volume > acceptorIntent.volume_max) { + if (Number(terms.volume) < Number(acceptorIntent.volume_min) || Number(terms.volume) > Number(acceptorIntent.volume_max)) { logger.warn('np_propose_deal_volume_out_of_range', { deal_id: msg.deal_id, - volume: terms.volume.toString(), - volume_min: acceptorIntent.volume_min.toString(), - volume_max: acceptorIntent.volume_max.toString(), + volume: terms.volume, + volume_min: acceptorIntent.volume_min, + volume_max: acceptorIntent.volume_max, }); return; } @@ -1249,8 +1248,8 @@ export function createNegotiationHandler(deps: NegotiationHandlerDeps): Negotiat logger.info('np_deal_accepted', { deal_id: msg.deal_id, proposer: terms.proposer_pubkey, - rate: terms.rate.toString(), - volume: terms.volume.toString(), + rate: terms.rate, + volume: terms.volume, }); // Only after the counterparty has been notified do we launch swap @@ -1505,8 +1504,8 @@ export function createNegotiationHandler(deps: NegotiationHandlerDeps): Negotiat async function proposeDeal( ownIntent: IntentRecord, counterparty: MarketSearchResult, - agreedRate: bigint, - agreedVolume: bigint, + agreedRate: string, + agreedVolume: string, escrowAddress: string, ): Promise { const now = Date.now(); @@ -1592,8 +1591,8 @@ export function createNegotiationHandler(deps: NegotiationHandlerDeps): Negotiat logger.info('np_deal_proposed', { deal_id: dealId, acceptor: counterparty.agentPublicKey, - rate: agreedRate.toString(), - volume: agreedVolume.toString(), + rate: agreedRate, + volume: agreedVolume, }); return dealRecord; @@ -2056,8 +2055,8 @@ export function createNegotiationHandler(deps: NegotiationHandlerDeps): Negotiat acceptor_address: String(raw['acceptor_address'] ?? ''), base_asset: String(raw['base_asset'] ?? ''), quote_asset: String(raw['quote_asset'] ?? ''), - rate: BigInt(String(raw['rate'] ?? '0')), - volume: BigInt(String(raw['volume'] ?? '0')), + rate: String(raw['rate'] ?? '0'), + volume: String(raw['volume'] ?? '0'), proposer_direction: String(raw['proposer_direction'] ?? 'sell') === 'buy' ? 'buy' : 'sell', escrow_address: String(raw['escrow_address'] ?? ''), deposit_timeout_sec: Number(raw['deposit_timeout_sec'] ?? 0), diff --git a/src/trader/swap-executor.test.ts b/src/trader/swap-executor.test.ts index c7a5a13..5d1ed07 100644 --- a/src/trader/swap-executor.test.ts +++ b/src/trader/swap-executor.test.ts @@ -67,8 +67,8 @@ function makeDealTerms(overrides?: Partial): DealTerms { acceptor_address: 'counterparty-addr', base_asset: 'ALPHA', quote_asset: 'USDC', - rate: 50n as unknown as bigint, - volume: 10n as unknown as bigint, + rate: '50', + volume: '10', proposer_direction: 'sell', escrow_address: 'escrow-1', deposit_timeout_sec: 300, @@ -100,7 +100,7 @@ function makeDeps(overrides?: Partial): SwapExecutorDeps { agentPubkey: AGENT_PUBKEY, agentAddress: AGENT_ADDRESS, swapDirectAddress: AGENT_ADDRESS, - payments: { receive: vi.fn().mockResolvedValue(undefined) }, + payments: { receive: vi.fn().mockResolvedValue(undefined), getDecimals: () => 0 }, logger: makeLogger(), ...overrides, }; diff --git a/src/trader/swap-executor.ts b/src/trader/swap-executor.ts index 5dc6dd5..57dfa15 100644 --- a/src/trader/swap-executor.ts +++ b/src/trader/swap-executor.ts @@ -165,6 +165,8 @@ export interface SwapExecutor { export interface SwapPaymentsAdapter { /** Trigger receive({ finalize: true }) to pick up payout tokens via DM. */ receive(): Promise; + /** Decimals lookup — backed by sphere-sdk TokenRegistry. */ + getDecimals(coinId: string): number; } export interface SwapExecutorDeps { @@ -222,10 +224,36 @@ function transitionDeal(deal: DealRecord, newState: DealState, swapId?: string | * * Exported so callers (e.g. trader-main.ts onDealAccepted) can use it directly. */ +/** + * Convert a whole-unit decimal value (volume or volume*rate) to the + * smallest-units integer-string required by sphere-sdk's swap module + * (\ validator). \ come from the + * SDK's TokenRegistry at the call site. + */ +function toSwapAmountString(whole: number, decimals: number): string { + const fixed = whole.toFixed(decimals); + const [intPart, fracPartRaw = ''] = fixed.split('.'); + const fracPart = fracPartRaw.padEnd(decimals, '0').slice(0, decimals); + // Strip a leading 0 from the integer part (the SDK accepts '^[1-9]' only), + // then strip leading zeros that the concat introduces. + const joined = (intPart ?? '0') + fracPart; + const stripped = joined.replace(/^0+/, ''); + return stripped || '0'; +} + export function buildSwapDealInput( deal: DealRecord, agentPubkey: string, agentAddress: string, + /** + * Optional decimals lookup. When omitted (e.g. legacy callers, unit + * tests using small-number fixtures), the function treats terms.volume + * and terms.rate as already in their final units (matching the + * pre-decimal-refactor behaviour). When provided, the function + * converts terms.volume (whole base units) and terms.volume * rate + * (whole quote units) into smallest-units integer strings. + */ + getDecimals?: (coinId: string) => number, ): SwapDealInput { const { terms } = deal; // Use pubkeysEqual to handle format drift between terms.*_pubkey (wire format, @@ -235,8 +263,15 @@ export function buildSwapDealInput( const ourAddress = agentAddress; const theirAddress = isProposer ? terms.acceptor_address : terms.proposer_address; - const baseAmount = terms.volume.toString(); - const quoteAmount = (terms.rate * terms.volume).toString(); + const baseAmount = getDecimals + ? toSwapAmountString(Number(terms.volume), getDecimals(terms.base_asset)) + : terms.volume; + const quoteAmount = getDecimals + ? toSwapAmountString( + Number(terms.volume) * Number(terms.rate), + getDecimals(terms.quote_asset), + ) + : String(Number(terms.rate) * Number(terms.volume)); const proposerSellsBase = terms.proposer_direction === 'sell'; @@ -475,7 +510,7 @@ export function createSwapExecutor(deps: SwapExecutorDeps): SwapExecutor { } // 4. Build SwapDealInput from DealTerms (proposer only) - const swapDealInput = buildSwapDealInput(deal, agentPubkey, swapAddress); + const swapDealInput = buildSwapDealInput(deal, agentPubkey, swapAddress, deps.payments?.getDecimals); // DIAGNOSTIC: log the full SwapDealInput so we can verify direction-to- // currency mapping. Bug-suspicion: if proposer is direction='buy' but @@ -644,7 +679,7 @@ export function createSwapExecutor(deps: SwapExecutorDeps): SwapExecutor { let candidates: ActiveDeal[] = nullSwapEntries; if (match !== undefined) { candidates = nullSwapEntries.filter((entry) => { - const input = buildSwapDealInput(entry.deal, agentPubkey, swapAddress); + const input = buildSwapDealInput(entry.deal, agentPubkey, swapAddress, deps.payments?.getDecimals); if (match.partyACurrency !== undefined && match.partyACurrency !== input.partyACurrency) return false; if (match.partyAAmount !== undefined && match.partyAAmount !== input.partyAAmount) return false; if (match.partyBCurrency !== undefined && match.partyBCurrency !== input.partyBCurrency) return false; diff --git a/src/trader/trader-main.ts b/src/trader/trader-main.ts index dc776f2..c75e678 100644 --- a/src/trader/trader-main.ts +++ b/src/trader/trader-main.ts @@ -14,6 +14,22 @@ import { join } from 'node:path'; import { pubkeysEqual } from '../shared/crypto.js'; import { withTimeout } from '../shared/with-timeout.js'; + +/** + * Convert a whole-unit number to a smallest-units bigint at the given + * decimal precision. Used at the wallet-reservation boundary where the + * SDK's bigint balances must be compared against the trader's + * human-units terms. Decimals are the only thing we ever look up; + * everywhere ELSE the trader works in plain decimal strings. + */ +function toSmallestUnitsBigInt(amount: number, decimals: number): bigint { + // `amount.toFixed(decimals)` rounds to the target precision; we then + // strip the decimal point and BigInt the resulting integer-string. + const fixed = amount.toFixed(decimals); + const [intPart, fracPartRaw = ''] = fixed.split('.'); + const fracPart = fracPartRaw.padEnd(decimals, '0').slice(0, decimals); + return BigInt((intPart ?? '0') + fracPart); +} import type { Logger } from '../shared/logger.js'; import type { TenantConfig } from '../shared/types.js'; import type { SphereDmSender, SphereDmReceiver } from '../tenant/types.js'; @@ -337,19 +353,23 @@ export function createTraderAgent(deps: TraderMainDeps): TraderAgent { } // Compute midpoint of the overlap range (spec Section 5) - const overlapMin = ownIntent.intent.rate_min > parsed.rate_min - ? ownIntent.intent.rate_min : parsed.rate_min; - const overlapMax = ownIntent.intent.rate_max < parsed.rate_max - ? ownIntent.intent.rate_max : parsed.rate_max; - const midRate = (overlapMin + overlapMax) / 2n; + // Decimal-string rates compared via Number — dimensionless ratios + // well within 2^53 precision. Midpoint serialized back to string. + const ownRateMin = Number(ownIntent.intent.rate_min); + const ownRateMax = Number(ownIntent.intent.rate_max); + const cpRateMin = Number(parsed.rate_min); + const cpRateMax = Number(parsed.rate_max); + const overlapMin = ownRateMin > cpRateMin ? ownRateMin : cpRateMin; + const overlapMax = ownRateMax < cpRateMax ? ownRateMax : cpRateMax; + const midRate = String((overlapMin + overlapMax) / 2); // Fan-out safety (W): when the intent-engine fans out to N candidates, // each receives remainingVolume/N instead of the full remainingVolume // so that two concurrent accepts cannot exceed volume_max. The per- // candidate volume is attached to the MarketSearchResult by the // intent engine as `__perCandidateVolume` (internal field). - const withPerCandidate = counterparty as MarketSearchResult & { __perCandidateVolume?: bigint }; - const remainingVolume = ownIntent.intent.volume_max - ownIntent.intent.volume_filled; + const withPerCandidate = counterparty as MarketSearchResult & { __perCandidateVolume?: number }; + const remainingVolume = Number(ownIntent.intent.volume_max) - Number(ownIntent.intent.volume_filled); const myShare = withPerCandidate.__perCandidateVolume ?? remainingVolume; // Cap to counterparty's [volume_min, volume_max] from the parsed @@ -357,21 +377,23 @@ export function createTraderAgent(deps: TraderMainDeps): TraderAgent { // capacity, so it can exceed the counterparty's max — which the // receiver rejects as VOLUME_OUT_OF_RANGE. Take the intersection of // both [vol_min, vol_max] ranges; if it's empty, abort the proposal. - const overlapMaxVol = myShare < parsed.volume_max ? myShare : parsed.volume_max; - const overlapMinVol = ownIntent.intent.volume_min > parsed.volume_min - ? ownIntent.intent.volume_min : parsed.volume_min; - if (overlapMaxVol < overlapMinVol || overlapMaxVol <= 0n) { + const cpVolMax = Number(parsed.volume_max); + const cpVolMin = Number(parsed.volume_min); + const ownVolMin = Number(ownIntent.intent.volume_min); + const overlapMaxVol = myShare < cpVolMax ? myShare : cpVolMax; + const overlapMinVol = ownVolMin > cpVolMin ? ownVolMin : cpVolMin; + if (overlapMaxVol < overlapMinVol || overlapMaxVol <= 0) { logger.info('match_found_no_volume_overlap', { intent_id: ownIntent.intent.intent_id, counterparty: counterparty.agentPublicKey, - my_share: myShare.toString(), + my_share: String(myShare), cp_volume_min: parsed.volume_min.toString(), cp_volume_max: parsed.volume_max.toString(), own_volume_min: ownIntent.intent.volume_min.toString(), }); return; } - const proposalVolume = overlapMaxVol; + const proposalVolume = String(overlapMaxVol); // Resolve a CONCRETE escrow address before proposing. Intents with // escrow_address='any' (the default) advertise flexibility, but the // np.propose_deal envelope MUST carry a real address: the receiver's @@ -459,13 +481,16 @@ export function createTraderAgent(deps: TraderMainDeps): TraderAgent { (weAreProposer && proposerSells) || (!weAreProposer && !proposerSells); const assetToReserve = weAreSeller ? deal.terms.base_asset : deal.terms.quote_asset; // Seller delivers `volume` units of base; buyer delivers `volume*rate` - // units of quote. The previous reservation always used `volume` which - // under-reserved the quote side whenever rate>1 (e.g. rate=2 deal - // would reserve 100 USDU but actually owe 200 USDU on payout, leading - // to insufficient-funds failures during settlement). - const amountToReserve = weAreSeller - ? deal.terms.volume - : deal.terms.volume * deal.terms.rate; + // units of quote. Both terms now arrive as decimal strings (whole + // units / dimensionless ratios); convert to smallest-units bigint + // here using the SDK's TokenRegistry decimals. This is the one + // place where decimals matter — at the actual wallet-reservation + // boundary, NOT at the description/match/search layers. + const amountWhole = weAreSeller + ? Number(deal.terms.volume) + : Number(deal.terms.volume) * Number(deal.terms.rate); + const decimals = payments.getDecimals(assetToReserve); + const amountToReserve = toSmallestUnitsBigInt(amountWhole, decimals); let reserved = false; try { @@ -740,7 +765,7 @@ export function createTraderAgent(deps: TraderMainDeps): TraderAgent { agentPubkey, agentAddress, swapDirectAddress: deps.swapDirectAddress ?? agentAddress, - payments: { receive: () => payments.refresh() }, + payments: { receive: () => payments.refresh(), getDecimals: payments.getDecimals.bind(payments) }, logger: logger.child({ component: 'swap-executor' }), }); diff --git a/src/trader/trader-state-store.test.ts b/src/trader/trader-state-store.test.ts index 2088551..89308e6 100644 --- a/src/trader/trader-state-store.test.ts +++ b/src/trader/trader-state-store.test.ts @@ -26,11 +26,11 @@ const sampleIntent: IntentRecord = { direction: 'buy', base_asset: 'ALPHA', quote_asset: 'USD', - rate_min: 100n, - rate_max: 200n, - volume_min: 10n, - volume_max: 50n, - volume_filled: 0n, + rate_min: '100', + rate_max: '200', + volume_min: '10', + volume_max: '50', + volume_filled: '0', escrow_address: 'escrow-1', deposit_timeout_sec: 300, expiry_ms: 9999999999999, @@ -52,8 +52,8 @@ const sampleDeal: DealRecord = { acceptor_address: 'addr-q', base_asset: 'ALPHA', quote_asset: 'USD', - rate: 150n, - volume: 25n, + rate: '150', + volume: '25', proposer_direction: 'sell', escrow_address: 'escrow-2', deposit_timeout_sec: 600, @@ -132,8 +132,8 @@ describe('createFsTraderStateStore', () => { expect(loaded!.intent.intent_id).toBe('int-001'); expect(loaded!.state).toBe('ACTIVE'); // Verify bigint round-trip - expect(loaded!.intent.rate_min).toBe(100n); - expect(loaded!.intent.rate_max).toBe(200n); + expect(loaded!.intent.rate_min).toBe('100'); + expect(loaded!.intent.rate_max).toBe('200'); }); it('saveIntent + loadIntents with filter', async () => { @@ -188,8 +188,8 @@ describe('createFsTraderStateStore', () => { expect(loaded!.terms.deal_id).toBe('deal-001'); expect(loaded!.state).toBe('PROPOSED'); // Verify bigint round-trip - expect(loaded!.terms.rate).toBe(150n); - expect(loaded!.terms.volume).toBe(25n); + expect(loaded!.terms.rate).toBe('150'); + expect(loaded!.terms.volume).toBe('25'); }); it('saveDeal + loadDeals with state filter', async () => { diff --git a/src/trader/types.test.ts b/src/trader/types.test.ts index d871945..feb138f 100644 --- a/src/trader/types.test.ts +++ b/src/trader/types.test.ts @@ -111,10 +111,10 @@ const sampleIntentFields = { direction: 'buy' as const, base_asset: 'ALPHA', quote_asset: 'BRAVO', - rate_min: 100n, - rate_max: 200n, - volume_min: 500n, - volume_max: 1000n, + rate_min: '100', + rate_max: '200', + volume_min: '500', + volume_max: '1000', escrow_address: 'escrow_01', deposit_timeout_sec: 60, expiry_ms: 1700000000000, @@ -175,11 +175,11 @@ const sampleIntent: TradingIntent = { direction: 'sell', base_asset: 'ALPHA', quote_asset: 'BRAVO', - rate_min: 100n, - rate_max: 200n, - volume_min: 500n, - volume_max: 1000n, - volume_filled: 0n, + rate_min: '100', + rate_max: '200', + volume_min: '500', + volume_max: '1000', + volume_filled: '0', escrow_address: 'escrow_01', deposit_timeout_sec: 60, expiry_ms: 1700000000000, @@ -211,10 +211,10 @@ describe('parseDescription', () => { expect(parsed!.direction).toBe('sell'); expect(parsed!.base_asset).toBe('ALPHA'); expect(parsed!.quote_asset).toBe('BRAVO'); - expect(parsed!.volume_min).toBe(500n); - expect(parsed!.volume_max).toBe(1000n); - expect(parsed!.rate_min).toBe(100n); - expect(parsed!.rate_max).toBe(200n); + expect(parsed!.volume_min).toBe('500'); + expect(parsed!.volume_max).toBe('1000'); + expect(parsed!.rate_min).toBe('100'); + expect(parsed!.rate_max).toBe('200'); expect(parsed!.escrow_address).toBe('escrow_01'); expect(parsed!.deposit_timeout_sec).toBe(60); expect(parsed!.expiry_ms).toBe(1700000000000); @@ -323,8 +323,8 @@ const validTerms: DealTerms = { acceptor_address: 'addr_02', base_asset: 'ALPHA', quote_asset: 'BRAVO', - rate: 150n, - volume: 500n, + rate: '150', + volume: '500', proposer_direction: 'sell', escrow_address: 'escrow_01', deposit_timeout_sec: 60, @@ -342,12 +342,12 @@ describe('validateDealTerms', () => { }); it('rejects zero rate', () => { - const err = validateDealTerms({ ...validTerms, rate: 0n }); + const err = validateDealTerms({ ...validTerms, rate: '0' }); expect(err).toContain('rate'); }); it('rejects zero volume', () => { - const err = validateDealTerms({ ...validTerms, volume: 0n }); + const err = validateDealTerms({ ...validTerms, volume: '0' }); expect(err).toContain('volume'); }); @@ -373,14 +373,19 @@ describe('validateDealTerms', () => { }); // M3 — rate / volume upper bound (2^128) - it('rejects rate > 2^128', () => { - const err = validateDealTerms({ ...validTerms, rate: 2n ** 128n + 1n }); + // M3 (post-decimal-refactor): rate / volume bound is Number.MAX_SAFE_INTEGER. + // Rates are dimensionless ratios; volumes are in whole base units — + // neither approaches 2^53 in any realistic trade. + it('rejects rate above Number.MAX_SAFE_INTEGER', () => { + const tooBig = String(Number.MAX_SAFE_INTEGER + 1); + const err = validateDealTerms({ ...validTerms, rate: tooBig }); expect(err).toContain('rate'); expect(err).toContain('maximum'); }); - it('rejects volume > 2^128', () => { - const err = validateDealTerms({ ...validTerms, volume: 2n ** 128n + 1n }); + it('rejects volume above Number.MAX_SAFE_INTEGER', () => { + const tooBig = String(Number.MAX_SAFE_INTEGER + 1); + const err = validateDealTerms({ ...validTerms, volume: tooBig }); expect(err).toContain('volume'); expect(err).toContain('maximum'); }); diff --git a/src/trader/types.ts b/src/trader/types.ts index bd878ea..4eaa9a4 100644 --- a/src/trader/types.ts +++ b/src/trader/types.ts @@ -69,11 +69,24 @@ export interface TradingIntent { readonly direction: 'buy' | 'sell'; readonly base_asset: string; readonly quote_asset: string; - readonly rate_min: bigint; - readonly rate_max: bigint; - readonly volume_min: bigint; - readonly volume_max: bigint; - readonly volume_filled: bigint; + /** + * Trading rate as a decimal string in QUOTE per BASE (whole units — + * no smallest-unit conversion). Per project convention: bigints are + * for token amounts at the transfer/storage layer; rates are + * dimensionless ratios and stay as plain decimal strings. The trader + * passes these straight through to encodeDescription/buildSearchQuery, + * and the semantic-search engine handles them as human-readable text. + */ + readonly rate_min: string; + readonly rate_max: string; + /** + * Trading volume as a decimal string in BASE whole units. Smallest- + * unit conversion (for actual SDK token transfers) happens at swap + * settlement time via the SDK's TokenRegistry. + */ + readonly volume_min: string; + readonly volume_max: string; + readonly volume_filled: string; readonly escrow_address: string; readonly deposit_timeout_sec: number; readonly expiry_ms: number; @@ -105,8 +118,10 @@ export interface DealTerms { readonly acceptor_address: string; readonly base_asset: string; readonly quote_asset: string; - readonly rate: bigint; - readonly volume: bigint; + /** Agreed rate as a decimal string (quote per base whole units). */ + readonly rate: string; + /** Agreed volume as a decimal string (base whole units). */ + readonly volume: string; readonly proposer_direction: 'buy' | 'sell'; readonly escrow_address: string; readonly deposit_timeout_sec: number; @@ -246,6 +261,13 @@ export interface PaymentsAdapter { getConfirmedBalance(coinId: string): bigint; /** Get all asset balances (no filter). */ getAllBalances(): SdkAssetBalance[]; + /** + * Decimals for a given coin (smallest-unit ↔ whole-unit conversion + * factor). Backed by sphere-sdk's TokenRegistry.getTokenDecimals(). + * Returns 18 as a sensible default when the registry has no entry + * (matches the default for un-curated test coins on testnet). + */ + getDecimals(coinId: string): number; /** Trigger a receive to fetch pending Nostr transfers. */ refresh(): Promise; /** diff --git a/src/trader/utils.ts b/src/trader/utils.ts index 8036c71..4012fdb 100644 --- a/src/trader/utils.ts +++ b/src/trader/utils.ts @@ -38,10 +38,10 @@ export function computeIntentId(intent: { readonly direction: 'buy' | 'sell'; readonly base_asset: string; readonly quote_asset: string; - readonly rate_min: bigint; - readonly rate_max: bigint; - readonly volume_min: bigint; - readonly volume_max: bigint; + readonly rate_min: string; + readonly rate_max: string; + readonly volume_min: string; + readonly volume_max: string; readonly escrow_address: string; readonly deposit_timeout_sec: number; readonly expiry_ms: number; @@ -73,8 +73,8 @@ export function computeIntentId(intent: { export function encodeDescription(intent: TradingIntent): string { const verb = intent.direction === 'sell' ? 'Selling' : 'Buying'; return [ - `${verb} ${intent.volume_min.toString()}-${intent.volume_max.toString()} ${intent.base_asset} for ${intent.quote_asset}.`, - `Rate: ${intent.rate_min.toString()}-${intent.rate_max.toString()} ${intent.quote_asset} per ${intent.base_asset}.`, + `${verb} ${intent.volume_min}-${intent.volume_max} ${intent.base_asset} for ${intent.quote_asset}.`, + `Rate: ${intent.rate_min}-${intent.rate_max} ${intent.quote_asset} per ${intent.base_asset}.`, `Escrow: ${intent.escrow_address}.`, `Deposit timeout: ${String(intent.deposit_timeout_sec)}s.`, `Expires: ${String(intent.expiry_ms)}.`, @@ -89,18 +89,18 @@ export interface ParsedDescription { readonly direction: 'buy' | 'sell'; readonly base_asset: string; readonly quote_asset: string; - readonly volume_min: bigint; - readonly volume_max: bigint; - readonly rate_min: bigint; - readonly rate_max: bigint; + readonly volume_min: string; + readonly volume_max: string; + readonly rate_min: string; + readonly rate_max: string; readonly escrow_address: string; readonly deposit_timeout_sec: number; /** Epoch ms when the intent expires. 0 if not present (legacy descriptions). */ readonly expiry_ms: number; } -const HEADER_RE = /^(Selling|Buying)\s+(\d+)-(\d+)\s+([A-Z0-9_]+)\s+for\s+([A-Z0-9_]+)\./; -const RATE_RE = /^Rate:\s+(\d+)-(\d+)\s+[A-Z0-9_]+\s+per\s+[A-Z0-9_]+\./; +const HEADER_RE = /^(Selling|Buying)\s+(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\s+([A-Z0-9_]+)\s+for\s+([A-Z0-9_]+)\./; +const RATE_RE = /^Rate:\s+(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\s+[A-Z0-9_]+\s+per\s+[A-Z0-9_]+\./; // Escrow address: alphanumeric + common address chars, but no '..' sequences const ESCROW_RE = /^Escrow:\s+([A-Za-z0-9_:/@-]+(?:\.[A-Za-z0-9_:/@-]+)*)\./; const TIMEOUT_RE = /^Deposit timeout:\s+(\d+)s\./; @@ -158,10 +158,10 @@ export function parseDescription(desc: string): ParsedDescription | null { direction: verb === 'Selling' ? 'sell' : 'buy', base_asset: baseAsset, quote_asset: quoteAsset, - volume_min: BigInt(volMin), - volume_max: BigInt(volMax), - rate_min: BigInt(rMin), - rate_max: BigInt(rMax), + volume_min: volMin, + volume_max: volMax, + rate_min: rMin, + rate_max: rMax, escrow_address: escrow, deposit_timeout_sec: Number(timeout), expiry_ms: expiryMs, @@ -175,24 +175,33 @@ export function parseDescription(desc: string): ParsedDescription | null { const MAX_EXPIRY_SEC = 7 * 24 * 60 * 60; // 7 days export function validateIntentParams(params: CreateIntentParams): string | null { - let rateMin: bigint; - let rateMax: bigint; - let volumeMin: bigint; - let volumeMax: bigint; - try { - rateMin = BigInt(params.rate_min); - rateMax = BigInt(params.rate_max); - volumeMin = BigInt(params.volume_min); - volumeMax = BigInt(params.volume_max); - } catch { - return 'rate and volume parameters must be valid integer strings'; + // Rates are dimensionless ratios; volumes are in BASE whole units. + // Both are decimal strings (e.g. "0.08", "50"). We compare via Number; + // for trading-sized ratios + asset volumes, Number's 2^53 ceiling is + // far above any realistic value. + const NUM_RE = /^\d+(?:\.\d+)?$/; + if (typeof params.rate_min !== 'string' || !NUM_RE.test(params.rate_min)) { + return 'rate_min must be a non-negative decimal string'; } + if (typeof params.rate_max !== 'string' || !NUM_RE.test(params.rate_max)) { + return 'rate_max must be a non-negative decimal string'; + } + if (typeof params.volume_min !== 'string' || !NUM_RE.test(params.volume_min)) { + return 'volume_min must be a non-negative decimal string'; + } + if (typeof params.volume_max !== 'string' || !NUM_RE.test(params.volume_max)) { + return 'volume_max must be a non-negative decimal string'; + } + const rateMin = Number(params.rate_min); + const rateMax = Number(params.rate_max); + const volumeMin = Number(params.volume_min); + const volumeMax = Number(params.volume_max); - if (rateMin <= 0n) return 'rate_min must be positive'; - if (rateMax <= 0n) return 'rate_max must be positive'; + if (!(rateMin > 0)) return 'rate_min must be positive'; + if (!(rateMax > 0)) return 'rate_max must be positive'; if (rateMin > rateMax) return 'rate_min must be <= rate_max'; - if (volumeMin <= 0n) return 'volume_min must be positive'; - if (volumeMax <= 0n) return 'volume_max must be positive'; + if (!(volumeMin > 0)) return 'volume_min must be positive'; + if (!(volumeMax > 0)) return 'volume_max must be positive'; if (volumeMin > volumeMax) return 'volume_min must be <= volume_max'; if (!Number.isFinite(params.expiry_sec)) return 'expiry_sec must be finite'; @@ -232,8 +241,8 @@ export function validateIntentParams(params: CreateIntentParams): string | null * counterparty proposing absurd values that pass the rate-range check on * an intent with unbounded rate_max (legacy intent). */ -const MAX_RATE = 2n ** 128n; -const MAX_VOLUME = 2n ** 128n; +const MAX_RATE = Number.MAX_SAFE_INTEGER; +const MAX_VOLUME = Number.MAX_SAFE_INTEGER; export function validateDealTerms(terms: DealTerms): string | null { if (!terms.deal_id || typeof terms.deal_id !== 'string') return 'deal_id is required'; @@ -252,14 +261,26 @@ export function validateDealTerms(terms: DealTerms): string | null { if (!terms.acceptor_address) return 'acceptor_address is required'; if (!terms.base_asset) return 'base_asset is required'; if (!terms.quote_asset) return 'quote_asset is required'; - if (terms.rate <= 0n) return 'rate must be positive'; - // SECURITY (M3): upper bound on rate / volume. Hostile proposals with - // 2^256 values pass intent-range checks on legacy unbounded intents and - // produce 2^512 in downstream `rate * volume` arithmetic, where uint256 - // truncation may corrupt the actual transferred amount. - if (terms.rate > MAX_RATE) return `rate exceeds maximum (${MAX_RATE})`; - if (terms.volume <= 0n) return 'volume must be positive'; - if (terms.volume > MAX_VOLUME) return `volume exceeds maximum (${MAX_VOLUME})`; + // rate and volume arrive as decimal strings (e.g. "0.08", "50"); + // validate format then compare via Number. Dimensionless ratios are + // well within 2^53 precision; whole-unit volumes in any realistic + // trade are too. + const NUM_RE = /^\d+(?:\.\d+)?$/; + if (typeof terms.rate !== 'string' || !NUM_RE.test(terms.rate)) { + return 'rate must be a non-negative decimal string'; + } + if (typeof terms.volume !== 'string' || !NUM_RE.test(terms.volume)) { + return 'volume must be a non-negative decimal string'; + } + const rateNum = Number(terms.rate); + const volumeNum = Number(terms.volume); + if (!(rateNum > 0)) return 'rate must be positive'; + // SECURITY (M3 historical): upper bound on rate × volume product. + // Decimal-string rates + whole-unit volumes never approach 2^53, so + // the bound is mostly belt-and-suspenders against malicious clients. + if (rateNum > MAX_RATE) return `rate exceeds maximum (${MAX_RATE})`; + if (!(volumeNum > 0)) return 'volume must be positive'; + if (volumeNum > MAX_VOLUME) return `volume exceeds maximum (${MAX_VOLUME})`; if (terms.proposer_direction !== 'buy' && terms.proposer_direction !== 'sell') { return 'proposer_direction must be "buy" or "sell"'; } diff --git a/test/e2e-live/helpers/contracts.ts b/test/e2e-live/helpers/contracts.ts index ce3dbb2..bea4be3 100644 --- a/test/e2e-live/helpers/contracts.ts +++ b/test/e2e-live/helpers/contracts.ts @@ -283,10 +283,10 @@ export type CreateMatchingIntents = ( terms: { base_asset: string; quote_asset: string; - rate_min: bigint; - rate_max: bigint; - volume_min: bigint; - volume_max: bigint; + rate_min: string; + rate_max: string; + volume_min: string; + volume_max: string; }, ) => Promise; diff --git a/test/e2e-live/helpers/scenario-helpers.ts b/test/e2e-live/helpers/scenario-helpers.ts index 9ea03fa..33c1f49 100644 --- a/test/e2e-live/helpers/scenario-helpers.ts +++ b/test/e2e-live/helpers/scenario-helpers.ts @@ -32,10 +32,10 @@ const LIST_DEALS_TIMEOUT_MS = 10_000; interface MatchingIntentsTerms { base_asset: string; quote_asset: string; - rate_min: bigint; - rate_max: bigint; - volume_min: bigint; - volume_max: bigint; + rate_min: string; + rate_max: string; + volume_min: string; + volume_max: string; escrow_address?: string; } diff --git a/test/e2e-live/helpers/tenant-fixture.test.ts b/test/e2e-live/helpers/tenant-fixture.test.ts index 467a76b..808004a 100644 --- a/test/e2e-live/helpers/tenant-fixture.test.ts +++ b/test/e2e-live/helpers/tenant-fixture.test.ts @@ -262,10 +262,10 @@ describe('createMatchingIntents', () => { const result = await createMatchingIntents(buyer, seller, { base_asset: 'UCT', quote_asset: 'USDC', - rate_min: 100n, - rate_max: 200n, - volume_min: 10n, - volume_max: 1000n, + rate_min: '100', + rate_max: '200', + volume_min: '10', + volume_max: '1000', }); expect(result.buyerIntentId).toBe('intent-buyer-1'); @@ -325,10 +325,10 @@ describe('createMatchingIntents', () => { createMatchingIntents(buyer, seller, { base_asset: 'UCT', quote_asset: 'USDC', - rate_min: 100n, - rate_max: 200n, - volume_min: 10n, - volume_max: 1000n, + rate_min: '100', + rate_max: '200', + volume_min: '10', + volume_max: '1000', }), ).rejects.toThrow(/buyer CREATE_INTENT not ok/); }); @@ -353,10 +353,10 @@ describe('createMatchingIntents', () => { const result = await createMatchingIntents(buyer, seller, { base_asset: 'UCT', quote_asset: 'USDC', - rate_min: 100n, - rate_max: 200n, - volume_min: 10n, - volume_max: 1000n, + rate_min: '100', + rate_max: '200', + volume_min: '10', + volume_max: '1000', }); expect(result.buyerIntentId).toBe('wrapped-buy'); diff --git a/test/e2e/trader-matching.e2e.test.ts b/test/e2e/trader-matching.e2e.test.ts index 363607a..a8357bc 100644 --- a/test/e2e/trader-matching.e2e.test.ts +++ b/test/e2e/trader-matching.e2e.test.ts @@ -106,10 +106,10 @@ function buildSearchResult(opts: { direction: 'buy' | 'sell'; baseAsset: string; quoteAsset: string; - rateMin: bigint; - rateMax: bigint; - volumeMin: bigint; - volumeMax: bigint; + rateMin: string; + rateMax: string; + volumeMin: string; + volumeMax: string; escrowAddress?: string; depositTimeoutSec?: number; expiresAt?: string; @@ -132,7 +132,7 @@ function buildSearchResult(opts: { rate_max: opts.rateMax, volume_min: opts.volumeMin, volume_max: opts.volumeMax, - volume_filled: 0n, + volume_filled: '0', escrow_address: escrow, deposit_timeout_sec: timeout, expiry_ms: expiryMs, @@ -238,10 +238,10 @@ describe('T2 — Intent Matching', () => { direction: 'buy', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 460n, - rateMax: 490n, - volumeMin: 200n, - volumeMax: 800n, + rateMin: '460', + rateMax: '490', + volumeMin: '200', + volumeMax: '800', }); ctx.market.setSearchResults([counterpartyResult]); @@ -284,10 +284,10 @@ describe('T2 — Intent Matching', () => { direction: 'sell', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 450n, - rateMax: 550n, - volumeMin: 100n, - volumeMax: 800n, + rateMin: '450', + rateMax: '550', + volumeMin: '100', + volumeMax: '800', }); ctx.market.setSearchResults([counterpartyResult]); @@ -302,12 +302,12 @@ describe('T2 — Intent Matching', () => { // calculation happens in the negotiation layer; the engine delegates. // The overlap_min = max(400, 450) = 450, overlap_max = min(500, 550) = 500. // We verify the match was detected (overlap exists). - expect(match.own.intent.rate_min).toBe(400n); - expect(match.own.intent.rate_max).toBe(500n); + expect(match.own.intent.rate_min).toBe('400'); + expect(match.own.intent.rate_max).toBe('500'); // Verify midpoint calculation from the own intent (as done in trader-main onMatchFound) - const midRate = (intent.intent.rate_min + intent.intent.rate_max) / 2n; - expect(midRate).toBe(450n); // floor((400 + 500) / 2) + const midRate = String((Number(intent.intent.rate_min) + Number(intent.intent.rate_max)) / 2); + expect(midRate).toBe('450'); // floor((400 + 500) / 2) ctx.engine.stop(); }); @@ -326,10 +326,10 @@ describe('T2 — Intent Matching', () => { direction: 'sell', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 450n, - rateMax: 500n, - volumeMin: 100n, - volumeMax: 800n, + rateMin: '450', + rateMax: '500', + volumeMin: '100', + volumeMax: '800', }), ]); @@ -357,10 +357,10 @@ describe('T2 — Intent Matching', () => { direction: 'buy', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 460n, - rateMax: 490n, - volumeMin: 150n, - volumeMax: 50n, // max volume from counterparty < own volume_min(200) + rateMin: '460', + rateMax: '490', + volumeMin: '150', + volumeMax: '50', // max volume from counterparty < own volume_min(200) }), ]); @@ -386,10 +386,10 @@ describe('T2 — Intent Matching', () => { direction: 'buy', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 460n, - rateMax: 490n, - volumeMin: 100n, - volumeMax: 800n, + rateMin: '460', + rateMax: '490', + volumeMin: '100', + volumeMax: '800', }), ]); @@ -416,10 +416,10 @@ describe('T2 — Intent Matching', () => { direction: 'buy', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 460n, - rateMax: 490n, - volumeMin: 100n, - volumeMax: 800n, + rateMin: '460', + rateMax: '490', + volumeMin: '100', + volumeMax: '800', }), ]); @@ -443,10 +443,10 @@ describe('T2 — Intent Matching', () => { direction: 'buy', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 460n, - rateMax: 490n, - volumeMin: 100n, - volumeMax: 800n, + rateMin: '460', + rateMax: '490', + volumeMin: '100', + volumeMax: '800', }), ]); @@ -490,10 +490,10 @@ describe('T2 — Intent Matching', () => { direction: 'buy', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 460n, - rateMax: 490n, - volumeMin: 100n, - volumeMax: 800n, + rateMin: '460', + rateMax: '490', + volumeMin: '100', + volumeMax: '800', }), ]); @@ -517,10 +517,10 @@ describe('T2 — Intent Matching', () => { direction: 'buy', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 460n, - rateMax: 490n, - volumeMin: 100n, - volumeMax: 800n, + rateMin: '460', + rateMax: '490', + volumeMin: '100', + volumeMax: '800', expiresAt: new Date(Date.now() - 3_600_000).toISOString(), expiryMs: Date.now() - 3_600_000, }), @@ -547,10 +547,10 @@ describe('T2 — Intent Matching', () => { direction: 'sell', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 460n, - rateMax: 490n, - volumeMin: 100n, - volumeMax: 800n, + rateMin: '460', + rateMax: '490', + volumeMin: '100', + volumeMax: '800', }), ]); @@ -737,10 +737,10 @@ describe('T14 — Edge Cases', () => { direction: 'buy', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 300n, - rateMax: 400n, - volumeMin: 100n, - volumeMax: 800n, + rateMin: '300', + rateMax: '400', + volumeMin: '100', + volumeMax: '800', }), ]); @@ -764,10 +764,10 @@ describe('T14 — Edge Cases', () => { direction: 'buy', baseAsset: 'BTC_L2', quoteAsset: 'USDC', - rateMin: 460n, - rateMax: 490n, - volumeMin: 100n, - volumeMax: 800n, + rateMin: '460', + rateMax: '490', + volumeMin: '100', + volumeMax: '800', }), ]); @@ -791,10 +791,10 @@ describe('T14 — Edge Cases', () => { direction: 'sell', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 460n, - rateMax: 490n, - volumeMin: 100n, - volumeMax: 800n, + rateMin: '460', + rateMax: '490', + volumeMin: '100', + volumeMax: '800', }), ]); @@ -824,10 +824,10 @@ describe('T14 — Edge Cases', () => { direction: 'buy', baseAsset: 'ALPHA', quoteAsset: 'USDC', - rateMin: 460n, - rateMax: 490n, - volumeMin: 100n, - volumeMax: 800n, + rateMin: '460', + rateMax: '490', + volumeMin: '100', + volumeMax: '800', }), ]); diff --git a/test/e2e/trader-multi-agent.e2e.test.ts b/test/e2e/trader-multi-agent.e2e.test.ts index fdb6eb6..874f369 100644 --- a/test/e2e/trader-multi-agent.e2e.test.ts +++ b/test/e2e/trader-multi-agent.e2e.test.ts @@ -288,11 +288,11 @@ function makeIntentRecord(opts: { direction: 'buy' | 'sell'; baseAsset?: string; quoteAsset?: string; - rateMin?: bigint; - rateMax?: bigint; - volumeMin?: bigint; - volumeMax?: bigint; - volumeFilled?: bigint; + rateMin?: string; + rateMax?: string; + volumeMin?: string; + volumeMax?: string; + volumeFilled?: string; }): IntentRecord { const intent: TradingIntent = { intent_id: opts.intentId, @@ -303,11 +303,11 @@ function makeIntentRecord(opts: { direction: opts.direction, base_asset: opts.baseAsset ?? 'ALPHA', quote_asset: opts.quoteAsset ?? 'USDC', - rate_min: opts.rateMin ?? 450n, - rate_max: opts.rateMax ?? 500n, - volume_min: opts.volumeMin ?? 100n, - volume_max: opts.volumeMax ?? 1000n, - volume_filled: opts.volumeFilled ?? 0n, + rate_min: opts.rateMin ?? '450', + rate_max: opts.rateMax ?? '500', + volume_min: opts.volumeMin ?? '100', + volume_max: opts.volumeMax ?? '1000', + volume_filled: opts.volumeFilled ?? '0', escrow_address: 'escrow-001', deposit_timeout_sec: 120, expiry_ms: Date.now() + 86_400_000, @@ -328,10 +328,10 @@ function makeCounterpartyResult(opts: { direction: 'buy' | 'sell'; baseAsset?: string; quoteAsset?: string; - rateMin?: bigint; - rateMax?: bigint; - volumeMin?: bigint; - volumeMax?: bigint; + rateMin?: string; + rateMax?: string; + volumeMin?: string; + volumeMax?: string; }): MarketSearchResult { const description = encodeDescription({ intent_id: opts.id, @@ -342,11 +342,11 @@ function makeCounterpartyResult(opts: { direction: opts.direction, base_asset: opts.baseAsset ?? 'ALPHA', quote_asset: opts.quoteAsset ?? 'USDC', - rate_min: opts.rateMin ?? 450n, - rate_max: opts.rateMax ?? 500n, - volume_min: opts.volumeMin ?? 100n, - volume_max: opts.volumeMax ?? 1000n, - volume_filled: 0n, + rate_min: opts.rateMin ?? '450', + rate_max: opts.rateMax ?? '500', + volume_min: opts.volumeMin ?? '100', + volume_max: opts.volumeMax ?? '1000', + volume_filled: '0', escrow_address: 'escrow-001', deposit_timeout_sec: 120, expiry_ms: Date.now() + 86_400_000, @@ -440,8 +440,8 @@ describe('T13 — Multi-Agent Swap Flows', () => { agentA.intentEngine.stop(); // Now also test the full negotiation flow manually - const agreedRate = 475n; - const agreedVolume = 300n; + const agreedRate = '475'; + const agreedVolume = '300'; const dealRecord = await agentA.negotiationHandler.proposeDeal( intentA, counterpartyB, @@ -546,8 +546,8 @@ describe('T13 — Multi-Agent Swap Flows', () => { const dealB = await agentB.negotiationHandler.proposeDeal( intentB, counterpartyA_forB, - 475n, - 300n, + '475', + '300', 'escrow-001', ); @@ -564,8 +564,8 @@ describe('T13 — Multi-Agent Swap Flows', () => { const dealC = await agentC.negotiationHandler.proposeDeal( intentC, counterpartyA_forC, - 480n, - 200n, + '480', + '200', 'escrow-001', ); @@ -699,8 +699,8 @@ describe('T13 — Multi-Agent Swap Flows', () => { const dealFromA = await agentA.negotiationHandler.proposeDeal( intentA, counterpartyB, - 475n, - 300n, + '475', + '300', 'escrow-001', ); @@ -755,7 +755,7 @@ describe('T13 — Multi-Agent Swap Flows', () => { direction: 'buy', baseAsset: 'USDC', quoteAsset: 'ALPHA', - volumeMax: 500n, + volumeMax: '500', }); // B's buy intent matches A's sell intent-X @@ -792,8 +792,8 @@ describe('T13 — Multi-Agent Swap Flows', () => { const dealX = await agentB.negotiationHandler.proposeDeal( intentB, counterpartyAX_forB, - 475n, - 500n, + '475', + '500', 'escrow-001', ); @@ -805,8 +805,8 @@ describe('T13 — Multi-Agent Swap Flows', () => { const dealY = await agentA.negotiationHandler.proposeDeal( intentY, counterpartyC, - 10n, - 500n, + '10', + '500', 'escrow-001', ); @@ -867,8 +867,8 @@ describe('T13 — Multi-Agent Swap Flows', () => { agentPubkey: agentA.pubkey, agentAddress: agentA.address, direction: 'sell', - rateMin: 450n, - rateMax: 500n, + rateMin: '450', + rateMax: '500', }); // Three counterparty buy intents with different rates: @@ -880,8 +880,8 @@ describe('T13 — Multi-Agent Swap Flows', () => { agentPublicKey: agentB.pubkey, agentAddress: agentB.address, direction: 'buy', - rateMin: 455n, - rateMax: 460n, + rateMin: '455', + rateMax: '460', }); const counterpartyC = makeCounterpartyResult({ @@ -889,8 +889,8 @@ describe('T13 — Multi-Agent Swap Flows', () => { agentPublicKey: agentC.pubkey, agentAddress: agentC.address, direction: 'buy', - rateMin: 485n, - rateMax: 490n, + rateMin: '485', + rateMax: '490', }); const counterpartyD = makeCounterpartyResult({ @@ -898,8 +898,8 @@ describe('T13 — Multi-Agent Swap Flows', () => { agentPublicKey: agentD.pubkey, agentAddress: agentD.address, direction: 'buy', - rateMin: 470n, - rateMax: 475n, + rateMin: '470', + rateMax: '475', }); // For a seller, the best rate is the highest buyer rate. @@ -940,8 +940,8 @@ describe('T13 — Multi-Agent Swap Flows', () => { const deal = await agentA.negotiationHandler.proposeDeal( intentA, counterpartyC, - 490n, - 300n, + '490', + '300', 'escrow-001', ); @@ -1013,15 +1013,15 @@ describe('T13 — Multi-Agent Swap Flows', () => { agentPubkey: agentB.pubkey, agentAddress: agentB.address, direction: 'buy', - volumeMin: 100n, - volumeMax: 700n, + volumeMin: '100', + volumeMax: '700', }); const dealB = await agentB.negotiationHandler.proposeDeal( intentB, counterpartyBView, - 475n, - 700n, + '475', + '700', 'escrow-001', ); @@ -1186,14 +1186,14 @@ describe('T13 — Multi-Agent Swap Flows', () => { agentPubkey: agentB.pubkey, agentAddress: agentB.address, direction: 'buy', - volumeMax: 700n, + volumeMax: '700', }); const dealB = await agentB.negotiationHandler.proposeDeal( intentB, counterpartyA_forB, - 475n, - 700n, + '475', + '700', 'escrow-001', ); @@ -1215,14 +1215,14 @@ describe('T13 — Multi-Agent Swap Flows', () => { agentPubkey: agentC.pubkey, agentAddress: agentC.address, direction: 'buy', - volumeMax: 500n, + volumeMax: '500', }); const dealC = await agentC.negotiationHandler.proposeDeal( intentC, counterpartyA_forC, - 480n, - 500n, + '480', + '500', 'escrow-001', ); diff --git a/test/e2e/trader-negotiation.e2e.test.ts b/test/e2e/trader-negotiation.e2e.test.ts index 4ba7498..73fef65 100644 --- a/test/e2e/trader-negotiation.e2e.test.ts +++ b/test/e2e/trader-negotiation.e2e.test.ts @@ -147,11 +147,11 @@ function makeAliceIntent(): IntentRecord { direction: 'sell', base_asset: 'ALPHA', quote_asset: 'USDC', - rate_min: 400n, - rate_max: 500n, - volume_min: 100n, - volume_max: 1000n, - volume_filled: 0n, + rate_min: '400', + rate_max: '500', + volume_min: '100', + volume_max: '1000', + volume_filled: '0', escrow_address: 'escrow-address-001', deposit_timeout_sec: 120, expiry_ms: Date.now() + 86_400_000, @@ -213,8 +213,8 @@ function computeDealId(terms: { proposer_intent_id: string; proposer_pubkey: string; quote_asset: string; - rate: bigint; - volume: bigint; + rate: string; + volume: string; }): string { const obj = { acceptor_intent_id: terms.acceptor_intent_id, @@ -260,8 +260,8 @@ describe('NP-0 Negotiation E2E', () => { const dealRecord = await ctx.alice.proposeDeal( aliceIntent, bobCounterparty, - 475n, - 300n, + '475', + '300', 'escrow-address-001', ); @@ -321,7 +321,7 @@ describe('NP-0 Negotiation E2E', () => { const aliceIntent = makeAliceIntent(); const bobCounterparty = makeBobCounterparty(); - const deal = await alice.proposeDeal(aliceIntent, bobCounterparty, 475n, 300n, 'escrow-001'); + const deal = await alice.proposeDeal(aliceIntent, bobCounterparty, '475', '300', 'escrow-001'); expect(deal.state).toBe('PROPOSED'); // Parse the sent proposal to get deal_id and msg_id @@ -359,7 +359,7 @@ describe('NP-0 Negotiation E2E', () => { const aliceIntent = makeAliceIntent(); const bobCounterparty = makeBobCounterparty(); - const deal = await ctx.alice.proposeDeal(aliceIntent, bobCounterparty, 475n, 300n, 'escrow-001'); + const deal = await ctx.alice.proposeDeal(aliceIntent, bobCounterparty, '475', '300', 'escrow-001'); // Independently compute the expected deal_id const expectedId = computeDealId({ @@ -377,8 +377,8 @@ describe('NP-0 Negotiation E2E', () => { proposer_intent_id: aliceIntent.intent.market_intent_id, proposer_pubkey: PK_ALICE, quote_asset: aliceIntent.intent.quote_asset, - rate: 475n, - volume: 300n, + rate: '475', + volume: '300', }); expect(deal.terms.deal_id).toBe(expectedId); @@ -413,7 +413,7 @@ describe('NP-0 Negotiation E2E', () => { }); const deal = await alice.proposeDeal( - makeAliceIntent(), makeBobCounterparty(), 475n, 300n, 'escrow-001', + makeAliceIntent(), makeBobCounterparty(), '475', '300', 'escrow-001', ); expect(deal.state).toBe('PROPOSED'); @@ -439,7 +439,7 @@ describe('NP-0 Negotiation E2E', () => { const ctx = createTwoAgentContext(); const deal = await ctx.alice.proposeDeal( - makeAliceIntent(), makeBobCounterparty(), 475n, 300n, 'escrow-001', + makeAliceIntent(), makeBobCounterparty(), '475', '300', 'escrow-001', ); // After cross-routing, both should be ACCEPTED @@ -475,7 +475,7 @@ describe('NP-0 Negotiation E2E', () => { }); await alice.proposeDeal( - makeAliceIntent(), makeBobCounterparty(), 475n, 300n, 'escrow-001', + makeAliceIntent(), makeBobCounterparty(), '475', '300', 'escrow-001', ); const proposeMsg: NpMessage = JSON.parse(aliceSentDms[0]!.content); @@ -519,7 +519,7 @@ describe('NP-0 Negotiation E2E', () => { }); await alice.proposeDeal( - makeAliceIntent(), makeBobCounterparty(), 475n, 300n, 'escrow-001', + makeAliceIntent(), makeBobCounterparty(), '475', '300', 'escrow-001', ); const proposeMsg: NpMessage = JSON.parse(aliceSentDms[0]!.content); @@ -565,7 +565,7 @@ describe('NP-0 Negotiation E2E', () => { }); const deal = await alice.proposeDeal( - makeAliceIntent(), makeBobCounterparty(), 475n, 300n, 'escrow-001', + makeAliceIntent(), makeBobCounterparty(), '475', '300', 'escrow-001', ); // Create an oversized message (> 64 KiB) @@ -595,7 +595,7 @@ describe('NP-0 Negotiation E2E', () => { }); const deal = await alice.proposeDeal( - makeAliceIntent(), makeBobCounterparty(), 475n, 300n, 'escrow-001', + makeAliceIntent(), makeBobCounterparty(), '475', '300', 'escrow-001', ); // Deliver malformed JSON @@ -624,7 +624,7 @@ describe('NP-0 Negotiation E2E', () => { }); const deal = await alice.proposeDeal( - makeAliceIntent(), makeBobCounterparty(), 475n, 300n, 'escrow-001', + makeAliceIntent(), makeBobCounterparty(), '475', '300', 'escrow-001', ); // Construct message with __proto__ pollution key using raw string @@ -678,8 +678,8 @@ describe('NP-0 Negotiation E2E', () => { proposer_intent_id: 'intent-alice-001', proposer_pubkey: PK_ALICE, quote_asset: 'USDC', - rate: 475n, - volume: 300n, + rate: '475', + volume: '300', }; const dealId1 = computeDealId(terms1); @@ -738,8 +738,8 @@ describe('NP-0 Negotiation E2E', () => { proposer_intent_id: 'intent-charlie-001', proposer_pubkey: PK_CHARLIE, quote_asset: 'USDC', - rate: 480n, - volume: 200n, + rate: '480', + volume: '200', }; const dealId2 = computeDealId(terms2); @@ -831,8 +831,8 @@ describe('NP-0 Negotiation E2E', () => { proposer_intent_id: `intent-alice-rate-${i}`, proposer_pubkey: PK_ALICE, quote_asset: 'USDC', - rate: BigInt(475 + i), - volume: BigInt(300 + i), + rate: String(475 + i), + volume: String(300 + i), }; const dealId = computeDealId(terms); const msgId = `f${i}345678-1234-1234-1234-123456789abc`; @@ -921,7 +921,7 @@ describe('NP-0 Negotiation E2E', () => { // Create a deal and force it to CANCELLED (terminal) state. const deal = await alice.proposeDeal( - makeAliceIntent(), makeBobCounterparty(), 475n, 300n, 'escrow-001', + makeAliceIntent(), makeBobCounterparty(), '475', '300', 'escrow-001', ); const dealId = deal.terms.deal_id; diff --git a/test/e2e/trader-swap.e2e.test.ts b/test/e2e/trader-swap.e2e.test.ts index 865f4af..57a0701 100644 --- a/test/e2e/trader-swap.e2e.test.ts +++ b/test/e2e/trader-swap.e2e.test.ts @@ -55,8 +55,8 @@ function makeDealTerms(overrides: Partial = {}): DealTerms { acceptor_address: ADDR_TRADER_B, base_asset: 'ALPHA', quote_asset: 'USDC', - rate: 475n, - volume: 300n, + rate: '475', + volume: '300', // proposer SELLS base (ALPHA) for quote (USDC). Must be set — swap-executor // uses strict === 'sell' when mapping to partyA/partyB, so undefined falls // through to the buyer branch and inverts the swap direction. @@ -240,7 +240,7 @@ describe('E2E: Trader Swap Execution', () => { }); it('T5.2: volume_filled updated on completion (callback receives deal + payoutVerified=true)', async () => { - const deal = makeDealRecord('ACCEPTED', { volume: 500n }); + const deal = makeDealRecord('ACCEPTED', { volume: '500' }); await ctx.executor.executeDeal(deal); ctx.executor.handleSwapCompleted('swap-001', true); @@ -250,7 +250,7 @@ describe('E2E: Trader Swap Execution', () => { }); // The callback receives the deal so the caller can update volume_filled - expect(ctx.completedDeals[0]!.deal.terms.volume).toBe(500n); + expect(ctx.completedDeals[0]!.deal.terms.volume).toBe('500'); expect(ctx.completedDeals[0]!.payoutVerified).toBe(true); }); @@ -336,7 +336,7 @@ describe('E2E: Trader Swap Execution', () => { // First deal: 400 out of 1000 total intent volume const deal = makeDealRecord('ACCEPTED', { deal_id: 'deal-partial-400', - volume: 400n, + volume: '400', }); await ctx.executor.executeDeal(deal); @@ -348,7 +348,7 @@ describe('E2E: Trader Swap Execution', () => { const completedDeal = ctx.completedDeals[0]!; expect(completedDeal.deal.state).toBe('COMPLETED'); - expect(completedDeal.deal.terms.volume).toBe(400n); + expect(completedDeal.deal.terms.volume).toBe('400'); expect(completedDeal.payoutVerified).toBe(true); // Remaining = 1000 - 400 = 600 >= volume_min @@ -362,7 +362,7 @@ describe('E2E: Trader Swap Execution', () => { // Fill 850 => remaining 150 < 200 => caller transitions to FILLED const deal = makeDealRecord('ACCEPTED', { deal_id: 'deal-fill-850', - volume: 850n, + volume: '850', }); await ctx.executor.executeDeal(deal); @@ -374,7 +374,7 @@ describe('E2E: Trader Swap Execution', () => { const completedDeal = ctx.completedDeals[0]!; expect(completedDeal.deal.state).toBe('COMPLETED'); - expect(completedDeal.deal.terms.volume).toBe(850n); + expect(completedDeal.deal.terms.volume).toBe('850'); // Caller checks: remaining (1000 - 850 = 150) < volume_min (200) => FILLED // This test verifies the executor correctly completes and hands off to the callback }); diff --git a/test/mocks/mock-payments-module.ts b/test/mocks/mock-payments-module.ts index 9f1297c..b6c7ece 100644 --- a/test/mocks/mock-payments-module.ts +++ b/test/mocks/mock-payments-module.ts @@ -67,6 +67,19 @@ export function createMockPaymentsModule(): MockPaymentsModule { return balances.get(coinId) ?? 0n; }, + /** + * Mock decimals lookup. Most trader test fixtures use simple coin + * symbols with small whole-number rates/volumes (e.g. ALPHA, USDC + * at 475 × 100 = 47500 scale), where production decimals do not + * matter for the test logic. Return 0 so smallest-unit conversion + * is a no-op (toSmallestUnitsBigInt with 0 decimals just BigInt()s + * the integer part). + */ + getDecimals(_coinId: string): number { + void _coinId; + return 0; + }, + getAllBalances() { return Array.from(balances.entries()).map(([coinId, amount]) => ({ coinId,