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
75 changes: 41 additions & 34 deletions apps/dashboard/src/components/network-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,19 @@ export function getExplorerAddressUrl(
address: string,
network?: string
): string {
if (type === 'evm') {
const explorerUrl = (network && EXPLORER_URLS[network]) || EXPLORER_URLS.base;
return `${explorerUrl}/address/${address}`;
}
if (type === 'solana') {
const cluster = network === 'solana-devnet' ? '?cluster=devnet' : '';
return `https://solscan.io/account/${address}${cluster}`;
}
if (type === 'stacks') {
return `https://explorer.hiro.so/address/${address}?chain=mainnet`;
const chain = network === 'stacks-testnet' ? 'testnet' : 'mainnet';
return `https://explorer.hiro.so/address/${address}?chain=${chain}`;
}
return `https://basescan.org/address/${address}`;
return `${EXPLORER_URLS.base}/address/${address}`;
}

// Balance thresholds for warnings
Expand Down Expand Up @@ -240,9 +245,12 @@ export interface WalletInfo {
address: string | null;
balance?: string;
balanceFormatted?: string;
clusterBalances?: {
solana?: { balance?: string; balanceFormatted?: string };
'solana-devnet'?: { balance?: string; balanceFormatted?: string };
networkBalances?: {
[network: string]: {
address?: string | null;
balance?: string;
balanceFormatted?: string;
};
};
}

Expand Down Expand Up @@ -305,21 +313,26 @@ export function WalletTypeCard({
const [copied, setCopied] = useState(false);
const [isImportOpen, setIsImportOpen] = useState(false);
const [importKey, setImportKey] = useState('');
const [solanaNetwork, setSolanaNetwork] = useState<'solana' | 'solana-devnet'>('solana');
const [selectedNetwork, setSelectedNetwork] = useState<string | null>(null);

const hasWallet = wallet?.address != null;
const displayNetworks = showTestnets ? networks : networks.filter(n => !n.testnet);
const activeBalance = type === 'solana'
? wallet?.clusterBalances?.[solanaNetwork]?.balanceFormatted ?? wallet?.balanceFormatted
: wallet?.balanceFormatted;
const walletNetworkIds = new Set(Object.keys(wallet?.networkBalances ?? {}));
const hasNetworkBalanceData = walletNetworkIds.size > 0;
const walletNetworks = networks.filter((network) => walletNetworkIds.has(network.v1Id));
const defaultNetwork = walletNetworks.find((network) => !network.testnet) ?? walletNetworks[0];
const activeNetwork = walletNetworks.find((network) => network.v1Id === selectedNetwork) ?? defaultNetwork;
const activeNetworkInfo = activeNetwork ? wallet?.networkBalances?.[activeNetwork.v1Id] : undefined;
const activeAddress = activeNetworkInfo ? activeNetworkInfo.address ?? '' : wallet?.address ?? '';
const activeBalance = activeNetworkInfo ? activeNetworkInfo.balanceFormatted : wallet?.balanceFormatted;
const balance = activeBalance ? parseFloat(activeBalance) : 0;
const balanceStatus = hasWallet
? balance === 0 ? 'empty' : balance < LOW_BALANCE_THRESHOLDS[type] ? 'low' : 'ok'
: null;

const handleCopy = async () => {
if (wallet?.address) {
await navigator.clipboard.writeText(wallet.address);
if (activeAddress) {
await navigator.clipboard.writeText(activeAddress);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
Expand All @@ -333,7 +346,7 @@ export function WalletTypeCard({

const icon = NETWORK_ICONS[type];
const nativeSymbol = NATIVE_SYMBOLS[type];
const activeNetwork = type === 'solana' ? solanaNetwork : undefined;
const activeNetworkId = activeNetwork?.v1Id;

return (
<Card className={hasWallet && balanceStatus === 'ok' ? 'border-green-500/30' : ''}>
Expand Down Expand Up @@ -365,7 +378,7 @@ export function WalletTypeCard({
<div className="space-y-4">
{/* Wallet Address */}
<div className="flex items-center gap-2 font-mono text-sm bg-muted p-2 rounded">
<span className="truncate flex-1">{wallet?.address}</span>
<span className="truncate flex-1">{activeAddress}</span>
<Button
variant="ghost"
size="sm"
Expand All @@ -375,7 +388,7 @@ export function WalletTypeCard({
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</Button>
<a
href={getExplorerAddressUrl(type, wallet?.address || '', activeNetwork)}
href={getExplorerAddressUrl(type, activeAddress, activeNetworkId)}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground"
Expand All @@ -384,26 +397,20 @@ export function WalletTypeCard({
</a>
</div>

{type === 'solana' && (
{walletNetworks.length > 1 && (
<div className="inline-flex h-9 items-center rounded-md bg-muted p-1">
<Button
type="button"
variant={solanaNetwork === 'solana' ? 'secondary' : 'ghost'}
size="sm"
className="h-7"
onClick={() => setSolanaNetwork('solana')}
>
Mainnet
</Button>
<Button
type="button"
variant={solanaNetwork === 'solana-devnet' ? 'secondary' : 'ghost'}
size="sm"
className="h-7"
onClick={() => setSolanaNetwork('solana-devnet')}
>
Devnet
</Button>
{walletNetworks.map((network) => (
<Button
key={network.v1Id}
type="button"
variant={activeNetwork?.v1Id === network.v1Id ? 'secondary' : 'ghost'}
size="sm"
className="h-7"
onClick={() => setSelectedNetwork(network.v1Id)}
>
{network.testnet ? 'Devnet' : 'Mainnet'}
</Button>
))}
</div>
)}

Expand Down Expand Up @@ -495,7 +502,7 @@ export function WalletTypeCard({
<NetworkPill
key={network.v1Id}
network={network}
active={hasWallet}
active={hasWallet && (!hasNetworkBalanceData || !network.testnet || walletNetworkIds.has(network.v1Id))}
/>
))}
</div>
Expand Down
41 changes: 36 additions & 5 deletions apps/dashboard/src/components/networks-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
WalletTypeCard,
SUPPORTED_NETWORKS,
getEvmNetworks,
getSolanaNetworks,
getStacksNetworks,
Expand Down Expand Up @@ -93,10 +92,23 @@ export function NetworksSection({ facilitatorId }: NetworksSectionProps) {
const getEvmWalletInfo = (): WalletInfo | null => {
if (!evmWallet?.hasWallet) return null;
const baseBalance = evmWallet.balances?.['8453'];
const baseSepoliaBalance = evmWallet.balances?.['84532'];
return {
address: evmWallet.address || null,
balance: baseBalance?.balance,
balanceFormatted: baseBalance?.formatted,
networkBalances: {
base: {
address: evmWallet.address || null,
balance: baseBalance?.balance,
balanceFormatted: baseBalance?.formatted,
},
'base-sepolia': {
address: evmWallet.address || null,
balance: baseSepoliaBalance?.balance,
balanceFormatted: baseSepoliaBalance?.formatted,
},
},
};
};

Expand All @@ -106,12 +118,14 @@ export function NetworksSection({ facilitatorId }: NetworksSectionProps) {
address: solanaWallet.address || null,
balance: solanaWallet.balance?.lamports,
balanceFormatted: solanaWallet.balance?.sol,
clusterBalances: {
networkBalances: {
solana: {
address: solanaWallet.address || null,
balance: solanaWallet.balances?.solana?.lamports,
balanceFormatted: solanaWallet.balances?.solana?.sol,
},
'solana-devnet': {
address: solanaWallet.address || null,
balance: solanaWallet.balances?.['solana-devnet']?.lamports,
balanceFormatted: solanaWallet.balances?.['solana-devnet']?.sol,
},
Expand All @@ -121,10 +135,27 @@ export function NetworksSection({ facilitatorId }: NetworksSectionProps) {

const getStacksWalletInfo = (): WalletInfo | null => {
if (!stacksWallet?.hasWallet) return null;
const mainnetAddress = stacksWallet.addresses?.stacks ?? stacksWallet.address;
const testnetAddress = stacksWallet.addresses?.['stacks-testnet'];
const mainnetBalance = stacksWallet.balances?.stacks ?? stacksWallet.balance;
const testnetBalance = stacksWallet.balances?.['stacks-testnet'];

return {
address: stacksWallet.address || null,
balance: stacksWallet.balance?.microStx,
balanceFormatted: stacksWallet.balance?.stx,
address: mainnetAddress || null,
balance: mainnetBalance?.microStx,
balanceFormatted: mainnetBalance?.stx,
networkBalances: {
stacks: {
address: mainnetAddress || null,
balance: mainnetBalance?.microStx,
balanceFormatted: mainnetBalance?.stx,
},
'stacks-testnet': {
address: testnetAddress || null,
balance: testnetBalance?.microStx,
balanceFormatted: testnetBalance?.stx,
},
},
};
};

Expand Down
10 changes: 9 additions & 1 deletion apps/dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export interface User {
export interface WalletInfo {
hasWallet: boolean;
address: string | null;
balances: Record<string, { balance: string; formatted: string }>;
balances: Record<string, { balance: string; formatted: string } | null>;
}

export interface SolanaWalletInfo {
Expand All @@ -93,6 +93,14 @@ export interface StacksWalletInfo {
hasWallet: boolean;
address: string | null;
balance: { stx: string; microStx: string } | null;
addresses?: {
stacks: string;
'stacks-testnet': string;
};
balances?: {
stacks: { stx: string; microStx: string } | null;
'stacks-testnet': { stx: string; microStx: string } | null;
};
}

export interface WalletGenerateResponse {
Expand Down
84 changes: 55 additions & 29 deletions packages/server/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
generateSolanaKeypair,
getSolanaPublicKey,
getSolanaBalance,
getStacksBalance,
isValidSolanaPrivateKey,
getNonceStatus,
forceResetNonce,
Expand Down Expand Up @@ -113,14 +114,14 @@ import {
getAllWalletsForUser,
getUSDCBalance,
getBaseUSDCBalance,
getStacksSTXBalance,
} from '../services/wallet.js';
import { generateWebhookSecret, deliverWebhook } from '../services/webhook.js';
import type { Hex } from 'viem';

const router: IRouter = Router();

type SolanaWalletNetwork = 'solana' | 'solana-devnet';
type StacksWalletNetwork = 'stacks' | 'stacks-testnet';

async function getOptionalSolanaBalance(network: SolanaWalletNetwork, address: string) {
try {
Expand All @@ -135,6 +136,32 @@ async function getOptionalSolanaBalance(network: SolanaWalletNetwork, address: s
}
}

async function getOptionalEvmBalance(chainId: number, address: Hex) {
try {
const result = await getWalletBalance(chainId, address);
return {
balance: result.balance.toString(),
formatted: result.formatted,
};
} catch (error) {
console.error(`Failed to get EVM balance for chain ${chainId}:`, error);
return null;
}
}

async function getOptionalStacksBalance(network: StacksWalletNetwork, address: string) {
try {
const result = await getStacksBalance(network, address);
return {
stx: result.formatted,
microStx: result.balance.toString(),
};
} catch (error) {
console.error(`Failed to get ${network} Stacks balance:`, error);
return null;
}
}

// Apply optional auth to all routes first to get user context
router.use(optionalAuth);

Expand Down Expand Up @@ -978,26 +1005,20 @@ router.get('/facilitators/:id/wallet', requireAuth, async (req: Request, res: Re
const privateKey = decryptPrivateKey(facilitator.encrypted_private_key);
const address = getWalletAddress(privateKey as Hex);

// Get balances for supported EVM chains
const balances: Record<string, { balance: string; formatted: string }> = {};
// Get balances for the primary EVM mainnet/devnet pair plus configured EVM chains.
const balances: Record<string, { balance: string; formatted: string } | null> = {};
const supportedChains = JSON.parse(facilitator.supported_chains) as (number | string)[];

const evmChainIds = new Set([8453, 84532]);
for (const chainId of supportedChains) {
// Only get balances for EVM chains (number chainIds)
if (typeof chainId === 'number') {
try {
const result = await getWalletBalance(chainId, address);
balances[String(chainId)] = {
balance: result.balance.toString(),
formatted: result.formatted,
};
} catch (e) {
// Skip chains that fail to fetch balance
console.error(`Failed to get balance for chain ${chainId}:`, e);
}
}
if (typeof chainId === 'number') evmChainIds.add(chainId);
}

await Promise.all(
[...evmChainIds].map(async (chainId) => {
balances[String(chainId)] = await getOptionalEvmBalance(chainId, address);
})
);

res.json({
hasWallet: true,
address,
Expand Down Expand Up @@ -1255,23 +1276,28 @@ router.get('/facilitators/:id/wallet/stacks', requireAuth, async (req: Request,
return;
}

// Decrypt to get address
// Decrypt to get mainnet/testnet addresses. Stacks addresses are network-specific.
const privateKey = decryptPrivateKey(facilitator.encrypted_stacks_private_key);
const address = getAddressFromPrivateKey(privateKey, 'mainnet');
const mainnetAddress = getAddressFromPrivateKey(privateKey, 'mainnet');
const testnetAddress = getAddressFromPrivateKey(privateKey, 'testnet');

// Get balance
let balance: { stx: string; microStx: string } | null = null;
try {
const balanceInfo = await getStacksSTXBalance(address);
balance = { stx: balanceInfo.formatted, microStx: balanceInfo.balance.toString() };
} catch {
// Balance check may fail if node is unreachable
}
const [mainnetBalance, testnetBalance] = await Promise.all([
getOptionalStacksBalance('stacks', mainnetAddress),
getOptionalStacksBalance('stacks-testnet', testnetAddress),
]);

res.json({
hasWallet: true,
address,
balance,
address: mainnetAddress,
addresses: {
stacks: mainnetAddress,
'stacks-testnet': testnetAddress,
},
balance: mainnetBalance,
balances: {
stacks: mainnetBalance,
'stacks-testnet': testnetBalance,
},
});
} catch (error) {
console.error('Get Stacks wallet error:', error);
Expand Down
Loading
Loading