From dd9e55b2ee7ff0a7b2799c1a533b7d05be7e6279 Mon Sep 17 00:00:00 2001 From: Sergey Timoshin Date: Tue, 8 Sep 2026 10:57:36 +0000 Subject: [PATCH] fix(photon): send JSON-RPC requests to the configured root URL --- js/stateless.js/README.md | 16 ++ js/stateless.js/src/rpc.ts | 10 +- .../tests/unit/rpc/json-rpc-transport.test.ts | 122 ++++++++++ sdk-libs/photon-api/CHANGELOG.md | 4 + sdk-libs/photon-api/src/lib.rs | 223 +++++++++++++----- 5 files changed, 310 insertions(+), 65 deletions(-) create mode 100644 js/stateless.js/tests/unit/rpc/json-rpc-transport.test.ts diff --git a/js/stateless.js/README.md b/js/stateless.js/README.md index 371743fab6..3c231b5c1d 100644 --- a/js/stateless.js/README.md +++ b/js/stateless.js/README.md @@ -29,6 +29,22 @@ Install this package in your project by running the following terminal command: npm install --save @lightprotocol/stateless.js ``` +### Helius mainnet + +Use the mainnet RPC root URL for both Solana and Photon calls: + +```typescript +import { createRpc } from '@lightprotocol/stateless.js'; + +const rpc = createRpc('https://mainnet.helius-rpc.com/?api-key=YOUR_KEY'); +const slot = await rpc.getIndexerSlot(); +``` + +The client posts JSON-RPC requests to this URL, with the method name in the +request body. Do not append a method path such as `/getIndexerSlot`. +The legacy Helius hostname is not required. A separate Photon endpoint can +still be supplied as the second argument to `createRpc`. + ## Documentation and Examples For a more detailed documentation on usage, please check [the respective section at the ZK Compression documentation.](https://www.zkcompression.com/developers/typescript-client) diff --git a/js/stateless.js/src/rpc.ts b/js/stateless.js/src/rpc.ts index 9e7658bbf6..20e2da4bb1 100644 --- a/js/stateless.js/src/rpc.ts +++ b/js/stateless.js/src/rpc.ts @@ -243,7 +243,9 @@ async function getCompressedTokenAccountsByOwnerOrDelegate( * * @param endpointOrWeb3JsConnection endpoint to the solana cluster or * Connection object - * @param compressionApiEndpoint Endpoint to the compression server + * @param compressionApiEndpoint JSON-RPC URL of the compression server. + * For Helius, use https://mainnet.helius-rpc.com/?api-key=KEY. + * Method names are sent in the body, not the URL. * @param proverEndpoint Endpoint to the prover server. defaults * to endpoint * @param connectionConfig Optional connection config @@ -301,7 +303,11 @@ export function wrapBigNumbersAsStrings(text: string): string { }); } -/** @internal */ +/** + * POST JSON-RPC to the configured URL unchanged, including authentication and + * any proxy path. Helius Gatekeeper routes by the method in the request body. + * @internal + */ export const rpcRequest = async ( rpcEndpoint: string, method: string, diff --git a/js/stateless.js/tests/unit/rpc/json-rpc-transport.test.ts b/js/stateless.js/tests/unit/rpc/json-rpc-transport.test.ts new file mode 100644 index 0000000000..bbcf3146bc --- /dev/null +++ b/js/stateless.js/tests/unit/rpc/json-rpc-transport.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Connection } from '@solana/web3.js'; +import { createRpc, rpcRequest } from '../../../src/rpc'; + +describe('Photon JSON-RPC transport', () => { + afterEach(() => vi.unstubAllGlobals()); + + const mockResponse = (result: unknown, status = 200) => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(result), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; + }; + + it.each([ + 'https://mainnet.helius-rpc.com?api-key=TEST_KEY', + 'https://mainnet.helius-rpc.com/?api-key=TEST_KEY', + 'https://devnet.helius-rpc.com/?api-key=TEST_KEY', + 'https://mainnet.legacy.helius-rpc.com/?api-key=TEST_KEY', + 'http://127.0.0.1:8784', + 'http://127.0.0.1:8784/', + 'https://rpc.example.com/photon?region=eu&api-key=key%2B%2F%3D%26value&tag=one&tag=two', + 'https://rpc.example.com/photon/?region=eu', + ])('posts to the configured URL unchanged: %s', async endpoint => { + const fetchMock = mockResponse({ + jsonrpc: '2.0', + id: 'test-account', + result: 'ok', + }); + + await expect(createRpc(endpoint).getIndexerHealth()).resolves.toBe( + 'ok', + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'test-account', + method: 'getIndexerHealth', + params: [], + }), + }); + }); + + it('uses an explicit compression URL with a web3 Connection', async () => { + const endpoint = 'https://mainnet.helius-rpc.com/?api-key=TEST_KEY'; + const fetchMock = mockResponse({ + jsonrpc: '2.0', + id: 'test-account', + result: 123, + }); + const rpc = createRpc( + new Connection('http://127.0.0.1:8899'), + endpoint, + ); + + await expect(rpc.getIndexerSlot()).resolves.toBe(123); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe(endpoint); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).method).toBe( + 'getIndexerSlot', + ); + }); + + it.each<[string, Record]>([ + ['getCompressedAccount', { hash: '11111111111111111111111111111111' }], + [ + 'getCompressedAccountV2', + { hash: '11111111111111111111111111111111' }, + ], + ['getValidityProof', { hashes: [], newAddressesWithTrees: [] }], + ['getValidityProofV2', { hashes: [], newAddressesWithTrees: [] }], + ])('carries %s and its parameters in the body', async (method, params) => { + const endpoint = 'https://mainnet.helius-rpc.com/?api-key=TEST_KEY'; + const response = { jsonrpc: '2.0', id: 'test-account', result: null }; + const fetchMock = mockResponse(response); + + await expect(rpcRequest(endpoint, method, params)).resolves.toEqual( + response, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe(endpoint); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ + jsonrpc: '2.0', + id: 'test-account', + method, + params, + }); + }); + + it('surfaces JSON-RPC errors without retrying a REST or legacy URL', async () => { + const fetchMock = mockResponse({ + jsonrpc: '2.0', + id: 'test-account', + error: { code: -32601, message: 'Method not found' }, + }); + + await expect( + createRpc( + 'https://mainnet.helius-rpc.com/?api-key=TEST_KEY', + ).getIndexerHealth(), + ).rejects.toThrow('Method not found'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('surfaces HTTP errors', async () => { + const fetchMock = mockResponse({ error: 'Unauthorized' }, 401); + + await expect( + createRpc( + 'https://mainnet.helius-rpc.com/?api-key=TEST_KEY', + ).getIndexerHealth(), + ).rejects.toThrow('HTTP error! status: 401'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/sdk-libs/photon-api/CHANGELOG.md b/sdk-libs/photon-api/CHANGELOG.md index 891d6f18c6..1782408dab 100644 --- a/sdk-libs/photon-api/CHANGELOG.md +++ b/sdk-libs/photon-api/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Send Photon calls as JSON-RPC POSTs to the configured RPC URL instead of appending `/methodName`. This supports `https://mainnet.helius-rpc.com/?api-key=YOUR_KEY` through Helius Gatekeeper without a legacy hostname. API keys and other query parameters are preserved, including percent-encoded values. + ### Breaking Changes - **Simplified `Configuration` struct.** The API key is now embedded in the URL as a query parameter: diff --git a/sdk-libs/photon-api/src/lib.rs b/sdk-libs/photon-api/src/lib.rs index 480d524ae2..477413fe6b 100644 --- a/sdk-libs/photon-api/src/lib.rs +++ b/sdk-libs/photon-api/src/lib.rs @@ -22,7 +22,8 @@ fn ensure_ring_provider() { pub mod apis { use super::*; - /// Configuration for the Photon API client. + /// Configuration for the Photon JSON-RPC API client. + /// Requests are posted to `base_path`; the method is carried in the JSON body. #[derive(Clone)] pub struct Configuration { pub base_path: String, @@ -52,8 +53,10 @@ pub mod apis { impl Configuration { /// Create a new configuration from a URL string. /// + /// Use the RPC root URL, e.g. `https://mainnet.helius-rpc.com/?api-key=YOUR_KEY`. + /// Method names are sent in the JSON-RPC body, never appended to the URL. /// If the URL contains an `api-key` query parameter, it is extracted - /// and appended to every request as `?api-key=KEY`. + /// and included with every request. Other query parameters are preserved. /// /// ```ignore /// // Without API key @@ -72,26 +75,29 @@ pub mod apis { } } - fn build_url(&self, endpoint: &str) -> String { - match &self.api_key { - Some(key) => format!("{}/{}?api-key={}", self.base_path, endpoint, key), - None => format!("{}/{}", self.base_path, endpoint), + pub(crate) fn parse_url(url: &str) -> (String, Option) { + let Ok(mut parsed) = reqwest::Url::parse(url) else { + // Preserve invalid input so reqwest reports the error when sending. + return (url.to_string(), None); + }; + let api_key = parsed + .query_pairs() + .find(|(name, _)| name == "api-key") + .map(|(_, value)| value.into_owned()); + if api_key.is_none() { + return (url.to_string(), None); } - } - pub(crate) fn parse_url(url: &str) -> (String, Option) { - if let Some(query_start) = url.find('?') { - let base = &url[..query_start]; - let query = &url[query_start + 1..]; - for param in query.split('&') { - if let Some(value) = param.strip_prefix("api-key=") { - return (base.to_string(), Some(value.to_string())); - } - } - (url.to_string(), None) - } else { - (url.to_string(), None) + let query: Vec<_> = parsed + .query_pairs() + .filter(|(name, _)| name != "api-key") + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect(); + parsed.set_query(None); + if !query.is_empty() { + parsed.query_pairs_mut().extend_pairs(query); } + (parsed.to_string(), api_key) } } @@ -408,20 +414,20 @@ pub mod apis { // ---------------------------------------------------------------- macro_rules! api_call { - ($fn_name:ident, $endpoint:expr, $body_type:ty, $response_type:ty) => { + ($fn_name:ident, $body_type:ty, $response_type:ty) => { pub async fn $fn_name( configuration: &Configuration, body: $body_type, ) -> Result<$response_type, Error<$response_type>> { - let url = configuration.build_url($endpoint); - let response = configuration + let mut request = configuration .client - .post(&url) + .post(&configuration.base_path) .header(reqwest::header::ACCEPT, "application/json") - .json(&body) - .send() - .await - .map_err(Error::Reqwest)?; + .json(&body); + if let Some(key) = &configuration.api_key { + request = request.query(&[("api-key", key)]); + } + let response = request.send().await.map_err(Error::Reqwest)?; let status = response.status().as_u16(); if status == 200 { @@ -439,175 +445,146 @@ pub mod apis { api_call!( get_compressed_account_post, - "getCompressedAccount", types::PostGetCompressedAccountBody, types::PostGetCompressedAccountResponse ); api_call!( get_compressed_account_balance_post, - "getCompressedAccountBalance", types::PostGetCompressedAccountBalanceBody, types::PostGetCompressedAccountBalanceResponse ); api_call!( get_compressed_accounts_by_owner_post, - "getCompressedAccountsByOwner", types::PostGetCompressedAccountsByOwnerBody, types::PostGetCompressedAccountsByOwnerResponse ); api_call!( get_compressed_accounts_by_owner_v2_post, - "getCompressedAccountsByOwnerV2", types::PostGetCompressedAccountsByOwnerV2Body, types::PostGetCompressedAccountsByOwnerV2Response ); api_call!( get_compressed_balance_by_owner_post, - "getCompressedBalanceByOwner", types::PostGetCompressedBalanceByOwnerBody, types::PostGetCompressedBalanceByOwnerResponse ); api_call!( get_compressed_mint_token_holders_post, - "getCompressedMintTokenHolders", types::PostGetCompressedMintTokenHoldersBody, types::PostGetCompressedMintTokenHoldersResponse ); api_call!( get_compressed_token_account_balance_post, - "getCompressedTokenAccountBalance", types::PostGetCompressedTokenAccountBalanceBody, types::PostGetCompressedTokenAccountBalanceResponse ); api_call!( get_compressed_token_accounts_by_delegate_post, - "getCompressedTokenAccountsByDelegate", types::PostGetCompressedTokenAccountsByDelegateBody, types::PostGetCompressedTokenAccountsByDelegateResponse ); api_call!( get_compressed_token_accounts_by_delegate_v2_post, - "getCompressedTokenAccountsByDelegateV2", types::PostGetCompressedTokenAccountsByDelegateV2Body, types::PostGetCompressedTokenAccountsByDelegateV2Response ); api_call!( get_compressed_token_accounts_by_owner_post, - "getCompressedTokenAccountsByOwner", types::PostGetCompressedTokenAccountsByOwnerBody, types::PostGetCompressedTokenAccountsByOwnerResponse ); api_call!( get_compressed_token_accounts_by_owner_v2_post, - "getCompressedTokenAccountsByOwnerV2", types::PostGetCompressedTokenAccountsByOwnerV2Body, types::PostGetCompressedTokenAccountsByOwnerV2Response ); api_call!( get_compressed_token_balances_by_owner_post, - "getCompressedTokenBalancesByOwner", types::PostGetCompressedTokenBalancesByOwnerBody, types::PostGetCompressedTokenBalancesByOwnerResponse ); api_call!( get_compressed_token_balances_by_owner_v2_post, - "getCompressedTokenBalancesByOwnerV2", types::PostGetCompressedTokenBalancesByOwnerV2Body, types::PostGetCompressedTokenBalancesByOwnerV2Response ); api_call!( get_compression_signatures_for_account_post, - "getCompressionSignaturesForAccount", types::PostGetCompressionSignaturesForAccountBody, types::PostGetCompressionSignaturesForAccountResponse ); api_call!( get_compression_signatures_for_address_post, - "getCompressionSignaturesForAddress", types::PostGetCompressionSignaturesForAddressBody, types::PostGetCompressionSignaturesForAddressResponse ); api_call!( get_compression_signatures_for_owner_post, - "getCompressionSignaturesForOwner", types::PostGetCompressionSignaturesForOwnerBody, types::PostGetCompressionSignaturesForOwnerResponse ); api_call!( get_compression_signatures_for_token_owner_post, - "getCompressionSignaturesForTokenOwner", types::PostGetCompressionSignaturesForTokenOwnerBody, types::PostGetCompressionSignaturesForTokenOwnerResponse ); api_call!( get_indexer_health_post, - "getIndexerHealth", types::PostGetIndexerHealthBody, types::PostGetIndexerHealthResponse ); api_call!( get_indexer_slot_post, - "getIndexerSlot", types::PostGetIndexerSlotBody, types::PostGetIndexerSlotResponse ); api_call!( get_multiple_compressed_account_proofs_post, - "getMultipleCompressedAccountProofs", types::PostGetMultipleCompressedAccountProofsBody, types::PostGetMultipleCompressedAccountProofsResponse ); api_call!( get_multiple_compressed_accounts_post, - "getMultipleCompressedAccounts", types::PostGetMultipleCompressedAccountsBody, types::PostGetMultipleCompressedAccountsResponse ); api_call!( get_multiple_new_address_proofs_v2_post, - "getMultipleNewAddressProofsV2", types::PostGetMultipleNewAddressProofsV2Body, types::PostGetMultipleNewAddressProofsV2Response ); api_call!( get_validity_proof_post, - "getValidityProof", types::PostGetValidityProofBody, types::PostGetValidityProofResponse ); api_call!( get_validity_proof_v2_post, - "getValidityProofV2", types::PostGetValidityProofV2Body, types::PostGetValidityProofV2Response ); api_call!( get_queue_elements_post, - "getQueueElements", types::PostGetQueueElementsBody, types::PostGetQueueElementsResponse ); api_call!( get_queue_leaf_indices_post, - "getQueueLeafIndices", types::PostGetQueueLeafIndicesBody, types::PostGetQueueLeafIndicesResponse ); api_call!( get_queue_info_post, - "getQueueInfo", types::PostGetQueueInfoBody, types::PostGetQueueInfoResponse ); api_call!( get_account_interface_post, - "getAccountInterface", types::PostGetAccountInterfaceBody, types::PostGetAccountInterfaceResponse ); api_call!( get_multiple_account_interfaces_post, - "getMultipleAccountInterfaces", types::PostGetMultipleAccountInterfacesBody, types::PostGetMultipleAccountInterfacesResponse ); @@ -621,7 +598,7 @@ mod tests { #[test] fn test_parse_url_with_api_key() { let (base, key) = Configuration::parse_url("https://rpc.example.com?api-key=MY_KEY"); - assert_eq!(base, "https://rpc.example.com"); + assert_eq!(base, "https://rpc.example.com/"); assert_eq!(key, Some("MY_KEY".to_string())); } @@ -636,14 +613,14 @@ mod tests { fn test_parse_url_with_other_query_params() { let (base, key) = Configuration::parse_url("https://rpc.example.com?other=value&api-key=KEY123"); - assert_eq!(base, "https://rpc.example.com"); + assert_eq!(base, "https://rpc.example.com/?other=value"); assert_eq!(key, Some("KEY123".to_string())); } #[test] fn test_new_with_api_key_in_url() { let config = Configuration::new("https://rpc.example.com?api-key=SECRET".to_string()); - assert_eq!(config.base_path, "https://rpc.example.com"); + assert_eq!(config.base_path, "https://rpc.example.com/"); assert_eq!(config.api_key, Some("SECRET".to_string())); } @@ -698,7 +675,7 @@ mod tests { #[tokio::test] async fn test_api_call_sends_correct_request() { use wiremock::{ - matchers::{header, method, path, query_param}, + matchers::{body_partial_json, header, method, path, query_param}, Mock, MockServer, ResponseTemplate, }; @@ -711,10 +688,15 @@ mod tests { }); Mock::given(method("POST")) - .and(path("/getIndexerHealth")) + .and(path("/")) .and(query_param("api-key", "TEST_KEY")) .and(header("accept", "application/json")) + .and(header("content-type", "application/json")) + .and(body_partial_json(serde_json::json!({ + "jsonrpc": "2.0", "id": "test-account", "method": "getIndexerHealth" + }))) .respond_with(ResponseTemplate::new(200).set_body_json(&response_json)) + .expect(1) .mount(&mock_server) .await; @@ -742,7 +724,7 @@ mod tests { }); Mock::given(method("POST")) - .and(path("/getIndexerHealth")) + .and(path("/")) .and(header("accept", "application/json")) .respond_with(ResponseTemplate::new(200).set_body_json(&response_json)) .mount(&mock_server) @@ -756,6 +738,121 @@ mod tests { result.expect("API call without api-key should succeed"); } + #[tokio::test] + async fn test_rpc_preserves_endpoint_and_query_parameters() { + use wiremock::{ + matchers::{body_json, method, path, query_param}, + Mock, MockServer, ResponseTemplate, + }; + + let server = MockServer::start().await; + let address = "11111111111111111111111111111111"; + let params = super::types::PostGetCompressedAccountBodyParams { + address: Some(super::types::SerializablePubkey(address.to_string())), + hash: None, + }; + let body = default_api::make_get_compressed_account_body(params); + for suffix in ["", "/", "/photon", "/photon/"] { + server.reset().await; + Mock::given(method("POST")) + .and(path(if suffix.is_empty() { "/" } else { suffix })) + .and(query_param("api-key", "key+/=&value")) + .and(query_param("region", "eu")) + .and(query_param("tag", "one")) + .and(query_param("tag", "two")) + .and(body_json(&body)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": "test-account", + "result": {"context": {"slot": 123}, "value": null} + }))) + .expect(1) + .mount(&server) + .await; + + let config = Configuration::new(format!( + "{}{suffix}?region=eu&api-key=key%2B%2F%3D%26value&tag=one&tag=two", + server.uri() + )); + let response = default_api::get_compressed_account_post(&config, body.clone()) + .await + .expect("RPC request should preserve endpoint, params and authentication"); + assert_eq!(response.result.unwrap().context.slot, 123); + server.verify().await; + } + } + + #[tokio::test] + async fn test_rpc_preserves_query_without_api_key_and_configured_api_key() { + use wiremock::{ + matchers::{method, path, query_param}, + Mock, MockServer, ResponseTemplate, + }; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/")) + .and(query_param("region", "eu")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": "test-account", "result": "ok" + }))) + .expect(2) + .mount(&server) + .await; + + let mut config = Configuration::new(format!("{}/?region=eu", server.uri())); + for key in [None, Some("key+/=&value".to_string())] { + config.api_key = key.clone(); + default_api::get_indexer_health_post( + &config, + default_api::make_get_indexer_health_body(), + ) + .await + .unwrap(); + let requests = server.received_requests().await.unwrap(); + let request = requests.last().unwrap(); + let sent_key = request + .url + .query_pairs() + .find(|(name, _)| name == "api-key") + .map(|(_, value)| value.into_owned()); + assert_eq!(sent_key, key); + } + } + + #[tokio::test] + async fn test_rpc_error_is_returned_without_rest_fallback() { + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "jsonrpc": "2.0", "id": "test-account", + "error": {"code": -32601, "message": "Method not found"} + }))) + .expect(1) + .mount(&server) + .await; + let config = Configuration::new(server.uri()); + let body = default_api::make_get_compressed_account_body( + super::types::PostGetCompressedAccountBodyParams { + address: Some(super::types::SerializablePubkey( + "11111111111111111111111111111111".to_string(), + )), + hash: None, + }, + ); + let response = default_api::get_compressed_account_post(&config, body) + .await + .unwrap(); + assert!(response.result.is_none()); + assert!(response.error.is_some()); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } + #[tokio::test] async fn test_api_call_error_response() { use wiremock::{ @@ -766,7 +863,7 @@ mod tests { let mock_server = MockServer::start().await; Mock::given(method("POST")) - .and(path("/getIndexerHealth")) + .and(path("/")) .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error")) .mount(&mock_server) .await;