From dcb4023bb9655c4095a42c451fea2248c6f3c346 Mon Sep 17 00:00:00 2001 From: Ben Tatum Date: Thu, 21 May 2026 12:59:58 -0400 Subject: [PATCH] feat: add public testnet support --- .../dashboard/src/components/network-card.tsx | 75 ++++----- .../src/components/networks-section.tsx | 41 ++++- apps/dashboard/src/lib/api.ts | 10 +- packages/server/src/routes/admin.ts | 84 ++++++---- packages/server/src/routes/facilitator.ts | 4 +- packages/server/src/routes/public.ts | 31 +++- .../services/public-testnet-support.test.ts | 106 +++++++++++++ .../src/services/public-testnet-support.ts | 144 ++++++++++++++++++ .../services/solana-devnet-support.test.ts | 62 -------- .../src/services/solana-devnet-support.ts | 75 --------- 10 files changed, 419 insertions(+), 213 deletions(-) create mode 100644 packages/server/src/services/public-testnet-support.test.ts create mode 100644 packages/server/src/services/public-testnet-support.ts delete mode 100644 packages/server/src/services/solana-devnet-support.test.ts delete mode 100644 packages/server/src/services/solana-devnet-support.ts diff --git a/apps/dashboard/src/components/network-card.tsx b/apps/dashboard/src/components/network-card.tsx index 386bfc9..f622401 100644 --- a/apps/dashboard/src/components/network-card.tsx +++ b/apps/dashboard/src/components/network-card.tsx @@ -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 @@ -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; + }; }; } @@ -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(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); } @@ -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 ( @@ -365,7 +378,7 @@ export function WalletTypeCard({
{/* Wallet Address */}
- {wallet?.address} + {activeAddress}
- {type === 'solana' && ( + {walletNetworks.length > 1 && (
- - + {walletNetworks.map((network) => ( + + ))}
)} @@ -495,7 +502,7 @@ export function WalletTypeCard({ ))}
diff --git a/apps/dashboard/src/components/networks-section.tsx b/apps/dashboard/src/components/networks-section.tsx index 64a90ab..6ba82a8 100644 --- a/apps/dashboard/src/components/networks-section.tsx +++ b/apps/dashboard/src/components/networks-section.tsx @@ -7,7 +7,6 @@ import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { WalletTypeCard, - SUPPORTED_NETWORKS, getEvmNetworks, getSolanaNetworks, getStacksNetworks, @@ -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, + }, + }, }; }; @@ -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, }, @@ -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, + }, + }, }; }; diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 675e88b..6f7abc0 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -76,7 +76,7 @@ export interface User { export interface WalletInfo { hasWallet: boolean; address: string | null; - balances: Record; + balances: Record; } export interface SolanaWalletInfo { @@ -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 { diff --git a/packages/server/src/routes/admin.ts b/packages/server/src/routes/admin.ts index 6d2795b..bf1ede4 100644 --- a/packages/server/src/routes/admin.ts +++ b/packages/server/src/routes/admin.ts @@ -79,6 +79,7 @@ import { generateSolanaKeypair, getSolanaPublicKey, getSolanaBalance, + getStacksBalance, isValidSolanaPrivateKey, getNonceStatus, forceResetNonce, @@ -113,7 +114,6 @@ import { getAllWalletsForUser, getUSDCBalance, getBaseUSDCBalance, - getStacksSTXBalance, } from '../services/wallet.js'; import { generateWebhookSecret, deliverWebhook } from '../services/webhook.js'; import type { Hex } from 'viem'; @@ -121,6 +121,7 @@ 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 { @@ -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); @@ -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 = {}; + // Get balances for the primary EVM mainnet/devnet pair plus configured EVM chains. + const balances: Record = {}; 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, @@ -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); diff --git a/packages/server/src/routes/facilitator.ts b/packages/server/src/routes/facilitator.ts index dd32007..38039c1 100644 --- a/packages/server/src/routes/facilitator.ts +++ b/packages/server/src/routes/facilitator.ts @@ -19,7 +19,7 @@ import { sendSettlementWebhook, deliverWebhook, generateWebhookSecret, type Prod import { executeAction, type ActionResult } from '../services/actions.js'; import { getWebhookById } from '../db/webhooks.js'; import { getProxyUrlBySlug } from '../db/proxy-urls.js'; -import { withPublicPaySolanaDevnetSupport } from '../services/solana-devnet-support.js'; +import { withPublicPayTestnetSupport } from '../services/public-testnet-support.js'; import type { Hex } from 'viem'; const router: IRouter = Router(); @@ -218,7 +218,7 @@ function buildFacilitatorConfig(record: FacilitatorRecord): FacilitatorConfig { updatedAt: new Date(record.updated_at), }; - return withPublicPaySolanaDevnetSupport(record, config); + return withPublicPayTestnetSupport(record, config); } /** diff --git a/packages/server/src/routes/public.ts b/packages/server/src/routes/public.ts index ab16987..ea8b3ff 100644 --- a/packages/server/src/routes/public.ts +++ b/packages/server/src/routes/public.ts @@ -2,6 +2,7 @@ import { Router, type Request, type Response, type IRouter } from 'express'; import { createFacilitator, type FacilitatorConfig, type TokenConfig, getSolanaPublicKey, networkToCaip2, isStacksNetwork } from '@openfacilitator/core'; import { OpenFacilitator, createPaymentMiddleware, type PaymentPayload, type PaymentRequirements } from '@openfacilitator/sdk'; import { privateKeyToAccount } from 'viem/accounts'; +import { getAddressFromPrivateKey } from '@stacks/transactions'; // SDK client for demo endpoint (uses default facilitator) const demoFacilitator = new OpenFacilitator(); @@ -15,7 +16,7 @@ import { createRegisteredServer, getRegisteredServersByResourceOwner, deleteRegi import { getOrCreateRefundConfig } from '../db/refund-configs.js'; import { reportFailure, executeClaimPayout, approveClaim, rejectClaim } from '../services/claims.js'; import { generateRefundWallet, getRefundWalletBalances, deleteRefundWallet, SUPPORTED_REFUND_NETWORKS } from '../services/refund-wallet.js'; -import { withSolanaDevnetSupport } from '../services/solana-devnet-support.js'; +import { withConfiguredTestnetSupport } from '../services/public-testnet-support.js'; import { requireAuth } from '../middleware/auth.js'; const router: IRouter = Router(); @@ -137,9 +138,11 @@ function getFreeFacilitatorConfig(): { config: FacilitatorConfig; evmPrivateKey? updatedAt: new Date(), }; - if (solanaPrivateKey) { - config = withSolanaDevnetSupport(config); - } + config = withConfiguredTestnetSupport(config, { + evm: !!evmPrivateKey, + solana: !!solanaPrivateKey, + stacks: !!stacksPrivateKey, + }); return { config, evmPrivateKey, solanaPrivateKey, stacksPrivateKey, evmAddress }; } @@ -175,7 +178,7 @@ router.get('/free/supported', (_req: Request, res: Response) => { // Build signers object with namespace prefixes const signers: Record = {}; - const evmAddress = process.env.FREE_FACILITATOR_EVM_ADDRESS; + const evmAddress = facilitatorData.evmAddress || process.env.FREE_FACILITATOR_EVM_ADDRESS; // Add EVM signer and feePayer if configured if (evmAddress) { @@ -425,6 +428,12 @@ router.get('/free/info', (_req: Request, res: Response) => { const solanaAddress = facilitatorData?.solanaPrivateKey ? getSolanaPublicKey(facilitatorData.solanaPrivateKey) : process.env.FREE_FACILITATOR_SOLANA_ADDRESS; + const stacksMainnetAddress = facilitatorData?.stacksPrivateKey + ? getAddressFromPrivateKey(facilitatorData.stacksPrivateKey, 'mainnet') + : undefined; + const stacksTestnetAddress = facilitatorData?.stacksPrivateKey + ? getAddressFromPrivateKey(facilitatorData.stacksPrivateKey, 'testnet') + : undefined; res.json({ name: 'OpenFacilitator Free', @@ -439,6 +448,10 @@ router.get('/free/info', (_req: Request, res: Response) => { available: true, feePayerAddress: evmAddress, } : { available: false }, + baseSepolia: facilitatorData?.evmPrivateKey ? { + available: true, + feePayerAddress: evmAddress, + } : { available: false }, solana: facilitatorData?.solanaPrivateKey ? { available: true, feePayerAddress: solanaAddress, @@ -447,6 +460,14 @@ router.get('/free/info', (_req: Request, res: Response) => { available: true, feePayerAddress: solanaAddress, } : { available: false }, + stacks: facilitatorData?.stacksPrivateKey ? { + available: true, + address: stacksMainnetAddress, + } : { available: false }, + stacksTestnet: facilitatorData?.stacksPrivateKey ? { + available: true, + address: stacksTestnetAddress, + } : { available: false }, }, limits: { note: 'Fair use policy applies. For high-volume usage, please self-host or get a managed instance.', diff --git a/packages/server/src/services/public-testnet-support.test.ts b/packages/server/src/services/public-testnet-support.test.ts new file mode 100644 index 0000000..fe94934 --- /dev/null +++ b/packages/server/src/services/public-testnet-support.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import type { FacilitatorConfig } from '@openfacilitator/core'; +import { + BASE_SEPOLIA_USDC_TOKEN, + SOLANA_DEVNET_USDC_TOKEN, + STACKS_TESTNET_TOKENS, + withConfiguredTestnetSupport, + withPublicPayTestnetSupport, +} from './public-testnet-support.js'; + +const baseConfig: FacilitatorConfig = { + id: 'facilitator-id', + name: 'Test Facilitator', + subdomain: 'pay', + ownerAddress: '0x0000000000000000000000000000000000000000', + supportedChains: [8453, 'solana', 'stacks'], + supportedTokens: [ + { + symbol: 'USDC', + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + decimals: 6, + chainId: 8453, + }, + { + symbol: 'USDC', + address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + decimals: 6, + chainId: 'solana', + }, + { + symbol: 'STX', + address: 'STX', + decimals: 6, + chainId: 'stacks', + }, + ], + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +}; + +describe('public testnet support', () => { + it('adds configured testnet chains and tokens to a config', () => { + const config = withConfiguredTestnetSupport(baseConfig, { + evm: true, + solana: true, + stacks: true, + }); + + expect(config.supportedChains).toContain(84532); + expect(config.supportedChains).toContain('solana-devnet'); + expect(config.supportedChains).toContain('stacks-testnet'); + expect(config.supportedTokens).toContainEqual(BASE_SEPOLIA_USDC_TOKEN); + expect(config.supportedTokens).toContainEqual(SOLANA_DEVNET_USDC_TOKEN); + for (const token of STACKS_TESTNET_TOKENS) { + expect(config.supportedTokens).toContainEqual(token); + } + }); + + it('adds only testnets for configured wallet families', () => { + const config = withConfiguredTestnetSupport(baseConfig, { + evm: true, + solana: false, + stacks: false, + }); + + expect(config.supportedChains).toContain(84532); + expect(config.supportedChains).not.toContain('solana-devnet'); + expect(config.supportedChains).not.toContain('stacks-testnet'); + }); + + it('adds testnet support for the public pay facilitator', () => { + const config = withPublicPayTestnetSupport( + { + subdomain: 'pay', + custom_domain: null, + additional_domains: '[]', + encrypted_private_key: 'encrypted-evm-key', + encrypted_solana_private_key: 'encrypted-solana-key', + encrypted_stacks_private_key: 'encrypted-stacks-key', + }, + baseConfig + ); + + expect(config.supportedChains).toContain(84532); + expect(config.supportedChains).toContain('solana-devnet'); + expect(config.supportedChains).toContain('stacks-testnet'); + }); + + it('does not add testnet support to other facilitators', () => { + const config = withPublicPayTestnetSupport( + { + subdomain: 'customer', + custom_domain: null, + additional_domains: '[]', + encrypted_private_key: 'encrypted-evm-key', + encrypted_solana_private_key: 'encrypted-solana-key', + encrypted_stacks_private_key: 'encrypted-stacks-key', + }, + { ...baseConfig, subdomain: 'customer' } + ); + + expect(config.supportedChains).not.toContain(84532); + expect(config.supportedChains).not.toContain('solana-devnet'); + expect(config.supportedChains).not.toContain('stacks-testnet'); + }); +}); diff --git a/packages/server/src/services/public-testnet-support.ts b/packages/server/src/services/public-testnet-support.ts new file mode 100644 index 0000000..d9e1e18 --- /dev/null +++ b/packages/server/src/services/public-testnet-support.ts @@ -0,0 +1,144 @@ +import { knownTokens, type FacilitatorConfig, type TokenConfig } from '@openfacilitator/core'; +import type { FacilitatorRecord } from '../db/types.js'; + +const BASE_SEPOLIA_CHAIN_ID = 84532; +const SOLANA_DEVNET_CHAIN_ID = 'solana-devnet'; +const STACKS_TESTNET_CHAIN_ID = 'stacks-testnet'; + +export const BASE_SEPOLIA_USDC_TOKEN: TokenConfig = { + symbol: 'USDC', + address: knownTokens.USDC[BASE_SEPOLIA_CHAIN_ID], + decimals: 6, + chainId: BASE_SEPOLIA_CHAIN_ID, +}; + +export const SOLANA_DEVNET_USDC_TOKEN: TokenConfig = { + symbol: 'USDC', + address: knownTokens.USDC[SOLANA_DEVNET_CHAIN_ID], + decimals: 6, + chainId: SOLANA_DEVNET_CHAIN_ID, +}; + +export const STACKS_TESTNET_TOKENS: TokenConfig[] = [ + { + symbol: 'STX', + address: knownTokens.STX[STACKS_TESTNET_CHAIN_ID], + decimals: 6, + chainId: STACKS_TESTNET_CHAIN_ID, + }, + { + symbol: 'sBTC', + address: knownTokens.sBTC[STACKS_TESTNET_CHAIN_ID], + decimals: 8, + chainId: STACKS_TESTNET_CHAIN_ID, + }, + { + symbol: 'USDCx', + address: knownTokens.USDCx[STACKS_TESTNET_CHAIN_ID], + decimals: 6, + chainId: STACKS_TESTNET_CHAIN_ID, + }, +]; + +function withChainAndTokens( + config: FacilitatorConfig, + chainId: number | string, + tokens: TokenConfig[] +): FacilitatorConfig { + const hasChain = config.supportedChains.some((supportedChainId) => String(supportedChainId) === String(chainId)); + const tokensToAdd = tokens.filter( + (token) => + !config.supportedTokens.some( + (supportedToken) => + String(supportedToken.chainId) === String(token.chainId) && + supportedToken.address.toLowerCase() === token.address.toLowerCase() + ) + ); + + return { + ...config, + supportedChains: hasChain ? config.supportedChains : [...config.supportedChains, chainId], + supportedTokens: tokensToAdd.length === 0 ? config.supportedTokens : [...config.supportedTokens, ...tokensToAdd], + }; +} + +export function withBaseSepoliaSupport(config: FacilitatorConfig): FacilitatorConfig { + return withChainAndTokens(config, BASE_SEPOLIA_CHAIN_ID, [BASE_SEPOLIA_USDC_TOKEN]); +} + +export function withSolanaDevnetSupport(config: FacilitatorConfig): FacilitatorConfig { + return withChainAndTokens(config, SOLANA_DEVNET_CHAIN_ID, [SOLANA_DEVNET_USDC_TOKEN]); +} + +export function withStacksTestnetSupport(config: FacilitatorConfig): FacilitatorConfig { + return withChainAndTokens(config, STACKS_TESTNET_CHAIN_ID, STACKS_TESTNET_TOKENS); +} + +export function withConfiguredTestnetSupport( + config: FacilitatorConfig, + configured: { evm?: boolean; solana?: boolean; stacks?: boolean } +): FacilitatorConfig { + let nextConfig = config; + + if (configured.evm) { + nextConfig = withBaseSepoliaSupport(nextConfig); + } + if (configured.solana) { + nextConfig = withSolanaDevnetSupport(nextConfig); + } + if (configured.stacks) { + nextConfig = withStacksTestnetSupport(nextConfig); + } + + return nextConfig; +} + +function parseAdditionalDomains(value: string | null): string[] { + if (!value) return []; + + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) + ? parsed.filter((domain): domain is string => typeof domain === 'string') + : []; + } catch { + return []; + } +} + +export function isPublicPayFacilitator( + record: Pick +): boolean { + const domains = [ + record.subdomain, + record.custom_domain, + ...parseAdditionalDomains(record.additional_domains), + ] + .filter((domain): domain is string => !!domain) + .map((domain) => domain.toLowerCase()); + + return domains.includes('pay') || domains.includes('pay.openfacilitator.io'); +} + +export function withPublicPayTestnetSupport( + record: Pick< + FacilitatorRecord, + | 'subdomain' + | 'custom_domain' + | 'additional_domains' + | 'encrypted_private_key' + | 'encrypted_solana_private_key' + | 'encrypted_stacks_private_key' + >, + config: FacilitatorConfig +): FacilitatorConfig { + if (!isPublicPayFacilitator(record)) { + return config; + } + + return withConfiguredTestnetSupport(config, { + evm: !!record.encrypted_private_key, + solana: !!record.encrypted_solana_private_key, + stacks: !!record.encrypted_stacks_private_key, + }); +} diff --git a/packages/server/src/services/solana-devnet-support.test.ts b/packages/server/src/services/solana-devnet-support.test.ts deleted file mode 100644 index 80a8a19..0000000 --- a/packages/server/src/services/solana-devnet-support.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { FacilitatorConfig } from '@openfacilitator/core'; -import { - SOLANA_DEVNET_USDC_TOKEN, - withPublicPaySolanaDevnetSupport, - withSolanaDevnetSupport, -} from './solana-devnet-support.js'; - -const baseConfig: FacilitatorConfig = { - id: 'facilitator-id', - name: 'Test Facilitator', - subdomain: 'pay', - ownerAddress: '0x0000000000000000000000000000000000000000', - supportedChains: ['solana'], - supportedTokens: [ - { - symbol: 'USDC', - address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - decimals: 6, - chainId: 'solana', - }, - ], - createdAt: new Date('2026-01-01T00:00:00Z'), - updatedAt: new Date('2026-01-01T00:00:00Z'), -}; - -describe('Solana devnet public facilitator support', () => { - it('adds Solana devnet and Circle devnet USDC to a config', () => { - const config = withSolanaDevnetSupport(baseConfig); - - expect(config.supportedChains).toContain('solana-devnet'); - expect(config.supportedTokens).toContainEqual(SOLANA_DEVNET_USDC_TOKEN); - }); - - it('adds devnet support for the public pay facilitator when a Solana key exists', () => { - const config = withPublicPaySolanaDevnetSupport( - { - subdomain: 'pay', - custom_domain: null, - additional_domains: '[]', - encrypted_solana_private_key: 'encrypted-key', - }, - baseConfig - ); - - expect(config.supportedChains).toContain('solana-devnet'); - }); - - it('does not add devnet support to other facilitators', () => { - const config = withPublicPaySolanaDevnetSupport( - { - subdomain: 'customer', - custom_domain: null, - additional_domains: '[]', - encrypted_solana_private_key: 'encrypted-key', - }, - { ...baseConfig, subdomain: 'customer' } - ); - - expect(config.supportedChains).not.toContain('solana-devnet'); - }); -}); diff --git a/packages/server/src/services/solana-devnet-support.ts b/packages/server/src/services/solana-devnet-support.ts deleted file mode 100644 index a1ac95f..0000000 --- a/packages/server/src/services/solana-devnet-support.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { knownTokens, type FacilitatorConfig, type TokenConfig } from '@openfacilitator/core'; -import type { FacilitatorRecord } from '../db/types.js'; - -const SOLANA_DEVNET_CHAIN_ID = 'solana-devnet'; - -export const SOLANA_DEVNET_USDC_TOKEN: TokenConfig = { - symbol: 'USDC', - address: knownTokens.USDC[SOLANA_DEVNET_CHAIN_ID], - decimals: 6, - chainId: SOLANA_DEVNET_CHAIN_ID, -}; - -/** - * Add Solana devnet to a facilitator config without changing the persisted DB row. - * Used for the public facilitator so devnet UAT can use the same Solana fee-payer key. - */ -export function withSolanaDevnetSupport(config: FacilitatorConfig): FacilitatorConfig { - const hasDevnetChain = config.supportedChains.some((chainId) => String(chainId) === SOLANA_DEVNET_CHAIN_ID); - const hasDevnetUsdc = config.supportedTokens.some( - (token) => - String(token.chainId) === SOLANA_DEVNET_CHAIN_ID && - token.address.toLowerCase() === SOLANA_DEVNET_USDC_TOKEN.address.toLowerCase() - ); - - return { - ...config, - supportedChains: hasDevnetChain - ? config.supportedChains - : [...config.supportedChains, SOLANA_DEVNET_CHAIN_ID], - supportedTokens: hasDevnetUsdc - ? config.supportedTokens - : [...config.supportedTokens, SOLANA_DEVNET_USDC_TOKEN], - }; -} - -function parseAdditionalDomains(value: string | null): string[] { - if (!value) return []; - - try { - const parsed = JSON.parse(value) as unknown; - return Array.isArray(parsed) - ? parsed.filter((domain): domain is string => typeof domain === 'string') - : []; - } catch { - return []; - } -} - -export function isPublicPayFacilitator( - record: Pick -): boolean { - const domains = [ - record.subdomain, - record.custom_domain, - ...parseAdditionalDomains(record.additional_domains), - ] - .filter((domain): domain is string => !!domain) - .map((domain) => domain.toLowerCase()); - - return domains.includes('pay') || domains.includes('pay.openfacilitator.io'); -} - -export function withPublicPaySolanaDevnetSupport( - record: Pick< - FacilitatorRecord, - 'subdomain' | 'custom_domain' | 'additional_domains' | 'encrypted_solana_private_key' - >, - config: FacilitatorConfig -): FacilitatorConfig { - if (!record.encrypted_solana_private_key || !isPublicPayFacilitator(record)) { - return config; - } - - return withSolanaDevnetSupport(config); -}