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
36 changes: 18 additions & 18 deletions src/trader/intent-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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]);
Expand Down
66 changes: 37 additions & 29 deletions src/trader/intent-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<MarketSearchResult & { __perCandidateVolume?: bigint }> = [];
const candidatesWithVolume: Array<MarketSearchResult & { __perCandidateVolume?: number }> = [];
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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});

Expand Down
17 changes: 16 additions & 1 deletion src/trader/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -438,6 +443,16 @@ export async function startTrader(): Promise<void> {
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) => ({
Expand Down
Loading
Loading