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(/\/$/, '');
+}