Skip to content

Commit c350596

Browse files
authored
fix(swap): correct fee simulation display with proper decimal conversion (#11)
* fix(swap): correct fee simulation display with proper decimal conversion - Add TokenInfoDto and CrossChainSwapsSimulateResultDto types matching updated API schema - Use token.decimals for token amount conversion (previously raw BigInt strings) - Use 10^18 divisor for USD fee values (wei format) - Add formatWeiToUsd() and formatTokenAmount() helper functions - Handle priceImpact=-1 as "negligible" instead of displaying "-1%" - Add sanity check warning for fees exceeding $1000 * fix(swap): correct return type for swapsSimulate API The API client already extracts .data from the response, so the function should return CrossChainSwapsSimulateItem[] not wrapped in ResponseDto. * docs(types): clarify that decimals (not realDecimals) should be used The implementation uses token.decimals for amount conversion as per the requirement that display decimals should match the token's standard.
1 parent cbc0475 commit c350596

4 files changed

Lines changed: 196 additions & 10 deletions

File tree

src/api/crosschain.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
CrossChainAccount,
77
WalletAsset,
88
TransactionResult,
9+
CrossChainSwapsSimulateItem,
910
} from '../types.js';
1011

1112
/** Get cross-chain account info */
@@ -33,7 +34,7 @@ export function swaps(token: string, swapList: CrossChainSwapDto[]) {
3334

3435
/** Simulate swaps (dry-run) */
3536
export function swapsSimulate(token: string, swapList: CrossChainSwapDto[]) {
36-
return post<TransactionResult[]>('/v1/tx/cross-chain/swaps-simulate', {
37+
return post<CrossChainSwapsSimulateItem[]>('/v1/tx/cross-chain/swaps-simulate', {
3738
token,
3839
body: { swaps: swapList },
3940
});

src/commands/swap.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { get } from '../api/client.js';
66
import { requireAuth } from '../config.js';
77
import { success, info, warn, spinner, assertApiOk, wrapAction, requireTransactionConfirmation, lookupToken, normalizeChain } from '../utils.js';
88
import { requireTouchId } from '../touchid.js';
9-
import { printTxResult, printKV } from '../formatters.js';
10-
import type { SwapSide, Chain } from '../types.js';
9+
import { printTxResult, printSwapSimulation } from '../formatters.js';
10+
import type { SwapSide, Chain, CrossChainSwapsSimulateItem } from '../types.js';
1111

1212
export const swapCommand = new Command('swap')
1313
.description('Swap tokens (cross-chain spot trading)')
@@ -112,15 +112,10 @@ export const swapCommand = new Command('swap')
112112
}]);
113113
spin.stop();
114114
assertApiOk(simRes, 'Simulation failed');
115-
console.log('');
116-
console.log(chalk.bold('Simulation Result:'));
117-
if (Array.isArray(simRes.data)) {
115+
if (simRes.data && Array.isArray(simRes.data)) {
118116
for (const item of simRes.data) {
119-
printKV(item as Record<string, unknown>);
120-
console.log('');
117+
printSwapSimulation(item);
121118
}
122-
} else if (simRes.data) {
123-
printKV(simRes.data as Record<string, unknown>);
124119
}
125120
return;
126121
}

src/formatters.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,38 @@
66

77
import chalk from 'chalk';
88
import Table from 'cli-table3';
9+
import type { CrossChainSwapsSimulateResultDto, CrossChainSwapsSimulateErrorDto } from './types.js';
10+
11+
// ─── Wei / BigInt formatting ──────────────────────────────────────────────
12+
13+
/** Convert wei (10^18) string to USD decimal string */
14+
export function formatWeiToUsd(weiStr: string): string {
15+
try {
16+
const wei = BigInt(weiStr);
17+
// Divide by 10^18 (wei to ether conversion)
18+
const dollars = Number(wei) / 1e18;
19+
return dollars.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 });
20+
} catch {
21+
return weiStr; // fallback to original if parsing fails
22+
}
23+
}
24+
25+
/** Convert BigInt amount string using token decimals */
26+
export function formatTokenAmount(amountStr: string, decimals: number): string {
27+
try {
28+
const amount = BigInt(amountStr);
29+
const value = Number(amount) / Math.pow(10, decimals);
30+
return value.toLocaleString('en-US', { maximumFractionDigits: decimals });
31+
} catch {
32+
return amountStr; // fallback to original if parsing fails
33+
}
34+
}
35+
36+
/** Check if a string looks like a wei value (large BigInt string) */
37+
function isWeiString(value: string): boolean {
38+
// Wei strings are typically large numbers (18+ digits)
39+
return /^\d{15,}$/.test(value);
40+
}
941

1042
// ─── Raw JSON mode ───────────────────────────────────────────────────────
1143

@@ -70,6 +102,12 @@ export function formatValue(value: unknown, key?: string): string {
70102
if (typeof value === 'string') {
71103
// Hex addresses — must check before numeric coercion (0x… is valid Number)
72104
if (/^0x[0-9a-fA-F]{20,}$/.test(value)) return chalk.yellow(value);
105+
106+
// Wei-format USD fee strings (key contains "FeeInUsd" and value is large BigInt string)
107+
if (key && /FeeInUsd$/i.test(key) && isWeiString(value)) {
108+
return `$${formatWeiToUsd(value)}`;
109+
}
110+
73111
// Numeric string that looks like a price / amount
74112
const num = Number(value);
75113
if (!isNaN(num) && value.trim() !== '') {
@@ -486,6 +524,100 @@ export function printCryptoMetrics(data: Record<string, unknown>): void {
486524
}
487525
}
488526

527+
// ─── Swap Simulation ─────────────────────────────────────────────────────
528+
529+
/** Check if a simulation result is an error */
530+
export function isSimulateError(
531+
item: CrossChainSwapsSimulateResultDto | CrossChainSwapsSimulateErrorDto,
532+
): item is CrossChainSwapsSimulateErrorDto {
533+
return 'error' in item;
534+
}
535+
536+
/** Pretty-print swap simulation result with wei-to-USD conversion */
537+
export function printSwapSimulation(
538+
result: CrossChainSwapsSimulateResultDto | CrossChainSwapsSimulateErrorDto,
539+
): void {
540+
if (_rawJson) {
541+
console.log(JSON.stringify(result, null, 2));
542+
return;
543+
}
544+
545+
// Handle error case
546+
if (isSimulateError(result)) {
547+
console.log(chalk.red.bold(` Error: ${result.error}`));
548+
if (result.message) {
549+
console.log(chalk.dim(` Message: ${result.message}`));
550+
}
551+
return;
552+
}
553+
554+
// Convert wei fees to USD (fees are always in 10^18 format)
555+
const totalFeeUsd = formatWeiToUsd(result.totalFeeInUsd);
556+
const gasFeeUsd = formatWeiToUsd(result.gasFeeInUsd);
557+
const serviceFeeUsd = formatWeiToUsd(result.serviceFeeInUsd);
558+
const lpFeeUsd = formatWeiToUsd(result.lpFeeInUsd);
559+
560+
// Sanity check: warn if total fee exceeds $1000
561+
const totalFeeNum = parseFloat(totalFeeUsd.replace(/,/g, ''));
562+
const feeWarning = totalFeeNum > 1000 ? chalk.yellow(' ⚠️ (unusually high)') : '';
563+
564+
// Print header
565+
console.log('');
566+
console.log(chalk.bold(' Simulation Result:'));
567+
568+
// Print token changes (using token.decimals for amount conversion)
569+
if (result.increased.length > 0) {
570+
console.log(chalk.green.bold(' Tokens Received:'));
571+
for (const change of result.increased) {
572+
const token = change.token;
573+
const decimals = token.decimals ?? 18;
574+
const amountFormatted = formatTokenAmount(change.amount, decimals);
575+
const amountUsd = change.amountInUSD ? formatWeiToUsd(change.amountInUSD) : null;
576+
577+
console.log(` ${chalk.bold(`$${token.symbol}`)}${token.name}`);
578+
console.log(` ${chalk.dim('Address')} : ${chalk.yellow(token.address)}`);
579+
console.log(` ${chalk.dim('Amount')} : ${amountFormatted}`);
580+
if (amountUsd) console.log(` ${chalk.dim('Value')} : $${amountUsd}`);
581+
}
582+
}
583+
584+
if (result.decreased.length > 0) {
585+
console.log(chalk.red.bold(' Tokens Spent:'));
586+
for (const change of result.decreased) {
587+
const token = change.token;
588+
const decimals = token.decimals ?? 18;
589+
const amountFormatted = formatTokenAmount(change.amount, decimals);
590+
const amountUsd = change.amountInUSD ? formatWeiToUsd(change.amountInUSD) : null;
591+
592+
console.log(` ${chalk.bold(`$${token.symbol}`)}${token.name}`);
593+
console.log(` ${chalk.dim('Address')} : ${chalk.yellow(token.address)}`);
594+
console.log(` ${chalk.dim('Amount')} : ${amountFormatted}`);
595+
if (amountUsd) console.log(` ${chalk.dim('Value')} : $${amountUsd}`);
596+
}
597+
}
598+
599+
// Print fees
600+
console.log('');
601+
console.log(chalk.bold(' Fees:'));
602+
console.log(` ${chalk.dim('Total Fee')} : $${totalFeeUsd}${feeWarning}`);
603+
console.log(` ${chalk.dim('Gas Fee')} : $${gasFeeUsd}`);
604+
console.log(` ${chalk.dim('Service Fee')} : $${serviceFeeUsd}`);
605+
console.log(` ${chalk.dim('LP Fee')} : $${lpFeeUsd}`);
606+
607+
// Print other info
608+
console.log('');
609+
console.log(` ${chalk.dim('Slippage')} : ${result.slippageBps} bps`);
610+
if (result.priceImpact !== null && result.priceImpact !== undefined) {
611+
// -1 means price impact could not be calculated or is negligible
612+
const impactStr = String(result.priceImpact).trim();
613+
const impactNum = parseFloat(impactStr);
614+
const impactDisplay = impactNum === -1 || impactStr === '-1'
615+
? chalk.dim('— (negligible)')
616+
: `${impactStr}%`;
617+
console.log(` ${chalk.dim('Price Impact')} : ${impactDisplay}`);
618+
}
619+
}
620+
489621
// ─── Helpers ─────────────────────────────────────────────────────────────
490622

491623
function truncate(str: string, len: number): string {

src/types.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,64 @@ export interface TransactionResult {
159159
[key: string]: unknown;
160160
}
161161

162+
// ─── Swap Simulation Types (from xneuro-core OpenAPI) ─────────────────────
163+
164+
export interface TokenInfoDto {
165+
name: string;
166+
symbol: string;
167+
address: string;
168+
chainId: number;
169+
decimals: number;
170+
realDecimals: number;
171+
price: number;
172+
image?: string;
173+
assetId?: string;
174+
type?: string;
175+
rank?: number | null;
176+
slotIndex?: number | null;
177+
}
178+
179+
export interface TokenChangeDto {
180+
token: TokenInfoDto;
181+
/** Amount of token changed (BigInt string, use token.decimals for conversion) */
182+
amount: string;
183+
/** Amount in USD (BigInt string in wei/10^18, may be null) */
184+
amountInUSD: string | null;
185+
}
186+
187+
export interface CrossChainSwapsSimulateResultDto {
188+
/** Tokens that increased in balance */
189+
increased: TokenChangeDto[];
190+
/** Tokens that decreased in balance */
191+
decreased: TokenChangeDto[];
192+
/** Total fee in USD (BigInt string in wei/10^18) */
193+
totalFeeInUsd: string;
194+
/** Gas fee in USD (BigInt string in wei/10^18) */
195+
gasFeeInUsd: string;
196+
/** Service fee in USD (BigInt string in wei/10^18) */
197+
serviceFeeInUsd: string;
198+
/** LP fee in USD (BigInt string in wei/10^18) */
199+
lpFeeInUsd: string;
200+
/** Slippage in basis points */
201+
slippageBps: number;
202+
/** Price impact (may be null) */
203+
priceImpact: string | null;
204+
}
205+
206+
export interface CrossChainSwapsSimulateErrorDto {
207+
error: string;
208+
message?: string;
209+
}
210+
211+
export type CrossChainSwapsSimulateItem =
212+
| CrossChainSwapsSimulateResultDto
213+
| CrossChainSwapsSimulateErrorDto;
214+
215+
export interface CrossChainSwapsSimulateResponseDto {
216+
success: boolean;
217+
data: CrossChainSwapsSimulateItem[];
218+
}
219+
162220
// ─── CrossChain (Spot Trading) ───────────────────────────────────────────
163221

164222
export type SwapSide = 'buy' | 'sell';

0 commit comments

Comments
 (0)