Skip to content

Commit 109bade

Browse files
committed
feat: enhance governance ballot functionality and bot integration
- Added rationaleComments field to the Ballot model in Prisma schema for storing draft rationale comments. - Updated bot-related API endpoints to support governance ballot creation and updates, allowing bots to submit rationale comments. - Improved proposal ID parsing logic across components to enhance error handling and type safety. - Enhanced the BotManagementCard to utilize typed bot scopes for better type safety. - Updated documentation to reflect new governance bot flow and API changes, ensuring clarity for developers.
1 parent a672589 commit 109bade

17 files changed

Lines changed: 1328 additions & 86 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE "Ballot"
2+
ADD COLUMN "rationaleComments" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
3+
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,10 @@ model Ballot {
113113
choices String[]
114114
anchorUrls String[] @default([])
115115
anchorHashes String[] @default([])
116+
rationaleComments String[] @default([])
116117
type Int
117118
createdAt DateTime @default(now())
119+
updatedAt DateTime @updatedAt
118120
}
119121

120122
model Proxy {

scripts/bot-ref/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,24 @@ BOT_TOKEN='...' BOT_CONFIG_PATH=bot-config.json npx tsx bot-client.ts walletIds
127127
```
128128

129129
The reference client only uses **bot-key auth** (POST /api/v1/botAuth). Wallet-based auth (getNonce + sign + authSigner) would require a real Cardano signer; implement that in your bot if needed.
130+
131+
## Governance bot flow
132+
133+
For governance automation, grant these bot scopes when creating the bot key:
134+
135+
- `governance:read` to call `GET /api/v1/governanceActiveProposals`
136+
- `ballot:write` to call `POST /api/v1/botBallotsUpsert`
137+
138+
Typical sequence:
139+
140+
1. `POST /api/v1/botAuth` -> get bot JWT
141+
2. `GET /api/v1/governanceActiveProposals?network=0|1&details=false`
142+
3. Bot decides `Yes`/`No`/`Abstain` + optional `rationaleComment`
143+
4. `POST /api/v1/botBallotsUpsert` with `{ walletId, ballotId|ballotName, proposals[] }`
144+
5. Human reviews draft rationale in UI and uploads to IPFS via the existing "Upload to IPFS & Save" action
145+
146+
Notes:
147+
148+
- `proposalId` format is `<txHash>#<certIndex>`.
149+
- Bots cannot set `anchorUrl` or `anchorHash`; only `rationaleComment` draft text is accepted.
150+
- If `ballotName` matches multiple governance ballots, the API returns `409`; use `ballotId` to disambiguate.
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals";
2+
import type { NextApiRequest, NextApiResponse } from "next";
3+
4+
const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>();
5+
const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise<void>>();
6+
const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>();
7+
const applyBotRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, botId: string) => boolean>();
8+
const enforceBodySizeMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean>();
9+
const verifyJwtMock = jest.fn();
10+
const isBotJwtMock = jest.fn();
11+
const assertBotWalletAccessMock = jest.fn();
12+
const findBotUserMock = jest.fn();
13+
const transactionMock = jest.fn();
14+
const parseScopeMock = jest.fn();
15+
const scopeIncludesMock = jest.fn();
16+
const isValidChoiceMock = jest.fn();
17+
const parseProposalIdMock = jest.fn();
18+
19+
const txMock = {
20+
ballot: {
21+
findUnique: jest.fn(),
22+
findMany: jest.fn(),
23+
create: jest.fn(),
24+
updateMany: jest.fn(),
25+
},
26+
};
27+
28+
jest.mock(
29+
"@/lib/cors",
30+
() => ({
31+
__esModule: true,
32+
addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock,
33+
cors: corsMock,
34+
}),
35+
{ virtual: true },
36+
);
37+
38+
jest.mock(
39+
"@/lib/security/requestGuards",
40+
() => ({
41+
__esModule: true,
42+
applyRateLimit: applyRateLimitMock,
43+
applyBotRateLimit: applyBotRateLimitMock,
44+
enforceBodySize: enforceBodySizeMock,
45+
}),
46+
{ virtual: true },
47+
);
48+
49+
jest.mock(
50+
"@/lib/verifyJwt",
51+
() => ({
52+
__esModule: true,
53+
verifyJwt: verifyJwtMock,
54+
isBotJwt: isBotJwtMock,
55+
}),
56+
{ virtual: true },
57+
);
58+
59+
jest.mock(
60+
"@/lib/governance",
61+
() => ({
62+
__esModule: true,
63+
isValidChoice: isValidChoiceMock,
64+
parseProposalId: parseProposalIdMock,
65+
}),
66+
{ virtual: true },
67+
);
68+
69+
jest.mock(
70+
"@/lib/auth/botKey",
71+
() => ({
72+
__esModule: true,
73+
parseScope: parseScopeMock,
74+
scopeIncludes: scopeIncludesMock,
75+
}),
76+
{ virtual: true },
77+
);
78+
79+
jest.mock(
80+
"@/lib/auth/botAccess",
81+
() => ({
82+
__esModule: true,
83+
assertBotWalletAccess: assertBotWalletAccessMock,
84+
}),
85+
{ virtual: true },
86+
);
87+
88+
jest.mock(
89+
"@/server/db",
90+
() => ({
91+
__esModule: true,
92+
db: {
93+
botUser: {
94+
findUnique: findBotUserMock,
95+
},
96+
$transaction: transactionMock,
97+
},
98+
}),
99+
{ virtual: true },
100+
);
101+
102+
type ResponseMock = NextApiResponse & { statusCode?: number };
103+
104+
function createMockResponse(): ResponseMock {
105+
const res = {
106+
statusCode: undefined as number | undefined,
107+
status: jest.fn<(code: number) => NextApiResponse>(),
108+
json: jest.fn<(payload: unknown) => unknown>(),
109+
end: jest.fn<() => void>(),
110+
setHeader: jest.fn<(name: string, value: string) => void>(),
111+
};
112+
113+
res.status.mockImplementation((code: number) => {
114+
res.statusCode = code;
115+
return res as unknown as NextApiResponse;
116+
});
117+
res.json.mockImplementation((payload: unknown) => payload);
118+
return res as unknown as ResponseMock;
119+
}
120+
121+
let handler: (req: NextApiRequest, res: NextApiResponse) => Promise<void | NextApiResponse>;
122+
123+
beforeAll(async () => {
124+
({ default: handler } = await import("../pages/api/v1/botBallotsUpsert"));
125+
});
126+
127+
beforeEach(() => {
128+
jest.clearAllMocks();
129+
applyRateLimitMock.mockReturnValue(true);
130+
applyBotRateLimitMock.mockReturnValue(true);
131+
enforceBodySizeMock.mockReturnValue(true);
132+
corsMock.mockResolvedValue(undefined);
133+
verifyJwtMock.mockReturnValue({ address: "addr_test1", botId: "bot-1", type: "bot" });
134+
isBotJwtMock.mockReturnValue(true);
135+
parseScopeMock.mockImplementation((scope: string) => JSON.parse(scope));
136+
scopeIncludesMock.mockImplementation((scopes: string[], required: string) =>
137+
scopes.includes(required),
138+
);
139+
isValidChoiceMock.mockReturnValue(true);
140+
parseProposalIdMock.mockImplementation((value: string) => {
141+
const [txHash, certIndex] = value.split("#");
142+
return { txHash, certIndex: Number(certIndex) };
143+
});
144+
findBotUserMock.mockResolvedValue({
145+
id: "bot-1",
146+
botKey: { scope: JSON.stringify(["multisig:read", "ballot:write"]) },
147+
});
148+
assertBotWalletAccessMock.mockResolvedValue({ wallet: { id: "wallet-1" }, role: "cosigner" });
149+
transactionMock.mockImplementation(async (cb: any) => cb(txMock));
150+
});
151+
152+
describe("botBallotsUpsert API", () => {
153+
it("rejects anchor fields in proposal payload", async () => {
154+
const req = {
155+
method: "POST",
156+
headers: { authorization: "Bearer token" },
157+
body: {
158+
walletId: "wallet-1",
159+
proposals: [
160+
{
161+
proposalId: "tx#0",
162+
proposalTitle: "Title",
163+
choice: "Yes",
164+
anchorUrl: "ipfs://should-not-be-allowed",
165+
},
166+
],
167+
},
168+
} as unknown as NextApiRequest;
169+
const res = createMockResponse();
170+
171+
await handler(req, res);
172+
173+
expect(res.status).toHaveBeenCalledWith(400);
174+
expect(transactionMock).not.toHaveBeenCalled();
175+
});
176+
177+
it("returns 409 when ballotName is ambiguous", async () => {
178+
txMock.ballot.findMany.mockResolvedValue([
179+
{ id: "b1", walletId: "wallet-1", type: 1, description: "Gov", updatedAt: new Date() },
180+
{ id: "b2", walletId: "wallet-1", type: 1, description: "Gov", updatedAt: new Date() },
181+
]);
182+
183+
const req = {
184+
method: "POST",
185+
headers: { authorization: "Bearer token" },
186+
body: {
187+
walletId: "wallet-1",
188+
ballotName: "Gov",
189+
proposals: [{ proposalId: "tx#0", proposalTitle: "Title", choice: "No" }],
190+
},
191+
} as unknown as NextApiRequest;
192+
const res = createMockResponse();
193+
194+
await handler(req, res);
195+
196+
expect(res.status).toHaveBeenCalledWith(409);
197+
expect(res.json).toHaveBeenCalledWith({
198+
error: "Multiple ballots match ballotName; provide ballotId to disambiguate",
199+
});
200+
});
201+
});

0 commit comments

Comments
 (0)