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 () => {