From 005d449d31169d0ec2625794d572e685ef923c01 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 16 May 2026 15:40:43 -0700 Subject: [PATCH 1/3] Chunk large governance vote submissions Split frontend vote relay payloads at the backend per-request entry cap so large operators can submit all signed votes without hitting too_many_entries. Co-authored-by: Cursor --- src/lib/governanceService.js | 53 +++++++++++++++++++++---------- src/lib/governanceService.test.js | 43 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/src/lib/governanceService.js b/src/lib/governanceService.js index c65b1997..99ace25b 100644 --- a/src/lib/governanceService.js +++ b/src/lib/governanceService.js @@ -66,6 +66,7 @@ const RECEIPTS_RECONCILE_PATH = '/gov/receipts/reconcile'; const RECEIPTS_SUMMARY_PATH = '/gov/receipts/summary'; const RECEIPTS_RECENT_PATH = '/gov/receipts/recent'; const HEX64_RE = /^[0-9a-fA-F]{64}$/; +const MAX_VOTE_ENTRIES_PER_REQUEST = 256; function govError(code, status, cause) { const e = new Error(code); @@ -85,6 +86,14 @@ function govError(code, status, cause) { return e; } +function chunkArray(values, size) { + const chunks = []; + for (let i = 0; i < values.length; i += size) { + chunks.push(values.slice(i, i + size)); + } + return chunks; +} + export function createGovernanceService(client = defaultClient) { async function lookupOwnedMasternodes(votingAddresses) { if (!Array.isArray(votingAddresses)) { @@ -110,9 +119,12 @@ export function createGovernanceService(client = defaultClient) { // Submit a batch of per-MN signed votes for one proposal. The // backend validates request shape, then fans out `voteraw` RPC - // calls with bounded concurrency. A per-entry `ok: false` does NOT - // fail the whole request: the promise resolves with a full - // `results` array so the UI can render per-row success/error. + // calls with bounded concurrency. The backend also caps one POST at + // 256 entries, so large operators are split into sequential chunks + // here and merged back into the same response shape. A per-entry + // `ok: false` does NOT fail the whole request: the promise resolves + // with a full `results` array so the UI can render per-row + // success/error. // // Throws only on: // - request-shape validation failures (400) @@ -146,19 +158,28 @@ export function createGovernanceService(client = defaultClient) { throw govError('no_entries', 0); } try { - const res = await client.post(VOTE_PATH, { - proposalHash, - voteOutcome, - voteSignal, - time, - entries, - }); - const data = res.data || {}; - return { - accepted: Number.isInteger(data.accepted) ? data.accepted : 0, - rejected: Number.isInteger(data.rejected) ? data.rejected : 0, - results: Array.isArray(data.results) ? data.results : [], - }; + const chunks = chunkArray(entries, MAX_VOTE_ENTRIES_PER_REQUEST); + const merged = { accepted: 0, rejected: 0, results: [] }; + for (const chunk of chunks) { + const res = await client.post(VOTE_PATH, { + proposalHash, + voteOutcome, + voteSignal, + time, + entries: chunk, + }); + const data = res.data || {}; + merged.accepted += Number.isInteger(data.accepted) + ? data.accepted + : 0; + merged.rejected += Number.isInteger(data.rejected) + ? data.rejected + : 0; + if (Array.isArray(data.results)) { + merged.results.push(...data.results); + } + } + return merged; } catch (err) { if (!err || !err.code) { throw govError('network_error', 0, err); diff --git a/src/lib/governanceService.test.js b/src/lib/governanceService.test.js index b231cdd0..6a8e7cc3 100644 --- a/src/lib/governanceService.test.js +++ b/src/lib/governanceService.test.js @@ -103,6 +103,49 @@ describe('governanceService.submitVote', () => { expect(adapter.history.post[0].headers['X-CSRF-Token']).toBe('tok'); }); + test('splits vote submissions above the backend per-request cap and merges results', async () => { + const { service, adapter } = makeService(); + const entries = Array.from({ length: 257 }, (_, i) => ({ + collateralHash: H64(i === 256 ? 'c' : 'b'), + collateralIndex: i, + voteSig: SIG, + })); + adapter.onPost('/gov/vote').reply((config) => { + const body = JSON.parse(config.data); + const results = body.entries.map((entry) => ({ + collateralHash: entry.collateralHash, + collateralIndex: entry.collateralIndex, + ok: entry.collateralIndex !== 256, + error: entry.collateralIndex === 256 ? 'vote_too_often' : undefined, + })); + return [ + 200, + { + accepted: results.filter((r) => r.ok).length, + rejected: results.filter((r) => !r.ok).length, + results, + }, + ]; + }); + + const out = await service.submitVote(validVoteBody({ entries })); + + expect(adapter.history.post).toHaveLength(2); + const firstBody = JSON.parse(adapter.history.post[0].data); + const secondBody = JSON.parse(adapter.history.post[1].data); + expect(firstBody.entries).toHaveLength(256); + expect(secondBody.entries).toHaveLength(1); + expect(out.accepted).toBe(256); + expect(out.rejected).toBe(1); + expect(out.results).toHaveLength(257); + expect(out.results[256]).toMatchObject({ + collateralHash: H64('c'), + collateralIndex: 256, + ok: false, + error: 'vote_too_often', + }); + }); + test('maps 429 too_many_vote_requests to rate_limited', async () => { const { service, adapter } = makeService(); adapter.onPost('/gov/vote').reply(429, { error: 'too_many_vote_requests' }); From 9d0f108745e73012a60535749c637da46cb77c31 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 16 May 2026 15:44:40 -0700 Subject: [PATCH 2/3] Preserve partial vote chunk results Return already-accepted chunk results with retryable per-entry failures when a later chunk request fails, instead of hiding partial success behind a top-level error. Co-authored-by: Cursor --- src/lib/governanceService.js | 98 +++++++++++++++++++------------ src/lib/governanceService.test.js | 48 +++++++++++++++ 2 files changed, 110 insertions(+), 36 deletions(-) diff --git a/src/lib/governanceService.js b/src/lib/governanceService.js index 99ace25b..fde1ebe0 100644 --- a/src/lib/governanceService.js +++ b/src/lib/governanceService.js @@ -94,6 +94,44 @@ function chunkArray(values, size) { return chunks; } +function normalizeSubmitError(err) { + if (!err || !err.code) { + return govError('network_error', 0, err); + } + switch (err.code) { + case 'too_many_vote_requests': + return govError('rate_limited', err.status, err); + case 'unsupported_vote_signal': + case 'invalid_vote_outcome': + case 'invalid_proposal_hash': + case 'no_entries': + case 'too_many_entries': + case 'time_in_future': + case 'time_too_old': + return err; // already canonical + default: + // Collapse transient 5xx responses into a single `server_error` + // code so retry descriptors don't need to enumerate every + // possible upstream failure mode. Keep 4xx codes verbatim. + if (Number.isInteger(err.status) && err.status >= 500) { + return govError('server_error', err.status, err); + } + return err; + } +} + +function failedVoteResults(chunks, startIndex, code) { + return chunks + .slice(startIndex) + .flat() + .map((entry) => ({ + collateralHash: entry.collateralHash, + collateralIndex: entry.collateralIndex, + ok: false, + error: code, + })); +} + export function createGovernanceService(client = defaultClient) { async function lookupOwnedMasternodes(votingAddresses) { if (!Array.isArray(votingAddresses)) { @@ -132,6 +170,11 @@ export function createGovernanceService(client = defaultClient) { // - auth loss (401 is propagated to the shared AuthContext // handler through the apiClient interceptor) // - network / 5xx errors + // + // Once any chunk succeeds, a later request failure is converted into + // per-entry failures for the current and remaining chunks. That + // preserves already-relayed votes in the DONE view and lets "Retry + // failed" target only the rows that did not receive a response. async function submitVote({ proposalHash, voteOutcome, @@ -157,10 +200,11 @@ export function createGovernanceService(client = defaultClient) { if (!Array.isArray(entries) || entries.length === 0) { throw govError('no_entries', 0); } - try { - const chunks = chunkArray(entries, MAX_VOTE_ENTRIES_PER_REQUEST); - const merged = { accepted: 0, rejected: 0, results: [] }; - for (const chunk of chunks) { + const chunks = chunkArray(entries, MAX_VOTE_ENTRIES_PER_REQUEST); + const merged = { accepted: 0, rejected: 0, results: [] }; + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + const chunk = chunks[chunkIndex]; + try { const res = await client.post(VOTE_PATH, { proposalHash, voteOutcome, @@ -178,40 +222,22 @@ export function createGovernanceService(client = defaultClient) { if (Array.isArray(data.results)) { merged.results.push(...data.results); } - } - return merged; - } catch (err) { - if (!err || !err.code) { - throw govError('network_error', 0, err); - } - // Map a handful of common backend codes to UI-stable aliases. - // Everything else is passed through verbatim so exhaustive - // UI error tables don't need periodic re-syncing. - switch (err.code) { - case 'too_many_vote_requests': - throw govError('rate_limited', err.status, err); - case 'unsupported_vote_signal': - case 'invalid_vote_outcome': - case 'invalid_proposal_hash': - case 'no_entries': - case 'too_many_entries': - case 'time_in_future': - case 'time_too_old': - throw err; // already canonical - default: - // Collapse transient 5xx responses into a single - // `server_error` code so the UI auto-retry descriptor - // knows to kick in without having to enumerate every - // possible upstream failure mode. Keep 4xx codes - // verbatim — those are actionable-by-user states - // (missing csrf, malformed body, etc.) that should - // surface their original code. - if (Number.isInteger(err.status) && err.status >= 500) { - throw govError('server_error', err.status, err); - } - throw err; + } catch (err) { + const normalized = normalizeSubmitError(err); + if (merged.results.length === 0) { + throw normalized; + } + const failures = failedVoteResults( + chunks, + chunkIndex, + normalized.code || 'submit_failed' + ); + merged.rejected += failures.length; + merged.results.push(...failures); + return merged; } } + return merged; } // Fetch the caller's stored vote receipts for a single proposal. diff --git a/src/lib/governanceService.test.js b/src/lib/governanceService.test.js index 6a8e7cc3..34b73e4c 100644 --- a/src/lib/governanceService.test.js +++ b/src/lib/governanceService.test.js @@ -146,6 +146,54 @@ describe('governanceService.submitVote', () => { }); }); + test('preserves successful chunks when a later chunk request fails', async () => { + const { service, adapter } = makeService(); + const entries = Array.from({ length: 513 }, (_, i) => ({ + collateralHash: H64(i >= 256 ? 'c' : 'b'), + collateralIndex: i, + voteSig: SIG, + })); + let requestCount = 0; + adapter.onPost('/gov/vote').reply((config) => { + requestCount += 1; + const body = JSON.parse(config.data); + if (requestCount === 2) { + return [429, { error: 'too_many_vote_requests' }]; + } + const results = body.entries.map((entry) => ({ + collateralHash: entry.collateralHash, + collateralIndex: entry.collateralIndex, + ok: true, + })); + return [ + 200, + { + accepted: results.length, + rejected: 0, + results, + }, + ]; + }); + + const out = await service.submitVote(validVoteBody({ entries })); + + expect(adapter.history.post).toHaveLength(2); + expect(out.accepted).toBe(256); + expect(out.rejected).toBe(257); + expect(out.results).toHaveLength(513); + expect(out.results[255]).toMatchObject({ collateralIndex: 255, ok: true }); + expect(out.results[256]).toMatchObject({ + collateralIndex: 256, + ok: false, + error: 'rate_limited', + }); + expect(out.results[512]).toMatchObject({ + collateralIndex: 512, + ok: false, + error: 'rate_limited', + }); + }); + test('maps 429 too_many_vote_requests to rate_limited', async () => { const { service, adapter } = makeService(); adapter.onPost('/gov/vote').reply(429, { error: 'too_many_vote_requests' }); From 6935e799c491bac00e94a0e9ec2e6ef94d4fe950 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 16 May 2026 15:49:51 -0700 Subject: [PATCH 3/3] Track vote chunk completion explicitly Use successful chunk completion rather than returned result rows to decide whether a later chunk failure should preserve partial progress. Co-authored-by: Cursor --- src/lib/governanceService.js | 4 +++- src/lib/governanceService.test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/lib/governanceService.js b/src/lib/governanceService.js index fde1ebe0..cbf01278 100644 --- a/src/lib/governanceService.js +++ b/src/lib/governanceService.js @@ -202,6 +202,7 @@ export function createGovernanceService(client = defaultClient) { } const chunks = chunkArray(entries, MAX_VOTE_ENTRIES_PER_REQUEST); const merged = { accepted: 0, rejected: 0, results: [] }; + let completedChunks = 0; for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const chunk = chunks[chunkIndex]; try { @@ -222,9 +223,10 @@ export function createGovernanceService(client = defaultClient) { if (Array.isArray(data.results)) { merged.results.push(...data.results); } + completedChunks += 1; } catch (err) { const normalized = normalizeSubmitError(err); - if (merged.results.length === 0) { + if (completedChunks === 0) { throw normalized; } const failures = failedVoteResults( diff --git a/src/lib/governanceService.test.js b/src/lib/governanceService.test.js index 34b73e4c..66bcaa87 100644 --- a/src/lib/governanceService.test.js +++ b/src/lib/governanceService.test.js @@ -194,6 +194,37 @@ describe('governanceService.submitVote', () => { }); }); + test('tracks successful chunks even if a success body omits results', async () => { + const { service, adapter } = makeService(); + const entries = Array.from({ length: 257 }, (_, i) => ({ + collateralHash: H64(i >= 256 ? 'c' : 'b'), + collateralIndex: i, + voteSig: SIG, + })); + let requestCount = 0; + adapter.onPost('/gov/vote').reply(() => { + requestCount += 1; + if (requestCount === 1) { + return [200, { accepted: 256, rejected: 0 }]; + } + return [429, { error: 'too_many_vote_requests' }]; + }); + + const out = await service.submitVote(validVoteBody({ entries })); + + expect(adapter.history.post).toHaveLength(2); + expect(out.accepted).toBe(256); + expect(out.rejected).toBe(1); + expect(out.results).toEqual([ + { + collateralHash: H64('c'), + collateralIndex: 256, + ok: false, + error: 'rate_limited', + }, + ]); + }); + test('maps 429 too_many_vote_requests to rate_limited', async () => { const { service, adapter } = makeService(); adapter.onPost('/gov/vote').reply(429, { error: 'too_many_vote_requests' });