@solana/connector 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +236 -0
  2. package/dist/{chunk-U64YZRJL.mjs → chunk-3STZXVXD.mjs} +254 -53
  3. package/dist/chunk-3STZXVXD.mjs.map +1 -0
  4. package/dist/{chunk-RIQH5W7D.js → chunk-I64FD2EH.js} +4 -3
  5. package/dist/chunk-I64FD2EH.js.map +1 -0
  6. package/dist/{chunk-CLXM6UEE.js → chunk-JUZVCBAI.js} +91 -85
  7. package/dist/chunk-JUZVCBAI.js.map +1 -0
  8. package/dist/{chunk-D6PZY5G6.js → chunk-NQXK7PGX.js} +30 -26
  9. package/dist/chunk-NQXK7PGX.js.map +1 -0
  10. package/dist/{chunk-N3Q2J2FG.mjs → chunk-QKVL45F6.mjs} +10 -6
  11. package/dist/chunk-QKVL45F6.mjs.map +1 -0
  12. package/dist/{chunk-P5MWBDFG.mjs → chunk-QL3IT3TS.mjs} +4 -3
  13. package/dist/chunk-QL3IT3TS.mjs.map +1 -0
  14. package/dist/{chunk-LUZWUZ5N.js → chunk-ULUYX23Q.js} +268 -67
  15. package/dist/chunk-ULUYX23Q.js.map +1 -0
  16. package/dist/{chunk-YTCSTE3Q.mjs → chunk-VMSZJPR5.mjs} +10 -4
  17. package/dist/chunk-VMSZJPR5.mjs.map +1 -0
  18. package/dist/compat.js +3 -3
  19. package/dist/compat.mjs +1 -1
  20. package/dist/headless.d.mts +2 -2
  21. package/dist/headless.d.ts +2 -2
  22. package/dist/headless.js +120 -120
  23. package/dist/headless.mjs +3 -3
  24. package/dist/index.d.mts +1 -1
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.js +147 -147
  27. package/dist/index.mjs +4 -4
  28. package/dist/react.d.mts +4 -4
  29. package/dist/react.d.ts +4 -4
  30. package/dist/react.js +28 -28
  31. package/dist/react.mjs +2 -2
  32. package/dist/{wallet-standard-shim-DC_Z7DS-.d.ts → wallet-standard-shim--YcrQNRt.d.ts} +83 -0
  33. package/dist/{wallet-standard-shim-Cp4vF4oo.d.mts → wallet-standard-shim-Dx7H8Ctf.d.mts} +83 -0
  34. package/package.json +1 -1
  35. package/dist/chunk-CLXM6UEE.js.map +0 -1
  36. package/dist/chunk-D6PZY5G6.js.map +0 -1
  37. package/dist/chunk-LUZWUZ5N.js.map +0 -1
  38. package/dist/chunk-N3Q2J2FG.mjs.map +0 -1
  39. package/dist/chunk-P5MWBDFG.mjs.map +0 -1
  40. package/dist/chunk-RIQH5W7D.js.map +0 -1
  41. package/dist/chunk-U64YZRJL.mjs.map +0 -1
  42. package/dist/chunk-YTCSTE3Q.mjs.map +0 -1
package/README.md CHANGED
@@ -608,6 +608,242 @@ const mobile = getDefaultMobileConfig({
608
608
 
609
609
  ---
610
610
 
611
+ ## Security Considerations
612
+
613
+ ### Token Image Privacy
614
+
615
+ When using `useTokens()` or `useTransactions()`, token metadata (including logo URLs) is fetched from external APIs. By default, these image URLs are returned directly, which means when your users' browsers fetch these images, the image host can see:
616
+
617
+ - User IP addresses
618
+ - Request timing (when users viewed their tokens)
619
+ - User agent and browser information
620
+
621
+ This could potentially be exploited by malicious token creators who set tracking URLs in their token metadata.
622
+
623
+ ### Image Proxy Configuration
624
+
625
+ To protect user privacy, you can configure an image proxy that fetches images on behalf of your users:
626
+
627
+ ```typescript
628
+ const config = getDefaultConfig({
629
+ appName: 'My App',
630
+ imageProxy: '/_next/image?w=64&q=75&url=', // Next.js Image Optimization
631
+ });
632
+ ```
633
+
634
+ When `imageProxy` is set, all token image URLs returned by `useTokens()` and `useTransactions()` will be automatically transformed:
635
+
636
+ ```
637
+ // Original URL from token metadata
638
+ https://raw.githubusercontent.com/.../token-logo.png
639
+
640
+ // Transformed URL (when imageProxy is set)
641
+ /_next/image?w=64&q=75&url=https%3A%2F%2Fraw.githubusercontent.com%2F...%2Ftoken-logo.png
642
+ ```
643
+
644
+ ### Common Proxy Options
645
+
646
+ | Service | Configuration |
647
+ |---------|---------------|
648
+ | **Next.js Image** | `imageProxy: '/_next/image?w=64&q=75&url='` |
649
+ | **Cloudflare** | `imageProxy: '/cdn-cgi/image/width=64,quality=75/'` |
650
+ | **imgproxy** | `imageProxy: 'https://imgproxy.example.com/insecure/fill/64/64/'` |
651
+ | **Custom API** | `imageProxy: '/api/image-proxy?url='` |
652
+
653
+ ### Custom Proxy API Route (Next.js Example)
654
+
655
+ ```typescript
656
+ // app/api/image-proxy/route.ts
657
+ import { NextRequest, NextResponse } from 'next/server';
658
+ import dns from 'dns/promises';
659
+
660
+ // Allowlist of permitted domains for image fetching
661
+ const ALLOWED_DOMAINS = [
662
+ 'raw.githubusercontent.com',
663
+ 'arweave.net',
664
+ 'ipfs.io',
665
+ 'cloudflare-ipfs.com',
666
+ 'nftstorage.link',
667
+ // Add other trusted image domains as needed
668
+ ];
669
+
670
+ // Check if an IP address falls within private/reserved ranges
671
+ function isPrivateOrReservedIP(ip: string): boolean {
672
+ // IPv4 private/reserved ranges
673
+ const ipv4PrivateRanges = [
674
+ /^127\./, // 127.0.0.0/8 (loopback)
675
+ /^10\./, // 10.0.0.0/8 (private)
676
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 (private)
677
+ /^192\.168\./, // 192.168.0.0/16 (private)
678
+ /^169\.254\./, // 169.254.0.0/16 (link-local/metadata)
679
+ /^0\./, // 0.0.0.0/8 (current network)
680
+ /^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./, // 100.64.0.0/10 (CGNAT)
681
+ /^192\.0\.0\./, // 192.0.0.0/24 (IETF protocol assignments)
682
+ /^192\.0\.2\./, // 192.0.2.0/24 (TEST-NET-1)
683
+ /^198\.51\.100\./, // 198.51.100.0/24 (TEST-NET-2)
684
+ /^203\.0\.113\./, // 203.0.113.0/24 (TEST-NET-3)
685
+ /^224\./, // 224.0.0.0/4 (multicast)
686
+ /^240\./, // 240.0.0.0/4 (reserved)
687
+ /^255\.255\.255\.255$/, // broadcast
688
+ ];
689
+
690
+ // IPv6 private/reserved ranges
691
+ const ipv6PrivatePatterns = [
692
+ /^::1$/, // loopback
693
+ /^fe80:/i, // link-local
694
+ /^fc00:/i, // unique local (fc00::/7)
695
+ /^fd/i, // unique local (fd00::/8)
696
+ /^::ffff:(127\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.|169\.254\.)/i, // IPv4-mapped
697
+ ];
698
+
699
+ // Check IPv4
700
+ for (const range of ipv4PrivateRanges) {
701
+ if (range.test(ip)) return true;
702
+ }
703
+
704
+ // Check IPv6
705
+ for (const pattern of ipv6PrivatePatterns) {
706
+ if (pattern.test(ip)) return true;
707
+ }
708
+
709
+ return false;
710
+ }
711
+
712
+ // Validate and parse the URL
713
+ function validateUrl(urlString: string): URL | null {
714
+ try {
715
+ const parsed = new URL(urlString);
716
+ // Only allow http and https protocols
717
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
718
+ return null;
719
+ }
720
+ return parsed;
721
+ } catch {
722
+ return null;
723
+ }
724
+ }
725
+
726
+ // Check if hostname is in the allowlist
727
+ function isAllowedDomain(hostname: string): boolean {
728
+ return ALLOWED_DOMAINS.some(
729
+ (domain) => hostname === domain || hostname.endsWith(`.${domain}`)
730
+ );
731
+ }
732
+
733
+ export async function GET(request: NextRequest) {
734
+ const urlParam = request.nextUrl.searchParams.get('url');
735
+
736
+ // (1) Ensure URL exists and parses correctly with http/https
737
+ if (!urlParam) {
738
+ return new NextResponse('Missing URL parameter', { status: 400 });
739
+ }
740
+
741
+ const parsedUrl = validateUrl(urlParam);
742
+ if (!parsedUrl) {
743
+ return new NextResponse('Invalid URL or protocol', { status: 400 });
744
+ }
745
+
746
+ // (2) Enforce allowlist of permitted domains
747
+ if (!isAllowedDomain(parsedUrl.hostname)) {
748
+ return new NextResponse('Domain not allowed', { status: 403 });
749
+ }
750
+
751
+ // (3) Resolve hostname and check for private/reserved IPs
752
+ try {
753
+ const addresses = await dns.resolve(parsedUrl.hostname);
754
+ for (const ip of addresses) {
755
+ if (isPrivateOrReservedIP(ip)) {
756
+ return new NextResponse('Resolved IP is not allowed', { status: 403 });
757
+ }
758
+ }
759
+ } catch {
760
+ return new NextResponse('Failed to resolve hostname', { status: 400 });
761
+ }
762
+
763
+ // (4) All checks passed - perform the fetch
764
+ try {
765
+ const response = await fetch(parsedUrl.toString());
766
+ const buffer = await response.arrayBuffer();
767
+
768
+ return new NextResponse(buffer, {
769
+ headers: {
770
+ 'Content-Type': response.headers.get('Content-Type') || 'image/png',
771
+ 'Cache-Control': 'public, max-age=86400',
772
+ },
773
+ });
774
+ } catch {
775
+ return new NextResponse('Failed to fetch image', { status: 500 });
776
+ }
777
+ }
778
+ ```
779
+
780
+ ---
781
+
782
+ ## CoinGecko API & Rate Limits
783
+
784
+ The `useTokens()` hook fetches token prices from CoinGecko. CoinGecko has rate limits that may affect your application:
785
+
786
+ ### Rate Limits (as of 2024)
787
+
788
+ | Tier | Rate Limit | API Key Required |
789
+ |------|------------|------------------|
790
+ | **Free (Public)** | 10-30 requests/minute | No |
791
+ | **Demo** | 30 requests/minute | Yes (free) |
792
+ | **Analyst** | 500 requests/minute | Yes (paid) |
793
+ | **Pro** | 1000+ requests/minute | Yes (paid) |
794
+
795
+ ### Handling Rate Limits
796
+
797
+ ConnectorKit automatically handles rate limits with:
798
+ - **Exponential backoff**: Retries with increasing delays
799
+ - **Jitter**: Random delay added to prevent thundering herd
800
+ - **Retry-After header**: Honors server-specified wait times
801
+ - **Bounded timeout**: Won't block forever (default 30s max)
802
+
803
+ ### Adding a CoinGecko API Key
804
+
805
+ For higher rate limits, add a free Demo API key from [CoinGecko](https://www.coingecko.com/en/api/pricing):
806
+
807
+ ```typescript
808
+ const config = getDefaultConfig({
809
+ appName: 'My App',
810
+ coingecko: {
811
+ apiKey: process.env.COINGECKO_API_KEY, // Demo or Pro API key
812
+ isPro: false, // Set to true for Pro API keys
813
+ },
814
+ });
815
+ ```
816
+
817
+ ### Advanced Configuration
818
+
819
+ ```typescript
820
+ const config = getDefaultConfig({
821
+ appName: 'My App',
822
+ coingecko: {
823
+ // API key for higher rate limits (optional)
824
+ apiKey: process.env.COINGECKO_API_KEY,
825
+
826
+ // Set to true if using a Pro API key (default: false for Demo keys)
827
+ isPro: false,
828
+
829
+ // Maximum retry attempts on 429 (default: 3)
830
+ maxRetries: 3,
831
+
832
+ // Base delay for exponential backoff in ms (default: 1000)
833
+ baseDelay: 1000,
834
+
835
+ // Maximum total timeout in ms (default: 30000)
836
+ maxTimeout: 30000,
837
+ },
838
+ });
839
+ ```
840
+
841
+ ### Caching
842
+
843
+ Token prices are cached for 60 seconds to minimize API calls. The retry logic only applies to uncached token IDs, so frequently-viewed tokens won't trigger additional API calls.
844
+
845
+ ---
846
+
611
847
  ## Advanced Usage
612
848
 
613
849
  ### Headless Client (Vue, Svelte, Vanilla JS)
@@ -1,6 +1,6 @@
1
- import { installPolyfills, ConnectorErrorBoundary, isMainnetCluster, isDevnetCluster, isTestnetCluster, isLocalCluster, getClusterExplorerUrl, getClusterType, formatAddress, copyAddressToClipboard, createTransactionSigner, createKitTransactionSigner, NetworkError, getTransactionUrl, ConnectorClient } from './chunk-YTCSTE3Q.mjs';
2
- import { createLogger, createSolanaClient, prepareTransaction } from './chunk-P5MWBDFG.mjs';
3
- import React, { createContext, useContext, useSyncExternalStore, useMemo, useState, useCallback, useEffect, useRef } from 'react';
1
+ import { installPolyfills, ConnectorErrorBoundary, isMainnetCluster, isDevnetCluster, isTestnetCluster, isLocalCluster, getClusterExplorerUrl, getClusterType, formatAddress, copyAddressToClipboard, createTransactionSigner, createKitTransactionSigner, NetworkError, getTransactionUrl, ConnectorClient } from './chunk-VMSZJPR5.mjs';
2
+ import { createLogger, __publicField, createSolanaClient, prepareTransaction } from './chunk-QL3IT3TS.mjs';
3
+ import React, { createContext, useContext, useSyncExternalStore, useMemo, useState, useCallback, useRef, useEffect } from 'react';
4
4
  import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
5
5
  import { address } from '@solana/addresses';
6
6
  import { signature } from '@solana/keys';
@@ -307,9 +307,9 @@ function formatSol(lamports, decimals = 4) {
307
307
  }) + " SOL";
308
308
  }
309
309
  function useBalance() {
310
- let { address: address$1, connected } = useAccount(); useCluster(); let client = useSolanaClient(), [lamports, setLamports] = useState(0n), [tokens, setTokens] = useState([]), [isLoading, setIsLoading] = useState(false), [error, setError] = useState(null), [lastUpdated, setLastUpdated] = useState(null), rpcClient = client?.client ?? null, fetchBalance = useCallback(async () => {
310
+ let { address: address$1, connected } = useAccount(); useCluster(); let client = useSolanaClient(), [lamports, setLamports] = useState(0n), [tokens, setTokens] = useState([]), [isLoading, setIsLoading] = useState(false), [error, setError] = useState(null), [lastUpdated, setLastUpdated] = useState(null), hasDataRef = useRef(false), rpcClient = client?.client ?? null, fetchBalance = useCallback(async () => {
311
311
  if (!connected || !address$1 || !rpcClient) {
312
- setLamports(0n), setTokens([]);
312
+ setLamports(0n), setTokens([]), hasDataRef.current = false;
313
313
  return;
314
314
  }
315
315
  setIsLoading(true), setError(null);
@@ -337,9 +337,9 @@ function useBalance() {
337
337
  } catch (tokenError) {
338
338
  console.warn("Failed to fetch token balances:", tokenError), setTokens([]);
339
339
  }
340
- setLastUpdated(/* @__PURE__ */ new Date());
340
+ hasDataRef.current = true, setLastUpdated(/* @__PURE__ */ new Date());
341
341
  } catch (err) {
342
- setError(err), console.error("Failed to fetch balance:", err);
342
+ hasDataRef.current || (setError(err), console.error("Failed to fetch balance:", err));
343
343
  } finally {
344
344
  setIsLoading(false);
345
345
  }
@@ -490,6 +490,10 @@ function formatAmount(tokenAmount, tokenDecimals, direction, solChange) {
490
490
  return `${solChange > 0 ? "+" : ""}${solChange.toFixed(4)} SOL`;
491
491
  }
492
492
  var tokenMetadataCache = /* @__PURE__ */ new Map();
493
+ function transformImageUrl(url, imageProxy) {
494
+ if (url)
495
+ return imageProxy ? `${imageProxy}${encodeURIComponent(url)}` : url;
496
+ }
493
497
  async function fetchTokenMetadata(mints) {
494
498
  let results = /* @__PURE__ */ new Map();
495
499
  if (mints.length === 0) return results;
@@ -500,16 +504,17 @@ async function fetchTokenMetadata(mints) {
500
504
  }
501
505
  if (uncachedMints.length === 0) return results;
502
506
  try {
503
- let url = new URL("https://lite-api.jup.ag/tokens/v2/search");
504
- url.searchParams.append("query", uncachedMints.join(","));
505
- let response = await fetch(url.toString(), {
507
+ let response = await fetch("https://token-list-api.solana.cloud/v1/mints?chainId=101", {
508
+ method: "POST",
509
+ headers: { "Content-Type": "application/json" },
510
+ body: JSON.stringify({ addresses: uncachedMints }),
506
511
  signal: AbortSignal.timeout(5e3)
507
512
  });
508
513
  if (!response.ok) return results;
509
- let items = await response.json();
510
- for (let item of items) {
511
- let metadata = { symbol: item.symbol, icon: item.icon };
512
- results.set(item.id, metadata), tokenMetadataCache.set(item.id, metadata);
514
+ let data = await response.json();
515
+ for (let item of data.content) {
516
+ let metadata = { symbol: item.symbol, icon: item.logoURI };
517
+ results.set(item.address, metadata), tokenMetadataCache.set(item.address, metadata);
513
518
  }
514
519
  } catch (error) {
515
520
  console.warn("[useTransactions] Failed to fetch token metadata:", error);
@@ -517,16 +522,19 @@ async function fetchTokenMetadata(mints) {
517
522
  return results;
518
523
  }
519
524
  function useTransactions(options = {}) {
520
- let { limit = 10, autoRefresh = false, refreshInterval = 6e4, fetchDetails = true } = options, { address: address$1, connected } = useAccount(), { cluster } = useCluster(), client = useSolanaClient(), [transactions, setTransactions] = useState([]), [isLoading, setIsLoading] = useState(false), [error, setError] = useState(null), [hasMore, setHasMore] = useState(true), [lastUpdated, setLastUpdated] = useState(null), beforeSignatureRef = useRef(void 0), prevDepsRef = useRef(
525
+ let { limit = 10, autoRefresh = false, refreshInterval = 6e4, fetchDetails = true } = options, { address: address$1, connected } = useAccount(), { cluster } = useCluster(), client = useSolanaClient(), connectorClient = useConnectorClient(), [transactions, setTransactions] = useState([]), [isLoading, setIsLoading] = useState(false), [error, setError] = useState(null), [hasMore, setHasMore] = useState(true), [lastUpdated, setLastUpdated] = useState(null), beforeSignatureRef = useRef(void 0), prevDepsRef = useRef(
521
526
  null
522
- ), rpcClient = client?.client ?? null, parseTransaction = useCallback(
527
+ ), rpcClient = client?.client ?? null, imageProxy = connectorClient?.getConfig().imageProxy, parseTransaction = useCallback(
523
528
  (tx, walletAddress, sig, blockTime, slot, err, explorerUrl) => {
524
529
  let { date, time } = formatDate(blockTime), baseInfo = {
525
530
  signature: sig,
526
531
  blockTime,
527
532
  slot,
528
533
  status: err ? "failed" : "success",
529
- error: err ? JSON.stringify(err) : void 0,
534
+ error: err ? JSON.stringify(
535
+ err,
536
+ (_key, value) => typeof value == "bigint" ? value.toString() : value
537
+ ) : void 0,
530
538
  type: "unknown",
531
539
  formattedDate: date,
532
540
  formattedTime: time,
@@ -631,7 +639,7 @@ function useTransactions(options = {}) {
631
639
  return {
632
640
  ...tx,
633
641
  tokenSymbol: meta.symbol,
634
- tokenIcon: meta.icon,
642
+ tokenIcon: transformImageUrl(meta.icon, imageProxy),
635
643
  // Update formatted amount with symbol
636
644
  formattedAmount: tx.formattedAmount ? `${tx.formattedAmount} ${meta.symbol}` : tx.formattedAmount
637
645
  };
@@ -655,7 +663,7 @@ function useTransactions(options = {}) {
655
663
  setIsLoading(false);
656
664
  }
657
665
  },
658
- [connected, address$1, rpcClient, cluster, limit, fetchDetails, parseTransaction]
666
+ [connected, address$1, rpcClient, cluster, limit, fetchDetails, parseTransaction, imageProxy]
659
667
  ), refetch = useCallback(async () => {
660
668
  beforeSignatureRef.current = void 0, await fetchTransactions(false);
661
669
  }, [fetchTransactions]), loadMoreFn = useCallback(async () => {
@@ -681,38 +689,221 @@ function useTransactions(options = {}) {
681
689
  [transactions, isLoading, error, hasMore, loadMoreFn, refetch, lastUpdated]
682
690
  );
683
691
  }
684
- var NATIVE_MINT = "So11111111111111111111111111111111111111112", metadataCache = /* @__PURE__ */ new Map();
685
- async function fetchJupiterMetadata(mints) {
692
+ function createTimeoutSignal(ms) {
693
+ if (typeof AbortSignal.timeout == "function")
694
+ return { signal: AbortSignal.timeout(ms), cleanup: () => {
695
+ } };
696
+ let controller = new AbortController(), timeoutId = setTimeout(() => controller.abort(), ms);
697
+ return {
698
+ signal: controller.signal,
699
+ cleanup: () => clearTimeout(timeoutId)
700
+ };
701
+ }
702
+ var NATIVE_MINT = "So11111111111111111111111111111111111111112", CACHE_MAX_SIZE = 500, PRICE_CACHE_TTL = 6e4, STALE_CLEANUP_INTERVAL = 12e4, COINGECKO_DEFAULT_MAX_RETRIES = 3, COINGECKO_DEFAULT_BASE_DELAY = 1e3, COINGECKO_DEFAULT_MAX_TIMEOUT = 3e4, COINGECKO_API_BASE_URL = "https://api.coingecko.com/api/v3", LRUCache = class {
703
+ constructor(maxSize, options) {
704
+ __publicField(this, "cache", /* @__PURE__ */ new Map());
705
+ __publicField(this, "maxSize");
706
+ __publicField(this, "getTtl");
707
+ __publicField(this, "getTimestamp");
708
+ this.maxSize = maxSize, this.getTtl = options?.getTtl, this.getTimestamp = options?.getTimestamp;
709
+ }
710
+ get(key) {
711
+ let value = this.cache.get(key);
712
+ if (value !== void 0) {
713
+ if (this.getTtl && this.getTimestamp) {
714
+ let ttl = this.getTtl(value), timestamp = this.getTimestamp(value);
715
+ if (ttl !== void 0 && timestamp !== void 0 && Date.now() - timestamp >= ttl) {
716
+ this.cache.delete(key);
717
+ return;
718
+ }
719
+ }
720
+ return this.cache.delete(key), this.cache.set(key, value), value;
721
+ }
722
+ }
723
+ set(key, value) {
724
+ if (this.cache.has(key) && this.cache.delete(key), this.cache.size >= this.maxSize) {
725
+ let oldestKey = this.cache.keys().next().value;
726
+ oldestKey !== void 0 && this.cache.delete(oldestKey);
727
+ }
728
+ this.cache.set(key, value);
729
+ }
730
+ has(key) {
731
+ return this.cache.has(key);
732
+ }
733
+ delete(key) {
734
+ return this.cache.delete(key);
735
+ }
736
+ clear() {
737
+ this.cache.clear();
738
+ }
739
+ get size() {
740
+ return this.cache.size;
741
+ }
742
+ /**
743
+ * Prune stale entries based on TTL.
744
+ * Only works if getTtl and getTimestamp are provided.
745
+ */
746
+ pruneStale() {
747
+ if (!this.getTtl || !this.getTimestamp) return 0;
748
+ let now = Date.now(), pruned = 0;
749
+ for (let [key, value] of this.cache) {
750
+ let ttl = this.getTtl(value), timestamp = this.getTimestamp(value);
751
+ ttl !== void 0 && timestamp !== void 0 && now - timestamp >= ttl && (this.cache.delete(key), pruned++);
752
+ }
753
+ return pruned;
754
+ }
755
+ }, metadataCache = new LRUCache(CACHE_MAX_SIZE), priceCache = new LRUCache(CACHE_MAX_SIZE, {
756
+ getTtl: () => PRICE_CACHE_TTL,
757
+ getTimestamp: (entry) => entry.timestamp
758
+ }), cleanupIntervalId = null, cleanupRefCount = 0;
759
+ function startCacheCleanup() {
760
+ cleanupRefCount++, cleanupIntervalId === null && (cleanupIntervalId = setInterval(() => {
761
+ priceCache.pruneStale();
762
+ }, STALE_CLEANUP_INTERVAL));
763
+ }
764
+ function stopCacheCleanup() {
765
+ cleanupRefCount = Math.max(0, cleanupRefCount - 1), cleanupRefCount === 0 && cleanupIntervalId !== null && (clearInterval(cleanupIntervalId), cleanupIntervalId = null);
766
+ }
767
+ function clearTokenCaches() {
768
+ metadataCache.clear(), priceCache.clear();
769
+ }
770
+ async function fetchSolanaTokenMetadata(mints) {
686
771
  let results = /* @__PURE__ */ new Map();
687
772
  if (mints.length === 0) return results;
688
- let uncachedMints = [];
773
+ let { signal, cleanup } = createTimeoutSignal(1e4);
774
+ try {
775
+ let response = await fetch("https://token-list-api.solana.cloud/v1/mints?chainId=101", {
776
+ method: "POST",
777
+ headers: { "Content-Type": "application/json" },
778
+ body: JSON.stringify({ addresses: mints }),
779
+ signal
780
+ });
781
+ if (cleanup(), !response.ok)
782
+ throw new Error(`Solana Token List API error: ${response.status}`);
783
+ let data = await response.json();
784
+ for (let item of data.content)
785
+ results.set(item.address, item);
786
+ } catch (error) {
787
+ cleanup(), console.warn("[useTokens] Solana Token List API failed:", error);
788
+ }
789
+ return results;
790
+ }
791
+ function calculateBackoffDelay(attempt, baseDelay, retryAfter) {
792
+ if (retryAfter !== void 0 && retryAfter > 0) {
793
+ let jitter2 = Math.random() * 500;
794
+ return retryAfter * 1e3 + jitter2;
795
+ }
796
+ let exponentialDelay = baseDelay * Math.pow(2, attempt), jitter = Math.random() * 500;
797
+ return exponentialDelay + jitter;
798
+ }
799
+ function parseRetryAfter(retryAfterHeader) {
800
+ if (!retryAfterHeader) return;
801
+ let seconds = parseInt(retryAfterHeader, 10);
802
+ if (!isNaN(seconds) && seconds >= 0)
803
+ return seconds;
804
+ let date = Date.parse(retryAfterHeader);
805
+ if (!isNaN(date)) {
806
+ let waitMs = date - Date.now();
807
+ return waitMs > 0 ? Math.ceil(waitMs / 1e3) : 0;
808
+ }
809
+ }
810
+ async function fetchCoinGeckoPrices(coingeckoIds, config) {
811
+ let results = /* @__PURE__ */ new Map();
812
+ if (coingeckoIds.length === 0) return results;
813
+ let now = Date.now(), uncachedIds = [];
814
+ for (let id of coingeckoIds) {
815
+ let cached = priceCache.get(id);
816
+ cached && now - cached.timestamp < PRICE_CACHE_TTL ? results.set(id, cached.price) : uncachedIds.push(id);
817
+ }
818
+ if (uncachedIds.length === 0) return results;
819
+ let maxRetries = config?.maxRetries ?? COINGECKO_DEFAULT_MAX_RETRIES, baseDelay = config?.baseDelay ?? COINGECKO_DEFAULT_BASE_DELAY, maxTimeout = config?.maxTimeout ?? COINGECKO_DEFAULT_MAX_TIMEOUT, apiKey = config?.apiKey, isPro = config?.isPro ?? false, url = `${COINGECKO_API_BASE_URL}/simple/price?ids=${uncachedIds.join(",")}&vs_currencies=usd`, headers = {};
820
+ apiKey && (headers[isPro ? "x-cg-pro-api-key" : "x-cg-demo-api-key"] = apiKey);
821
+ let startTime = Date.now(), attempt = 0, lastError = null;
822
+ for (; attempt <= maxRetries; ) {
823
+ let elapsedTime = Date.now() - startTime;
824
+ if (elapsedTime >= maxTimeout) {
825
+ console.warn(
826
+ `[useTokens] CoinGecko API: Total timeout (${maxTimeout}ms) exceeded after ${attempt} attempts. Returning cached/partial results.`
827
+ );
828
+ break;
829
+ }
830
+ let remainingTimeout = maxTimeout - elapsedTime, requestTimeout = Math.min(1e4, remainingTimeout), { signal, cleanup } = createTimeoutSignal(requestTimeout);
831
+ try {
832
+ let response = await fetch(url, {
833
+ headers,
834
+ signal
835
+ });
836
+ if (cleanup(), response.status === 429) {
837
+ let retryAfter = parseRetryAfter(response.headers.get("Retry-After")), delay = calculateBackoffDelay(attempt, baseDelay, retryAfter);
838
+ if (console.warn(
839
+ `[useTokens] CoinGecko API rate limited (429). Attempt ${attempt + 1}/${maxRetries + 1}. Retry-After: ${retryAfter ?? "not specified"}s. Waiting ${Math.round(delay)}ms before retry. Consider adding an API key for higher limits: https://www.coingecko.com/en/api/pricing`
840
+ ), Date.now() - startTime + delay >= maxTimeout) {
841
+ console.warn(
842
+ `[useTokens] CoinGecko API: Skipping retry - would exceed total timeout (${maxTimeout}ms). Returning cached/partial results.`
843
+ );
844
+ break;
845
+ }
846
+ await new Promise((resolve) => setTimeout(resolve, delay)), attempt++;
847
+ continue;
848
+ }
849
+ if (!response.ok)
850
+ throw new Error(`CoinGecko API error: ${response.status} ${response.statusText}`);
851
+ let data = await response.json(), fetchTime = Date.now();
852
+ for (let [id, priceData] of Object.entries(data))
853
+ priceData?.usd !== void 0 && (results.set(id, priceData.usd), priceCache.set(id, { price: priceData.usd, timestamp: fetchTime }));
854
+ return results;
855
+ } catch (error) {
856
+ if (cleanup(), lastError = error, error instanceof DOMException && error.name === "AbortError" ? console.warn(
857
+ `[useTokens] CoinGecko API request timed out. Attempt ${attempt + 1}/${maxRetries + 1}.`
858
+ ) : console.warn(
859
+ `[useTokens] CoinGecko API request failed. Attempt ${attempt + 1}/${maxRetries + 1}:`,
860
+ error
861
+ ), attempt < maxRetries) {
862
+ let delay = calculateBackoffDelay(attempt, baseDelay);
863
+ Date.now() - startTime + delay < maxTimeout && await new Promise((resolve) => setTimeout(resolve, delay));
864
+ }
865
+ attempt++;
866
+ }
867
+ }
868
+ return attempt > maxRetries && console.warn(
869
+ `[useTokens] CoinGecko API: All ${maxRetries + 1} attempts failed. Returning cached/partial results. Last error: ${lastError?.message ?? "Unknown error"}. If you are frequently rate limited, consider adding an API key: https://www.coingecko.com/en/api/pricing`
870
+ ), results;
871
+ }
872
+ async function fetchTokenMetadataHybrid(mints, coingeckoConfig) {
873
+ let results = /* @__PURE__ */ new Map();
874
+ if (mints.length === 0) return results;
875
+ let uncachedMints = [], now = Date.now();
689
876
  for (let mint of mints) {
690
877
  let cached = metadataCache.get(mint);
691
- cached ? results.set(mint, cached) : uncachedMints.push(mint);
878
+ cached ? (cached.coingeckoId && (!priceCache.get(cached.coingeckoId) || now - (priceCache.get(cached.coingeckoId)?.timestamp ?? 0) >= PRICE_CACHE_TTL) && cached.coingeckoId && uncachedMints.push(mint), results.set(mint, cached)) : uncachedMints.push(mint);
692
879
  }
693
880
  if (uncachedMints.length === 0) return results;
694
- try {
695
- let url = new URL("https://lite-api.jup.ag/tokens/v2/search");
696
- url.searchParams.append("query", uncachedMints.join(","));
697
- let response = await fetch(url.toString(), {
698
- signal: AbortSignal.timeout(1e4)
699
- });
700
- if (!response.ok)
701
- throw new Error(`Jupiter API error: ${response.status}`);
702
- let items = await response.json();
703
- for (let item of items) {
704
- let metadata = {
705
- id: item.id,
706
- name: item.id === NATIVE_MINT ? "Solana" : item.name,
707
- symbol: item.symbol,
708
- decimals: item.decimals,
709
- icon: item.icon,
710
- usdPrice: item.usdPrice
711
- };
712
- results.set(item.id, metadata), metadataCache.set(item.id, metadata);
881
+ let solanaMetadata = await fetchSolanaTokenMetadata(uncachedMints), coingeckoIdToMint = /* @__PURE__ */ new Map();
882
+ for (let [mint, meta] of solanaMetadata)
883
+ meta.extensions?.coingeckoId && coingeckoIdToMint.set(meta.extensions.coingeckoId, mint);
884
+ for (let mint of mints) {
885
+ let cached = metadataCache.get(mint);
886
+ cached?.coingeckoId && !coingeckoIdToMint.has(cached.coingeckoId) && coingeckoIdToMint.set(cached.coingeckoId, mint);
887
+ }
888
+ let prices = await fetchCoinGeckoPrices([...coingeckoIdToMint.keys()], coingeckoConfig);
889
+ for (let [mint, meta] of solanaMetadata) {
890
+ let coingeckoId = meta.extensions?.coingeckoId, usdPrice = coingeckoId ? prices.get(coingeckoId) : void 0, combined = {
891
+ address: meta.address,
892
+ name: meta.address === NATIVE_MINT ? "Solana" : meta.name,
893
+ symbol: meta.symbol,
894
+ decimals: meta.decimals,
895
+ logoURI: meta.logoURI,
896
+ coingeckoId,
897
+ usdPrice
898
+ };
899
+ results.set(mint, combined), metadataCache.set(mint, combined);
900
+ }
901
+ for (let [coingeckoId, mint] of coingeckoIdToMint) {
902
+ let cached = results.get(mint) ?? metadataCache.get(mint);
903
+ if (cached) {
904
+ let usdPrice = prices.get(coingeckoId);
905
+ usdPrice !== void 0 && (cached.usdPrice = usdPrice, results.set(mint, cached), metadataCache.set(mint, cached));
713
906
  }
714
- } catch (error) {
715
- console.warn("[useTokens] Jupiter API failed:", error);
716
907
  }
717
908
  return results;
718
909
  }
@@ -730,6 +921,12 @@ function formatUsd(amount, decimals, usdPrice) {
730
921
  maximumFractionDigits: 2
731
922
  });
732
923
  }
924
+ function transformImageUrl2(url, imageProxy) {
925
+ if (!url) return;
926
+ if (!imageProxy) return url;
927
+ let encodedUrl = encodeURIComponent(url);
928
+ return imageProxy.endsWith("/") ? imageProxy + encodedUrl : imageProxy + "/" + encodedUrl;
929
+ }
733
930
  function useTokens(options = {}) {
734
931
  let {
735
932
  includeZeroBalance = false,
@@ -737,7 +934,7 @@ function useTokens(options = {}) {
737
934
  refreshInterval = 6e4,
738
935
  fetchMetadata = true,
739
936
  includeNativeSol = true
740
- } = options, { address: address$1, connected } = useAccount(), client = useSolanaClient(), [tokens, setTokens] = useState([]), [isLoading, setIsLoading] = useState(false), [error, setError] = useState(null), [lastUpdated, setLastUpdated] = useState(null), [totalAccounts, setTotalAccounts] = useState(0), rpcClient = client?.client ?? null, fetchTokens = useCallback(async () => {
937
+ } = options, { address: address$1, connected } = useAccount(), client = useSolanaClient(), connectorClient = useConnectorClient(), [tokens, setTokens] = useState([]), [isLoading, setIsLoading] = useState(false), [error, setError] = useState(null), [lastUpdated, setLastUpdated] = useState(null), [totalAccounts, setTotalAccounts] = useState(0), rpcClient = client?.client ?? null, connectorConfig = connectorClient?.getConfig(), imageProxy = connectorConfig?.imageProxy, coingeckoConfig = connectorConfig?.coingecko, fetchTokens = useCallback(async () => {
741
938
  if (!connected || !address$1 || !rpcClient) {
742
939
  setTokens([]), setTotalAccounts(0);
743
940
  return;
@@ -779,16 +976,16 @@ function useTokens(options = {}) {
779
976
  }
780
977
  }
781
978
  if (setTokens([...tokenList]), setTotalAccounts(tokenAccountsResult.value.length + (includeNativeSol ? 1 : 0)), setLastUpdated(/* @__PURE__ */ new Date()), fetchMetadata && mints.length > 0) {
782
- let metadata = await fetchJupiterMetadata(mints);
979
+ let metadata = await fetchTokenMetadataHybrid(mints, coingeckoConfig);
783
980
  for (let i = 0; i < tokenList.length; i++) {
784
981
  let meta = metadata.get(tokenList[i].mint);
785
982
  meta && (tokenList[i] = {
786
983
  ...tokenList[i],
787
984
  name: meta.name,
788
985
  symbol: meta.symbol,
789
- logo: meta.icon,
986
+ logo: transformImageUrl2(meta.logoURI, imageProxy),
790
987
  usdPrice: meta.usdPrice,
791
- formattedUsd: formatUsd(tokenList[i].amount, tokenList[i].decimals, meta.usdPrice)
988
+ formattedUsd: meta.usdPrice ? formatUsd(tokenList[i].amount, tokenList[i].decimals, meta.usdPrice) : void 0
792
989
  });
793
990
  }
794
991
  tokenList.sort((a, b) => {
@@ -803,14 +1000,18 @@ function useTokens(options = {}) {
803
1000
  } finally {
804
1001
  setIsLoading(false);
805
1002
  }
806
- }, [connected, address$1, rpcClient, includeZeroBalance, fetchMetadata, includeNativeSol]);
807
- return useEffect(() => {
1003
+ }, [connected, address$1, rpcClient, includeZeroBalance, fetchMetadata, includeNativeSol, imageProxy, coingeckoConfig]);
1004
+ useEffect(() => {
808
1005
  fetchTokens();
809
1006
  }, [fetchTokens]), useEffect(() => {
810
1007
  if (!connected || !autoRefresh) return;
811
1008
  let interval = setInterval(fetchTokens, refreshInterval);
812
1009
  return () => clearInterval(interval);
813
- }, [connected, autoRefresh, refreshInterval, fetchTokens]), useMemo(
1010
+ }, [connected, autoRefresh, refreshInterval, fetchTokens]), useEffect(() => (startCacheCleanup(), () => stopCacheCleanup()), []);
1011
+ let wasConnectedRef = useRef(connected);
1012
+ return useEffect(() => {
1013
+ wasConnectedRef.current && !connected && clearTokenCaches(), wasConnectedRef.current = connected;
1014
+ }, [connected]), useMemo(
814
1015
  () => ({
815
1016
  tokens,
816
1017
  isLoading,
@@ -1980,5 +2181,5 @@ function TokenListElement({
1980
2181
  TokenListElement.displayName = "TokenListElement";
1981
2182
 
1982
2183
  export { AccountElement, BalanceElement, ClusterElement, ConnectorProvider, DisconnectElement, TokenListElement, TransactionHistoryElement, UnifiedProvider, WalletListElement, useAccount, useBalance, useCluster, useConnector, useConnectorClient, useGillSolanaClient, useGillTransactionSigner, useKitTransactionSigner, useSolanaClient, useTokens, useTransactionPreparer, useTransactionSigner, useTransactions, useWalletInfo };
1983
- //# sourceMappingURL=chunk-U64YZRJL.mjs.map
1984
- //# sourceMappingURL=chunk-U64YZRJL.mjs.map
2184
+ //# sourceMappingURL=chunk-3STZXVXD.mjs.map
2185
+ //# sourceMappingURL=chunk-3STZXVXD.mjs.map