From 1ca461191418fe29441edd4534524fdbcdec2854 Mon Sep 17 00:00:00 2001 From: Pedro Binotto Date: Fri, 29 May 2026 13:35:59 -0300 Subject: [PATCH 1/6] fix: remove description field from lean payload --- apps/api/src/controllers/proposals/onchainProposals.ts | 6 +++--- apps/api/src/mappers/proposals/onchainProposals.ts | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/api/src/controllers/proposals/onchainProposals.ts b/apps/api/src/controllers/proposals/onchainProposals.ts index 433cb4bd6..2c3f72f7d 100644 --- a/apps/api/src/controllers/proposals/onchainProposals.ts +++ b/apps/api/src/controllers/proposals/onchainProposals.ts @@ -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: { @@ -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: { @@ -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: { diff --git a/apps/api/src/mappers/proposals/onchainProposals.ts b/apps/api/src/mappers/proposals/onchainProposals.ts index fd08b16a2..1bc4a27fc 100644 --- a/apps/api/src/mappers/proposals/onchainProposals.ts +++ b/apps/api/src/mappers/proposals/onchainProposals.ts @@ -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, }); @@ -109,7 +109,9 @@ export const ProposalResponseSchema = z format: "ethereum-address", }), title: z.string().openapi({ description: "Proposal title." }), - description: z.string().openapi({ description: "Proposal body." }), + description: z.string().optional().openapi({ + description: "Proposal body. Omitted when the request sets `lean=true`.", + }), startBlock: z .number() .int() @@ -207,7 +209,6 @@ export const ProposalMapper = { txHash: p.txHash, proposerAccountId: p.proposerAccountId, title: p.title, - description: p.description, startBlock: p.startBlock, endBlock: p.endBlock, timestamp: Number(p.timestamp), @@ -231,6 +232,7 @@ export const ProposalMapper = { if (options.lean) return base; return { ...base, + description: p.description, calldatas: p.calldatas, values: p.values.map((v) => v.toString()), targets: p.targets, From 18aef3474e8e69ce9162d0ab67a68bf90809bc3d Mon Sep 17 00:00:00 2001 From: Pedro Binotto Date: Fri, 29 May 2026 13:57:24 -0300 Subject: [PATCH 2/6] chore: changeset --- .changeset/lean-proposals-strip-description.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/lean-proposals-strip-description.md diff --git a/.changeset/lean-proposals-strip-description.md b/.changeset/lean-proposals-strip-description.md new file mode 100644 index 000000000..53cb46c0c --- /dev/null +++ b/.changeset/lean-proposals-strip-description.md @@ -0,0 +1,7 @@ +--- +"@anticapture/api": patch +"@anticapture/gateful": patch +"@anticapture/api-gateway": patch +--- + +Onchain proposals endpoints (`/proposals`, `/proposals/search`, `/proposals/{id}`) now also omit the `description` field when `lean=true`, further reducing payload size. From 3e7b0139db11564146c133edce1f68e403b253fb Mon Sep 17 00:00:00 2001 From: Pedro Binotto Date: Fri, 29 May 2026 14:17:31 -0300 Subject: [PATCH 3/6] chore: update gateful.json --- apps/gateful/openapi/gateful.json | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/gateful/openapi/gateful.json b/apps/gateful/openapi/gateful.json index cf3619679..1adb12599 100644 --- a/apps/gateful/openapi/gateful.json +++ b/apps/gateful/openapi/gateful.json @@ -1017,7 +1017,7 @@ }, "description": { "type": "string", - "description": "Proposal body." + "description": "Proposal body. Omitted when the request sets `lean=true`." }, "startBlock": { "type": "integer", @@ -1118,7 +1118,6 @@ "txHash", "proposerAccountId", "title", - "description", "startBlock", "endBlock", "timestamp", @@ -4546,7 +4545,7 @@ "get": { "operationId": "proposals", "summary": "Get proposals", - "description": "Returns a list of proposals. Pass `lean=true` to omit calldatas/values/targets and reduce payload size.", + "description": "Returns a list of proposals. Pass `lean=true` to omit calldatas/values/targets and the proposal description, reducing payload size.", "tags": ["proposals", "skip-pagination"], "parameters": [ { @@ -4636,11 +4635,11 @@ } ], "default": false, - "description": "When true, omit execution-payload fields (calldatas, values, targets) to reduce response size. Defaults to false. Accepts true/false/1/0.", + "description": "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 }, "required": false, - "description": "When true, omit execution-payload fields (calldatas, values, targets) to reduce response size. Defaults to false. Accepts true/false/1/0.", + "description": "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.", "name": "lean", "in": "query" } @@ -4686,7 +4685,7 @@ "get": { "operationId": "searchProposals", "summary": "Search proposals", - "description": "Returns proposals whose title or identifier partially matches the query. Pass `lean=true` to omit calldatas/values/targets.", + "description": "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"], "parameters": [ { @@ -4735,11 +4734,11 @@ } ], "default": false, - "description": "When true, omit execution-payload fields (calldatas, values, targets) to reduce response size. Defaults to false. Accepts true/false/1/0.", + "description": "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 }, "required": false, - "description": "When true, omit execution-payload fields (calldatas, values, targets) to reduce response size. Defaults to false. Accepts true/false/1/0.", + "description": "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.", "name": "lean", "in": "query" } @@ -4785,7 +4784,7 @@ "get": { "operationId": "proposal", "summary": "Get a proposal by ID", - "description": "Returns a single proposal by its ID. Pass `lean=true` to omit calldatas/values/targets.", + "description": "Returns a single proposal by its ID. Pass `lean=true` to omit calldatas/values/targets and the proposal description.", "tags": ["proposals"], "parameters": [ { @@ -4810,11 +4809,11 @@ } ], "default": false, - "description": "When true, omit execution-payload fields (calldatas, values, targets) to reduce response size. Defaults to false. Accepts true/false/1/0.", + "description": "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 }, "required": false, - "description": "When true, omit execution-payload fields (calldatas, values, targets) to reduce response size. Defaults to false. Accepts true/false/1/0.", + "description": "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.", "name": "lean", "in": "query" } From d56279a0e81c644d564fb9c0e5a429e8ecca5ce5 Mon Sep 17 00:00:00 2001 From: Pedro Binotto Date: Fri, 29 May 2026 14:19:51 -0300 Subject: [PATCH 4/6] fix: update dashboard downstream --- .../components/proposal-overview/DescriptionTabContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/features/governance/components/proposal-overview/DescriptionTabContent.tsx b/apps/dashboard/features/governance/components/proposal-overview/DescriptionTabContent.tsx index 4d086502c..e9adcadbf 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/DescriptionTabContent.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/DescriptionTabContent.tsx @@ -176,7 +176,7 @@ export const DescriptionTabContent = ({ }, }} > - {cleanMarkdown(proposal.description)} + {cleanMarkdown(proposal.description ?? "")} ); From c549c9519f43cfab8cdb696b6db1aa9059f1c777 Mon Sep 17 00:00:00 2001 From: Pedro Binotto Date: Fri, 29 May 2026 14:20:36 -0300 Subject: [PATCH 5/6] fix(dashboard): handle optional proposal description in description tab The onchain proposals contract now omits `description` when `lean=true`, making it optional in the generated client. Guard the description tab against the undefined case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/lean-proposals-dashboard-description.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lean-proposals-dashboard-description.md diff --git a/.changeset/lean-proposals-dashboard-description.md b/.changeset/lean-proposals-dashboard-description.md new file mode 100644 index 000000000..0e28f3684 --- /dev/null +++ b/.changeset/lean-proposals-dashboard-description.md @@ -0,0 +1,5 @@ +--- +"@anticapture/dashboard": patch +--- + +Handle the now-optional proposal `description` in the proposal description tab, since the onchain proposals contract omits it when `lean=true`. From 9e8af0fe318bfa773ad5f7708d80f9a0f5698a40 Mon Sep 17 00:00:00 2001 From: Pedro Binotto Date: Fri, 29 May 2026 15:41:06 -0300 Subject: [PATCH 6/6] refactor: use set + discriminator to narrow down type --- .../lean-proposals-dashboard-description.md | 2 +- .../lean-proposals-strip-description.md | 2 +- .../onchainProposals.integration.test.ts | 33 ++++ .../src/mappers/proposals/onchainProposals.ts | 168 +++++++++-------- .../(main)/proposals/[proposalId]/page.tsx | 4 +- apps/dashboard/app/sitemap.test.ts | 1 + .../governance-overview/GovernanceSection.tsx | 19 +- .../DescriptionTabContent.tsx | 2 +- .../features/governance/hooks/useProposal.ts | 22 ++- .../features/governance/hooks/useProposals.ts | 13 +- .../features/governance/types/index.ts | 19 +- apps/gateful/openapi/gateful.json | 171 +++++++++++++++++- 12 files changed, 340 insertions(+), 116 deletions(-) diff --git a/.changeset/lean-proposals-dashboard-description.md b/.changeset/lean-proposals-dashboard-description.md index 0e28f3684..8d99e049f 100644 --- a/.changeset/lean-proposals-dashboard-description.md +++ b/.changeset/lean-proposals-dashboard-description.md @@ -2,4 +2,4 @@ "@anticapture/dashboard": patch --- -Handle the now-optional proposal `description` in the proposal description tab, since the onchain proposals contract omits it when `lean=true`. +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). diff --git a/.changeset/lean-proposals-strip-description.md b/.changeset/lean-proposals-strip-description.md index 53cb46c0c..4046cb7fc 100644 --- a/.changeset/lean-proposals-strip-description.md +++ b/.changeset/lean-proposals-strip-description.md @@ -4,4 +4,4 @@ "@anticapture/api-gateway": patch --- -Onchain proposals endpoints (`/proposals`, `/proposals/search`, `/proposals/{id}`) now also omit the `description` field when `lean=true`, further reducing payload size. +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. diff --git a/apps/api/src/controllers/proposals/onchainProposals.integration.test.ts b/apps/api/src/controllers/proposals/onchainProposals.integration.test.ts index ed3411291..ef4702a9d 100644 --- a/apps/api/src/controllers/proposals/onchainProposals.integration.test.ts +++ b/apps/api/src/controllers/proposals/onchainProposals.integration.test.ts @@ -109,6 +109,7 @@ beforeEach(async () => { }); const BASE_PROPOSAL_FIELDS = { + variant: "full", daoId: "ENS", proposerAccountId: getAddress("0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa"), title: "Test Proposal", @@ -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()); diff --git a/apps/api/src/mappers/proposals/onchainProposals.ts b/apps/api/src/mappers/proposals/onchainProposals.ts index 1bc4a27fc..65c105b91 100644 --- a/apps/api/src/mappers/proposals/onchainProposals.ts +++ b/apps/api/src/mappers/proposals/onchainProposals.ts @@ -97,83 +97,92 @@ export const ProposalByIdQuerySchema = z export type ProposalSearchRequest = z.infer; +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().optional().openapi({ - description: "Proposal body. Omitted when the request sets `lean=true`.", - }), - 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, @@ -203,7 +212,7 @@ export const ProposalMapper = { blockTime: number, options: { lean?: boolean } = {}, ): ProposalResponse => { - const base: ProposalResponse = { + const core = { id: p.id, daoId: p.daoId, txHash: p.txHash, @@ -229,9 +238,10 @@ 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()), diff --git a/apps/dashboard/app/[daoId]/(main)/proposals/[proposalId]/page.tsx b/apps/dashboard/app/[daoId]/(main)/proposals/[proposalId]/page.tsx index c5d0d3487..039dc0457 100644 --- a/apps/dashboard/app/[daoId]/(main)/proposals/[proposalId]/page.tsx +++ b/apps/dashboard/app/[daoId]/(main)/proposals/[proposalId]/page.tsx @@ -44,7 +44,9 @@ export async function generateMetadata(props: Props): Promise { const descriptionBody = proposal ? isOffchainProposal(proposal) ? proposal.body - : proposal.description + : proposal.variant === "full" + ? proposal.description + : undefined : undefined; const canonicalPath = isOffchain diff --git a/apps/dashboard/app/sitemap.test.ts b/apps/dashboard/app/sitemap.test.ts index f5241fd1e..4f98ce007 100644 --- a/apps/dashboard/app/sitemap.test.ts +++ b/apps/dashboard/app/sitemap.test.ts @@ -15,6 +15,7 @@ jest.mock("@anticapture/client", () => ({ function buildOnchainProposal(id: string): OnchainProposal { return { + variant: "full", abstainVotes: 0n, againstVotes: 0n, calldatas: [], diff --git a/apps/dashboard/features/governance/components/governance-overview/GovernanceSection.tsx b/apps/dashboard/features/governance/components/governance-overview/GovernanceSection.tsx index 10d905374..0abc29807 100644 --- a/apps/dashboard/features/governance/components/governance-overview/GovernanceSection.tsx +++ b/apps/dashboard/features/governance/components/governance-overview/GovernanceSection.tsx @@ -3,7 +3,6 @@ import { orderDirectionEnum, type OffchainSearchProposalsPathParamsDaoEnumKey, - type ProposalsQueryResponse, type SearchProposalsPathParamsDaoEnumKey, } from "@anticapture/client"; import { @@ -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, @@ -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)); @@ -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, }; }; @@ -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(() => { diff --git a/apps/dashboard/features/governance/components/proposal-overview/DescriptionTabContent.tsx b/apps/dashboard/features/governance/components/proposal-overview/DescriptionTabContent.tsx index e9adcadbf..4d086502c 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/DescriptionTabContent.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/DescriptionTabContent.tsx @@ -176,7 +176,7 @@ export const DescriptionTabContent = ({ }, }} > - {cleanMarkdown(proposal.description ?? "")} + {cleanMarkdown(proposal.description)} ); diff --git a/apps/dashboard/features/governance/hooks/useProposal.ts b/apps/dashboard/features/governance/hooks/useProposal.ts index 5dc4c0e0e..485583818 100644 --- a/apps/dashboard/features/governance/hooks/useProposal.ts +++ b/apps/dashboard/features/governance/hooks/useProposal.ts @@ -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, diff --git a/apps/dashboard/features/governance/hooks/useProposals.ts b/apps/dashboard/features/governance/hooks/useProposals.ts index d6095d10d..2f7c8cc0e 100644 --- a/apps/dashboard/features/governance/hooks/useProposals.ts +++ b/apps/dashboard/features/governance/hooks/useProposals.ts @@ -8,7 +8,10 @@ import { useProposalsInfinite } from "@anticapture/client/hooks"; import { useMemo } from "react"; import { formatUnits } from "viem"; -import type { Proposal as GovernanceProposal } from "@/features/governance/types"; +import { + isFullProposal, + type Proposal as GovernanceProposal, +} from "@/features/governance/types"; import { getProposalState, getProposalStatus, @@ -96,7 +99,9 @@ export const useProposals = ( }); const proposals = useMemo(() => { - const rawProposals = data?.pages.flatMap((page) => page.items) ?? []; + const rawProposals = ( + data?.pages.flatMap((page) => page.items) ?? [] + ).filter(isFullProposal); return rawProposals.map((proposal) => { const forVotes = Number(formatUnits(proposal.forVotes, decimals)); @@ -122,8 +127,8 @@ export const useProposals = ( }, quorum: quorum.toFixed(2), timeText: getTimeText(proposal.startTimestamp, proposal.endTimestamp), - values: proposal.values?.map((value) => value.toString()) ?? [], - targets: proposal.targets ?? [], + values: proposal.values.map((value) => value.toString()), + targets: proposal.targets, } satisfies GovernanceProposal; }); }, [data, decimals]); diff --git a/apps/dashboard/features/governance/types/index.ts b/apps/dashboard/features/governance/types/index.ts index d6e4ed4f7..84b42ec9e 100644 --- a/apps/dashboard/features/governance/types/index.ts +++ b/apps/dashboard/features/governance/types/index.ts @@ -3,9 +3,24 @@ import type { ProposalsQueryResponse, } from "@anticapture/client"; -type ClientProposalListItem = ProposalsQueryResponse["items"][number]; +// The proposals endpoints return a `variant`-tagged union (full | lean). The +// dashboard's detail/list views always request the full payload, so narrow to +// the full variant and drop the discriminator to keep these domain types flat. +export type OnchainFullProposalItem = Extract< + ProposalsQueryResponse["items"][number], + { variant: "full" } +>; -type ClientProposalDetails = ProposalQueryResponse; +export const isFullProposal = ( + proposal: ProposalsQueryResponse["items"][number], +): proposal is OnchainFullProposalItem => proposal.variant === "full"; + +type ClientProposalListItem = Omit; + +type ClientProposalDetails = Omit< + Extract, + "variant" +>; export enum ProposalStatus { PENDING = "pending", diff --git a/apps/gateful/openapi/gateful.json b/apps/gateful/openapi/gateful.json index 1adb12599..4c148c1dc 100644 --- a/apps/gateful/openapi/gateful.json +++ b/apps/gateful/openapi/gateful.json @@ -992,6 +992,24 @@ "required": ["items", "totalCount"] }, "OnchainProposal": { + "oneOf": [ + { + "$ref": "#/components/schemas/OnchainFullProposal" + }, + { + "$ref": "#/components/schemas/OnchainLeanProposal" + } + ], + "discriminator": { + "propertyName": "variant", + "mapping": { + "full": "#/components/schemas/OnchainFullProposal", + "lean": "#/components/schemas/OnchainLeanProposal" + } + }, + "description": "Onchain proposal. Narrow by the `variant` discriminator: `full` includes the execution payload and description; `lean` omits them." + }, + "OnchainFullProposal": { "type": "object", "properties": { "id": { @@ -1015,10 +1033,6 @@ "type": "string", "description": "Proposal title." }, - "description": { - "type": "string", - "description": "Proposal body. Omitted when the request sets `lean=true`." - }, "startBlock": { "type": "integer", "description": "Start block number." @@ -1083,12 +1097,26 @@ "format": "bigint", "description": "Required quorum encoded as a decimal string." }, + "proposalType": { + "type": "integer", + "nullable": true, + "description": "Optional proposal type discriminator." + }, + "variant": { + "type": "string", + "enum": ["full"], + "description": "Discriminator. `full` when the execution payload and proposal description are included." + }, + "description": { + "type": "string", + "description": "Proposal body." + }, "calldatas": { "type": "array", "items": { "type": "string" }, - "description": "Encoded calldata payloads executed by the proposal. Omitted when the request sets `lean=true`." + "description": "Encoded calldata payloads executed by the proposal." }, "values": { "type": "array", @@ -1096,7 +1124,7 @@ "type": "string", "format": "bigint" }, - "description": "ETH values attached to each call, encoded as strings. Omitted when the request sets `lean=true`." + "description": "ETH values attached to each call, encoded as strings." }, "targets": { "type": "array", @@ -1104,12 +1132,135 @@ "type": "string", "format": "ethereum-address" }, - "description": "Contract targets invoked by the proposal. Omitted when the request sets `lean=true`." + "description": "Contract targets invoked by the proposal." + } + }, + "required": [ + "id", + "daoId", + "txHash", + "proposerAccountId", + "title", + "startBlock", + "endBlock", + "timestamp", + "status", + "forVotes", + "againstVotes", + "abstainVotes", + "startTimestamp", + "endTimestamp", + "queuedTimestamp", + "executedTimestamp", + "queuedTxHash", + "executedTxHash", + "quorum", + "proposalType", + "variant", + "description", + "calldatas", + "values", + "targets" + ], + "description": "Full onchain proposal, returned when the request does not set `lean=true`." + }, + "OnchainLeanProposal": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Onchain proposal identifier." + }, + "daoId": { + "type": "string", + "description": "DAO identifier (uppercase, e.g. \"ENS\")." + }, + "txHash": { + "type": "string", + "description": "Proposal creation transaction hash." + }, + "proposerAccountId": { + "type": "string", + "format": "ethereum-address", + "description": "Address that created the proposal." + }, + "title": { + "type": "string", + "description": "Proposal title." + }, + "startBlock": { + "type": "integer", + "description": "Start block number." + }, + "endBlock": { + "type": "integer", + "description": "End block number." + }, + "timestamp": { + "type": "integer", + "description": "Proposal creation timestamp in Unix seconds." + }, + "status": { + "type": "string", + "description": "Current proposal status." + }, + "forVotes": { + "type": "string", + "format": "bigint", + "description": "Votes cast in favor, encoded as a decimal string." + }, + "againstVotes": { + "type": "string", + "format": "bigint", + "description": "Votes cast against, encoded as a decimal string." + }, + "abstainVotes": { + "type": "string", + "format": "bigint", + "description": "Abstain votes, encoded as a decimal string." + }, + "startTimestamp": { + "type": "integer", + "description": "Proposal start timestamp in Unix seconds." + }, + "endTimestamp": { + "type": "integer", + "description": "Proposal end timestamp in Unix seconds." + }, + "queuedTimestamp": { + "type": "integer", + "nullable": true, + "description": "Timestamp (Unix seconds) when the proposal was queued, or null if it never was." + }, + "executedTimestamp": { + "type": "integer", + "nullable": true, + "description": "Timestamp (Unix seconds) when the proposal was executed, or null if it never was." + }, + "queuedTxHash": { + "type": "string", + "nullable": true, + "description": "Transaction hash of the queue event, or null if the proposal was never queued." + }, + "executedTxHash": { + "type": "string", + "nullable": true, + "description": "Transaction hash of the execute event, or null if the proposal was never executed." + }, + "quorum": { + "type": "string", + "format": "bigint", + "description": "Required quorum encoded as a decimal string." }, "proposalType": { "type": "integer", "nullable": true, "description": "Optional proposal type discriminator." + }, + "variant": { + "type": "string", + "enum": ["lean"], + "description": "Discriminator. `lean` when the execution payload (calldatas/values/targets) and proposal description are omitted to reduce response size." } }, "required": [ @@ -1132,8 +1283,10 @@ "queuedTxHash", "executedTxHash", "quorum", - "proposalType" - ] + "proposalType", + "variant" + ], + "description": "Lean onchain proposal, returned when the request sets `lean=true`. Omits calldatas/values/targets and the proposal description." }, "OnchainProposalStatusList": { "type": "array",