diff --git a/src/components/CollapsibleLeftPanel.jsx b/src/components/CollapsibleLeftPanel.jsx index c64c2eb..0d6d163 100644 --- a/src/components/CollapsibleLeftPanel.jsx +++ b/src/components/CollapsibleLeftPanel.jsx @@ -16,8 +16,13 @@ const CollapsibleLeftPanel = ({ handleTokensChange, handleWithWrapToggle, handleStagingToggle, + handleTestEnvToggle, + handleTestEnvUrlChange, + handleTestEnvBlockNumberChange, handleQuantizedModeToggle, handleDebugIntermediateToggle, + testEnvSession, + onDestroySession, handleFromTokensExclusionToggle, handleToTokensExclusionToggle, onFindPath, @@ -96,8 +101,13 @@ const CollapsibleLeftPanel = ({ handleTokensChange={handleTokensChange} handleWithWrapToggle={handleWithWrapToggle} handleStagingToggle={handleStagingToggle} + handleTestEnvToggle={handleTestEnvToggle} + handleTestEnvUrlChange={handleTestEnvUrlChange} + handleTestEnvBlockNumberChange={handleTestEnvBlockNumberChange} handleQuantizedModeToggle={handleQuantizedModeToggle} handleDebugIntermediateToggle={handleDebugIntermediateToggle} + testEnvSession={testEnvSession} + onDestroySession={onDestroySession} handleFromTokensExclusionToggle={handleFromTokensExclusionToggle} handleToTokensExclusionToggle={handleToTokensExclusionToggle} onFindPath={onFindPath} diff --git a/src/components/FlowVisualization.jsx b/src/components/FlowVisualization.jsx index fbce2cb..39a5af0 100644 --- a/src/components/FlowVisualization.jsx +++ b/src/components/FlowVisualization.jsx @@ -6,6 +6,7 @@ import { usePerformance } from '@/contexts/PerformanceContext'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { decomposeFlow, transfersFromRoutes } from '@/utils/flowDecomposition'; import { parseAddressList } from '@/services/circlesApi'; +import { getOrCreateSession, destroySession } from '@/services/testEnvService'; import Header from '@/components/ui/header'; import CollapsibleLeftPanel from '@/components/CollapsibleLeftPanel'; import CytoscapeVisualization from '@/components/CytoscapeVisualization'; @@ -49,6 +50,9 @@ const FlowVisualization = () => { handleTokensChange, handleWithWrapToggle, handleStagingToggle, + handleTestEnvToggle, + handleTestEnvUrlChange, + handleTestEnvBlockNumberChange, handleQuantizedModeToggle, handleDebugIntermediateToggle, handleFromTokensExclusionToggle, @@ -58,7 +62,13 @@ const FlowVisualization = () => { validateFormData, } = useFormData(); const [formWarnings, setFormWarnings] = useState([]); - + const [testEnvSession, setTestEnvSession] = useState(null); + + const handleDestroySession = useCallback(async () => { + await destroySession(); + setTestEnvSession(null); + }, []); + const { pathData, rawPathData, @@ -337,8 +347,35 @@ const FlowVisualization = () => { setSelectedTransactionId(null); clearHighlights(); + // If test-env mode, ensure we have a valid session attached + if (requestData.UseTestEnv) { + if (!requestData.TestEnvBlockNumber) { + setFormWarnings(['Enter a block number for test environment mode']); + return; + } + const parsedBlock = Number(requestData.TestEnvBlockNumber); + if (!Number.isInteger(parsedBlock) || parsedBlock < 0) { + setFormWarnings(['Block number must be a non-negative integer']); + return; + } + try { + const session = await getOrCreateSession(requestData.TestEnvUrl, parsedBlock); + setTestEnvSession(session); + await loadPathData({ ...requestData, testEnvSession: session }); + } catch (err) { + const isMaxSessions = err.message.includes('Maximum concurrent sessions'); + const hint = isMaxSessions + ? ' — close unused sessions or wait for them to expire (30 min TTL).' + : ''; + setFormWarnings([`Test-env session error: ${err.message}${hint}`]); + setTestEnvSession(null); + } + return; + } + + setTestEnvSession(null); await loadPathData(requestData); - }, [loadPathData, clearHighlights]); + }, [loadPathData, clearHighlights, setFormWarnings, setTestEnvSession]); const selectQuickTokensByPredicate = useCallback(async (predicate) => { const allTokens = Array.from(new Set( @@ -416,6 +453,7 @@ const FlowVisualization = () => { if (!validation.isValid) { return; } + await executeFindPath(baseData); }, [formData, executeFindPath, validateFormData]); @@ -836,6 +874,11 @@ const FlowVisualization = () => { handleTokensChange={handleTokensChange} handleWithWrapToggle={handleWithWrapToggle} handleStagingToggle={handleStagingToggle} + handleTestEnvToggle={handleTestEnvToggle} + handleTestEnvUrlChange={handleTestEnvUrlChange} + handleTestEnvBlockNumberChange={handleTestEnvBlockNumberChange} + testEnvSession={testEnvSession} + onDestroySession={handleDestroySession} handleQuantizedModeToggle={handleQuantizedModeToggle} handleDebugIntermediateToggle={handleDebugIntermediateToggle} handleFromTokensExclusionToggle={handleFromTokensExclusionToggle} diff --git a/src/components/PathFinderForm.jsx b/src/components/PathFinderForm.jsx index 5f49217..ae7842a 100644 --- a/src/components/PathFinderForm.jsx +++ b/src/components/PathFinderForm.jsx @@ -58,8 +58,13 @@ const PathFinderForm = ({ handleTokensChange, handleWithWrapToggle, handleStagingToggle, + handleTestEnvToggle, + handleTestEnvUrlChange, + handleTestEnvBlockNumberChange, handleQuantizedModeToggle, handleDebugIntermediateToggle, + testEnvSession, + onDestroySession, handleFromTokensExclusionToggle, handleToTokensExclusionToggle, onFindPath, @@ -358,6 +363,66 @@ const PathFinderForm = ({ /> + {formData.UseStaging && ( +
+ + +
+ )} + {formData.UseTestEnv && ( +
+
+ + +
+
+ + +
+ {testEnvSession && ( +
+ + Block {testEnvSession.blockNumber} + {testEnvSession.expiresAt && ( + + · expires {new Date(testEnvSession.expiresAt).toLocaleTimeString()} + + )} + + +
+ )} + {!testEnvSession && ( +
Sessions last 30 minutes. Max 10 concurrent sessions on the server.
+ )} + {formData.UseTestEnv && !formData.TestEnvBlockNumber && ( +
Enter a block number to query historical state
+ )} +
+ )}
{ const handleStagingToggle = () => { setFormData(prev => ({ ...prev, - UseStaging: !prev.UseStaging + UseStaging: !prev.UseStaging, + // Turning off staging also turns off test-env (test-env requires staging) + UseTestEnv: !prev.UseStaging ? prev.UseTestEnv : false, + })); + }; + + const handleTestEnvToggle = () => { + setFormData(prev => ({ + ...prev, + UseTestEnv: !prev.UseTestEnv, + // Test-env requires staging — auto-enable it + UseStaging: !prev.UseTestEnv ? true : prev.UseStaging, })); }; + const handleTestEnvUrlChange = (e) => { + setFormData(prev => ({ ...prev, TestEnvUrl: e.target.value })); + }; + + const handleTestEnvBlockNumberChange = (e) => { + setFormData(prev => ({ ...prev, TestEnvBlockNumber: e.target.value })); + }; + const handleQuantizedModeToggle = () => { setFormData(prev => ({ ...prev, @@ -365,6 +391,9 @@ export const useFormData = () => { handleTokensChange, handleWithWrapToggle, handleStagingToggle, + handleTestEnvToggle, + handleTestEnvUrlChange, + handleTestEnvBlockNumberChange, handleQuantizedModeToggle, handleDebugIntermediateToggle, handleFromTokensExclusionToggle, diff --git a/src/services/circlesApi.js b/src/services/circlesApi.js index f25b72f..9afa254 100644 --- a/src/services/circlesApi.js +++ b/src/services/circlesApi.js @@ -12,6 +12,7 @@ import cacheService from './cacheService'; export const API_ENDPOINT = 'https://rpc.aboutcircles.com/'; export const STAGING_ENDPOINT = 'https://staging.circlesubi.network/'; +export const DEFAULT_TEST_ENV_URL = 'https://staging.circlesubi.network/test-env'; const fetchTokenBalancesForAddress = async (address) => { const res = await fetch(API_ENDPOINT, { @@ -110,8 +111,15 @@ export const ethToWei = (crcAmount) => { }; export const findPath = async (formData, sdkRpc) => { + // Test environment mode: route through test-env RPC proxy + if (formData.UseTestEnv) { + if (!formData.testEnvSession) { + throw new Error('Test environment is enabled but no active session is attached'); + } + return findPathViaTestEnv(formData); + } + // If staging endpoint requested, create a temporary SDK client for it - const endpoint = formData.UseStaging ? STAGING_ENDPOINT : API_ENDPOINT; const rpc = formData.UseStaging ? new CirclesRpc(STAGING_ENDPOINT) : sdkRpc; try { // Include and exclude can coexist when quick-filter sends both @@ -189,6 +197,100 @@ export const findPath = async (formData, sdkRpc) => { } }; +/** + * Sends a circlesV2_findPath JSON-RPC call through the test-env RPC proxy. + * The proxy adds X-Max-Block-Number header, so the pathfinder uses a block-filtered graph. + */ +const findPathViaTestEnv = async (formData) => { + const { testEnvSession } = formData; + if (!testEnvSession?.rpcProxyUrl) { + throw new Error('No active test-env session'); + } + + const fromTokensArray = formData.IsFromTokensExcluded + ? [] : parseAddressList(formData.FromTokens); + const excludedFromTokensArray = parseAddressList(formData.ExcludedFromTokens); + const toTokensArray = formData.IsToTokensExcluded + ? [] : parseAddressList(formData.ToTokens); + const excludedToTokensArray = parseAddressList(formData.ExcludedToTokens); + + const flowRequest = { + source: normalizeAddress(formData.From), + sink: normalizeAddress(formData.To), + targetFlow: String(BigInt(formData.Amount)), + withWrap: formData.WithWrap || false, + quantizedMode: formData.QuantizedMode || false, + debugShowIntermediateSteps: formData.DebugShowIntermediateSteps || false, + }; + + if (fromTokensArray.length > 0) flowRequest.fromTokens = fromTokensArray; + if (toTokensArray.length > 0) flowRequest.toTokens = toTokensArray; + if (excludedFromTokensArray.length > 0) flowRequest.excludedFromTokens = excludedFromTokensArray; + if (excludedToTokensArray.length > 0) flowRequest.excludedToTokens = excludedToTokensArray; + if (formData.MaxTransfers) flowRequest.maxTransfers = Number(formData.MaxTransfers); + + const simulatedBalances = parseJsonArray(formData.SimulatedBalances) + .map(e => { + const holder = normalizeAddress(e?.holder); + const token = normalizeAddress(e?.token); + const amount = toValidUint(e?.amount); + if (!holder || !token || amount === null) return null; + return { holder, token, amount: amount.toString(), isWrapped: e?.isWrapped === true, isStatic: e?.isStatic === true }; + }).filter(Boolean); + + const simulatedTrusts = parseJsonArray(formData.SimulatedTrusts) + .map(e => { + const truster = normalizeAddress(e?.truster); + const trustee = normalizeAddress(e?.trustee); + if (!truster || !trustee) return null; + return { truster, trustee }; + }).filter(Boolean); + + const simulatedConsentedAvatars = dedupeAddresses( + parseAddressList(formData.SimulatedConsentedAvatars) + ); + + if (simulatedBalances.length > 0) flowRequest.simulatedBalances = simulatedBalances; + if (simulatedTrusts.length > 0) flowRequest.simulatedTrusts = simulatedTrusts; + if (simulatedConsentedAvatars.length > 0) flowRequest.simulatedConsentedAvatars = simulatedConsentedAvatars; + + console.log('Test-env findPath via RPC proxy:', testEnvSession.rpcProxyUrl, flowRequest); + + try { + const response = await fetch(testEnvSession.rpcProxyUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'circlesV2_findPath', + params: [flowRequest] + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Test-env RPC error: ${response.status} — ${error}`); + } + + const rpcResponse = await response.json(); + if (rpcResponse?.error) { + throw new Error(rpcResponse.error.message || 'RPC error from test-env'); + } + + if (!rpcResponse.result) { + throw new Error('Test-env RPC returned no result — no path found or block has no Circles activity'); + } + + // Note: token metadata and profile enrichment still uses production endpoints. + // Historical path data is correct, but displayed metadata reflects current state. + return stringifyBigInts(rpcResponse.result); + } catch (err) { + console.error('Test-env findPath error:', err); + throw err; + } +}; + export const processPath = async (rawPath, sourceAddress) => { // Re-parse values to bigints for SDK processing const pathWithBigInts = { diff --git a/src/services/testEnvService.js b/src/services/testEnvService.js new file mode 100644 index 0000000..6a0e225 --- /dev/null +++ b/src/services/testEnvService.js @@ -0,0 +1,147 @@ +/** + * Test Environment session management for historical pathfinding. + * + * Creates and manages sessions against the Circles test environment, + * which proxies RPC calls with X-Max-Block-Number for block-filtered queries. + * + * Note: Post-pathfinding enrichment (token info, profiles, balances) still + * uses production endpoints — historical path data is correct, but displayed + * metadata reflects current state. This is a known v1 limitation. + */ + +import { DEFAULT_TEST_ENV_URL } from './circlesApi'; + +let activeSession = null; + +/** + * Creates a test-env session at the given block number. + * Reuses existing session if same block and not expired. + */ +export async function getOrCreateSession(testEnvUrl, blockNumber) { + const baseUrl = resolveBaseUrl(testEnvUrl); + const numBlock = Number(blockNumber); + if (!Number.isInteger(numBlock) || numBlock < 0) { + throw new Error('Block number must be a non-negative integer'); + } + + // Reuse if same block and not expired + if (activeSession + && activeSession.baseUrl === baseUrl + && activeSession.blockNumber === numBlock + && activeSession.expiresAt > new Date()) { + return activeSession; + } + + // Clean up old session + if (activeSession) { + await destroySession().catch(() => {}); + } + + const response = await fetch(`${baseUrl}/api/v1/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + blockNumber: numBlock, + features: ['db', 'rpc'], + ttl: '30m' + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to create test-env session: ${response.status} — ${error}`); + } + + const session = await response.json(); + + if (!session.sessionId) { + throw new Error('Server returned session without sessionId'); + } + + // Explicitly construct — don't spread unknown server fields + activeSession = { + sessionId: session.sessionId, + baseUrl, + blockNumber: numBlock, + expiresAt: new Date(session.expiresAt), + rpcProxyUrl: `${baseUrl}/api/v1/session/${session.sessionId}/rpc`, + status: session.status, + }; + + return activeSession; +} + +/** + * Destroys the active session. + */ +export async function destroySession() { + if (!activeSession) return; + + const { baseUrl, sessionId } = activeSession; + activeSession = null; + + try { + const response = await fetch(`${baseUrl}/api/v1/session/${sessionId}`, { method: 'DELETE' }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + console.warn(`Failed to destroy test-env session ${sessionId}: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`); + } + } catch (err) { + // Non-fatal — session will expire via TTL, but log for debugging + console.warn(`Failed to destroy test-env session ${sessionId}:`, err.message); + } +} + +/** + * Gets the active session info (or null if expired/missing). + */ +export function getActiveSession() { + if (activeSession && activeSession.expiresAt <= new Date()) { + activeSession = null; + } + return activeSession; +} + +/** + * Checks if a block number exists in the test-env database. + */ +export async function checkBlockExists(testEnvUrl, blockNumber) { + const baseUrl = resolveBaseUrl(testEnvUrl); + try { + const response = await fetch(`${baseUrl}/api/v1/blocks/${blockNumber}/exists`); + if (!response.ok) return false; + const data = await response.json(); + return data?.exists === true; + } catch { + return false; + } +} + +/** + * Gets the latest indexed block from the test-env. + */ +export async function getLatestBlock(testEnvUrl) { + const baseUrl = resolveBaseUrl(testEnvUrl); + const response = await fetch(`${baseUrl}/api/v1/blocks/current`); + if (!response.ok) throw new Error('Failed to get latest block'); + const data = await response.json(); + return data?.blockNumber; +} + +/** + * Gets the health status of the test-env. + */ +export async function getTestEnvHealth(testEnvUrl) { + const baseUrl = resolveBaseUrl(testEnvUrl); + try { + const response = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(5000) }); + if (!response.ok) return null; + return response.json(); + } catch { + return null; + } +} + +function resolveBaseUrl(testEnvUrl) { + return (testEnvUrl || DEFAULT_TEST_ENV_URL).replace(/\/$/, ''); +}