Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions src/lib/api/st0xApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,56 @@ export interface ApiTokenProofsResponse {
receipts: ApiTokenProofReceipt[];
}

// ============================================================================
// Token Detail Types
// ============================================================================

export interface ApiTokenDetailsError {
address: string;
message: string;
}

export interface ApiTokenDetailsSummary {
address: string;
deployTimestamp?: number;
receiptContractAddress?: string | null;
name: string;
symbol: string;
decimals: number;
totalSupply: string;
holderCount: number;
transferCount: number;
bridgedSupply: string;
depositVolume: string;
withdrawVolume: string;
activityVolume: string;
}

export interface ApiTokenDetailsActivityRow {
id: string;
txHash: string;
caller: string;
amount: string;
timestamp: number;
receiptId: string;
}

export interface ApiTokenDetails extends ApiTokenDetailsSummary {
sftVaultAddress: string;
deployTimestamp: number;
deployer: string;
admin: string;
activity: {
deposits: ApiTokenDetailsActivityRow[];
withdraws: ApiTokenDetailsActivityRow[];
};
}

export interface ApiTokenDetailsListResponse {
data: ApiTokenDetailsSummary[];
errors: ApiTokenDetailsError[];
}

// ============================================================================
// Wrap Ratio Types
// ============================================================================
Expand Down Expand Up @@ -277,6 +327,29 @@ export async function apiGetTokenProofs(address: string): Promise<ApiTokenProofs
return fetchJson<ApiTokenProofsResponse>(apiUrl(`/v1/tokens/${address}/proofs`));
}

/**
* Fetch ST0x token detail summaries from the REST API.
*/
export async function apiGetTokenDetails(): Promise<ApiTokenDetailsListResponse> {
assertBrowser('apiGetTokenDetails');
return fetchJson<ApiTokenDetailsListResponse>(apiUrl('/v1/tokens/details'));
}

/**
* Fetch ST0x token details and recent activity for a single token.
*/
export async function apiGetTokenDetailsByAddress(
address: string,
options?: { activityLimit?: number }
): Promise<ApiTokenDetails> {
assertBrowser('apiGetTokenDetailsByAddress');
return fetchJson<ApiTokenDetails>(
apiUrl(`/v1/tokens/${address}/details`, {
activityLimit: options?.activityLimit
})
);
}

/**
* Fetch current wrap ratios for supported wrapped tokens.
*/
Expand Down
11 changes: 1 addition & 10 deletions src/lib/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,7 @@
$: sortedAssets = $sfts
? [...$sfts]
.map<AssetWithMetrics>((sft) => {
// Calculate total on-chain volume (deposits + withdrawals)
const depositVolume = sft.deposits.reduce(
(sum: bigint, d: { amount: string }) => sum + BigInt(d.amount),
BigInt(0)
);
const withdrawVolume = sft.withdraws.reduce(
(sum: bigint, w: { amount: string }) => sum + BigInt(w.amount),
BigInt(0)
);
const totalVolume = depositVolume + withdrawVolume;
const totalVolume = BigInt(sft.activityVolume ?? '0');
// Use token config symbol for price lookup so legacy symbols (e.g. tSTOX) resolve to the wrapped token's price feed (wtSTOX / AMEX:SPLG)
const tokenInfo = findApiTokenByAnyAddress(apiTokens, sft.address);
const symbolForPrice = tokenInfo?.symbol ?? sft.symbol;
Expand Down
77 changes: 72 additions & 5 deletions src/lib/queries/vaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import {
} from '@tanstack/svelte-query';
import { browser } from '$app/environment';
import type { Network } from '$lib/config/network';
import { getSfts, getSftById } from '$lib/api/subgraph';
import {
apiGetTokenDetails,
apiGetTokenDetailsByAddress,
type ApiTokenDetails,
type ApiTokenDetailsActivityRow,
type ApiTokenDetailsSummary
} from '$lib/api/st0xApi';
import type { OffchainAssetReceiptVault } from '$lib/types/OffchainAssetReceiptVault';
import { queryClient } from '$lib/clients/queryClient';
import { createRaindexClient } from '$lib/clients/raindex';
Expand All @@ -16,13 +22,73 @@ import type { RaindexVault, SgVault } from '@rainlanguage/orderbook';
// SFT/Token Queries (OffchainAssetReceiptVault - the tokenized assets)
// =============================================================================

function toReceiptActivity(row: ApiTokenDetailsActivityRow) {
return {
id: row.id,
transaction: { id: row.txHash },
emitter: { address: row.caller },
receipt: {
id: row.receiptId,
receiptId: row.receiptId,
receiptInformations: []
},
amount: row.amount,
caller: { address: row.caller },
timestamp: String(row.timestamp)
};
}

function tokenDetailsSummaryToVault(
summary: ApiTokenDetailsSummary,
detail?: ApiTokenDetails,
chainId?: number
): OffchainAssetReceiptVault {
return {
id: summary.address,
totalShares: summary.totalSupply,
holderCount: summary.holderCount,
transferCount: summary.transferCount,
bridgedSupply: summary.bridgedSupply,
depositVolume: summary.depositVolume,
withdrawVolume: summary.withdrawVolume,
activityVolume: summary.activityVolume,
sftVaultAddress: detail?.sftVaultAddress,
address: summary.address as `0x${string}`,
deployer: detail?.deployer ?? '',
admin: detail?.admin ?? '',
name: summary.name,
symbol: summary.symbol,
deployTimestamp:
detail?.deployTimestamp !== undefined
? String(detail.deployTimestamp)
: summary.deployTimestamp !== undefined
? String(summary.deployTimestamp)
: '',
receiptContractAddress: summary.receiptContractAddress ?? '',
tokenHolders: [],
receiptVaultInformations: [],
withdraws: detail?.activity.withdraws.map(toReceiptActivity) ?? [],
deposits: detail?.activity.deposits.map(toReceiptActivity) ?? [],
shareTransfers: [],
chainId
};
}

export function createSftsQuery(network: Network | null) {
return createQuery<OffchainAssetReceiptVault[]>({
queryKey: ['sfts', network?.id],
enabled: Boolean(browser && network?.subgraph_url),
enabled: Boolean(browser && network),
staleTime: Infinity,
refetchInterval: false,
queryFn: () => getSfts(network as Network)
queryFn: async () => {
const response = await apiGetTokenDetails();
if (response.errors.length) {
console.warn('Some token details failed to load', response.errors);
}
return response.data.map((summary) =>
tokenDetailsSummaryToVault(summary, undefined, network?.chainId)
);
}
});
}

Expand Down Expand Up @@ -60,15 +126,16 @@ export function createSingleSftQuery(

return createQuery<OffchainAssetReceiptVault | null>({
queryKey: ['sft', network?.id, tokenId],
enabled: Boolean(network?.subgraph_url && tokenId),
enabled: Boolean(browser && network && tokenId),
staleTime: 30_000,
refetchInterval: false,
refetchOnWindowFocus: true, // Only refetch on focus if stale
initialData: getCachedToken() ?? undefined,
initialDataUpdatedAt: getCachedTimestamp(),
queryFn: async () => {
if (!network || !tokenId) return null;
return getSftById(tokenId, network);
const detail = await apiGetTokenDetailsByAddress(tokenId, { activityLimit: 5 });
return tokenDetailsSummaryToVault(detail, detail, network.chainId);
}
});
}
Expand Down
7 changes: 7 additions & 0 deletions src/lib/types/OffchainAssetReceiptVault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export type MetaV1S = {
export type OffchainAssetReceiptVault = {
id: string;
totalShares: string;
holderCount?: number;
transferCount?: number;
bridgedSupply?: string;
depositVolume?: string;
withdrawVolume?: string;
activityVolume?: string;
sftVaultAddress?: string;
address: Hex;
deployer: string;
admin: string;
Expand Down
37 changes: 14 additions & 23 deletions src/routes/(main)/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@
import { formatUnits } from 'viem';
import { goto } from '$app/navigation';
import Table from '$lib/components/ui/table/Table.svelte';
import type { OffchainAssetReceiptVault } from '$lib/types/OffchainAssetReceiptVault';
import QuickTrade from '$lib/components/QuickTrade.svelte';
import { tutorialActive, tutorialStep } from '$lib/stores/tutorialStore';
import Footer from '$lib/components/Footer.svelte';
import { track, trackPageView } from '$lib/services/analytics';
import { initScrollTracking } from '$lib/utils/scrollTracking';
import { toBigInt } from '$lib/utils/tokenMath';

function startTour() {
tutorialActive.set(true);
Expand Down Expand Up @@ -70,6 +70,11 @@
let priceFeedsQuery = createPriceFeedsQuery($currentNetwork);
$: priceFeedsQuery = createPriceFeedsQuery($currentNetwork);

function formatBaseUnitAmount(value: string | null | undefined): string {
const amount = toBigInt(value);
return amount === null ? '0' : formatUnits(amount, 18);
}

let cleanupScrollTracking: (() => void) | null = null;

onMount(() => {
Expand Down Expand Up @@ -106,7 +111,6 @@
};

let processedTokens: TokenRow[] = [];
let sftLookup = new Map<string, OffchainAssetReceiptVault>();
let isVaultLoading = false;
let vaultsError: string | null = null;
let hasVaults = false;
Expand All @@ -119,13 +123,6 @@
: !hasVaults && $vaultsQuery?.error
? String($vaultsQuery.error)
: null;
$: sftLookup = new Map<string, OffchainAssetReceiptVault>(
($sfts ?? []).map((vault: OffchainAssetReceiptVault) => [vault.id, vault])
);

function sumAmounts(entries?: Array<{ amount: string }>): bigint {
return (entries ?? []).reduce((sum: bigint, entry) => sum + BigInt(entry.amount), 0n);
}

const pioneerLogos = [
{ alt: 'Holo', src: '/images/pioneers/holo.svg', scale: 0.8 },
Expand Down Expand Up @@ -156,11 +153,9 @@
name: sft.name,
symbol: sft.symbol,
price,
totalHolders: sft.tokenHolders
.filter((holder: { balance: string }) => BigInt(holder.balance) > BigInt(0))
.length.toString(),
totalSupply: formatUnits(BigInt(sft.totalShares), 18),
totalTransfers: sft.shareTransfers.length.toString(),
totalHolders: String(sft.holderCount ?? 0),
totalSupply: formatBaseUnitAmount(sft.bridgedSupply ?? sft.totalShares),
totalTransfers: String(sft.transferCount ?? 0),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
createdAt: sft.deployTimestamp,
isSft: true
});
Expand Down Expand Up @@ -397,16 +392,12 @@
</tr>
{:else}
{#each processedTokens as token (token.id)}
{@const sft = sftLookup.get(token.id)}
{@const deposits = sumAmounts(sft?.deposits)}
{@const withdraws = sumAmounts(sft?.withdraws)}
{@const circulating = deposits - withdraws}
{@const circulatingSupply = parseFloat(formatUnits(circulating, 18))}
{@const bridgedSupply = Number(token.totalSupply)}
{@const displayPrice =
typeof token.price === 'number' ? token.price : Number(token.price ?? NaN)}
{@const marketCap =
displayPrice != null && Number.isFinite(displayPrice)
? circulatingSupply * displayPrice
? bridgedSupply * displayPrice
: null}
<tr
class="cursor-pointer transition-all hover:bg-yellow-500/5"
Expand Down Expand Up @@ -446,9 +437,9 @@
</td>
<td class="hidden px-3 py-3 sm:table-cell sm:px-5 sm:py-4">
<div class="text-sm text-gray-300">
{circulatingSupply >= 1000
? `${(circulatingSupply / 1000).toFixed(2)}K`
: circulatingSupply.toFixed(2)}
{bridgedSupply >= 1000
? `${(bridgedSupply / 1000).toFixed(2)}K`
: bridgedSupply.toFixed(2)}
</div>
</td>
<td class="hidden px-3 py-3 sm:table-cell sm:px-5 sm:py-4">
Expand Down
21 changes: 4 additions & 17 deletions src/routes/(main)/dashboard/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@
// User Vaults Query - no polling, invalidated after order deployment
$: vaultsListQuery = createUserVaultsQuery($currentNetwork, $walletAddress);

// Query user's wallet holdings from SFTs - fetches balances via multicall (single RPC request)
// Query user's wallet holdings from REST token details - fetches balances via multicall (single RPC request)
// We query balances on wrapped token addresses from the REST API token list since those are traded.
const walletHoldingsQuery = createQuery(
derived(
Expand All @@ -363,10 +363,9 @@
staleTime: QUERY_STALE_TIME_MS,
queryFn: async () => {
if (!$sfts || !$walletAddress || !$wagmiConfig) return [];
const normalizedWalletAddress = $walletAddress.toLowerCase();

// Map subgraph SFTs to their wrapped token addresses from the API token list.
// The subgraph returns unwrapped addresses, but we need to query wrapped token balances
// Token details already normalize to wrapped addresses, but token lookup keeps
// legacy/unwrapped variants safe for older cached rows.
const sftsWithWrappedAddresses = $sfts.map((sft) => {
const tokenConfig = findApiTokenByAnyAddress(ALL_TOKENS, sft.address);
return {
Expand All @@ -393,13 +392,6 @@

if (result.status === 'success') {
walletBalance = result.result as bigint;
} else {
// Fall back to subgraph data if multicall fails for this token
const userHolder = sft.tokenHolders.find(
(holder: { address: string }) =>
holder.address.toLowerCase() === normalizedWalletAddress
);
walletBalance = userHolder ? BigInt(userHolder.balance) : 0n;
}

const tokenConfig = findApiTokenByAnyAddress(ALL_TOKENS, sft.address);
Expand All @@ -415,19 +407,14 @@
});
} catch (error) {
console.error('Multicall failed for wallet holdings:', error);
// Fall back to subgraph data for all tokens
return $sfts.map((sft) => {
const userHolder = sft.tokenHolders.find(
(holder: { address: string }) =>
holder.address.toLowerCase() === normalizedWalletAddress
);
const tokenConfig = findApiTokenByAnyAddress(ALL_TOKENS, sft.address);
return {
id: sft.id,
address: tokenConfig?.address ?? sft.address,
name: tokenConfig?.name ?? sft.name,
symbol: tokenConfig?.symbol ?? sft.symbol,
walletBalance: userHolder ? BigInt(userHolder.balance) : 0n,
walletBalance: 0n,
decimals: 18
};
});
Expand Down
Loading
Loading