Add healthchecks for all candles + Claim ui fee button
This commit is contained in:
142
src/Managing.Api/HealthChecks/CandleDataDetailedHealthCheck.cs
Normal file
142
src/Managing.Api/HealthChecks/CandleDataDetailedHealthCheck.cs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
using Managing.Application.Abstractions.Services;
|
||||||
|
using Managing.Common;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using static Managing.Common.Enums;
|
||||||
|
|
||||||
|
namespace Managing.Api.HealthChecks
|
||||||
|
{
|
||||||
|
public class CandleDataDetailedHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly IExchangeService _exchangeService;
|
||||||
|
private readonly TradingExchanges _exchangeToCheck = TradingExchanges.Evm;
|
||||||
|
|
||||||
|
public CandleDataDetailedHealthCheck(IExchangeService exchangeService)
|
||||||
|
{
|
||||||
|
_exchangeService = exchangeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Define timeframes to check with their appropriate start dates and expected freshness
|
||||||
|
var timeframeChecks = new[]
|
||||||
|
{
|
||||||
|
(timeframe: Timeframe.FiveMinutes, startDate: now.AddDays(-1), maxAgeMins: 7),
|
||||||
|
(timeframe: Timeframe.FifteenMinutes, startDate: now.AddDays(-1), maxAgeMins: 17),
|
||||||
|
(timeframe: Timeframe.OneHour, startDate: now.AddDays(-3), maxAgeMins: 62),
|
||||||
|
(timeframe: Timeframe.FourHour, startDate: now.AddDays(-7), maxAgeMins: 242),
|
||||||
|
(timeframe: Timeframe.OneDay, startDate: now.AddDays(-30), maxAgeMins: 1442)
|
||||||
|
};
|
||||||
|
|
||||||
|
var supportedTickers = Constants.GMX.Config.SupportedTickers;
|
||||||
|
var tickerResults = new Dictionary<string, object>();
|
||||||
|
var isHealthy = true;
|
||||||
|
var totalChecks = 0;
|
||||||
|
var healthyChecks = 0;
|
||||||
|
var degradedChecks = 0;
|
||||||
|
|
||||||
|
foreach (var ticker in supportedTickers)
|
||||||
|
{
|
||||||
|
var degradedTimeframeResults = new List<Dictionary<string, object>>();
|
||||||
|
|
||||||
|
foreach (var (timeframe, startDate, maxAgeMins) in timeframeChecks)
|
||||||
|
{
|
||||||
|
totalChecks++;
|
||||||
|
|
||||||
|
// Fetch candles for this ticker and timeframe
|
||||||
|
var candles = await _exchangeService.GetCandlesInflux(
|
||||||
|
_exchangeToCheck,
|
||||||
|
ticker,
|
||||||
|
startDate,
|
||||||
|
timeframe);
|
||||||
|
|
||||||
|
if (candles == null || !candles.Any())
|
||||||
|
{
|
||||||
|
var checkResult = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Timeframe"] = timeframe.ToString(),
|
||||||
|
["StartDate"] = startDate,
|
||||||
|
["Status"] = "Degraded",
|
||||||
|
["Message"] = $"No candle data found for {ticker} - {timeframe}"
|
||||||
|
};
|
||||||
|
degradedTimeframeResults.Add(checkResult);
|
||||||
|
isHealthy = false;
|
||||||
|
degradedChecks++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var latestCandle = candles.OrderByDescending(c => c.Date).FirstOrDefault();
|
||||||
|
var timeDiff = now - latestCandle.Date;
|
||||||
|
|
||||||
|
if (timeDiff.TotalMinutes > maxAgeMins)
|
||||||
|
{
|
||||||
|
var checkResult = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["Timeframe"] = timeframe.ToString(),
|
||||||
|
["StartDate"] = startDate,
|
||||||
|
["LatestCandleDate"] = latestCandle.Date,
|
||||||
|
["TimeDifference"] = $"{timeDiff.TotalMinutes:F2} minutes",
|
||||||
|
["CandleCount"] = candles.Count(),
|
||||||
|
["Status"] = "Degraded",
|
||||||
|
["Message"] = $"Data for {ticker} - {timeframe} is outdated. Latest candle is from {latestCandle.Date:yyyy-MM-dd HH:mm:ss} UTC"
|
||||||
|
};
|
||||||
|
degradedTimeframeResults.Add(checkResult);
|
||||||
|
isHealthy = false;
|
||||||
|
degradedChecks++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
healthyChecks++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add ticker to results if it has degraded checks
|
||||||
|
if (degradedTimeframeResults.Any())
|
||||||
|
{
|
||||||
|
tickerResults[ticker.ToString()] = degradedTimeframeResults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build summary
|
||||||
|
var summary = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["TotalChecks"] = totalChecks,
|
||||||
|
["HealthyChecks"] = healthyChecks,
|
||||||
|
["DegradedChecks"] = degradedChecks,
|
||||||
|
["CheckedTickers"] = supportedTickers.Select(t => t.ToString()).ToArray(),
|
||||||
|
["CheckedTimeframes"] = timeframeChecks.Select(tc => tc.timeframe.ToString()).ToArray(),
|
||||||
|
["TickerResults"] = tickerResults
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isHealthy)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Healthy(
|
||||||
|
$"All candle data is up-to-date for {supportedTickers.Length} tickers across {timeframeChecks.Length} timeframes",
|
||||||
|
data: summary);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Degraded(
|
||||||
|
$"{degradedChecks} out of {totalChecks} candle checks are outdated or missing",
|
||||||
|
data: summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy(
|
||||||
|
"Error checking detailed candle data health",
|
||||||
|
ex,
|
||||||
|
data: new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["ErrorMessage"] = ex.Message,
|
||||||
|
["ErrorType"] = ex.GetType().Name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,10 @@ using static Managing.Common.Enums;
|
|||||||
|
|
||||||
namespace Managing.Api.HealthChecks
|
namespace Managing.Api.HealthChecks
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Lightweight health check for candle data - only checks ETH across all timeframes
|
||||||
|
/// For comprehensive checks across all tickers, see CandleDataDetailedHealthCheck
|
||||||
|
/// </summary>
|
||||||
public class CandleDataHealthCheck : IHealthCheck
|
public class CandleDataHealthCheck : IHealthCheck
|
||||||
{
|
{
|
||||||
private readonly IExchangeService _exchangeService;
|
private readonly IExchangeService _exchangeService;
|
||||||
@@ -90,13 +94,13 @@ namespace Managing.Api.HealthChecks
|
|||||||
if (isHealthy)
|
if (isHealthy)
|
||||||
{
|
{
|
||||||
return HealthCheckResult.Healthy(
|
return HealthCheckResult.Healthy(
|
||||||
"All candle timeframes are up-to-date",
|
$"All {_tickerToCheck} candle timeframes are up-to-date",
|
||||||
data: resultsDictionary);
|
data: resultsDictionary);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return HealthCheckResult.Degraded(
|
return HealthCheckResult.Degraded(
|
||||||
"One or more candle timeframes are outdated or missing",
|
$"One or more {_tickerToCheck} candle timeframes are outdated or missing",
|
||||||
data: resultsDictionary);
|
data: resultsDictionary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ builder.Services.AddHealthChecks()
|
|||||||
.AddUrlGroup(new Uri($"{influxUrl}/health"), name: "influxdb", tags: ["database"])
|
.AddUrlGroup(new Uri($"{influxUrl}/health"), name: "influxdb", tags: ["database"])
|
||||||
.AddCheck<Web3ProxyHealthCheck>("web3proxy", tags: ["api", "external"])
|
.AddCheck<Web3ProxyHealthCheck>("web3proxy", tags: ["api", "external"])
|
||||||
.AddCheck<CandleDataHealthCheck>("candle-data", tags: ["database", "candles"])
|
.AddCheck<CandleDataHealthCheck>("candle-data", tags: ["database", "candles"])
|
||||||
|
.AddCheck<CandleDataDetailedHealthCheck>("candle-data-detailed", tags: ["database", "candles-detailed"])
|
||||||
.AddCheck<GmxConnectivityHealthCheck>("gmx-connectivity", tags: ["api", "external"])
|
.AddCheck<GmxConnectivityHealthCheck>("gmx-connectivity", tags: ["api", "external"])
|
||||||
.AddCheck<OrleansHealthCheck>("orleans-cluster", tags: ["orleans", "cluster"]);
|
.AddCheck<OrleansHealthCheck>("orleans-cluster", tags: ["orleans", "cluster"]);
|
||||||
|
|
||||||
@@ -337,6 +338,13 @@ app.UseEndpoints(endpoints =>
|
|||||||
|
|
||||||
endpoints.MapHealthChecks("/health", new HealthCheckOptions
|
endpoints.MapHealthChecks("/health", new HealthCheckOptions
|
||||||
{
|
{
|
||||||
|
Predicate = r => !r.Tags.Contains("candles-detailed"),
|
||||||
|
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
endpoints.MapHealthChecks("/health-candles", new HealthCheckOptions
|
||||||
|
{
|
||||||
|
Predicate = r => r.Tags.Contains("candles-detailed"),
|
||||||
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
|
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"validate": "./scripts/validate"
|
"validate": "./scripts/validate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@gmx-io/sdk": "^1.3.1",
|
||||||
"@heroicons/react": "^1.0.6",
|
"@heroicons/react": "^1.0.6",
|
||||||
"@microsoft/signalr": "^6.0.5",
|
"@microsoft/signalr": "^6.0.5",
|
||||||
"@privy-io/react-auth": "^2.7.2",
|
"@privy-io/react-auth": "^2.7.2",
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React, {useState} from 'react'
|
||||||
|
import {usePrivy} from '@privy-io/react-auth'
|
||||||
|
import {ALLOWED_TICKERS, useClaimUiFees, useClaimUiFeesTransaction} from '../../../hooks/useClaimUiFees'
|
||||||
|
import Toast from '../Toast/Toast'
|
||||||
|
|
||||||
|
interface ClaimUiFeesButtonProps {
|
||||||
|
accountAddress: string
|
||||||
|
onSuccess?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button component to claim UI fees from GMX
|
||||||
|
* Automatically fetches markets and tokens from GMX SDK and calls the ExchangeRouter contract
|
||||||
|
*/
|
||||||
|
const ClaimUiFeesButton: React.FC<ClaimUiFeesButtonProps> = ({
|
||||||
|
accountAddress,
|
||||||
|
onSuccess,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const { authenticated } = usePrivy()
|
||||||
|
const { claimUiFees, isLoading, error, txHash, isConnected, address } = useClaimUiFees()
|
||||||
|
const { isConfirming, isConfirmed, isError: txError } = useClaimUiFeesTransaction(txHash)
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleClaim = async () => {
|
||||||
|
if (!authenticated || !isConnected) {
|
||||||
|
const t = new Toast('Error')
|
||||||
|
t.update('error', 'Please connect your wallet first')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accountAddress) {
|
||||||
|
const t = new Toast('Error')
|
||||||
|
t.update('error', 'No account address provided')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = new Toast('Claiming UI fees...')
|
||||||
|
setLocalError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
toast.update('info', 'Fetching claimable markets from GMX SDK...')
|
||||||
|
|
||||||
|
const hash = await claimUiFees(accountAddress)
|
||||||
|
|
||||||
|
toast.update('info', `Transaction submitted: ${hash.slice(0, 10)}...`)
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||||
|
setLocalError(errorMessage)
|
||||||
|
toast.update('error', `Failed to claim UI fees: ${errorMessage}`)
|
||||||
|
console.error('Error claiming UI fees:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor transaction confirmation
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isConfirmed) {
|
||||||
|
const t = new Toast('Success')
|
||||||
|
t.update('success', 'UI fees claimed successfully!')
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isConfirmed, onSuccess])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (txError) {
|
||||||
|
const t = new Toast('Error')
|
||||||
|
t.update('error', 'Transaction failed')
|
||||||
|
}
|
||||||
|
}, [txError])
|
||||||
|
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (isLoading) return 'Signing...'
|
||||||
|
if (isConfirming) return 'Confirming...'
|
||||||
|
if (isConfirmed) return 'Claimed!'
|
||||||
|
return 'Claim UI Fees'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isButtonDisabled = disabled || !authenticated || !isConnected || isLoading || isConfirming
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
className={`btn btn-primary ${isLoading || isConfirming ? 'loading' : ''}`}
|
||||||
|
onClick={handleClaim}
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
>
|
||||||
|
{getButtonText()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isConnected && authenticated && (
|
||||||
|
<p className="text-sm text-warning">Please connect your wallet to claim UI fees</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!authenticated && (
|
||||||
|
<p className="text-sm text-warning">Please authenticate to claim UI fees</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || localError) && (
|
||||||
|
<p className="text-sm text-error">{error || localError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{txHash && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="text-info">Transaction Hash:</p>
|
||||||
|
<a
|
||||||
|
href={`https://arbiscan.io/tx/${txHash}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="link link-primary break-all"
|
||||||
|
>
|
||||||
|
{txHash}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isConnected && address && (
|
||||||
|
<p className="text-xs text-base-content/70">
|
||||||
|
Connected: {address.slice(0, 6)}...{address.slice(-4)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs text-base-content/60">
|
||||||
|
<p className="mb-2">
|
||||||
|
This will use the GMX SDK to fetch claimable markets and call the ExchangeRouter contract.
|
||||||
|
</p>
|
||||||
|
<details className="text-xs">
|
||||||
|
<summary className="cursor-pointer hover:text-base-content/80">
|
||||||
|
Filtered tickers ({ALLOWED_TICKERS.length})
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 p-2 bg-base-300 rounded">
|
||||||
|
{ALLOWED_TICKERS.join(', ')}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClaimUiFeesButton
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from './ClaimUiFeesButton'
|
||||||
|
|
||||||
244
src/Managing.WebApp/src/hooks/useClaimUiFees.ts
Normal file
244
src/Managing.WebApp/src/hooks/useClaimUiFees.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import {useState} from 'react'
|
||||||
|
import {useAccount, useChainId, useSwitchChain, useWaitForTransactionReceipt, useWriteContract} from 'wagmi'
|
||||||
|
import {arbitrum} from 'viem/chains'
|
||||||
|
import {parseAbi} from 'viem'
|
||||||
|
import {GmxSdk} from '@gmx-io/sdk'
|
||||||
|
|
||||||
|
// ExchangeRouter contract address on Arbitrum
|
||||||
|
const EXCHANGE_ROUTER_ADDRESS = '0x5aC4e27341e4cCcb3e5FD62f9E62db2Adf43dd57'
|
||||||
|
|
||||||
|
// ABI for the claimUiFees function
|
||||||
|
const CLAIM_UI_FEES_ABI = parseAbi([
|
||||||
|
'function claimUiFees(address[] memory markets, address[] memory tokens, address receiver) external payable returns (uint256[] memory)',
|
||||||
|
])
|
||||||
|
|
||||||
|
// GMX SDK Configuration
|
||||||
|
const GMX_CONFIG = {
|
||||||
|
chainId: arbitrum.id,
|
||||||
|
oracleUrl: "https://arbitrum-api.gmxinfra.io",
|
||||||
|
rpcUrl: "https://arb1.arbitrum.io/rpc",
|
||||||
|
subsquidUrl: "https://gmx.squids.live/gmx-synthetics-arbitrum:prod/api/graphql",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed tickers for claiming UI fees - matches backend Constants.cs
|
||||||
|
const ALLOWED_TICKERS = [
|
||||||
|
'BTC',
|
||||||
|
'ETH',
|
||||||
|
'BNB',
|
||||||
|
'DOGE',
|
||||||
|
'ADA',
|
||||||
|
'SOL',
|
||||||
|
'XRP',
|
||||||
|
'LINK',
|
||||||
|
'RENDER',
|
||||||
|
'SUI',
|
||||||
|
'GMX',
|
||||||
|
'ARB',
|
||||||
|
'PEPE',
|
||||||
|
'PENDLE',
|
||||||
|
'AAVE',
|
||||||
|
'HYPE'
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type AllowedTicker = typeof ALLOWED_TICKERS[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps GMX token symbols to our ticker constants
|
||||||
|
*/
|
||||||
|
function normalizeTokenSymbol(symbol: string): string {
|
||||||
|
// GMX uses WBTC.e, WETH, etc. - normalize to our ticker format
|
||||||
|
return symbol
|
||||||
|
.replace('WBTC.e', 'BTC')
|
||||||
|
.replace('WBTC', 'BTC')
|
||||||
|
.replace('WETH', 'ETH')
|
||||||
|
.replace('BTC.b', 'BTC')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to claim UI fees from GMX ExchangeRouter contract
|
||||||
|
*/
|
||||||
|
export const useClaimUiFees = () => {
|
||||||
|
const { address, isConnected } = useAccount()
|
||||||
|
const chainId = useChainId()
|
||||||
|
const { switchChainAsync } = useSwitchChain()
|
||||||
|
const { writeContractAsync } = useWriteContract()
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [txHash, setTxHash] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches market and token addresses for claiming UI fees using GMX SDK directly
|
||||||
|
* Filters markets based on allowed tickers
|
||||||
|
*/
|
||||||
|
const fetchClaimMarkets = async (
|
||||||
|
account: string,
|
||||||
|
allowedTickers: readonly string[] = ALLOWED_TICKERS
|
||||||
|
): Promise<{ markets: `0x${string}`[], tokens: `0x${string}`[], filteredCount: number, totalCount: number }> => {
|
||||||
|
try {
|
||||||
|
// Initialize GMX SDK
|
||||||
|
const sdk = new GmxSdk({
|
||||||
|
...GMX_CONFIG,
|
||||||
|
account: account,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch markets info from GMX SDK
|
||||||
|
const { marketsInfoData } = await sdk.markets.getMarketsInfo()
|
||||||
|
|
||||||
|
if (!marketsInfoData) {
|
||||||
|
throw new Error('No markets info data available')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Markets info data:', marketsInfoData)
|
||||||
|
|
||||||
|
const marketAddresses: `0x${string}`[] = []
|
||||||
|
const tokenAddresses: `0x${string}`[] = []
|
||||||
|
let totalMarkets = 0
|
||||||
|
let filteredMarkets = 0
|
||||||
|
|
||||||
|
// Build claim parameters from markets info
|
||||||
|
for (const [marketAddress, marketInfo] of Object.entries(marketsInfoData)) {
|
||||||
|
// Skip swap-only markets (they don't have index tokens for claiming)
|
||||||
|
if (!marketInfo.indexToken || marketInfo.indexToken.address === '0x0000000000000000000000000000000000000000') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
totalMarkets++
|
||||||
|
|
||||||
|
// Get the index token symbol and normalize it
|
||||||
|
const indexTokenSymbol = normalizeTokenSymbol(marketInfo.indexToken.symbol)
|
||||||
|
|
||||||
|
// Filter by allowed tickers
|
||||||
|
if (!allowedTickers.includes(indexTokenSymbol)) {
|
||||||
|
console.log(`Skipping market ${marketInfo.name} (${indexTokenSymbol}) - not in allowed tickers`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredMarkets++
|
||||||
|
|
||||||
|
console.log(`Including market ${marketInfo.name} (${indexTokenSymbol})`)
|
||||||
|
|
||||||
|
// Add market address twice (for long and short positions/tokens)
|
||||||
|
marketAddresses.push(marketAddress as `0x${string}`, marketAddress as `0x${string}`)
|
||||||
|
|
||||||
|
// Add long token and short token addresses
|
||||||
|
tokenAddresses.push(
|
||||||
|
marketInfo.longToken.address as `0x${string}`,
|
||||||
|
marketInfo.shortToken.address as `0x${string}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Fetched claim markets:', {
|
||||||
|
marketAddresses,
|
||||||
|
tokenAddresses,
|
||||||
|
filteredMarkets,
|
||||||
|
totalMarkets,
|
||||||
|
allowedTickers
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
markets: marketAddresses,
|
||||||
|
tokens: tokenAddresses,
|
||||||
|
filteredCount: filteredMarkets,
|
||||||
|
totalCount: totalMarkets
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching claim markets:', err)
|
||||||
|
throw new Error(`Failed to fetch claim markets: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claims UI fees by calling the ExchangeRouter contract
|
||||||
|
* This function will automatically fetch the necessary market and token addresses
|
||||||
|
* @param accountAddress The account address to claim fees for
|
||||||
|
* @param customTickers Optional custom list of tickers to filter by (defaults to ALLOWED_TICKERS)
|
||||||
|
*/
|
||||||
|
const claimUiFees = async (accountAddress: string, customTickers?: readonly string[]) => {
|
||||||
|
if (!isConnected || !address) {
|
||||||
|
throw new Error('Wallet not connected')
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setTxHash(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Switch to Arbitrum if not already on it
|
||||||
|
if (chainId !== arbitrum.id) {
|
||||||
|
await switchChainAsync({ chainId: arbitrum.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch markets and tokens from GMX SDK with ticker filtering
|
||||||
|
const { markets, tokens, filteredCount, totalCount } = await fetchClaimMarkets(
|
||||||
|
accountAddress,
|
||||||
|
customTickers
|
||||||
|
)
|
||||||
|
|
||||||
|
if (markets.length === 0 || tokens.length === 0) {
|
||||||
|
throw new Error(`No claimable UI fees found for the selected tickers (filtered ${filteredCount}/${totalCount} markets)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markets.length !== tokens.length) {
|
||||||
|
throw new Error('Markets and tokens arrays must have the same length')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Claiming UI fees for:')
|
||||||
|
console.log(`Filtered ${filteredCount}/${totalCount} markets`)
|
||||||
|
console.log('Markets:', markets)
|
||||||
|
console.log('Tokens:', tokens)
|
||||||
|
console.log('Receiver:', address)
|
||||||
|
|
||||||
|
// Call the claimUiFees function on the contract
|
||||||
|
const hash = await writeContractAsync({
|
||||||
|
address: EXCHANGE_ROUTER_ADDRESS,
|
||||||
|
abi: CLAIM_UI_FEES_ABI,
|
||||||
|
functionName: 'claimUiFees',
|
||||||
|
args: [markets, tokens, address],
|
||||||
|
// The function is payable, but we don't need to send ETH
|
||||||
|
})
|
||||||
|
|
||||||
|
setTxHash(hash)
|
||||||
|
return hash
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||||
|
setError(errorMessage)
|
||||||
|
throw new Error(`Failed to claim UI fees: ${errorMessage}`)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
claimUiFees,
|
||||||
|
fetchClaimMarkets,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
txHash,
|
||||||
|
isConnected,
|
||||||
|
address,
|
||||||
|
allowedTickers: ALLOWED_TICKERS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the allowed tickers for use in other components
|
||||||
|
export { ALLOWED_TICKERS, type AllowedTicker }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to monitor transaction status
|
||||||
|
*/
|
||||||
|
export const useClaimUiFeesTransaction = (txHash: string | null) => {
|
||||||
|
const { data, isLoading, isSuccess, isError } = useWaitForTransactionReceipt({
|
||||||
|
hash: txHash as `0x${string}` | undefined,
|
||||||
|
chainId: arbitrum.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
transactionData: data,
|
||||||
|
isConfirming: isLoading,
|
||||||
|
isConfirmed: isSuccess,
|
||||||
|
isError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,15 +5,23 @@ import LogIn from '../../components/mollecules/LogIn/LogIn'
|
|||||||
import useCookie from '../../hooks/useCookie'
|
import useCookie from '../../hooks/useCookie'
|
||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
import {useAuthStore} from '../../app/store/accountStore'
|
import {useAuthStore} from '../../app/store/accountStore'
|
||||||
|
import {ALLOWED_TICKERS, useClaimUiFees, useClaimUiFeesTransaction} from '../../hooks/useClaimUiFees'
|
||||||
|
import Toast from '../../components/mollecules/Toast/Toast'
|
||||||
|
|
||||||
export const Auth = ({ children }: any) => {
|
export const Auth = ({ children }: any) => {
|
||||||
const { getCookie, deleteCookie } = useCookie()
|
const { getCookie, deleteCookie } = useCookie()
|
||||||
const { isConnected } = useAccount()
|
const { isConnected, address: walletAddress } = useAccount()
|
||||||
const { login, ready, authenticated, user } = usePrivy()
|
const { login, ready, authenticated, user } = usePrivy()
|
||||||
const token = getCookie('token')
|
const token = getCookie('token')
|
||||||
const onInitialize = useAuthStore((state) => state.onInitialize)
|
const onInitialize = useAuthStore((state) => state.onInitialize)
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [claimAccountAddress, setClaimAccountAddress] = useState('')
|
||||||
|
const [showClaimSection, setShowClaimSection] = useState(false)
|
||||||
|
|
||||||
|
// Claim UI fees hook
|
||||||
|
const { claimUiFees, isLoading: isClaimingFees, txHash, error: claimError } = useClaimUiFees()
|
||||||
|
const { isConfirming, isConfirmed, isError: txError } = useClaimUiFeesTransaction(txHash)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
@@ -30,7 +38,49 @@ export const Auth = ({ children }: any) => {
|
|||||||
if (authenticated && token) {
|
if (authenticated && token) {
|
||||||
onInitialize()
|
onInitialize()
|
||||||
}
|
}
|
||||||
}, [authenticated, token, onInitialize]);
|
}, [authenticated, token, onInitialize])
|
||||||
|
|
||||||
|
// Handle successful claim
|
||||||
|
useEffect(() => {
|
||||||
|
if (isConfirmed) {
|
||||||
|
const toast = new Toast('Success')
|
||||||
|
toast.update('success', 'UI fees claimed successfully!')
|
||||||
|
}
|
||||||
|
}, [isConfirmed])
|
||||||
|
|
||||||
|
// Handle transaction error
|
||||||
|
useEffect(() => {
|
||||||
|
if (txError) {
|
||||||
|
const toast = new Toast('Error')
|
||||||
|
toast.update('error', 'Transaction failed')
|
||||||
|
}
|
||||||
|
}, [txError])
|
||||||
|
|
||||||
|
const handleClaimUiFees = async () => {
|
||||||
|
if (!isConnected) {
|
||||||
|
const toast = new Toast('Error')
|
||||||
|
toast.update('error', 'Please connect your wallet first')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!claimAccountAddress.trim()) {
|
||||||
|
const toast = new Toast('Error')
|
||||||
|
toast.update('error', 'Please enter an account address')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = new Toast('Claiming UI fees...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
toast.update('info', 'Fetching claimable markets from GMX SDK...')
|
||||||
|
await claimUiFees(claimAccountAddress.trim())
|
||||||
|
toast.update('info', 'Transaction submitted, waiting for confirmation...')
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||||
|
toast.update('error', `Failed to claim UI fees: ${errorMessage}`)
|
||||||
|
console.error('Error claiming UI fees:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!ready || isLoading) {
|
if (!ready || isLoading) {
|
||||||
return <div>Loading...</div>;
|
return <div>Loading...</div>;
|
||||||
@@ -39,7 +89,7 @@ export const Auth = ({ children }: any) => {
|
|||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
deleteCookie('token')
|
deleteCookie('token')
|
||||||
return (
|
return (
|
||||||
<div style={{ ...styles }}>
|
<div style={{ ...styles, flexDirection: 'column', gap: '20px' }}>
|
||||||
<button
|
<button
|
||||||
onClick={login}
|
onClick={login}
|
||||||
style={{
|
style={{
|
||||||
@@ -54,6 +104,120 @@ export const Auth = ({ children }: any) => {
|
|||||||
>
|
>
|
||||||
Login with Privy
|
Login with Privy
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Claim UI Fees Section */}
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowClaimSection(!showClaimSection)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: showClaimSection ? '#6B7280' : '#10B981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '16px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showClaimSection ? 'Hide' : 'Claim UI Fees'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showClaimSection && (
|
||||||
|
<div style={{
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
borderRadius: '8px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
width: '100%'
|
||||||
|
}}>
|
||||||
|
<h3 style={{ margin: '0 0 15px 0', fontSize: '18px', color: '#111827' }}>
|
||||||
|
Claim GMX UI Fees
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 10px 0', fontSize: '14px', color: '#6B7280' }}>
|
||||||
|
Connect your wallet and enter the account address to claim UI fees from GMX.
|
||||||
|
</p>
|
||||||
|
<details style={{ marginBottom: '15px', fontSize: '12px', color: '#6B7280' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', fontWeight: '500' }}>
|
||||||
|
Filtered tickers ({ALLOWED_TICKERS.length})
|
||||||
|
</summary>
|
||||||
|
<div style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
borderRadius: '4px',
|
||||||
|
lineHeight: '1.6'
|
||||||
|
}}>
|
||||||
|
{ALLOWED_TICKERS.join(', ')}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{isConnected && walletAddress && (
|
||||||
|
<div style={{ marginBottom: '15px', fontSize: '12px', color: '#059669' }}>
|
||||||
|
✓ Wallet connected: {walletAddress.slice(0, 6)}...{walletAddress.slice(-4)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isConnected && (
|
||||||
|
<div style={{ marginBottom: '15px', fontSize: '12px', color: '#DC2626' }}>
|
||||||
|
⚠ Please connect your wallet first
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter account address (0x...)"
|
||||||
|
value={claimAccountAddress}
|
||||||
|
onChange={(e) => setClaimAccountAddress(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px',
|
||||||
|
marginBottom: '15px',
|
||||||
|
border: '1px solid #D1D5DB',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleClaimUiFees}
|
||||||
|
disabled={!isConnected || isClaimingFees || isConfirming || !claimAccountAddress.trim()}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: (!isConnected || isClaimingFees || isConfirming || !claimAccountAddress.trim()) ? '#9CA3AF' : '#10B981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: (!isConnected || isClaimingFees || isConfirming || !claimAccountAddress.trim()) ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '16px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isClaimingFees ? 'Signing...' : isConfirming ? 'Confirming...' : isConfirmed ? 'Claimed!' : 'Claim UI Fees'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(claimError) && (
|
||||||
|
<div style={{ marginTop: '10px', fontSize: '12px', color: '#DC2626' }}>
|
||||||
|
{claimError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{txHash && (
|
||||||
|
<div style={{ marginTop: '10px', fontSize: '12px' }}>
|
||||||
|
<p style={{ color: '#059669', margin: '0 0 5px 0' }}>Transaction Hash:</p>
|
||||||
|
<a
|
||||||
|
href={`https://arbiscan.io/tx/${txHash}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: '#3B82F6', wordBreak: 'break-all' }}
|
||||||
|
>
|
||||||
|
{txHash}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} else if (!token) {
|
} else if (!token) {
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import {useMemo, useState} from 'react'
|
|||||||
import {AccountClient, GmxClaimableSummary} from '../../../generated/ManagingApi'
|
import {AccountClient, GmxClaimableSummary} from '../../../generated/ManagingApi'
|
||||||
import useApiUrlStore from '../../../app/store/apiStore'
|
import useApiUrlStore from '../../../app/store/apiStore'
|
||||||
import {Table} from '../../../components/mollecules'
|
import {Table} from '../../../components/mollecules'
|
||||||
|
import ClaimUiFeesButton from '../../../components/mollecules/ClaimUiFeesButton'
|
||||||
|
import {usePrivy} from '@privy-io/react-auth'
|
||||||
|
|
||||||
function AccountFee() {
|
function AccountFee() {
|
||||||
const [accountName, setAccountName] = useState('')
|
const [accountName, setAccountName] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [data, setData] = useState<GmxClaimableSummary | null>(null)
|
const [data, setData] = useState<GmxClaimableSummary | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [accountAddress, setAccountAddress] = useState<string>('')
|
||||||
const { apiUrl } = useApiUrlStore()
|
const { apiUrl } = useApiUrlStore()
|
||||||
|
const { authenticated } = usePrivy()
|
||||||
|
|
||||||
const handleFetch = async () => {
|
const handleFetch = async () => {
|
||||||
if (!accountName.trim()) {
|
if (!accountName.trim()) {
|
||||||
@@ -18,11 +22,23 @@ function AccountFee() {
|
|||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setAccountAddress('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const accountClient = new AccountClient({}, apiUrl)
|
const accountClient = new AccountClient({}, apiUrl)
|
||||||
const result = await accountClient.account_GetGmxClaimableSummary(accountName.trim())
|
|
||||||
|
// Fetch both the claimable summary and the account details to get the address
|
||||||
|
const [result, accountDetails] = await Promise.all([
|
||||||
|
accountClient.account_GetGmxClaimableSummary(accountName.trim()),
|
||||||
|
accountClient.account_GetAccount(accountName.trim())
|
||||||
|
])
|
||||||
|
|
||||||
setData(result)
|
setData(result)
|
||||||
|
|
||||||
|
// Set the account address for claiming
|
||||||
|
if (accountDetails && accountDetails.key) {
|
||||||
|
setAccountAddress(accountDetails.key)
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to fetch GMX claimable summary')
|
setError(err.message || 'Failed to fetch GMX claimable summary')
|
||||||
setData(null)
|
setData(null)
|
||||||
@@ -31,6 +47,13 @@ function AccountFee() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClaimSuccess = () => {
|
||||||
|
// Refresh the data after successful claim
|
||||||
|
if (accountName) {
|
||||||
|
handleFetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Table columns for all sections
|
// Table columns for all sections
|
||||||
const dataColumns = useMemo(
|
const dataColumns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -175,6 +198,21 @@ function AccountFee() {
|
|||||||
data={uiFeesData}
|
data={uiFeesData}
|
||||||
showPagination={false}
|
showPagination={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Claim UI Fees Button */}
|
||||||
|
{authenticated && accountAddress && (
|
||||||
|
<div className="mt-4 p-4 bg-base-200 rounded-lg">
|
||||||
|
<h4 className="text-lg font-semibold mb-2">Claim UI Fees</h4>
|
||||||
|
<p className="text-sm mb-4 text-base-content/70">
|
||||||
|
Connect your wallet and claim UI fees directly from the GMX ExchangeRouter contract.
|
||||||
|
The markets and tokens will be automatically fetched from the GMX SDK.
|
||||||
|
</p>
|
||||||
|
<ClaimUiFeesButton
|
||||||
|
accountAddress={accountAddress}
|
||||||
|
onSuccess={handleClaimSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user