Skip to content

Commit a9ccd41

Browse files
authored
feat: Lookup for account if it is not found (#757)
## Description PR adds a feature to look for a not-found account at other clusters. This PR also fixes bugs in the `url` helper that incorrectly resolve URL parameters in some cases. ## Type of change - [x] Bug fix - [x] New feature - [ ] Protocol integration - [ ] Documentation update - [ ] Other (please describe): ## Screenshots <img width="1005" height="512" alt="image" src="https://github.com/user-attachments/assets/f42ba092-abf1-4a03-82d3-67c02940f347" /> <img width="787" height="615" alt="image" src="https://github.com/user-attachments/assets/81afdc31-1b0a-46cb-9a34-a139b590ec44" /> Demo https://github.com/user-attachments/assets/799a992d-3683-486c-bd84-9bba4631df1e https://github.com/user-attachments/assets/9d937b0c-aad0-430b-892c-9cbc46568056 Screencasts have increased timeouts to better understand the logic. ## Testing Testnet > Mainnet: http://localhost:3000/address/aidexzymD6Ljv4Kf1yRj9iLeLz69MF2NuqJ2BvNdZXd/idl?cluster=testnet Mainnet > Testnet: http://localhost:3000/address/G5N6K3qW86GSkNEpywcbJk42LjEZoshzECFg1LNVjSLa ## Related Issues n/a ## Checklist - [x] My code follows the project's style guidelines - [x] I have added tests that prove my fix/feature works - [x] All tests pass locally and in CI ~- [ ] I have updated documentation as needed~ - [x] CI/CD checks pass - [x] I have included screenshots for protocol screens (if applicable) ~- [ ] For security-related features, I have included links to related information~
1 parent 2d4a13e commit a9ccd41

File tree

4 files changed

+456
-12
lines changed

4 files changed

+456
-12
lines changed

app/components/SearchBar.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useHotkeys } from '@mantine/hooks';
44
import { useCluster } from '@providers/cluster';
55
import { VersionedMessage } from '@solana/web3.js';
6-
import { Cluster } from '@utils/cluster';
6+
import { Cluster, clusterSlug } from '@utils/cluster';
77
import { SearchElement } from '@utils/token-search';
88
import bs58 from 'bs58';
99
import { useRouter, useSearchParams } from 'next/navigation';
@@ -18,6 +18,7 @@ import { FetchedDomainInfo } from '../api/domain-info/[domain]/route';
1818
import { FeatureInfoType } from '../utils/feature-gate/types';
1919
import { LOADER_IDS, LoaderName, PROGRAM_INFO_BY_ID, SPECIAL_IDS, SYSVAR_IDS } from '../utils/programs';
2020
import { searchTokens } from '../utils/token-search';
21+
import { pickClusterParams } from '../utils/url';
2122
import { useDebouncedAsync } from '../utils/use-debounce-async';
2223
import { MIN_MESSAGE_LENGTH } from './inspector/RawInputCard';
2324

@@ -48,7 +49,14 @@ export function SearchBar() {
4849
if (meta.action === 'select-option') {
4950
// Always use the pathname directly if it contains query params
5051
if (pathname.includes('?')) {
51-
router.push(pathname);
52+
const [path, currentSearchParamsString] = pathname.split('?');
53+
// break pathname to preserve existing params and allow to keep same cluster
54+
const nextPath = pickClusterParams(
55+
path,
56+
new URLSearchParams(currentSearchParamsString),
57+
new URLSearchParams(`cluster=${clusterSlug(cluster)}`)
58+
);
59+
router.push(nextPath);
5260
} else {
5361
// Only preserve existing query params for paths without their own params
5462
const nextQueryString = searchParams?.toString();

app/components/account/UnknownAccountCard.tsx

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
'use client';
2+
13
import { Address } from '@components/common/Address';
24
import { SolBalance } from '@components/common/SolBalance';
35
import { TableCardBody } from '@components/common/TableCardBody';
46
import { Account } from '@providers/accounts';
57
import { useCluster } from '@providers/cluster';
8+
import { address as createAddress, createSolanaRpc } from '@solana/kit';
9+
import { Cluster, clusterName, clusterSlug, clusterUrl } from '@utils/cluster';
610
import { addressLabel } from '@utils/tx';
7-
import React from 'react';
11+
import { useClusterPath } from '@utils/url';
12+
import { useSearchParams } from 'next/navigation';
13+
import { useEffect, useRef, useState } from 'react';
814

915
export function UnknownAccountCard({ account }: { account: Account }) {
1016
const { cluster } = useCluster();
@@ -32,7 +38,11 @@ export function UnknownAccountCard({ account }: { account: Account }) {
3238
<tr>
3339
<td>Balance (SOL)</td>
3440
<td className="text-lg-end">
35-
{account.lamports === 0 ? 'Account does not exist' : <SolBalance lamports={account.lamports} />}
41+
{account.lamports === 0 ? (
42+
<AccountNofFound account={account} />
43+
) : (
44+
<SolBalance lamports={account.lamports} />
45+
)}
3646
</td>
3747
</tr>
3848

@@ -58,3 +68,162 @@ export function UnknownAccountCard({ account }: { account: Account }) {
5868
</div>
5969
);
6070
}
71+
72+
type SearchStatus = 'idle' | 'searching' | 'found' | 'not-found';
73+
74+
function useClusterAccountSearch(address: string, currentCluster: Cluster, _enableCustomClsuter?: boolean) {
75+
const searchParams = useSearchParams();
76+
const [status, setStatus] = useState<SearchStatus>('idle');
77+
const [searchingCluster, setSearchingCluster] = useState<Cluster | null>(null);
78+
const [foundCluster, setFoundCluster] = useState<Cluster | null>(null);
79+
const searchIdRef = useRef(0);
80+
81+
const sleep = () => new Promise(res => setTimeout(res, 700));
82+
83+
// Extract the customUrl value to avoid re-running effect on searchParams object change
84+
const customUrl = searchParams?.get('customUrl');
85+
86+
useEffect(() => {
87+
// Increment search ID to track this specific search
88+
const currentSearchId = ++searchIdRef.current;
89+
90+
// NOTE: This ref-based approach prevents duplicate requests within a single component instance,
91+
// but if multiple instances of UnknownAccountCard are rendered (e.g., as both a Suspense fallback
92+
// and returned from CompressedNftCard), each instance will still make its own RPC requests.
93+
// Consider moving the search logic to a parent component or using React Context to share state
94+
// between instances if duplicate requests become a performance issue.
95+
96+
const clusters = [Cluster.MainnetBeta, Cluster.Devnet, Cluster.Testnet].filter(c => c !== currentCluster);
97+
98+
const hasCustomUrlEnabled = Boolean(customUrl);
99+
100+
// add custom url if parameter is present
101+
if (hasCustomUrlEnabled) {
102+
clusters.push(Cluster.Custom);
103+
}
104+
105+
const searchClusters = async () => {
106+
// Check if this search is still the current one
107+
if (searchIdRef.current !== currentSearchId) return;
108+
109+
setStatus('searching');
110+
111+
// search cluster one by one to not make extra requests
112+
for (const cluster of clusters) {
113+
// Check if this search has been superseded
114+
if (searchIdRef.current !== currentSearchId) return;
115+
116+
setSearchingCluster(cluster);
117+
118+
try {
119+
let url = clusterUrl(cluster, '');
120+
121+
// adjust url for a custom cluster as `clusterUrl` does not return one
122+
if (customUrl && cluster === Cluster.Custom) {
123+
url = customUrl;
124+
}
125+
126+
const rpc = createSolanaRpc(url);
127+
const accountInfo = await rpc.getAccountInfo(createAddress(address), { encoding: 'base64' }).send();
128+
129+
// Check again before updating state
130+
if (searchIdRef.current !== currentSearchId) return;
131+
132+
if (accountInfo.value !== null) {
133+
setFoundCluster(cluster);
134+
setStatus('found');
135+
return;
136+
} else {
137+
// not only prevent span but allow component to react properly without making the structure complex
138+
await sleep();
139+
}
140+
} catch (error) {
141+
// Check if this search is still active before continuing
142+
if (searchIdRef.current !== currentSearchId) return;
143+
// Continue to next cluster
144+
}
145+
}
146+
147+
// Final check before updating not-found status
148+
if (searchIdRef.current === currentSearchId) {
149+
setStatus('not-found');
150+
setSearchingCluster(null);
151+
}
152+
};
153+
154+
searchClusters();
155+
156+
// Cleanup function to mark this search as cancelled
157+
return () => {
158+
if (searchIdRef.current === currentSearchId) {
159+
searchIdRef.current += 1;
160+
}
161+
};
162+
}, [address, currentCluster, customUrl]);
163+
164+
return { foundCluster, searchingCluster, status };
165+
}
166+
167+
const LABELS = {
168+
'not-found': 'Account does not exist',
169+
};
170+
171+
function AccountNofFound({ account, labels = LABELS }: { account: Account; labels?: typeof LABELS }) {
172+
const { cluster } = useCluster();
173+
const address = account.pubkey.toBase58();
174+
const { status, searchingCluster, foundCluster } = useClusterAccountSearch(address, cluster);
175+
176+
if (status === 'searching' && searchingCluster !== null) {
177+
return (
178+
<span>
179+
<SearchingAddressIndicator searchingCluster={searchingCluster} />
180+
<span className="align-middle">{labels['not-found']}</span>
181+
</span>
182+
);
183+
}
184+
185+
const isAddressFoundOnAnotherClsuter = status === 'found' && foundCluster !== null;
186+
187+
return isAddressFoundOnAnotherClsuter ? (
188+
<span>
189+
<AdjacentAddressLink address={address} foundCluster={foundCluster} />
190+
<span className="align-middle">{labels['not-found']}</span>
191+
</span>
192+
) : (
193+
<span>{labels['not-found']}</span>
194+
);
195+
}
196+
197+
function AdjacentAddressLink({ address, foundCluster }: { address: string; foundCluster: Cluster }) {
198+
const moniker = clusterSlug(foundCluster);
199+
const foundClusterPath = useClusterPath({
200+
additionalParams: new URLSearchParams(`cluster=${moniker}`),
201+
pathname: `/address/${address}`,
202+
});
203+
204+
return (
205+
<a href={foundClusterPath} className="text-info align-middle" style={{ marginRight: '5px' }}>
206+
Found on {clusterName(foundCluster)}
207+
</a>
208+
);
209+
}
210+
211+
function SearchingAddressIndicator({ searchingCluster }: { searchingCluster: Cluster }) {
212+
const spinnerCls = 'spinner-grow spinner-grow-sm';
213+
214+
return (
215+
<>
216+
<span
217+
style={{
218+
height: '10px',
219+
marginRight: '5px',
220+
width: '10px',
221+
}}
222+
className={`${spinnerCls} align-middle d-inline-block`}
223+
/>
224+
<span className="text-muted align-middle" style={{ marginRight: '10px', verticalAlign: 'middle' }}>
225+
checking {clusterName(searchingCluster).toLowerCase()}
226+
</span>
227+
</>
228+
);
229+
}

0 commit comments

Comments
 (0)