Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/merkle/merkleClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ import { joinUrl } from '../utils/url';
const DEFAULT_MERKLE_REQUEST_TIMEOUT_MS = 15_000;

/**
* Build query string with repeated keys (cid=1&cid=2...).
* Build query string in rails-style bracket notation:
* cid[0]=1&cid[1]=2&cid[2]=3
*
* The freezer service uses `serde_qs` on the server side, which by
* default expects this format, NOT the rack-style repeated bare keys
* (cid=1&cid=2&cid=3). Repeated bare keys produce
* "Multiple values for one key: cid" 400 errors.
*/
const withRepeatedQuery = (url: string, key: string, values: Array<string | number>) => {
const withIndexedQuery = (url: string, key: string, values: Array<string | number>) => {
const search = new URLSearchParams();
for (const v of values) search.append(key, String(v));
values.forEach((v, i) => search.append(`${key}[${i}]`, String(v)));
const qs = search.toString();
return qs ? `${url}?${qs}` : url;
};
Comment on lines +18 to 23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The withIndexedQuery function unconditionally appends a ? to the URL. If the baseUrl (or the path joined to it) already contains query parameters, this will result in an invalid URL with multiple ? characters (e.g., https://api.com?key=123?cid[0]=...). While the current usage in getProofByCids might be safe with a simple base URL, making this utility more robust prevents bugs when the SDK is configured with a base URL that includes query parameters (e.g., for authentication or proxying).

Suggested change
const withIndexedQuery = (url: string, key: string, values: Array<string | number>) => {
const search = new URLSearchParams();
for (const v of values) search.append(key, String(v));
values.forEach((v, i) => search.append(`${key}[${i}]`, String(v)));
const qs = search.toString();
return qs ? `${url}?${qs}` : url;
};
const withIndexedQuery = (url: string, key: string, values: Array<string | number>) => {
const search = new URLSearchParams();
values.forEach((v, i) => search.append(key + '[' + i + ']', String(v)));
const qs = search.toString();
return qs ? url + (url.includes('?') ? '&' : '?') + qs : url;
};

Expand Down Expand Up @@ -116,7 +122,7 @@ export class MerkleClient {
if (!Array.isArray(cids) || cids.length === 0) {
throw new SdkError('MERKLE', 'Merkle proof requires at least one cid', { cids });
}
const url = withRepeatedQuery(joinUrl(this.baseUrl, '/api/v1/merkle'), 'cid', cids);
const url = withIndexedQuery(joinUrl(this.baseUrl, '/api/v1/merkle'), 'cid', cids);
this.debugEmit?.({ type: 'debug', payload: { scope: 'http:merkle', message: 'request', detail: { method: 'GET', url } } });
let response: Response;
const requestTimeoutMs =
Expand Down
31 changes: 31 additions & 0 deletions tests/merkleClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,35 @@ describe('MerkleClient.getProofByCids', () => {
message: 'Merkle proof request failed',
});
});

// Regression: server uses serde_qs which expects `cid[N]=...`,
// not repeated bare `cid=...&cid=...`. The latter produces a 400
// "Multiple values for one key: cid". Verify the URL we send.
it('builds query in rails-style bracket notation, not repeated bare keys', async () => {
let capturedUrl = '';
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
capturedUrl = url;
return new Response(
JSON.stringify({
proof: [
{ path: [], leaf_index: '0' },
{ path: [], leaf_index: '1' },
{ path: [], leaf_index: '2' },
],
merkle_root: '0x1',
latest_cid: 0,
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}),
);
const client = new MerkleClient('https://merkle.example');
await client.getProofByCids([6067, 5055, 7792]);
expect(capturedUrl).toContain('cid%5B0%5D=6067');
expect(capturedUrl).toContain('cid%5B1%5D=5055');
expect(capturedUrl).toContain('cid%5B2%5D=7792');
expect(capturedUrl).not.toMatch(/[?&]cid=\d+&cid=\d+/);
});
});
4 changes: 2 additions & 2 deletions tests/merkleEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('MerkleEngine', () => {
expect(engine.currentMerkleRootIndex(33)).toBe(1);
});

it('fetches remote proof with repeated cid query', async () => {
it('fetches remote proof using rails-style cid[N] query (server uses serde_qs)', async () => {
const engine = new MerkleEngine(() => ({ merkleProofUrl: 'https://merkle.invalid' }), bridge);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
Expand All @@ -46,7 +46,7 @@ describe('MerkleEngine', () => {

const res = await engine.getProofByCids({ chainId: 1, cids: [7], totalElements: 33n });
expect(res.latest_cid).toBe(0);
expect(fetchMock).toHaveBeenCalledWith('https://merkle.invalid/api/v1/merkle?cid=7', expect.objectContaining({ signal: expect.anything() }));
expect(fetchMock).toHaveBeenCalledWith('https://merkle.invalid/api/v1/merkle?cid%5B0%5D=7', expect.objectContaining({ signal: expect.anything() }));
});

it('throws SdkError(MERKLE) when all utxos lack memos', async () => {
Expand Down
Loading