Swap tokens

This commit is contained in:
2025-07-06 14:00:44 +07:00
parent 8096db495a
commit c7dec76809
21 changed files with 1124 additions and 173 deletions

View File

@@ -89,7 +89,6 @@ Key Principles
- Place static content and interfaces at file end.
- Use content variables for static content outside render functions.
- Minimize 'use client', 'useEffect', and 'setState'. Favor RSC.
- Use Zod for form validation.
- Wrap client components in Suspense with fallback.
- Use dynamic loading for non-critical components.
- Optimize images: WebP format, size data, lazy loading.
@@ -105,5 +104,6 @@ Key Principles
- When you think its necessary update all the code from the database to the front end
- Do not update ManagingApi.ts, once you made a change on the backend endpoint, execute the command to regenerate ManagingApi.ts on the frontend; cd src/Managing.Nswag && dotnet build
- Do not reference new react library if a component already exist in mollecules or atoms
- After finishing the editing, build the project
Follow the official Microsoft documentation and ASP.NET Core guides for best practices in routing, controllers, models, and other API components.

View File

@@ -1,4 +1,5 @@
using Managing.Application.Abstractions.Services;
using Managing.Api.Models.Requests;
using Managing.Application.Abstractions.Services;
using Managing.Domain.Accounts;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -89,6 +90,30 @@ namespace Managing.Api.Controllers
return Ok(result);
}
/// <summary>
/// Swaps tokens on GMX for a specific account.
/// </summary>
/// <param name="name">The name of the account to perform the swap for.</param>
/// <param name="request">The swap request containing ticker symbols, amount, and order parameters.</param>
/// <returns>The swap response with transaction details.</returns>
[HttpPost]
[Route("{name}/gmx-swap")]
public async Task<ActionResult<SwapInfos>> SwapGmxTokens(string name, [FromBody] SwapTokensRequest request)
{
var user = await GetUser();
var result = await _AccountService.SwapGmxTokensAsync(
user,
name,
request.FromTicker,
request.ToTicker,
request.Amount,
request.OrderType,
request.TriggerRatio,
request.AllowedSlippage
);
return Ok(result);
}
/// <summary>
/// Deletes a specific account by name for the authenticated user.
/// </summary>

View File

@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using static Managing.Common.Enums;
namespace Managing.Api.Models.Requests;
/// <summary>
/// Request model for GMX token swap operations
/// </summary>
public class SwapTokensRequest
{
/// <summary>
/// The ticker symbol of the token to swap from
/// </summary>
[Required]
public Ticker FromTicker { get; set; }
/// <summary>
/// The ticker symbol of the token to swap to
/// </summary>
[Required]
public Ticker ToTicker { get; set; }
/// <summary>
/// The amount to swap
/// </summary>
[Required]
[Range(0.000001, double.MaxValue, ErrorMessage = "Amount must be greater than 0")]
public double Amount { get; set; }
/// <summary>
/// The order type (market or limit)
/// </summary>
public string OrderType { get; set; } = "market";
/// <summary>
/// The trigger ratio for limit orders (optional)
/// </summary>
public double? TriggerRatio { get; set; }
/// <summary>
/// The allowed slippage percentage (default 0.5%)
/// </summary>
[Range(0, 100, ErrorMessage = "Allowed slippage must be between 0 and 100")]
public double AllowedSlippage { get; set; } = 0.5;
}

View File

@@ -1,5 +1,6 @@
using Managing.Domain.Accounts;
using Managing.Domain.Users;
using static Managing.Common.Enums;
namespace Managing.Application.Abstractions.Services;
@@ -14,4 +15,7 @@ public interface IAccountService
Task<Account> GetAccountByKey(string key, bool hideSecrets, bool getBalance);
IEnumerable<Account> GetAccountsBalancesByUser(User user, bool hideSecrets = true);
Task<GmxClaimableSummary> GetGmxClaimableSummaryAsync(User user, string accountName);
Task<SwapInfos> SwapGmxTokensAsync(User user, string accountName, Ticker fromTicker, Ticker toTicker,
double amount, string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5);
}

View File

@@ -1,4 +1,5 @@
using Managing.Domain.Accounts;
using static Managing.Common.Enums;
namespace Managing.Application.Abstractions.Services
{
@@ -9,5 +10,8 @@ namespace Managing.Application.Abstractions.Services
Task<T> CallGmxServiceAsync<T>(string endpoint, object payload);
Task<T> GetGmxServiceAsync<T>(string endpoint, object payload = null);
Task<GmxClaimableSummary> GetGmxClaimableSummaryAsync(string account);
Task<SwapInfos> SwapGmxTokensAsync(string account, Ticker fromTicker, Ticker toTicker, double amount,
string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5);
}
}

View File

@@ -1,9 +1,9 @@
using Managing.Application.Abstractions.Repositories;
using Managing.Application.Abstractions.Services;
using Managing.Common;
using Managing.Domain.Accounts;
using Managing.Domain.Users;
using Microsoft.Extensions.Logging;
using static Managing.Common.Enums;
namespace Managing.Application.Accounts;
@@ -47,15 +47,15 @@ public class AccountService : IAccountService
{
request.User = user;
if (request.Exchange == Enums.TradingExchanges.Evm
&& request.Type == Enums.AccountType.Trader)
if (request.Exchange == TradingExchanges.Evm
&& request.Type == AccountType.Trader)
{
var keys = _evmManager.GenerateAddress();
request.Key = keys.Key;
request.Secret = keys.Secret;
}
else if (request.Exchange == Enums.TradingExchanges.Evm
&& request.Type == Enums.AccountType.Privy)
else if (request.Exchange == TradingExchanges.Evm
&& request.Type == AccountType.Privy)
{
if (string.IsNullOrEmpty(request.Key))
{
@@ -200,6 +200,45 @@ public class AccountService : IAccountService
}
}
public async Task<SwapInfos> SwapGmxTokensAsync(User user, string accountName, Ticker fromTicker, Ticker toTicker, double amount, string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5)
{
// Get the account for the user
var account = await GetAccountByUser(user, accountName, true, false);
if (account == null)
{
throw new ArgumentException($"Account '{accountName}' not found for user '{user.Name}'");
}
// Ensure the account has a valid address/key
if (string.IsNullOrEmpty(account.Key))
{
throw new ArgumentException($"Account '{accountName}' does not have a valid address");
}
try
{
// Call the Web3ProxyService to swap GMX tokens
var swapInfos = await _web3ProxyService.SwapGmxTokensAsync(
account.Key,
fromTicker,
toTicker,
amount,
orderType,
triggerRatio,
allowedSlippage
);
return swapInfos;
}
catch (Exception ex) when (!(ex is ArgumentException || ex is InvalidOperationException))
{
_logger.LogError(ex, "Error swapping GMX tokens for account {AccountName} and user {UserName}",
accountName, user.Name);
throw new InvalidOperationException($"Failed to swap GMX tokens: {ex.Message}", ex);
}
}
private void ManageProperties(bool hideSecrets, bool getBalance, Account account)
{
if (account != null)

View File

@@ -0,0 +1,37 @@
namespace Managing.Domain.Accounts;
/// <summary>
/// Domain model for swap operation information
/// </summary>
public class SwapInfos
{
/// <summary>
/// Whether the swap operation was successful
/// </summary>
public bool Success { get; set; }
/// <summary>
/// Transaction hash if successful
/// </summary>
public string Hash { get; set; }
/// <summary>
/// Success message
/// </summary>
public string Message { get; set; }
/// <summary>
/// Error message if failed
/// </summary>
public string Error { get; set; }
/// <summary>
/// Error type if failed
/// </summary>
public string ErrorType { get; set; }
/// <summary>
/// Suggestion for error resolution
/// </summary>
public string Suggestion { get; set; }
}

View File

@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
using Newtonsoft.Json;
namespace Managing.Infrastructure.Evm.Models.Proxy;
/// <summary>
/// Response model for GMX swap operations
/// </summary>
public class GmxSwapResponse : Web3ProxyBaseResponse
{
/// <summary>
/// Transaction hash if successful
/// </summary>
[JsonProperty("hash")]
[JsonPropertyName("hash")]
public string Hash { get; set; }
/// <summary>
/// Success message
/// </summary>
[JsonProperty("message")]
[JsonPropertyName("message")]
public string Message { get; set; }
/// <summary>
/// Error type if failed
/// </summary>
[JsonProperty("errorType")]
[JsonPropertyName("errorType")]
public string ErrorType { get; set; }
/// <summary>
/// Suggestion for error resolution
/// </summary>
[JsonProperty("suggestion")]
[JsonPropertyName("suggestion")]
public string Suggestion { get; set; }
}

View File

@@ -6,6 +6,7 @@ using Managing.Application.Abstractions.Services;
using Managing.Domain.Accounts;
using Managing.Infrastructure.Evm.Models.Proxy;
using Microsoft.Extensions.Options;
using static Managing.Common.Enums;
namespace Managing.Infrastructure.Evm.Services
{
@@ -180,6 +181,38 @@ namespace Managing.Infrastructure.Evm.Services
};
}
public async Task<SwapInfos> SwapGmxTokensAsync(string account, Ticker fromTicker, Ticker toTicker, double amount, string orderType = "market", double? triggerRatio = null, double allowedSlippage = 0.5)
{
var payload = new
{
account,
fromTicker = fromTicker.ToString(),
toTicker = toTicker.ToString(),
amount,
orderType,
triggerRatio,
allowedSlippage
};
var response = await CallGmxServiceAsync<GmxSwapResponse>("/swap-tokens", payload);
if (response == null)
{
throw new Web3ProxyException("GMX swap response is null");
}
// Map from infrastructure model to domain model
return new SwapInfos
{
Success = response.Success,
Hash = response.Hash,
Message = response.Message,
Error = null, // GmxSwapResponse doesn't have Error property
ErrorType = response.ErrorType,
Suggestion = response.Suggestion
};
}
private async Task HandleErrorResponse(HttpResponseMessage response)
{
var statusCode = (int)response.StatusCode;

View File

@@ -113,6 +113,7 @@ declare module 'fastify' {
closeGmxPosition: typeof closeGmxPosition;
getGmxTrade: typeof getGmxTrade;
getGmxPositions: typeof getGmxPositions;
swapGmxTokens: typeof swapGmxTokens;
}
}

View File

@@ -101,6 +101,45 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
)
})
// Define route to swap tokens
fastify.post('/swap-tokens', {
schema: {
body: Type.Object({
account: Type.String(),
fromTicker: Type.String(),
toTicker: Type.String(),
amount: Type.Number(),
orderType: Type.Optional(Type.Union([Type.Literal('market'), Type.Literal('limit')])),
triggerRatio: Type.Optional(Type.Number()),
allowedSlippage: Type.Optional(Type.Number())
}),
response: {
200: Type.Object({
success: Type.Boolean(),
hash: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
error: Type.Optional(Type.String()),
errorType: Type.Optional(Type.String()),
suggestion: Type.Optional(Type.String())
})
}
}
}, async (request, reply) => {
const { account, fromTicker, toTicker, amount, orderType, triggerRatio, allowedSlippage } = request.body
// Call the swapGmxTokens method from the GMX plugin
return request.swapGmxTokens(
reply,
account,
fromTicker,
toTicker,
amount,
orderType || 'market',
triggerRatio,
allowedSlippage || 0.5
)
})
// Define route to get a trade
fastify.get('/trades', {
schema: {

View File

@@ -14,6 +14,7 @@ const Modal: React.FC<IModalProps> = ({
return (
<div className="container mx-auto">
{showModal ? (
onSubmit ? (
<form onSubmit={onSubmit}>
<div className="modal modal-bottom sm:modal-middle modal-open">
<div className="modal-box !max-w-4xl !w-11/12">
@@ -27,6 +28,19 @@ const Modal: React.FC<IModalProps> = ({
</div>
</div>
</form>
) : (
<div className="modal modal-bottom sm:modal-middle modal-open">
<div className="modal-box !max-w-4xl !w-11/12">
<ModalHeader
titleHeader={titleHeader}
onClose={onClose}
onSubmit={onSubmit}
showModal={showModal}
/>
{children}
</div>
</div>
)
) : null}
</div>
)

View File

@@ -247,6 +247,49 @@ export class AccountClient extends AuthorizedApiBase {
}
return Promise.resolve<GmxClaimableSummary>(null as any);
}
account_SwapGmxTokens(name: string, request: SwapTokensRequest): Promise<SwapInfos> {
let url_ = this.baseUrl + "/Account/{name}/gmx-swap";
if (name === undefined || name === null)
throw new Error("The parameter 'name' must be defined.");
url_ = url_.replace("{name}", encodeURIComponent("" + name));
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(request);
let options_: RequestInit = {
body: content_,
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
}
};
return this.transformOptions(options_).then(transformedOptions_ => {
return this.http.fetch(url_, transformedOptions_);
}).then((_response: Response) => {
return this.processAccount_SwapGmxTokens(_response);
});
}
protected processAccount_SwapGmxTokens(response: Response): Promise<SwapInfos> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200) {
return response.text().then((_responseText) => {
let result200: any = null;
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SwapInfos;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
console.log(_responseText)
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<SwapInfos>(null as any);
}
}
export class BacktestClient extends AuthorizedApiBase {
@@ -2871,68 +2914,22 @@ export interface RebateStatsData {
discountFactor?: number;
}
export interface Backtest {
id: string;
finalPnl: number;
winRate: number;
growthPercentage: number;
hodlPercentage: number;
config: TradingBotConfig;
positions: Position[];
signals: Signal[];
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
optimizedMoneyManagement: MoneyManagement;
user: User;
indicatorsValues: { [key in keyof typeof IndicatorType]?: IndicatorsResultBase; };
score: number;
export interface SwapInfos {
success?: boolean;
hash?: string | null;
message?: string | null;
error?: string | null;
errorType?: string | null;
suggestion?: string | null;
}
export interface TradingBotConfig {
accountName: string;
moneyManagement: MoneyManagement;
ticker: Ticker;
timeframe: Timeframe;
isForWatchingOnly: boolean;
botTradingBalance: number;
isForBacktest: boolean;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
name: string;
riskManagement?: RiskManagement | null;
scenario?: Scenario | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
}
export interface MoneyManagement {
name: string;
timeframe: Timeframe;
stopLoss: number;
takeProfit: number;
leverage: number;
user?: User | null;
}
export enum Timeframe {
FiveMinutes = "FiveMinutes",
FifteenMinutes = "FifteenMinutes",
ThirtyMinutes = "ThirtyMinutes",
OneHour = "OneHour",
FourHour = "FourHour",
OneDay = "OneDay",
OneMinute = "OneMinute",
export interface SwapTokensRequest {
fromTicker: Ticker;
toTicker: Ticker;
amount: number;
orderType?: string | null;
triggerRatio?: number | null;
allowedSlippage?: number;
}
export enum Ticker {
@@ -3044,6 +3041,70 @@ export enum Ticker {
Unknown = "Unknown",
}
export interface Backtest {
id: string;
finalPnl: number;
winRate: number;
growthPercentage: number;
hodlPercentage: number;
config: TradingBotConfig;
positions: Position[];
signals: Signal[];
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
optimizedMoneyManagement: MoneyManagement;
user: User;
indicatorsValues: { [key in keyof typeof IndicatorType]?: IndicatorsResultBase; };
score: number;
}
export interface TradingBotConfig {
accountName: string;
moneyManagement: MoneyManagement;
ticker: Ticker;
timeframe: Timeframe;
isForWatchingOnly: boolean;
botTradingBalance: number;
isForBacktest: boolean;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
name: string;
riskManagement?: RiskManagement | null;
scenario?: Scenario | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
}
export interface MoneyManagement {
name: string;
timeframe: Timeframe;
stopLoss: number;
takeProfit: number;
leverage: number;
user?: User | null;
}
export enum Timeframe {
FiveMinutes = "FiveMinutes",
FifteenMinutes = "FifteenMinutes",
ThirtyMinutes = "ThirtyMinutes",
OneHour = "OneHour",
FourHour = "FourHour",
OneDay = "OneDay",
OneMinute = "OneMinute",
}
export interface RiskManagement {
adverseProbabilityThreshold: number;
favorableProbabilityThreshold: number;

View File

@@ -83,68 +83,22 @@ export interface RebateStatsData {
discountFactor?: number;
}
export interface Backtest {
id: string;
finalPnl: number;
winRate: number;
growthPercentage: number;
hodlPercentage: number;
config: TradingBotConfig;
positions: Position[];
signals: Signal[];
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
optimizedMoneyManagement: MoneyManagement;
user: User;
indicatorsValues: { [key in keyof typeof IndicatorType]?: IndicatorsResultBase; };
score: number;
export interface SwapInfos {
success?: boolean;
hash?: string | null;
message?: string | null;
error?: string | null;
errorType?: string | null;
suggestion?: string | null;
}
export interface TradingBotConfig {
accountName: string;
moneyManagement: MoneyManagement;
ticker: Ticker;
timeframe: Timeframe;
isForWatchingOnly: boolean;
botTradingBalance: number;
isForBacktest: boolean;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
name: string;
riskManagement?: RiskManagement | null;
scenario?: Scenario | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
}
export interface MoneyManagement {
name: string;
timeframe: Timeframe;
stopLoss: number;
takeProfit: number;
leverage: number;
user?: User | null;
}
export enum Timeframe {
FiveMinutes = "FiveMinutes",
FifteenMinutes = "FifteenMinutes",
ThirtyMinutes = "ThirtyMinutes",
OneHour = "OneHour",
FourHour = "FourHour",
OneDay = "OneDay",
OneMinute = "OneMinute",
export interface SwapTokensRequest {
fromTicker: Ticker;
toTicker: Ticker;
amount: number;
orderType?: string | null;
triggerRatio?: number | null;
allowedSlippage?: number;
}
export enum Ticker {
@@ -256,6 +210,70 @@ export enum Ticker {
Unknown = "Unknown",
}
export interface Backtest {
id: string;
finalPnl: number;
winRate: number;
growthPercentage: number;
hodlPercentage: number;
config: TradingBotConfig;
positions: Position[];
signals: Signal[];
candles: Candle[];
startDate: Date;
endDate: Date;
statistics: PerformanceMetrics;
fees: number;
walletBalances: KeyValuePairOfDateTimeAndDecimal[];
optimizedMoneyManagement: MoneyManagement;
user: User;
indicatorsValues: { [key in keyof typeof IndicatorType]?: IndicatorsResultBase; };
score: number;
}
export interface TradingBotConfig {
accountName: string;
moneyManagement: MoneyManagement;
ticker: Ticker;
timeframe: Timeframe;
isForWatchingOnly: boolean;
botTradingBalance: number;
isForBacktest: boolean;
cooldownPeriod: number;
maxLossStreak: number;
flipPosition: boolean;
name: string;
riskManagement?: RiskManagement | null;
scenario?: Scenario | null;
scenarioName?: string | null;
maxPositionTimeHours?: number | null;
closeEarlyWhenProfitable?: boolean;
flipOnlyWhenInProfit: boolean;
useSynthApi?: boolean;
useForPositionSizing?: boolean;
useForSignalFiltering?: boolean;
useForDynamicStopLoss?: boolean;
}
export interface MoneyManagement {
name: string;
timeframe: Timeframe;
stopLoss: number;
takeProfit: number;
leverage: number;
user?: User | null;
}
export enum Timeframe {
FiveMinutes = "FiveMinutes",
FifteenMinutes = "FifteenMinutes",
ThirtyMinutes = "ThirtyMinutes",
OneHour = "OneHour",
FourHour = "FourHour",
OneDay = "OneDay",
OneMinute = "OneMinute",
}
export interface RiskManagement {
adverseProbabilityThreshold: number;
favorableProbabilityThreshold: number;

View File

@@ -299,6 +299,7 @@ export type ICardPositionFlipped = {
export type IAccountRowDetail = {
balances: Balance[]
showTotal?: boolean
account?: Account
}
export type IGridTile = {

View File

@@ -0,0 +1,42 @@
import {useCallback, useState} from 'react'
import {extractErrorMessage} from '../utils/apiErrorHandler'
interface UseApiErrorReturn {
error: string | null
setError: (error: string | null) => void
clearError: () => void
handleError: (err: unknown) => string
handleApiErrorWithToast: (err: unknown, toast: any) => void
}
/**
* Custom hook for handling API errors consistently across components
* @returns Error handling utilities
*/
export function useApiError(): UseApiErrorReturn {
const [error, setError] = useState<string | null>(null)
const clearError = useCallback(() => {
setError(null)
}, [])
const handleError = useCallback((err: unknown): string => {
const errorMessage = extractErrorMessage(err)
setError(errorMessage)
return errorMessage
}, [])
const handleApiErrorWithToast = useCallback((err: unknown, toast: any) => {
const errorMessage = extractErrorMessage(err)
setError(errorMessage)
toast.update('error', `Error: ${errorMessage}`)
}, [])
return {
error,
setError,
clearError,
handleError,
handleApiErrorWithToast
}
}

View File

@@ -0,0 +1,263 @@
import React, {useState} from 'react'
import type {SubmitHandler} from 'react-hook-form'
import {useForm} from 'react-hook-form'
import {Account, AccountClient, Ticker,} from '../../../generated/ManagingApi'
import Modal from '../../../components/mollecules/Modal/Modal'
import useApiUrlStore from '../../../app/store/apiStore'
import {FormInput, Toast} from '../../../components/mollecules'
import {useApiError} from '../../../hooks/useApiError'
interface SwapModalProps {
isOpen: boolean
onClose: () => void
account: Account
fromTicker: Ticker
availableAmount: number
}
interface SwapFormInput {
fromTicker: Ticker
toTicker: Ticker
amount: number
orderType: string
triggerRatio?: number
allowedSlippage: number
}
const SwapModal: React.FC<SwapModalProps> = ({
isOpen,
onClose,
account,
fromTicker,
availableAmount,
}) => {
const [isLoading, setIsLoading] = useState(false)
const { error, setError, handleApiErrorWithToast } = useApiError()
const { apiUrl } = useApiUrlStore()
const client = new AccountClient({}, apiUrl)
const [selectedToTicker, setSelectedToTicker] = useState<Ticker>(Ticker.USDC)
const [selectedOrderType, setSelectedOrderType] = useState<string>('market')
const { register, handleSubmit, watch, setValue } = useForm<SwapFormInput>({
defaultValues: {
fromTicker: fromTicker,
toTicker: Ticker.USDC,
amount: availableAmount * 0.1, // Start with 10% of available amount
orderType: 'market',
allowedSlippage: 0.5,
}
})
const watchedAmount = watch('amount')
function setSelectedToTickerEvent(e: React.ChangeEvent<HTMLSelectElement>) {
setSelectedToTicker(e.target.value as Ticker)
}
function setSelectedOrderTypeEvent(e: React.ChangeEvent<HTMLSelectElement>) {
setSelectedOrderType(e.target.value)
}
const onSubmit: SubmitHandler<SwapFormInput> = async (form) => {
const t = new Toast(`Swapping ${form.amount} ${form.fromTicker} to ${form.toTicker} on ${account.name}`)
setIsLoading(true)
setError(null)
try {
const result = await client.account_SwapGmxTokens(
account.name,
{
fromTicker: form.fromTicker,
toTicker: form.toTicker,
amount: form.amount,
orderType: form.orderType,
triggerRatio: form.triggerRatio,
allowedSlippage: form.allowedSlippage,
}
)
if (result.success) {
t.update('success', `Swap successful! Hash: ${result.hash}`)
onClose()
} else {
console.log(result)
const errorMessage = result.error || result.message || 'Swap failed'
setError(errorMessage)
t.update('error', `Swap failed: ${errorMessage}`)
}
} catch (err) {
handleApiErrorWithToast(err, t)
} finally {
setIsLoading(false)
}
}
const handleFormSubmit = (e: React.FormEvent) => {
e.preventDefault()
handleSubmit(onSubmit)(e)
}
const modalContent = (
<>
{isLoading ? (
<div className="text-center py-4">
<span className="loading loading-spinner loading-md"></span>
<p>Processing swap...</p>
</div>
) : error ? (
<div className="alert alert-error mb-4">
<p>{error}</p>
</div>
) : (
<>
<div className="mb-4">
<p className="mb-2">
<strong>Account:</strong> {account.name}
</p>
<p className="mb-2">
<strong>From:</strong> {fromTicker}
</p>
</div>
<form onSubmit={handleFormSubmit}>
<div className="space-y-4 mb-4">
<FormInput label="To Ticker" htmlFor="toTicker">
<select
className="select select-bordered w-full"
{...register('toTicker', {
onChange(e) {
setSelectedToTickerEvent(e)
},
value: selectedToTicker,
})}
>
{Object.values(Ticker)
.filter(ticker => ticker !== fromTicker) // Exclude the from ticker
.map((ticker) => (
<option key={ticker} value={ticker}>
{ticker}
</option>
))}
</select>
</FormInput>
<FormInput label="Amount" htmlFor="amount">
<div className="w-full">
<input
type="number"
step="any"
placeholder="Enter amount to swap"
className="input input-bordered w-full mb-2"
{...register('amount', {
valueAsNumber: true,
min: 0.0001,
max: availableAmount,
required: true
})}
/>
<div className="w-full">
<input
type="range"
min="0"
max={availableAmount}
step={availableAmount / 100}
className="range range-primary w-full"
value={watchedAmount || 0}
onChange={(e) => {
const value = parseFloat(e.target.value)
setValue('amount', value)
}}
/>
<div className="text-center text-xs text-gray-500 mt-1">
{watchedAmount && availableAmount > 0 ? (
<span>{((watchedAmount / availableAmount) * 100).toFixed(1)}% of available balance</span>
) : (
<span>0% of available balance</span>
)}
</div>
</div>
</div>
</FormInput>
<FormInput label="Order Type" htmlFor="orderType">
<select
className="select select-bordered w-full"
{...register('orderType', {
onChange(e) {
setSelectedOrderTypeEvent(e)
},
value: selectedOrderType,
})}
>
<option value="market">Market</option>
<option value="limit">Limit</option>
<option value="stop">Stop</option>
</select>
</FormInput>
<FormInput label="Allowed Slippage (%)" htmlFor="allowedSlippage">
<input
type="number"
step="0.1"
placeholder="0.5"
className="input input-bordered w-full"
{...register('allowedSlippage', {
valueAsNumber: true,
min: 0.1,
max: 10,
value: 0.5
})}
/>
</FormInput>
{selectedOrderType === 'limit' && (
<FormInput label="Trigger Ratio" htmlFor="triggerRatio">
<input
type="number"
step="any"
placeholder="Enter trigger ratio"
className="input input-bordered w-full"
{...register('triggerRatio', { valueAsNumber: true })}
/>
</FormInput>
)}
<button
type="submit"
className="btn btn-primary w-full mt-2"
disabled={isLoading || !watchedAmount || watchedAmount <= 0}
>
{isLoading ? (
<span className="loading loading-spinner"></span>
) : (
`Swap ${watchedAmount || 0} ${fromTicker} to ${selectedToTicker}`
)}
</button>
</div>
</form>
<div className="mt-4">
<div className="alert alert-info">
<p className="text-sm">
<strong>Note:</strong> Ensure account has sufficient balance for the swap.
</p>
</div>
</div>
</>
)}
</>
)
return (
<Modal
showModal={isOpen}
onClose={onClose}
titleHeader="Swap Tokens on GMX"
>
{modalContent}
</Modal>
)
}
export default SwapModal

View File

@@ -1,9 +1,54 @@
import React from 'react'
import React, {useState} from 'react'
import {FiRefreshCw} from 'react-icons/fi'
import { SelectColumnFilter, Table } from '../../../components/mollecules'
import { IAccountRowDetail } from '../../../global/type'
import {SelectColumnFilter, Table} from '../../../components/mollecules'
import type {IAccountRowDetail} from '../../../global/type.tsx'
import type {Account, Balance} from '../../../generated/ManagingApi'
import {Ticker} from '../../../generated/ManagingApi'
import SwapModal from './SwapModal'
const columns = [
interface IAccountRowDetailProps extends IAccountRowDetail {
account: Account
}
const AccountRowDetails: React.FC<IAccountRowDetailProps> = ({
balances,
showTotal,
account,
}) => {
const [swapModalState, setSwapModalState] = useState<{
isOpen: boolean
fromTicker: Ticker | null
availableAmount: number
}>({
isOpen: false,
fromTicker: null,
availableAmount: 0,
})
const handleSwapClick = (balance: Balance) => {
if (balance.tokenName && balance.amount) {
// Convert tokenName to Ticker enum
const ticker = balance.tokenName.toUpperCase() as Ticker
if (Object.values(Ticker).includes(ticker)) {
setSwapModalState({
isOpen: true,
fromTicker: ticker,
availableAmount: balance.amount,
})
}
}
}
const closeSwapModal = () => {
setSwapModalState({
isOpen: false,
fromTicker: null,
availableAmount: 0,
})
}
const columns = [
{
Header: 'Chain',
accessor: 'chain.name',
@@ -42,12 +87,32 @@ const columns = [
accessor: 'price',
disableFilters: true,
},
]
{
Cell: ({ cell }: any) => {
const balance = cell.row.original as Balance
return (
<div className="flex justify-center">
{balance.tokenName && balance.amount && balance.amount > 0 && Object.values(Ticker).includes(balance.tokenName.toUpperCase() as Ticker) && (
<button
className="btn btn-xs btn-outline btn-info"
onClick={() => handleSwapClick(balance)}
title={`Swap ${balance.tokenName}`}
>
<FiRefreshCw className="h-3 w-3" />
<span className="ml-1">Swap</span>
</button>
)}
</div>
)
},
Header: 'Actions',
accessor: 'actions',
disableFilters: true,
disableSortBy: true,
},
]
const AccountRowDetails: React.FC<IAccountRowDetail> = ({
balances,
showTotal,
}) => {
return (
<>
<Table
@@ -56,6 +121,16 @@ const AccountRowDetails: React.FC<IAccountRowDetail> = ({
showTotal={showTotal}
showPagination={false}
/>
{swapModalState.isOpen && swapModalState.fromTicker && (
<SwapModal
isOpen={swapModalState.isOpen}
onClose={closeSwapModal}
account={account}
fromTicker={swapModalState.fromTicker}
availableAmount={swapModalState.availableAmount}
/>
)}
</>
)
}

View File

@@ -196,6 +196,7 @@ const AccountTable: React.FC<IAccountList> = ({ list, isFetching }) => {
<AccountRowDetails
balances={balances}
showTotal={true}
account={row.original}
></AccountRowDetails>
) : (
<div>No balances</div>

View File

@@ -0,0 +1,161 @@
# API Error Handling Utilities
This module provides utilities for handling API errors consistently across the application.
## Functions
### `extractErrorMessage(err: unknown): string`
Extracts meaningful error messages from API exceptions and other errors.
**Parameters:**
- `err` - The error object caught from API calls
**Returns:**
- A user-friendly error message string
**Usage:**
```typescript
import { extractErrorMessage } from '../utils/apiErrorHandler'
try {
const result = await apiCall()
} catch (err) {
const errorMessage = extractErrorMessage(err)
console.log(errorMessage) // "Insufficient balance for swap"
}
```
### `handleApiError(err: unknown)`
Handles API errors and returns a standardized error object with additional context.
**Parameters:**
- `err` - The error object caught from API calls
**Returns:**
```typescript
{
message: string
isApiException: boolean
status?: number
response?: string
}
```
**Usage:**
```typescript
import { handleApiError } from '../utils/apiErrorHandler'
try {
const result = await apiCall()
} catch (err) {
const errorInfo = handleApiError(err)
console.log(errorInfo.message) // "Insufficient balance for swap"
console.log(errorInfo.status) // 400
console.log(errorInfo.isApiException) // true
}
```
## Custom Hook
### `useApiError()`
A React hook that provides error handling utilities for API calls.
**Returns:**
```typescript
{
error: string | null
setError: (error: string | null) => void
clearError: () => void
handleError: (err: unknown) => string
handleApiErrorWithToast: (err: unknown, toast: any) => void
}
```
**Usage:**
```typescript
import { useApiError } from '../hooks/useApiError'
function MyComponent() {
const { error, setError, clearError, handleError, handleApiErrorWithToast } = useApiError()
const handleApiCall = async () => {
try {
const result = await apiCall()
clearError() // Clear any previous errors
} catch (err) {
// Option 1: Simple error handling
const errorMessage = handleError(err)
// Option 2: Error handling with toast
handleApiErrorWithToast(err, toast)
}
}
return (
<div>
{error && <div className="alert alert-error">{error}</div>}
<button onClick={handleApiCall}>Make API Call</button>
</div>
)
}
```
## Error Types Handled
1. **ApiException** - Generated client exceptions with server response details
2. **Error** - Standard JavaScript Error objects
3. **Unknown** - Any other error types
## Response Parsing
The utilities attempt to parse JSON responses in this order:
1. `message` - Standard error message field
2. `error` - Alternative error field
3. `detail` - ASP.NET Core validation error field
4. Raw response text if JSON parsing fails
5. Exception message as final fallback
## Examples
### Basic Usage
```typescript
try {
const result = await client.someApiCall()
} catch (err) {
const errorMessage = extractErrorMessage(err)
setError(errorMessage)
}
```
### With Toast Notifications
```typescript
const { handleApiErrorWithToast } = useApiError()
try {
const result = await client.someApiCall()
toast.update('success', 'Operation successful!')
} catch (err) {
handleApiErrorWithToast(err, toast)
}
```
### With Custom Error Handling
```typescript
const { handleError } = useApiError()
try {
const result = await client.someApiCall()
} catch (err) {
const errorMessage = handleError(err)
// Custom logic based on error type
if (errorMessage.includes('insufficient')) {
showBalanceWarning()
} else if (errorMessage.includes('network')) {
showRetryButton()
}
}
```

View File

@@ -0,0 +1,50 @@
/**
* Extracts meaningful error messages from API exceptions and other errors
* @param err - The error object caught from API calls
* @returns A user-friendly error message
*/
export function extractErrorMessage(err: unknown): string {
// Handle ApiException specifically to extract the actual error message
if (err && typeof err === 'object' && 'isApiException' in err) {
const apiException = err as any
try {
// Try to parse the response as JSON to get the actual error message
const responseData = JSON.parse(apiException.response)
return responseData.message || responseData.error || responseData.detail || apiException.message
} catch {
// If parsing fails, use the response text directly
return apiException.response || apiException.message
}
} else if (err instanceof Error) {
return err.message
}
return 'An unknown error occurred'
}
/**
* Handles API errors and returns a standardized error object
* @param err - The error object caught from API calls
* @returns An object containing the error message and additional context
*/
export function handleApiError(err: unknown): {
message: string
isApiException: boolean
status?: number
response?: string
} {
if (err && typeof err === 'object' && 'isApiException' in err) {
const apiException = err as any
return {
message: extractErrorMessage(err),
isApiException: true,
status: apiException.status,
response: apiException.response
}
}
return {
message: extractErrorMessage(err),
isApiException: false
}
}