From 5f615a79801e46d98d9c5bcf2fdb246e5c7974b8 Mon Sep 17 00:00:00 2001 From: farmer Date: Tue, 28 Apr 2026 13:57:37 +0800 Subject: [PATCH] fix(merkle): use rails-style cid[N] query, not repeated bare cid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The freezer service uses serde_qs to parse query params, which by default expects array values as `cid[0]=...&cid[1]=...&cid[2]=...` (rails-style). Sending repeated bare keys `cid=...&cid=...&cid=...` (rack-style) returns: HTTP 400 Multiple values for one key: "cid" Every SDK consumer hitting `freezer.{chain}.2.o.cash/api/v1/merkle` fails on multi-cid requests — automation on Sepolia, the in-app SDK path, etc. The frontend's MerkleManager already uses bracket notation and works; only this SDK helper was the outlier. Rename the helper from `withRepeatedQuery` to `withIndexedQuery` and build query strings as `cid[N]=value` to match server expectations. Update merkleEngine.test.ts assertion (was locking in the broken format) and add a regression test in merkleClient.test.ts that explicitly checks the URL contains `cid%5B0%5D=...&cid%5B1%5D=...` and does NOT match `[?&]cid=\d+&cid=\d+`. 124/124 tests pass. --- src/merkle/merkleClient.ts | 14 ++++++++++---- tests/merkleClient.test.ts | 31 +++++++++++++++++++++++++++++++ tests/merkleEngine.test.ts | 4 ++-- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/merkle/merkleClient.ts b/src/merkle/merkleClient.ts index 057045b..f4f8012 100644 --- a/src/merkle/merkleClient.ts +++ b/src/merkle/merkleClient.ts @@ -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) => { +const withIndexedQuery = (url: string, key: string, values: Array) => { 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; }; @@ -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 = diff --git a/tests/merkleClient.test.ts b/tests/merkleClient.test.ts index 2a0f2c2..5fd3358 100644 --- a/tests/merkleClient.test.ts +++ b/tests/merkleClient.test.ts @@ -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+/); + }); }); diff --git a/tests/merkleEngine.test.ts b/tests/merkleEngine.test.ts index a50b763..1438bc6 100644 --- a/tests/merkleEngine.test.ts +++ b/tests/merkleEngine.test.ts @@ -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, @@ -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 () => {