Skip to content
Merged
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
143 changes: 96 additions & 47 deletions src/lib/governanceService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -85,6 +86,52 @@ 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;
}

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)) {
Expand All @@ -110,16 +157,24 @@ 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)
// - rate-limiter (429)
// - 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,
Expand All @@ -145,52 +200,46 @@ export function createGovernanceService(client = defaultClient) {
if (!Array.isArray(entries) || entries.length === 0) {
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 : [],
};
} 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;
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 {
const res = await client.post(VOTE_PATH, {
proposalHash,
voteOutcome,
voteSignal,
time,
entries: chunk,
});
Comment on lines +209 to +215
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Surface partial chunk success on mid-batch failure

When any later chunk request fails, submitVote throws and discards results from earlier chunks that were already accepted by the backend. This changes the operation from effectively all-or-nothing (single request) to partially committed but reported as total failure, which breaks retry assumptions in callers (they will resend already-submitted entries and show misleading error state). A concrete case is entries.length > 256 where chunk 1 succeeds and chunk 2 hits a transient 429/network error: users see only an error even though some votes were already relayed.

Useful? React with 👍 / 👎.

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);
}
completedChunks += 1;
} catch (err) {
const normalized = normalizeSubmitError(err);
if (completedChunks === 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.
Expand Down
122 changes: 122 additions & 0 deletions src/lib/governanceService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,128 @@ 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('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('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' });
Expand Down
Loading