From 37f79f524322ba667729ff772e6659fb8571dcbd Mon Sep 17 00:00:00 2001 From: Francesco Medas Date: Thu, 4 Jun 2026 15:49:23 +0400 Subject: [PATCH 1/3] fix(governance-api): scope proposal balance fetch to the submitter getLiquidity downloaded the entire gZIL ZRC2 balances map (~5MB) on every proposal just to read one address, taking ~30s from the cluster and exceeding the gateway's 30s backend_timeout, returning 504. - Fetch only the submitter's entry (index [ownerKey]) instead of the full map. - Normalise the key to lowercase 0x (Scilla state key format; also fixes a latent checksum mismatch for bech32/ZilPay addresses). - Seed the submitter's key so ZilSwap/XCAD LP balances are still credited with no direct balance. - Guard null RPC results; default missing balances to '0' (fail-closed gate). - Add framework-free regression tests. --- products/governance-api/lib/routes/message.ts | 2 +- .../lib/zilliqa/custom-fetch.test.ts | 85 +++++++++++++++++++ .../lib/zilliqa/custom-fetch.ts | 19 +++-- 3 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 products/governance-api/lib/zilliqa/custom-fetch.test.ts diff --git a/products/governance-api/lib/routes/message.ts b/products/governance-api/lib/routes/message.ts index b3ac9d9b1..d18cb4f2c 100644 --- a/products/governance-api/lib/routes/message.ts +++ b/products/governance-api/lib/routes/message.ts @@ -244,7 +244,7 @@ message.post("/message", async (req, res) => { log.info({ token: base16Token, address: base16owner, userBalance }, "Zilliqa liquidity fetched"); - const _balance = new BN(userBalance); + const _balance = new BN(userBalance || "0"); const _minGZIL = new BN("30000000000000000"); if (msg.token == gZIL && _balance.lt(_minGZIL)) { diff --git a/products/governance-api/lib/zilliqa/custom-fetch.test.ts b/products/governance-api/lib/zilliqa/custom-fetch.test.ts new file mode 100644 index 000000000..27f1410b8 --- /dev/null +++ b/products/governance-api/lib/zilliqa/custom-fetch.test.ts @@ -0,0 +1,85 @@ +// ABOUTME: Framework-free assertions for getLiquidity's submitter-scoped balance fetch. +// ABOUTME: Run with `node --require ts-node/register lib/zilliqa/custom-fetch.test.ts`. +import assert from "assert"; +import { blockchain } from "./custom-fetch"; + +// The Scilla ZRC2 `balances` map is keyed by lowercase 0x addresses (verified against +// api.zilliqa.com). getLiquidity must (a) fetch ONLY the submitter's entry — not the whole +// multi-MB map — and (b) read it back with a normalized lowercase-0x key. + +async function scopedFetchTest() { + const b: any = new blockchain(); + let sent: any; + b._send = async (batch: any[]) => { + sent = batch; + return [ + { result: { pools: {} } }, + { result: { balances: {} } }, + { result: { xpools: {} } }, + { result: { xbalances: {} } }, + { result: { balances: { "0xabc": "123" } } }, + { result: { total_supply: "1000" } }, + ]; + }; + + // Mixed-case inputs to prove normalization. + const out = await b.getLiquidity("0xA845c1034CD077bd8D32be0447239c7E4be6cb21", "0xABC"); + + assert.deepStrictEqual( + sent[4].params[2], + ["0xabc"], + "balances call must be scoped to the submitter (lowercase 0x), not the full map" + ); + assert.notDeepStrictEqual(sent[4].params[2], [], "must NOT fetch the entire balances map"); + assert.strictEqual(out.userBalance, "123", "userBalance read via normalized key"); + console.log("OK scopedFetchTest"); +} + +async function nullResultTest() { + const b: any = new blockchain(); + b._send = async () => [ + { result: null }, + { result: null }, + { result: null }, + { result: null }, + { result: null }, + { result: null }, + ]; + const out = await b.getLiquidity("0xA845c1034CD077bd8D32be0447239c7E4be6cb21", "0xfe56"); + assert.strictEqual(out.userBalance, "0", "missing balance => '0' (no throw)"); + assert.strictEqual(out.totalSupply, "0", "missing total_supply => '0' (no throw)"); + console.log("OK nullResultTest"); +} + +async function lpHolderCreditedWithoutDirectBalance() { + // A submitter whose gZIL sits ONLY in ZilSwap liquidity (no direct balance entry) must still + // be credited via the seeded key. Without the seed this returns "0" and wrongly blocks them. + const b: any = new blockchain(); + const TOKEN = "0xa845c1034cd077bd8d32be0447239c7e4be6cb21"; + const OWNER = "0xabc"; // -> ownerKey "0xabc" + b._send = async () => [ + { result: { pools: { [TOKEN]: { arguments: ["zilReserve", "1000000"] } } } }, + { result: { balances: { [TOKEN]: { "0xabc": "50" } } } }, // owner = 100% of a 50-unit pool + { result: { xpools: {} } }, + { result: { xbalances: {} } }, + { result: { balances: {} } }, // NO direct token balance for the owner + { result: { total_supply: "999" } }, + ]; + const out = await b.getLiquidity(TOKEN, OWNER); + assert.strictEqual( + out.userBalance, + "1000000", + "LP-only holder must be credited their pool share via the seeded key" + ); + console.log("OK lpHolderCreditedWithoutDirectBalance"); +} + +(async () => { + await scopedFetchTest(); + await nullResultTest(); + await lpHolderCreditedWithoutDirectBalance(); + console.log("ALL PASS"); +})().catch((e) => { + console.error("FAIL:", e.message); + process.exit(1); +}); diff --git a/products/governance-api/lib/zilliqa/custom-fetch.ts b/products/governance-api/lib/zilliqa/custom-fetch.ts index 3a3be7e78..ada39cf38 100644 --- a/products/governance-api/lib/zilliqa/custom-fetch.ts +++ b/products/governance-api/lib/zilliqa/custom-fetch.ts @@ -23,6 +23,9 @@ export class blockchain { private _zero = new BN(0); public async getLiquidity(token: string, address: string) { + // ZRC2 `balances` is keyed by lowercase 0x addresses; normalise the submitter's key so + // we can fetch ONLY their entry instead of downloading the entire (multi-MB) holder map. + const ownerKey = "0x" + this._toHex(address); const batch = [ { method: RPCMethod.GetSmartContractSubState, @@ -54,7 +57,7 @@ export class blockchain { }, { method: RPCMethod.GetSmartContractSubState, - params: [this._toHex(token), TokenFields.Balances, []], + params: [this._toHex(token), TokenFields.Balances, [ownerKey]], id: 1, jsonrpc: `2.0`, }, @@ -72,13 +75,19 @@ export class blockchain { logger.error({ err, error_code: "ZILLIQA_RPC_FAILED" }, "Zilliqa RPC call failed"); throw err; } - let tokenBalances = res[4]["result"][TokenFields.Balances]; - const totalSupply = res[5]["result"][TokenFields.TotalSupply]; + let tokenBalances = res[4]?.["result"]?.[TokenFields.Balances] ?? {}; + const totalSupply = res[5]?.["result"]?.[TokenFields.TotalSupply] ?? "0"; + + // Seed the submitter's key so the LP crediting below can augment it even when the user + // holds no *direct* token balance (their gZIL may sit only in ZilSwap/XCAD liquidity). + if (!(ownerKey in tokenBalances)) { + tokenBalances[ownerKey] = "0"; + } tokenBalances = this._parseZilSwap(res, token, tokenBalances); tokenBalances = this._parseXcad(res, token, tokenBalances); - const userBalance = tokenBalances[address]; + const userBalance = tokenBalances[ownerKey] ?? "0"; logger.info({ token, address, userBalance }, "Zilliqa liquidity fetched"); return { @@ -178,7 +187,7 @@ export class blockchain { } private _toHex(address: string) { - return String(address).replace("0x", "").toLowerCase(); + return String(address).replace(/^0x/i, "").toLowerCase(); } private async _send(batch: object[]) { From 8ec616c38cfd683e24dac11ac1ee7556fa439f4f Mon Sep 17 00:00:00 2001 From: Francesco Medas Date: Thu, 4 Jun 2026 15:49:32 +0400 Subject: [PATCH 2/3] fix(governance-snapshot): always settle request() so the UI never hangs client.request left its promise pending when an error response was not JSON (a 504 gateway HTML page) or the request was CORS-blocked, leaving the publish spinner stuck forever. - Rewrite with async/await; always resolve or reject. - AbortController with a 45s timeout. - Parse error bodies defensively; reject with {code,error_description} or a fallback. - Add framework-free regression tests. --- .../src/helpers/client.test.ts | 110 ++++++++++++++++++ .../governance-snapshot/src/helpers/client.ts | 64 +++++++--- 2 files changed, 157 insertions(+), 17 deletions(-) create mode 100644 products/governance-snapshot/src/helpers/client.test.ts diff --git a/products/governance-snapshot/src/helpers/client.test.ts b/products/governance-snapshot/src/helpers/client.test.ts new file mode 100644 index 000000000..8603a87d7 --- /dev/null +++ b/products/governance-snapshot/src/helpers/client.test.ts @@ -0,0 +1,110 @@ +// ABOUTME: Framework-free assertions that client.request always settles (never hangs the UI). +// ABOUTME: Run via ts-node transpile-only; mocks window + fetch. See command in the fix PR. +import assert from "assert"; + +(global as any).window = { VUE_APP_HUB_URL: "http://hub.test" }; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +import client from "./client"; + +async function nonJsonErrorDoesNotHang() { + // A gateway 504 returns an HTML body → res.json() throws. Must still reject, not hang. + (global as any).fetch = async () => ({ + ok: false, + status: 504, + json: async () => { + throw new SyntaxError("Unexpected token < in JSON"); + } + }); + let rejected = false; + let val: any; + try { + await client.request("message", { a: 1 }); + } catch (e) { + rejected = true; + val = e; + } + assert.strictEqual(rejected, true, "504 with non-JSON body must reject, not hang"); + assert.ok( + val && typeof val.error_description === "string", + "rejection must carry a usable error_description" + ); + console.log("OK nonJsonErrorDoesNotHang:", val.error_description); +} + +async function successResolves() { + (global as any).fetch = async () => ({ + ok: true, + status: 200, + json: async () => ({ ipfsHash: "Qm123" }) + }); + const out: any = await client.request("message", { a: 1 }); + assert.deepStrictEqual(out, { ipfsHash: "Qm123" }, "success resolves to parsed JSON"); + console.log("OK successResolves"); +} + +async function abortRejects() { + (global as any).fetch = async () => { + const e: any = new Error("aborted"); + e.name = "AbortError"; + throw e; + }; + let rejected = false; + let val: any; + try { + await client.request("spaces"); + } catch (e) { + rejected = true; + val = e; + } + assert.strictEqual(rejected, true, "abort/timeout must reject"); + assert.ok(val.error_description.includes("timed out"), "AbortError => timeout message"); + console.log("OK abortRejects:", val.error_description); +} + +async function errorWithJsonRejectsServerObject() { + // A normal 400 (e.g. MIN_BALANCE) carries JSON the UI shows; reject with the server's object. + (global as any).fetch = async () => ({ + ok: false, + status: 400, + json: async () => ({ + code: 11, + error_description: "You require 30 $gZIL or more to submit a proposal." + }) + }); + let val: any; + try { + await client.request("message", { a: 1 }); + } catch (e) { + val = e; + } + assert.strictEqual(val.code, 11, "rejects with the server's JSON error object"); + assert.ok(val.error_description.includes("gZIL"), "preserves server error_description"); + console.log("OK errorWithJsonRejectsServerObject"); +} + +async function successEmptyBodyResolves() { + // A 2xx with a non-JSON/empty body must resolve (to undefined), never hang or throw. + (global as any).fetch = async () => ({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError("empty body"); + } + }); + const out = await client.request("spaces"); + assert.strictEqual(out, undefined, "non-JSON 2xx resolves to undefined"); + console.log("OK successEmptyBodyResolves"); +} + +(async () => { + await nonJsonErrorDoesNotHang(); + await successResolves(); + await abortRejects(); + await errorWithJsonRejectsServerObject(); + await successEmptyBodyResolves(); + console.log("ALL PASS"); +})().catch((e) => { + console.error("FAIL:", e.message); + process.exit(1); +}); diff --git a/products/governance-snapshot/src/helpers/client.ts b/products/governance-snapshot/src/helpers/client.ts index 0312a4aed..1136453ab 100644 --- a/products/governance-snapshot/src/helpers/client.ts +++ b/products/governance-snapshot/src/helpers/client.ts @@ -1,25 +1,55 @@ +// ABOUTME: Thin fetch wrapper for the governance-api. Guarantees the returned promise always +// ABOUTME: settles (timeout + defensive error parsing) so the UI never hangs on a 504/non-JSON. + +const REQUEST_TIMEOUT_MS = 45000; + class Client { - request(command, body?) { + async request(command, body?) { const url = `${window['VUE_APP_HUB_URL']}/api/${command}`; - let init; + const init: any = {}; if (body) { - init = { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) + init.method = 'POST'; + init.headers = { + Accept: 'application/json', + 'Content-Type': 'application/json' }; + init.body = JSON.stringify(body); + } + + // Bound the request so a hung/slow backend (e.g. the gateway 504s at 30s) can never + // leave the caller waiting forever. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + init.signal = controller.signal; + + let res; + try { + res = await fetch(url, init); + } catch (e) { + // Network failure, CORS block, or abort (timeout). Reject with a usable shape. + const aborted = e && e.name === 'AbortError'; + return Promise.reject({ + error_description: aborted + ? 'Request timed out. Please try again.' + : 'Network error. Please try again.' + }); + } finally { + clearTimeout(timer); } - return new Promise((resolve, reject) => { - fetch(url, init) - .then(res => { - if (res.ok) return resolve(res.json()); - throw res; - }) - .catch(e => e.json().then(json => reject(json))); - }); + + // Error responses are frequently NOT JSON (a gateway 504 returns an HTML page), so parse + // defensively instead of letting `res.json()` reject and strand the outer promise. + const payload = await res.json().catch(() => undefined); + + if (res.ok) { + return payload; + } + + return Promise.reject( + payload && typeof payload === 'object' + ? payload + : { error_description: `Request failed (HTTP ${res.status}).` } + ); } } From e96d9c871028eab1ae2bb262cf1dca3063e31445 Mon Sep 17 00:00:00 2001 From: Francesco Medas Date: Thu, 4 Jun 2026 16:33:56 +0400 Subject: [PATCH 3/3] fix(governance): preserve full voter-scoring snapshot; raise backend timeout PR #777 review (C1): scoping the balances fetch to the submitter also shrank the IPFS-pinned snapshot that the frontend uses as the whole-electorate voter-scoring oracle (get-scores.ts reads proposal.balances[voter] before any live fallback), so proposals created after deploy would have counted only the submitter's vote. - custom-fetch.ts: keep fetching the FULL holder map (index []) for the pinned snapshot; retain the lowercase-key gate lookup, null guards and LP seeding. - backendpolicy.yaml (staging + production): GCPBackendPolicy timeoutSec=90 so the ~30s full-map fetch is not killed by GKE's 30s default (the actual 504 fix). - client.ts: raise request timeout to 95s (above the gateway) so a real 504 surfaces instead of the client aborting first. - test: fullMapFetchTest now asserts the FULL map is fetched + electorate retained (guards C1); wire 'npm test' in both packages (M1). --- .../cd/overlays/production/backendpolicy.yaml | 4 ++++ .../cd/overlays/staging/backendpolicy.yaml | 4 ++++ .../lib/zilliqa/custom-fetch.test.ts | 22 +++++++++++-------- .../lib/zilliqa/custom-fetch.ts | 12 +++++++--- products/governance-api/package.json | 3 ++- products/governance-snapshot/package.json | 3 ++- .../governance-snapshot/src/helpers/client.ts | 8 ++++--- 7 files changed, 39 insertions(+), 17 deletions(-) diff --git a/products/governance-api/cd/overlays/production/backendpolicy.yaml b/products/governance-api/cd/overlays/production/backendpolicy.yaml index ed486f4c9..9c4fbba68 100644 --- a/products/governance-api/cd/overlays/production/backendpolicy.yaml +++ b/products/governance-api/cd/overlays/production/backendpolicy.yaml @@ -3,6 +3,10 @@ kind: GCPBackendPolicy metadata: name: governance-api spec: + default: + # Proposal creation fetches + pins the full gZIL holder balances snapshot (~30s). Raise the + # backend timeout above GKE's 30s default so the request is not killed with a 504. + timeoutSec: 90 targetRef: group: "" kind: Service diff --git a/products/governance-api/cd/overlays/staging/backendpolicy.yaml b/products/governance-api/cd/overlays/staging/backendpolicy.yaml index ed486f4c9..9c4fbba68 100644 --- a/products/governance-api/cd/overlays/staging/backendpolicy.yaml +++ b/products/governance-api/cd/overlays/staging/backendpolicy.yaml @@ -3,6 +3,10 @@ kind: GCPBackendPolicy metadata: name: governance-api spec: + default: + # Proposal creation fetches + pins the full gZIL holder balances snapshot (~30s). Raise the + # backend timeout above GKE's 30s default so the request is not killed with a 504. + timeoutSec: 90 targetRef: group: "" kind: Service diff --git a/products/governance-api/lib/zilliqa/custom-fetch.test.ts b/products/governance-api/lib/zilliqa/custom-fetch.test.ts index 27f1410b8..9231dfeae 100644 --- a/products/governance-api/lib/zilliqa/custom-fetch.test.ts +++ b/products/governance-api/lib/zilliqa/custom-fetch.test.ts @@ -7,7 +7,11 @@ import { blockchain } from "./custom-fetch"; // api.zilliqa.com). getLiquidity must (a) fetch ONLY the submitter's entry — not the whole // multi-MB map — and (b) read it back with a normalized lowercase-0x key. -async function scopedFetchTest() { +async function fullMapFetchTest() { + // The balances RPC MUST fetch the FULL holder map (index []): it is pinned to IPFS as the + // whole-electorate voter-scoring snapshot. Scoping it to the submitter collapses vote tallies + // to the submitter alone (regression C1). The submitter's own balance is still read back via a + // normalized lowercase-0x key (fixes a latent bech32 checksum mismatch). const b: any = new blockchain(); let sent: any; b._send = async (batch: any[]) => { @@ -17,22 +21,22 @@ async function scopedFetchTest() { { result: { balances: {} } }, { result: { xpools: {} } }, { result: { xbalances: {} } }, - { result: { balances: { "0xabc": "123" } } }, + { result: { balances: { "0xabc": "123", "0xdef": "999" } } }, { result: { total_supply: "1000" } }, ]; }; - // Mixed-case inputs to prove normalization. + // Mixed-case submitter input to prove read-key normalization. const out = await b.getLiquidity("0xA845c1034CD077bd8D32be0447239c7E4be6cb21", "0xABC"); assert.deepStrictEqual( sent[4].params[2], - ["0xabc"], - "balances call must be scoped to the submitter (lowercase 0x), not the full map" + [], + "balances must fetch the FULL map (index []) for the voter-scoring snapshot (guards C1)" ); - assert.notDeepStrictEqual(sent[4].params[2], [], "must NOT fetch the entire balances map"); - assert.strictEqual(out.userBalance, "123", "userBalance read via normalized key"); - console.log("OK scopedFetchTest"); + assert.strictEqual(out.userBalance, "123", "submitter balance read via normalized lowercase key"); + assert.ok("0xdef" in out.balances, "full electorate snapshot retained for scoring"); + console.log("OK fullMapFetchTest"); } async function nullResultTest() { @@ -75,7 +79,7 @@ async function lpHolderCreditedWithoutDirectBalance() { } (async () => { - await scopedFetchTest(); + await fullMapFetchTest(); await nullResultTest(); await lpHolderCreditedWithoutDirectBalance(); console.log("ALL PASS"); diff --git a/products/governance-api/lib/zilliqa/custom-fetch.ts b/products/governance-api/lib/zilliqa/custom-fetch.ts index ada39cf38..53801e1c8 100644 --- a/products/governance-api/lib/zilliqa/custom-fetch.ts +++ b/products/governance-api/lib/zilliqa/custom-fetch.ts @@ -23,8 +23,14 @@ export class blockchain { private _zero = new BN(0); public async getLiquidity(token: string, address: string) { - // ZRC2 `balances` is keyed by lowercase 0x addresses; normalise the submitter's key so - // we can fetch ONLY their entry instead of downloading the entire (multi-MB) holder map. + // Normalise the submitter's key (lowercase 0x, matching Scilla map keys) for the gate + // lookup + LP seeding below. + // + // NOTE: the FULL holder map is fetched on purpose (index [] below). It is pinned to IPFS + // as the whole-electorate voter-scoring snapshot (governance-snapshot get-scores.ts reads + // proposal.balances[voter]). Do NOT scope this to [ownerKey]: that shrinks the snapshot + // and collapses vote tallies to the submitter alone. The resulting ~30s fetch is covered + // by the raised gateway/client timeouts. const ownerKey = "0x" + this._toHex(address); const batch = [ { @@ -57,7 +63,7 @@ export class blockchain { }, { method: RPCMethod.GetSmartContractSubState, - params: [this._toHex(token), TokenFields.Balances, [ownerKey]], + params: [this._toHex(token), TokenFields.Balances, []], id: 1, jsonrpc: `2.0`, }, diff --git a/products/governance-api/package.json b/products/governance-api/package.json index d85f45ff7..3b084a4b0 100644 --- a/products/governance-api/package.json +++ b/products/governance-api/package.json @@ -7,7 +7,8 @@ "db:migrate": "npx sequelize db:migrate", "db:seed": "npx sequelize db:seed:all", "db:create": "npx sequelize db:create", - "start": "node --require ts-node/register index.ts" + "start": "node --require ts-node/register index.ts", + "test": "node --require ts-node/register lib/zilliqa/custom-fetch.test.ts" }, "author": "Hicaru", "license": "MIT", diff --git a/products/governance-snapshot/package.json b/products/governance-snapshot/package.json index f1e573f79..15f41a0f3 100644 --- a/products/governance-snapshot/package.json +++ b/products/governance-snapshot/package.json @@ -5,7 +5,8 @@ "scripts": { "serve": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service serve", "build": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build", - "lint": "vue-cli-service lint" + "lint": "vue-cli-service lint", + "test": "TS_NODE_TRANSPILE_ONLY=1 TS_NODE_SKIP_PROJECT=1 TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\",\"target\":\"es2019\",\"esModuleInterop\":true}' node --require ../governance-api/node_modules/ts-node/register src/helpers/client.test.ts" }, "dependencies": { "@zilliqa-js/zilliqa": "3.5.0", diff --git a/products/governance-snapshot/src/helpers/client.ts b/products/governance-snapshot/src/helpers/client.ts index 1136453ab..d28ce58b5 100644 --- a/products/governance-snapshot/src/helpers/client.ts +++ b/products/governance-snapshot/src/helpers/client.ts @@ -1,7 +1,10 @@ // ABOUTME: Thin fetch wrapper for the governance-api. Guarantees the returned promise always // ABOUTME: settles (timeout + defensive error parsing) so the UI never hangs on a 504/non-JSON. -const REQUEST_TIMEOUT_MS = 45000; +// Proposal creation pins the full gZIL holder snapshot (~30s); the gateway backend timeout is +// raised to 90s to match, so keep this client ceiling just above it to surface a real 504 +// instead of aborting first. It is a safety net against an indefinitely hung backend. +const REQUEST_TIMEOUT_MS = 95000; class Client { async request(command, body?) { @@ -16,8 +19,7 @@ class Client { init.body = JSON.stringify(body); } - // Bound the request so a hung/slow backend (e.g. the gateway 504s at 30s) can never - // leave the caller waiting forever. + // Bound the request so a hung/slow backend can never leave the caller waiting forever. const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); init.signal = controller.signal;