Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/lean-proposals-dashboard-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@anticapture/dashboard": patch
---

Adapt the governance UI to the new `variant`-tagged onchain proposals response: narrow the SDK union to the `full` variant in the proposal hooks, search adapter, and detail page (the dashboard always requests the full payload).
7 changes: 7 additions & 0 deletions .changeset/lean-proposals-strip-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@anticapture/api": patch
"@anticapture/gateful": patch
"@anticapture/api-gateway": patch
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Regenerate the gateway schema before publishing

Fresh evidence in the current commit is that the REST OpenAPI snapshot was updated, but this changeset still publishes @anticapture/api-gateway while apps/api-gateway/schema.graphql:1620 continues to declare OnchainProposal.description: String!. When GraphQL callers pass lean: true, the upstream API now omits that field, so GraphQL will treat the missing non-null value as an execution error/null parent instead of returning the lean proposal payload.

Useful? React with 👍 / 👎.

Comment on lines +2 to +4
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bump the generated REST client package

Because this changeset updates the Gateful OpenAPI contract for /proposals but only bumps @anticapture/gateful, external consumers of @anticapture/client will not get a new published SDK with the variant union and lean responses that omit description. The client package is generated from apps/gateful/openapi/gateful.json during its codegen/build task and does not depend on @anticapture/gateful, so Changesets will not publish it unless it is listed here as well.

Useful? React with 👍 / 👎.

---

Model the onchain proposals response (`/proposals`, `/proposals/search`, `/proposals/{id}`) as a `variant`-tagged discriminated union. When `lean=true` the API returns the `lean` variant (omitting calldatas/values/targets and the proposal description to reduce payload size); otherwise it returns the `full` variant. Clients can narrow on the `variant` discriminator for exact typing instead of guarding optional fields.
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ beforeEach(async () => {
});

const BASE_PROPOSAL_FIELDS = {
variant: "full",
daoId: "ENS",
proposerAccountId: getAddress("0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa"),
title: "Test Proposal",
Expand Down Expand Up @@ -162,6 +163,38 @@ describe("Onchain Proposals Controller", () => {
expect(body).toEqual({ items: [], totalCount: 0 });
});

it("should return lean items omitting payload and description when lean=true", async () => {
await db.insert(proposalsOnchain).values(createProposal());

const res = await app.request("/proposals?lean=true");

expect(res.status).toBe(200);
const body = await res.json();
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
description,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
calldatas,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
values,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
targets,
...leanFields
} = BASE_PROPOSAL_FIELDS;
expect(body).toEqual({
totalCount: 1,
items: [
{
...leanFields,
variant: "lean",
id: "1",
txHash: "0xabc123",
timestamp: 1700000000,
},
],
});
});

it("should accept pagination parameters", async () => {
await db.insert(proposalsOnchain).values(createProposal());

Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/controllers/proposals/onchainProposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function proposals(
path: "/proposals",
summary: "Get proposals",
description:
"Returns a list of proposals. Pass `lean=true` to omit calldatas/values/targets and reduce payload size.",
"Returns a list of proposals. Pass `lean=true` to omit calldatas/values/targets and the proposal description, reducing payload size.",
tags: ["proposals", "skip-pagination"],
middleware: [setCacheControl(60)],
request: {
Expand Down Expand Up @@ -90,7 +90,7 @@ export function proposals(
path: "/proposals/search",
summary: "Search proposals",
description:
"Returns proposals whose title or identifier partially matches the query. Pass `lean=true` to omit calldatas/values/targets.",
"Returns proposals whose title or identifier partially matches the query. Pass `lean=true` to omit calldatas/values/targets and the proposal description.",
tags: ["proposals", "skip-pagination"],
middleware: [setCacheControl(60)],
request: {
Expand Down Expand Up @@ -139,7 +139,7 @@ export function proposals(
path: "/proposals/{id}",
summary: "Get a proposal by ID",
description:
"Returns a single proposal by its ID. Pass `lean=true` to omit calldatas/values/targets.",
"Returns a single proposal by its ID. Pass `lean=true` to omit calldatas/values/targets and the proposal description.",
tags: ["proposals"],
middleware: [setCacheControl(60)],
request: {
Expand Down
170 changes: 91 additions & 79 deletions apps/api/src/mappers/proposals/onchainProposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const OnchainProposalStatusListSchema = commaDelimitedEnumQueryParam(
const leanQueryParam = () =>
booleanQueryParam(false).openapi({
description:
"When true, omit execution-payload fields (calldatas, values, targets) to reduce response size. Defaults to false. Accepts true/false/1/0.",
"When true, omit execution-payload fields (calldatas, values, targets) and the proposal description to reduce response size. Defaults to false. Accepts true/false/1/0.",
example: false,
});

Expand Down Expand Up @@ -97,81 +97,92 @@ export const ProposalByIdQuerySchema = z

export type ProposalSearchRequest = z.infer<typeof ProposalSearchRequestSchema>;

const ProposalCoreSchema = z.object({
id: z.string().openapi({ description: "Onchain proposal identifier." }),
daoId: daoIdField(),
txHash: z
.string()
.openapi({ description: "Proposal creation transaction hash." }),
proposerAccountId: z.string().openapi({
description: "Address that created the proposal.",
format: "ethereum-address",
}),
title: z.string().openapi({ description: "Proposal title." }),
startBlock: z.number().int().openapi({ description: "Start block number." }),
endBlock: z.number().int().openapi({ description: "End block number." }),
timestamp: unixSecondsIntField(
"Proposal creation timestamp in Unix seconds.",
),
status: z.string().openapi({ description: "Current proposal status." }),
forVotes: decimalStringField(
"Votes cast in favor, encoded as a decimal string.",
),
againstVotes: decimalStringField(
"Votes cast against, encoded as a decimal string.",
),
abstainVotes: decimalStringField(
"Abstain votes, encoded as a decimal string.",
),
startTimestamp: unixSecondsIntField(
"Proposal start timestamp in Unix seconds.",
),
endTimestamp: unixSecondsIntField("Proposal end timestamp in Unix seconds."),
queuedTimestamp: unixSecondsIntField(
"Timestamp (Unix seconds) when the proposal was queued, or null if it never was.",
).nullable(),
executedTimestamp: unixSecondsIntField(
"Timestamp (Unix seconds) when the proposal was executed, or null if it never was.",
).nullable(),
queuedTxHash: z.string().nullable().openapi({
description:
"Transaction hash of the queue event, or null if the proposal was never queued.",
}),
executedTxHash: z.string().nullable().openapi({
description:
"Transaction hash of the execute event, or null if the proposal was never executed.",
}),
quorum: decimalStringField("Required quorum encoded as a decimal string."),
proposalType: z.number().int().nullable().openapi({
description: "Optional proposal type discriminator.",
}),
});

const FullProposalSchema = ProposalCoreSchema.extend({
variant: z.literal("full").openapi({
description:
"Discriminator. `full` when the execution payload and proposal description are included.",
}),
description: z.string().openapi({ description: "Proposal body." }),
calldatas: z.array(z.string()).openapi({
description: "Encoded calldata payloads executed by the proposal.",
}),
values: z.array(z.string().openapi({ format: "bigint" })).openapi({
description: "ETH values attached to each call, encoded as strings.",
}),
targets: z.array(z.string().openapi({ format: "ethereum-address" })).openapi({
description: "Contract targets invoked by the proposal.",
}),
}).openapi("OnchainFullProposal", {
description:
"Full onchain proposal, returned when the request does not set `lean=true`.",
});

const LeanProposalSchema = ProposalCoreSchema.extend({
variant: z.literal("lean").openapi({
description:
"Discriminator. `lean` when the execution payload (calldatas/values/targets) and proposal description are omitted to reduce response size.",
}),
}).openapi("OnchainLeanProposal", {
description:
"Lean onchain proposal, returned when the request sets `lean=true`. Omits calldatas/values/targets and the proposal description.",
});

export const ProposalResponseSchema = z
.object({
id: z.string().openapi({ description: "Onchain proposal identifier." }),
daoId: daoIdField(),
txHash: z
.string()
.openapi({ description: "Proposal creation transaction hash." }),
proposerAccountId: z.string().openapi({
description: "Address that created the proposal.",
format: "ethereum-address",
}),
title: z.string().openapi({ description: "Proposal title." }),
description: z.string().openapi({ description: "Proposal body." }),
startBlock: z
.number()
.int()
.openapi({ description: "Start block number." }),
endBlock: z.number().int().openapi({ description: "End block number." }),
timestamp: unixSecondsIntField(
"Proposal creation timestamp in Unix seconds.",
),
status: z.string().openapi({ description: "Current proposal status." }),
forVotes: decimalStringField(
"Votes cast in favor, encoded as a decimal string.",
),
againstVotes: decimalStringField(
"Votes cast against, encoded as a decimal string.",
),
abstainVotes: decimalStringField(
"Abstain votes, encoded as a decimal string.",
),
startTimestamp: unixSecondsIntField(
"Proposal start timestamp in Unix seconds.",
),
endTimestamp: unixSecondsIntField(
"Proposal end timestamp in Unix seconds.",
),
queuedTimestamp: unixSecondsIntField(
"Timestamp (Unix seconds) when the proposal was queued, or null if it never was.",
).nullable(),
executedTimestamp: unixSecondsIntField(
"Timestamp (Unix seconds) when the proposal was executed, or null if it never was.",
).nullable(),
queuedTxHash: z.string().nullable().openapi({
description:
"Transaction hash of the queue event, or null if the proposal was never queued.",
}),
executedTxHash: z.string().nullable().openapi({
description:
"Transaction hash of the execute event, or null if the proposal was never executed.",
}),
quorum: decimalStringField("Required quorum encoded as a decimal string."),
calldatas: z.array(z.string()).optional().openapi({
description:
"Encoded calldata payloads executed by the proposal. Omitted when the request sets `lean=true`.",
}),
values: z
.array(z.string().openapi({ format: "bigint" }))
.optional()
.openapi({
description:
"ETH values attached to each call, encoded as strings. Omitted when the request sets `lean=true`.",
}),
targets: z
.array(z.string().openapi({ format: "ethereum-address" }))
.optional()
.openapi({
description:
"Contract targets invoked by the proposal. Omitted when the request sets `lean=true`.",
}),
proposalType: z.number().int().nullable().openapi({
description: "Optional proposal type discriminator.",
}),
})
.openapi("OnchainProposal");
.discriminatedUnion("variant", [FullProposalSchema, LeanProposalSchema])
.openapi("OnchainProposal", {
description:
"Onchain proposal. Narrow by the `variant` discriminator: `full` includes the execution payload and description; `lean` omits them.",
});

export const ProposalsResponseSchema = paginatedListResponse(
ProposalResponseSchema,
Expand Down Expand Up @@ -201,13 +212,12 @@ export const ProposalMapper = {
blockTime: number,
options: { lean?: boolean } = {},
): ProposalResponse => {
const base: ProposalResponse = {
const core = {
id: p.id,
daoId: p.daoId,
txHash: p.txHash,
proposerAccountId: p.proposerAccountId,
title: p.title,
description: p.description,
startBlock: p.startBlock,
endBlock: p.endBlock,
timestamp: Number(p.timestamp),
Expand All @@ -228,9 +238,11 @@ export const ProposalMapper = {
queuedTxHash: p.queuedTxHash,
executedTxHash: p.executedTxHash,
};
if (options.lean) return base;
if (options.lean) return { ...core, variant: "lean" };
return {
...base,
...core,
variant: "full",
description: p.description,
calldatas: p.calldatas,
values: p.values.map((v) => v.toString()),
targets: p.targets,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
const descriptionBody = proposal
? isOffchainProposal(proposal)
? proposal.body
: proposal.description
: proposal.variant === "full"
? proposal.description
: undefined
: undefined;

const canonicalPath = isOffchain
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/app/sitemap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ jest.mock("@anticapture/client", () => ({

function buildOnchainProposal(id: string): OnchainProposal {
return {
variant: "full",
abstainVotes: 0n,
againstVotes: 0n,
calldatas: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import {
orderDirectionEnum,
type OffchainSearchProposalsPathParamsDaoEnumKey,
type ProposalsQueryResponse,
type SearchProposalsPathParamsDaoEnumKey,
} from "@anticapture/client";
import {
Expand Down Expand Up @@ -35,7 +34,11 @@ import { canCreateProposalForDao } from "@/features/create-proposal/constants";
import { ProposalItem } from "@/features/governance/components/proposal-overview/ProposalItem";
import { useOffchainProposals } from "@/features/governance/hooks/useOffchainProposals";
import { useProposals } from "@/features/governance/hooks/useProposals";
import type { Proposal as GovernanceProposal } from "@/features/governance/types";
import {
isFullProposal,
type OnchainFullProposalItem,
type Proposal as GovernanceProposal,
} from "@/features/governance/types";
import {
getProposalState,
getProposalStatus,
Expand All @@ -55,7 +58,7 @@ const ONCHAIN_TAB = { label: "Onchain", value: "onchain" };
const OFFCHAIN_TAB = { label: "Offchain", value: "offchain" };

const toGovernanceProposal = (
proposal: ProposalsQueryResponse["items"][number],
proposal: OnchainFullProposalItem,
decimals: number,
): GovernanceProposal => {
const forVotes = Number(formatUnits(proposal.forVotes, decimals));
Expand Down Expand Up @@ -84,8 +87,8 @@ const toGovernanceProposal = (
proposal.startTimestamp.toString(),
proposal.endTimestamp.toString(),
),
values: proposal.values?.map((value) => value.toString()) ?? [],
targets: proposal.targets ?? [],
values: proposal.values.map((value) => value.toString()),
targets: proposal.targets,
};
};

Expand Down Expand Up @@ -161,9 +164,9 @@ export const GovernanceSection = () => {

const searchOnchainProposals = useMemo(() => {
if (!isSearchActive) return [];
return (searchData?.items ?? []).map((p) =>
toGovernanceProposal(p, decimals),
);
return (searchData?.items ?? [])
.filter(isFullProposal)
.map((p) => toGovernanceProposal(p, decimals));
}, [isSearchActive, searchData, decimals]);

const searchOffchainProposals = useMemo(() => {
Expand Down
22 changes: 12 additions & 10 deletions apps/dashboard/features/governance/hooks/useProposal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,20 @@ export const useProposal = ({
{ query: { enabled: !!proposalId } },
);

const full = data?.variant === "full" ? data : null;

return {
data: data
data: full
? {
...data,
status: getProposalStatus(data.status),
quorum: data.quorum.toString(),
forVotes: data.forVotes.toString(),
againstVotes: data.againstVotes.toString(),
abstainVotes: data.abstainVotes.toString(),
calldatas: data.calldatas ?? [],
targets: data.targets ?? [],
values: data.values?.map((value) => value.toString()) ?? [],
...full,
status: getProposalStatus(full.status),
quorum: full.quorum.toString(),
forVotes: full.forVotes.toString(),
againstVotes: full.againstVotes.toString(),
abstainVotes: full.abstainVotes.toString(),
calldatas: full.calldatas,
targets: full.targets,
values: full.values.map((value) => value.toString()),
}
: null,
isLoading,
Expand Down
Loading
Loading