Get fees to claims

This commit is contained in:
2025-06-13 14:22:38 +07:00
parent 5188b5faec
commit 3f34c56968
13 changed files with 580 additions and 20 deletions

View File

@@ -75,6 +75,20 @@ namespace Managing.Api.Controllers
return Ok(await _AccountService.GetAccountByUser(user, name, true, true));
}
/// <summary>
/// Retrieves the GMX claimable fees summary for a specific account.
/// </summary>
/// <param name="name">The name of the account to get claimable fees for.</param>
/// <returns>The GMX claimable fees summary including funding fees, UI fees, and rebate stats.</returns>
[HttpGet]
[Route("{name}/gmx-claimable-summary")]
public async Task<ActionResult<GmxClaimableSummary>> GetGmxClaimableSummary(string name)
{
var user = await GetUser();
var result = await _AccountService.GetGmxClaimableSummaryAsync(user, name);
return Ok(result);
}
/// <summary>
/// Deletes a specific account by name for the authenticated user.
/// </summary>

View File

@@ -13,4 +13,5 @@ public interface IAccountService
Task<Account> GetAccountByUser(User user, string name, bool hideSecrets, bool getBalance);
Task<Account> GetAccountByKey(string key, bool hideSecrets, bool getBalance);
IEnumerable<Account> GetAccountsBalancesByUser(User user, bool hideSecrets = true);
Task<GmxClaimableSummary> GetGmxClaimableSummaryAsync(User user, string accountName);
}

View File

@@ -1,4 +1,6 @@
namespace Managing.Infrastructure.Evm.Abstractions
using Managing.Domain.Accounts;
namespace Managing.Application.Abstractions.Services
{
public interface IWeb3ProxyService
{
@@ -6,5 +8,6 @@ namespace Managing.Infrastructure.Evm.Abstractions
Task<T> GetPrivyServiceAsync<T>(string endpoint, object payload = null);
Task<T> CallGmxServiceAsync<T>(string endpoint, object payload);
Task<T> GetGmxServiceAsync<T>(string endpoint, object payload = null);
Task<GmxClaimableSummary> GetGmxClaimableSummaryAsync(string account);
}
}

View File

@@ -1,5 +1,4 @@
using Managing.Application.Abstractions;
using Managing.Application.Abstractions.Repositories;
using Managing.Application.Abstractions.Repositories;
using Managing.Application.Abstractions.Services;
using Managing.Common;
using Managing.Domain.Accounts;
@@ -14,6 +13,7 @@ public class AccountService : IAccountService
private readonly IExchangeService _exchangeService;
private readonly IEvmManager _evmManager;
private readonly ICacheService _cacheService;
private readonly IWeb3ProxyService _web3ProxyService;
private readonly ILogger<AccountService> _logger;
public AccountService(
@@ -21,13 +21,15 @@ public class AccountService : IAccountService
ILogger<AccountService> logger,
IExchangeService exchangeService,
IEvmManager evmManager,
ICacheService cacheService)
ICacheService cacheService,
IWeb3ProxyService web3ProxyService)
{
_accountRepository = accountRepository;
_logger = logger;
_exchangeService = exchangeService;
_evmManager = evmManager;
_cacheService = cacheService;
_web3ProxyService = web3ProxyService;
}
public async Task<Account> CreateAccount(User user, Account request)
@@ -161,6 +163,38 @@ public class AccountService : IAccountService
return accounts;
}
public async Task<GmxClaimableSummary> GetGmxClaimableSummaryAsync(User user, string accountName)
{
// 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 get GMX claimable summary
var infrastructureResponse = await _web3ProxyService.GetGmxClaimableSummaryAsync(account.Key);
// Map from infrastructure model to domain model
return infrastructureResponse;
}
catch (Exception ex) when (!(ex is ArgumentException || ex is InvalidOperationException))
{
_logger.LogError(ex, "Error getting GMX claimable summary for account {AccountName} and user {UserName}",
accountName, user.Name);
throw new InvalidOperationException($"Failed to get GMX claimable summary: {ex.Message}", ex);
}
}
private void ManageProperties(bool hideSecrets, bool getBalance, Account account)
{
if (account != null)

View File

@@ -0,0 +1,38 @@
namespace Managing.Domain.Accounts;
/// <summary>
/// GMX claimable summary data containing funding fees, UI fees, and rebate stats
/// </summary>
public class GmxClaimableSummary
{
public FundingFeesData ClaimableFundingFees { get; set; }
public UiFeesData ClaimableUiFees { get; set; }
public RebateStatsData RebateStats { get; set; }
}
/// <summary>
/// Funding fees claimable data
/// </summary>
public class FundingFeesData
{
public double TotalUsdc { get; set; }
}
/// <summary>
/// UI fees claimable data
/// </summary>
public class UiFeesData
{
public double TotalUsdc { get; set; }
}
/// <summary>
/// Rebate statistics data
/// </summary>
public class RebateStatsData
{
public double TotalRebateUsdc { get; set; }
public double DiscountUsdc { get; set; }
public double RebateFactor { get; set; }
public double DiscountFactor { get; set; }
}

View File

@@ -0,0 +1,40 @@
using Newtonsoft.Json;
namespace Managing.Infrastructure.Evm.Models.Proxy;
public class ClaimableFundingFees
{
[JsonProperty("totalUsdc")] public double TotalUsdc { get; set; }
}
public class ClaimableUiFees
{
[JsonProperty("totalUsdc")] public double TotalUsdc { get; set; }
}
public class Data
{
[JsonProperty("claimableFundingFees")] public ClaimableFundingFees ClaimableFundingFees { get; set; }
[JsonProperty("claimableUiFees")] public ClaimableUiFees ClaimableUiFees { get; set; }
[JsonProperty("rebateStats")] public RebateStats RebateStats { get; set; }
}
public class RebateStats
{
[JsonProperty("totalRebateUsdc")] public double TotalRebateUsdc { get; set; }
[JsonProperty("discountUsdc")] public double DiscountUsdc { get; set; }
[JsonProperty("rebateFactor")] public double RebateFactor { get; set; }
[JsonProperty("discountFactor")] public double DiscountFactor { get; set; }
}
public class ClaimingFeesResponse
{
[JsonProperty("success")] public bool Success { get; set; }
[JsonProperty("data")] public Data Data { get; set; }
}

View File

@@ -1,9 +1,11 @@
using System.Net.Http.Json;
using Managing.Infrastructure.Evm.Abstractions;
using Microsoft.Extensions.Options;
using System.Text;
using System.Text.Json;
using System.Web;
using Managing.Application.Abstractions.Services;
using Managing.Domain.Accounts;
using Managing.Infrastructure.Evm.Models.Proxy;
using Microsoft.Extensions.Options;
namespace Managing.Infrastructure.Evm.Services
{
@@ -146,6 +148,38 @@ namespace Managing.Infrastructure.Evm.Services
}
}
public async Task<GmxClaimableSummary> GetGmxClaimableSummaryAsync(string account)
{
var payload = new { account };
var response = await GetGmxServiceAsync<ClaimingFeesResponse>("/claimable-summary", payload);
if (response.Data == null)
{
throw new Web3ProxyException("GMX claimable summary data is null");
}
// Map from Web3Proxy response model to domain model
return new GmxClaimableSummary
{
ClaimableFundingFees = new FundingFeesData
{
TotalUsdc = response.Data.ClaimableFundingFees.TotalUsdc
},
ClaimableUiFees = new UiFeesData
{
TotalUsdc = response.Data.ClaimableUiFees.TotalUsdc
},
RebateStats = new RebateStatsData
{
TotalRebateUsdc = response.Data.RebateStats.TotalRebateUsdc,
DiscountUsdc = response.Data.RebateStats.DiscountUsdc,
RebateFactor = response.Data.RebateStats.RebateFactor,
DiscountFactor = response.Data.RebateStats.DiscountFactor
}
};
}
private async Task HandleErrorResponse(HttpResponseMessage response)
{
var statusCode = (int)response.StatusCode;
@@ -198,7 +232,7 @@ namespace Managing.Infrastructure.Evm.Services
return string.Empty;
}
var queryString = new System.Text.StringBuilder("?");
var queryString = new StringBuilder("?");
bool isFirst = true;
foreach (var prop in properties)

View File

@@ -1122,10 +1122,10 @@ export const getClaimableFundingFeesImpl = async (
sdk: GmxSdk
): Promise<ClaimableFundingData> => {
try {
const { marketsInfoData } = await getMarketsInfoWithCache(sdk);
const { marketsInfoData, tokensData } = await getMarketsInfoWithCache(sdk);
if (!marketsInfoData) {
throw new Error("No markets info data available");
if (!marketsInfoData || !tokensData) {
throw new Error("No markets info data or tokens data available");
}
const marketAddresses = Object.keys(marketsInfoData);
@@ -1173,7 +1173,7 @@ export const getClaimableFundingFeesImpl = async (
const result = await sdk.executeMulticall(multicallRequest);
// Parse the response
// Parse the response and convert to USD
return Object.entries(result.data).reduce((claimableFundingData, [marketAddress, callsResult]: [string, any]) => {
const market = marketsInfoData[marketAddress];
@@ -1181,12 +1181,30 @@ export const getClaimableFundingFeesImpl = async (
return claimableFundingData;
}
// Get market divisor for proper decimal conversion
const marketDivisor = 1; // You might need to implement getMarketDivisor function
// Get token data for price conversion
const longTokenData = tokensData[market.longToken.address];
const shortTokenData = tokensData[market.shortToken.address];
if (!longTokenData || !shortTokenData) {
console.warn(`Missing token data for market ${marketAddress}`);
return claimableFundingData;
}
// Convert from wei to token units and then to USD
const longAmount = Number(callsResult.claimableFundingAmountLong.returnValues[0]);
const shortAmount = Number(callsResult.claimableFundingAmountShort.returnValues[0]);
// Convert from wei to token units using decimals
const longTokenUnits = longAmount / Math.pow(10, longTokenData.decimals);
const shortTokenUnits = shortAmount / Math.pow(10, shortTokenData.decimals);
// Convert to USD using token prices
const longUsdValue = longTokenUnits * (Number(longTokenData.prices.minPrice) / Math.pow(10, 30)); // GMX prices are in 30 decimals
const shortUsdValue = shortTokenUnits * (Number(shortTokenData.prices.minPrice) / Math.pow(10, 30));
claimableFundingData[marketAddress] = {
claimableFundingAmountLong: Number(callsResult.claimableFundingAmountLong.returnValues[0]) / marketDivisor,
claimableFundingAmountShort: Number(callsResult.claimableFundingAmountShort.returnValues[0]) / marketDivisor,
claimableFundingAmountLong: longUsdValue,
claimableFundingAmountShort: shortUsdValue,
};
return claimableFundingData;

View File

@@ -1,6 +1,11 @@
import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'
import {Type} from '@sinclair/typebox'
import {TradeDirection} from '../../../generated/ManagingApiTypes'
import {
getClaimableFundingFeesImpl,
getClaimableUiFeesImpl,
getGmxRebateStatsImpl
} from '../../../plugins/custom/gmx.js'
const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
// Define route to open a position
@@ -149,6 +154,93 @@ const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
account
)
})
// Define route to get all claimable fees and rebate stats
fastify.get('/claimable-summary', {
schema: {
querystring: Type.Object({
account: Type.String()
}),
response: {
200: Type.Object({
success: Type.Boolean(),
data: Type.Optional(Type.Object({
claimableFundingFees: Type.Object({
totalUsdc: Type.Number()
}),
claimableUiFees: Type.Object({
totalUsdc: Type.Number()
}),
rebateStats: Type.Object({
totalRebateUsdc: Type.Number(),
discountUsdc: Type.Number(),
rebateFactor: Type.Number(),
discountFactor: Type.Number()
})
})),
error: Type.Optional(Type.String())
})
}
}
}, async (request, reply) => {
try {
const { account } = request.query
// Get GMX client for the account
const sdk = await request.getClientForAddress(account)
// Call all three implementation functions in parallel
const [fundingFeesData, uiFeesData, rebateStatsData] = await Promise.all([
getClaimableFundingFeesImpl(sdk),
getClaimableUiFeesImpl(sdk),
getGmxRebateStatsImpl(sdk)
])
// Process funding fees data - only calculate totals
let totalFundingLong = 0
let totalFundingShort = 0
if (fundingFeesData) {
Object.values(fundingFeesData).forEach(marketData => {
totalFundingLong += marketData.claimableFundingAmountLong
totalFundingShort += marketData.claimableFundingAmountShort
})
}
// Process UI fees data - only calculate totals
let totalUiFees = 0
if (uiFeesData) {
Object.values(uiFeesData).forEach(marketData => {
totalUiFees += marketData.claimableUiFeeAmount
})
}
return {
success: true,
data: {
claimableFundingFees: {
totalUsdc: totalFundingLong + totalFundingShort
},
claimableUiFees: {
totalUsdc: totalUiFees
},
rebateStats: {
totalRebateUsdc: rebateStatsData?.totalRebateUsd || 0,
discountUsdc: rebateStatsData?.discountUsd || 0,
rebateFactor: rebateStatsData?.rebateFactor || 0,
discountFactor: rebateStatsData?.discountFactor || 0
}
}
}
} catch (error) {
console.error('Error getting claimable summary:', error)
return {
success: false,
error: `Failed to get claimable summary: ${error instanceof Error ? error.message : 'Unknown error'}`
}
}
})
}
export default plugin

View File

@@ -16,9 +16,9 @@ describe('swap tokens implementation', () => {
try {
const result = await swapGmxTokensImpl(
sdk,
Ticker.GMX, // fromTicker
Ticker.BTC, // fromTicker
Ticker.USDC, // toTicker
2.06 // amount
0.000056 // amount
)
assert.strictEqual(typeof result, 'string')

View File

@@ -209,6 +209,44 @@ export class AccountClient extends AuthorizedApiBase {
}
return Promise.resolve<Account[]>(null as any);
}
account_GetGmxClaimableSummary(name: string): Promise<GmxClaimableSummary> {
let url_ = this.baseUrl + "/Account/{name}/gmx-claimable-summary";
if (name === undefined || name === null)
throw new Error("The parameter 'name' must be defined.");
url_ = url_.replace("{name}", encodeURIComponent("" + name));
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "GET",
headers: {
"Accept": "application/json"
}
};
return this.transformOptions(options_).then(transformedOptions_ => {
return this.http.fetch(url_, transformedOptions_);
}).then((_response: Response) => {
return this.processAccount_GetGmxClaimableSummary(_response);
});
}
protected processAccount_GetGmxClaimableSummary(response: Response): Promise<GmxClaimableSummary> {
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 GmxClaimableSummary;
return result200;
});
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<GmxClaimableSummary>(null as any);
}
}
export class BacktestClient extends AuthorizedApiBase {
@@ -2703,6 +2741,50 @@ export interface Chain {
chainId?: number;
}
export interface GmxClaimableSummary {
claimableFundingFees?: FundingFeesData | null;
claimableUiFees?: UiFeesData | null;
rebateStats?: RebateStatsData | null;
summary?: SummaryData | null;
}
export interface FundingFeesData {
totalLongUsdc?: number;
totalShortUsdc?: number;
totalUsdc?: number;
markets?: { [key: string]: FundingFeesMarketData; } | null;
}
export interface FundingFeesMarketData {
claimableFundingAmountLong?: number;
claimableFundingAmountShort?: number;
}
export interface UiFeesData {
totalUsdc?: number;
markets?: { [key: string]: UiFeesMarketData; } | null;
}
export interface UiFeesMarketData {
claimableUiFeeAmount?: number;
}
export interface RebateStatsData {
totalRebateUsdc?: number;
discountUsdc?: number;
volume?: number;
tier?: number;
rebateFactor?: number;
discountFactor?: number;
}
export interface SummaryData {
totalClaimableUsdc?: number;
hasFundingFees?: boolean;
hasUiFees?: boolean;
hasRebates?: boolean;
}
export interface Backtest {
id: string;
finalPnl: number;

View File

@@ -0,0 +1,198 @@
import {useMemo, useState} from 'react'
import {AccountClient, GmxClaimableSummary} from '../../../generated/ManagingApi'
import useApiUrlStore from '../../../app/store/apiStore'
import {Table} from '../../../components/mollecules'
function AccountFee() {
const [accountName, setAccountName] = useState('')
const [loading, setLoading] = useState(false)
const [data, setData] = useState<GmxClaimableSummary | null>(null)
const [error, setError] = useState<string | null>(null)
const { apiUrl } = useApiUrlStore()
const handleFetch = async () => {
if (!accountName.trim()) {
setError('Please enter an account name')
return
}
setLoading(true)
setError(null)
try {
const accountClient = new AccountClient({}, apiUrl)
const result = await accountClient.account_GetGmxClaimableSummary(accountName.trim())
setData(result)
} catch (err: any) {
setError(err.message || 'Failed to fetch GMX claimable summary')
setData(null)
} finally {
setLoading(false)
}
}
// Table columns for all sections
const dataColumns = useMemo(
() => [
{
Header: 'Type',
accessor: 'type',
},
{
Header: 'Amount (USDC)',
accessor: 'amount',
},
],
[]
)
// Funding fees table data
const fundingFeesData = useMemo(() => {
if (!data?.claimableFundingFees) return []
return [
{
type: 'Total Funding Fees',
amount: `$${data.claimableFundingFees.totalUsdc?.toFixed(2) || '0.00'}`,
},
]
}, [data])
// UI fees table data
const uiFeesData = useMemo(() => {
if (!data?.claimableUiFees) return []
return [
{
type: 'Total UI Fees',
amount: `$${data.claimableUiFees.totalUsdc?.toFixed(2) || '0.00'}`,
},
]
}, [data])
// Rebate stats table data
const rebateStatsData = useMemo(() => {
if (!data?.rebateStats) return []
return [
{
type: 'Total Rebate USDC',
amount: `$${data.rebateStats.totalRebateUsdc?.toFixed(2) || '0.00'}`,
},
{
type: 'Discount USDC',
amount: `$${data.rebateStats.discountUsdc?.toFixed(2) || '0.00'}`,
},
{
type: 'Rebate Factor',
amount: (data.rebateStats.rebateFactor || 0).toFixed(4),
},
{
type: 'Discount Factor',
amount: (data.rebateStats.discountFactor || 0).toFixed(4),
},
]
}, [data])
// Calculate total claimable for display
const totalClaimable = useMemo(() => {
if (!data) return 0
const fundingTotal = data.claimableFundingFees?.totalUsdc || 0
const uiTotal = data.claimableUiFees?.totalUsdc || 0
return fundingTotal + uiTotal
}, [data])
return (
<div className="p-6">
<h2 className="text-2xl font-bold mb-6">GMX Account Fees</h2>
{/* Input Section */}
<div className="mb-6 flex gap-4 items-end">
<div className="form-control">
<label className="label">
<span className="label-text">Account Name</span>
</label>
<input
type="text"
placeholder="Enter account name"
className="input input-bordered w-full max-w-xs"
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
disabled={loading}
/>
</div>
<button
className={`btn btn-primary ${loading ? 'loading' : ''}`}
onClick={handleFetch}
disabled={loading}
>
{loading ? 'Fetching...' : 'Fetch'}
</button>
</div>
{/* Error Display */}
{error && (
<div className="alert alert-error mb-6">
<div>
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m-2 2" />
</svg>
<span>{error}</span>
</div>
</div>
)}
{/* Results Section */}
{data && (
<div className="space-y-8">
{/* Total Summary */}
<div className="stats shadow">
<div className="stat">
<div className="stat-title">Total Claimable</div>
<div className="stat-value text-primary">${totalClaimable.toFixed(2)}</div>
<div className="stat-desc">Combined funding fees and UI fees</div>
</div>
</div>
{/* Funding Fees Section */}
{data.claimableFundingFees && (data.claimableFundingFees.totalUsdc ?? 0) > 0 && (
<div>
<h3 className="text-xl font-semibold mb-4">Funding Fees</h3>
<Table
columns={dataColumns}
data={fundingFeesData}
showPagination={false}
/>
</div>
)}
{/* UI Fees Section */}
{data.claimableUiFees && (data.claimableUiFees.totalUsdc ?? 0) > 0 && (
<div>
<h3 className="text-xl font-semibold mb-4">UI Fees</h3>
<Table
columns={dataColumns}
data={uiFeesData}
showPagination={false}
/>
</div>
)}
{/* Rebate Stats Section */}
{data.rebateStats && ((data.rebateStats.totalRebateUsdc ?? 0) > 0 || (data.rebateStats.discountUsdc ?? 0) > 0) && (
<div>
<h3 className="text-xl font-semibold mb-4">Rebate Statistics</h3>
<Table
columns={dataColumns}
data={rebateStatsData}
showPagination={false}
/>
</div>
)}
</div>
)}
</div>
)
}
export default AccountFee

View File

@@ -8,6 +8,7 @@ import MoneyManagementSettings from './moneymanagement/moneyManagement'
import Theme from './theme'
import DefaultConfig from './defaultConfig/defaultConfig'
import UserInfoSettings from './UserInfoSettings'
import AccountFee from './accountFee/accountFee'
type TabsType = {
label: string
@@ -33,18 +34,23 @@ const tabs: TabsType = [
label: 'Account Settings',
},
{
Component: Theme,
Component: AccountFee,
index: 4,
label: 'Account Fee',
},
{
Component: Theme,
index: 5,
label: 'Theme',
},
{
Component: DefaultConfig,
index: 5,
index: 6,
label: 'Quick Start Config',
},
{
Component: HealthChecks,
index: 6,
index: 7,
label: 'Health Checks',
},
]