Skip to content

Commit 35a2f19

Browse files
r4topunkclaude
andcommitted
feat(propose): require a Proposal Commitment acknowledgment
Adds a public commitment proposers acknowledge before submitting, and records that acknowledgment in the proposal description that goes onchain. - New /propose/terms page (EN + PT-BR) with the 7-clause commitment - Checkbox on the preview step, gating the submit button - One-line footer appended to the description, always English and version stamped, so the record stays searchable and auditable across the DAO The form schema keeps `termsAccepted` optional because ActionForms saves each transaction through the whole form's handleSubmit — requiring it there would block adding the transactions needed to reach the checkbox. The hard requirement lives in proposalSubmissionSchema, which the server action parses, so the client cannot skip it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3874eeb commit 35a2f19

10 files changed

Lines changed: 318 additions & 6 deletions

File tree

messages/en/metadata.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,5 +111,9 @@
111111
"migrate": {
112112
"title": "Gnars Migration — Gnars DAO",
113113
"description": "Consolidate your scattered Zora coins into $gnars in one batched flow. Routed through ZORA, with a burn on every migration to strengthen $gnars liquidity."
114+
},
115+
"proposeTerms": {
116+
"title": "Proposal Commitment — Gnars DAO",
117+
"description": "The public commitment every proposer acknowledges before submitting a proposal to Gnars DAO."
114118
}
115119
}

messages/en/propose.json

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,10 @@
225225
"failedToSubmit": "Failed to submit proposal",
226226
"thirdwebNotConfigured": "Thirdweb client not configured",
227227
"connectWalletToSubmit": "Connect wallet to submit a proposal",
228-
"pleaseFixErrors": "Please fix validation errors before submitting"
228+
"pleaseFixErrors": "Please fix validation errors before submitting",
229+
"termsLabel": "I have read and accept the <link>Gnars Proposal Commitment</link>",
230+
"termsHint": "A short note recording this acknowledgment is appended to your proposal description.",
231+
"termsRequired": "You must accept the Proposal Commitment before submitting."
229232
},
230233
"droposalBuilder": {
231234
"media": {
@@ -295,5 +298,43 @@
295298
},
296299
"breadcrumbCreate": "Create Proposal",
297300
"breadcrumbTemplates": "Templates"
301+
},
302+
"terms": {
303+
"title": "Proposal Commitment",
304+
"subtitle": "A public commitment, not a legal contract. It is enforced by transparency and reputation, not by courts.",
305+
"intro": "By submitting a proposal to Gnars DAO, you acknowledge the following.",
306+
"callout": "Gnars is built on action over talk. Approval is not just access to funds — it is trust from the community.",
307+
"clauses": {
308+
"goodFaith": {
309+
"title": "Good faith",
310+
"body": "I have described the scope, timeline, budget, and deliverables of this proposal accurately, and I intend to execute it as written."
311+
},
312+
"treasuryRespect": {
313+
"title": "Treasury respect",
314+
"body": "Treasury funds are community resources, not casual capital. If this proposal is approved, I will keep spending aligned with the approved plan."
315+
},
316+
"noSilence": {
317+
"title": "No silence",
318+
"body": "I will post progress updates as propdates while the work is in flight, and I will report delays, blockers, or changes in scope publicly and early — before trust breaks."
319+
},
320+
"proofOfWork": {
321+
"title": "Proof of work",
322+
"body": "Updates will include evidence the work exists: links, commits, repos, files, media, transactions, or live pages."
323+
},
324+
"finalReport": {
325+
"title": "Final report",
326+
"body": "When the work is done, I will post a closing report covering what was delivered, how the budget was used, what remains reusable, and what I learned."
327+
},
328+
"openByDefault": {
329+
"title": "Open by default",
330+
"body": "Where applicable, code, media, documentation, and creative outputs will be delivered CC0 or open-source. Any part that cannot be shared openly is disclosed in the proposal itself."
331+
},
332+
"proportionalSafeguards": {
333+
"title": "Proportional safeguards",
334+
"body": "I understand that larger requests may be expected to use milestone-based releases, shared custody, or both, and that voters will weigh this when deciding."
335+
}
336+
},
337+
"versionLabel": "Version {version}",
338+
"backToPropose": "Back to creating a proposal"
298339
}
299340
}

messages/pt-br/metadata.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,5 +111,9 @@
111111
"migrate": {
112112
"title": "Migração Gnars — Gnars DAO",
113113
"description": "Consolide suas coins da Zora espalhadas em $gnars em um fluxo em lote. Roteado via ZORA, com queima a cada migração para fortalecer a liquidez de $gnars."
114+
},
115+
"proposeTerms": {
116+
"title": "Compromisso de Proposta — Gnars DAO",
117+
"description": "O compromisso público que todo proponente aceita antes de enviar uma proposta para a Gnars DAO."
114118
}
115119
}

messages/pt-br/propose.json

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,10 @@
225225
"failedToSubmit": "Falha ao enviar proposta",
226226
"thirdwebNotConfigured": "Cliente Thirdweb não configurado",
227227
"connectWalletToSubmit": "Conecte a wallet para enviar uma proposta",
228-
"pleaseFixErrors": "Por favor, corrija os erros de validação antes de enviar"
228+
"pleaseFixErrors": "Por favor, corrija os erros de validação antes de enviar",
229+
"termsLabel": "Li e aceito o <link>Compromisso de Proposta da Gnars</link>",
230+
"termsHint": "Uma linha registrando esse aceite é anexada à descrição da sua proposta.",
231+
"termsRequired": "Você precisa aceitar o Compromisso de Proposta antes de enviar."
229232
},
230233
"droposalBuilder": {
231234
"media": {
@@ -295,5 +298,43 @@
295298
},
296299
"breadcrumbCreate": "Criar Proposta",
297300
"breadcrumbTemplates": "Templates"
301+
},
302+
"terms": {
303+
"title": "Compromisso de Proposta",
304+
"subtitle": "Um compromisso público, não um contrato legal. Vale por transparência e reputação, não por tribunal.",
305+
"intro": "Ao enviar uma proposta para a Gnars DAO, você reconhece o seguinte.",
306+
"callout": "A Gnars é feita de ação, não de discurso. Aprovação não é só acesso a fundos — é confiança da comunidade.",
307+
"clauses": {
308+
"goodFaith": {
309+
"title": "Boa-fé",
310+
"body": "Descrevi o escopo, o prazo, o orçamento e as entregas desta proposta de forma honesta, e pretendo executá-la como está escrito."
311+
},
312+
"treasuryRespect": {
313+
"title": "Respeito ao tesouro",
314+
"body": "O tesouro é recurso da comunidade, não dinheiro solto. Se esta proposta for aprovada, mantenho os gastos alinhados com o plano aprovado."
315+
},
316+
"noSilence": {
317+
"title": "Sem sumiço",
318+
"body": "Vou postar updates de progresso como propdates enquanto o trabalho estiver rolando, e avisar sobre atrasos, bloqueios ou mudança de escopo em público e cedo — antes de quebrar a confiança."
319+
},
320+
"proofOfWork": {
321+
"title": "Prova de trabalho",
322+
"body": "Os updates vêm com evidência de que o trabalho existe: links, commits, repositórios, arquivos, mídia, transações ou páginas no ar."
323+
},
324+
"finalReport": {
325+
"title": "Relatório final",
326+
"body": "Quando terminar, posto um relatório de fechamento contando o que foi entregue, como o orçamento foi usado, o que fica reutilizável e o que aprendi."
327+
},
328+
"openByDefault": {
329+
"title": "Aberto por padrão",
330+
"body": "Quando fizer sentido, código, mídia, documentação e outputs criativos saem em CC0 ou open-source. O que não puder ser aberto fica declarado na própria proposta."
331+
},
332+
"proportionalSafeguards": {
333+
"title": "Salvaguardas proporcionais",
334+
"body": "Entendo que pedidos maiores podem exigir liberação por milestone, custódia compartilhada ou ambos, e que quem vota vai levar isso em conta."
335+
}
336+
},
337+
"versionLabel": "Versão {version}",
338+
"backToPropose": "Voltar para criar proposta"
298339
}
299340
}

src/app/[locale]/propose/actions.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
"use server";
22

33
import { z } from "zod";
4-
import { proposalSchema } from "@/components/proposals/schema";
4+
import { proposalSchema, proposalSubmissionSchema } from "@/components/proposals/schema";
55
import { ipfsToGatewayUrl } from "@/lib/pinata";
6+
import { PROPOSAL_COMMITMENT_FOOTER } from "@/lib/proposal-commitment";
67
import { encodeTransactions } from "@/lib/proposal-utils";
78

89
type ProposalFormValues = z.infer<typeof proposalSchema>;
910

1011
export async function createProposalAction(formData: ProposalFormValues) {
11-
const validationResult = proposalSchema.safeParse(formData);
12+
const validationResult = proposalSubmissionSchema.safeParse(formData);
1213
if (!validationResult.success) {
1314
throw new Error("Invalid proposal data");
1415
}
@@ -27,7 +28,12 @@ export async function createProposalAction(formData: ProposalFormValues) {
2728
formattedDescription = `![Banner](${imageUrl})\n\n${description}`;
2829
}
2930

30-
// 3. Format: Title && Description
31+
// 3. Record the Proposal Commitment acknowledgment. `proposalSchema` already
32+
// rejected anything without `termsAccepted`, so reaching here means the
33+
// proposer opted in on the preview step.
34+
formattedDescription = `${formattedDescription}${PROPOSAL_COMMITMENT_FOOTER}`;
35+
36+
// 4. Format: Title && Description
3137
// Nouns Builder uses && as separator (NOT $$ or #)
3238
const fullDescription = `${title}&&${formattedDescription}`;
3339

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Metadata } from "next";
2+
import { getTranslations, setRequestLocale } from "next-intl/server";
3+
import { ArrowLeft } from "lucide-react";
4+
import { Link } from "@/i18n/navigation";
5+
import {
6+
PROPOSAL_COMMITMENT_CLAUSES,
7+
PROPOSAL_COMMITMENT_VERSION,
8+
} from "@/lib/proposal-commitment";
9+
10+
export async function generateMetadata({
11+
params,
12+
}: {
13+
params: Promise<{ locale: string }>;
14+
}): Promise<Metadata> {
15+
const { locale } = await params;
16+
const t = await getTranslations({ locale, namespace: "metadata.proposeTerms" });
17+
const path = "/propose/terms";
18+
const canonical = locale === "en" ? path : `/pt-br${path}`;
19+
return {
20+
title: t("title"),
21+
description: t("description"),
22+
alternates: {
23+
canonical,
24+
languages: {
25+
en: path,
26+
"pt-br": `/pt-br${path}`,
27+
"x-default": path,
28+
},
29+
},
30+
openGraph: {
31+
title: t("title"),
32+
description: t("description"),
33+
locale: locale === "pt-br" ? "pt_BR" : "en_US",
34+
type: "article",
35+
},
36+
twitter: {
37+
card: "summary_large_image",
38+
title: t("title"),
39+
description: t("description"),
40+
},
41+
};
42+
}
43+
44+
interface ProposeTermsPageProps {
45+
params: Promise<{ locale: string }>;
46+
}
47+
48+
export default async function ProposeTermsPage({ params }: ProposeTermsPageProps) {
49+
const { locale } = await params;
50+
setRequestLocale(locale);
51+
const t = await getTranslations("propose.terms");
52+
53+
return (
54+
<div className="py-8">
55+
<article className="max-w-2xl mx-auto">
56+
<header>
57+
<h1 className="text-3xl font-bold">{t("title")}</h1>
58+
<p className="text-muted-foreground mt-2 text-balance">{t("subtitle")}</p>
59+
</header>
60+
61+
<p className="mt-6">{t("intro")}</p>
62+
63+
<p className="mt-6 border-l-2 border-primary pl-4 text-muted-foreground italic">
64+
{t("callout")}
65+
</p>
66+
67+
<ol className="mt-8 space-y-6">
68+
{PROPOSAL_COMMITMENT_CLAUSES.map((clause, index) => (
69+
<li key={clause} className="flex gap-4">
70+
<span
71+
aria-hidden
72+
className="shrink-0 font-mono text-sm text-muted-foreground pt-0.5 tabular-nums"
73+
>
74+
{String(index + 1).padStart(2, "0")}
75+
</span>
76+
<div className="space-y-1">
77+
<h2 className="font-semibold">{t(`clauses.${clause}.title`)}</h2>
78+
<p className="text-muted-foreground text-pretty">{t(`clauses.${clause}.body`)}</p>
79+
</div>
80+
</li>
81+
))}
82+
</ol>
83+
84+
<footer className="mt-10 border-t pt-4 flex flex-wrap items-center justify-between gap-3 text-sm">
85+
<span className="font-mono text-muted-foreground">
86+
{t("versionLabel", { version: PROPOSAL_COMMITMENT_VERSION })}
87+
</span>
88+
<Link
89+
href="/propose"
90+
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
91+
>
92+
<ArrowLeft className="h-4 w-4" />
93+
{t("backToPropose")}
94+
</Link>
95+
</footer>
96+
</article>
97+
</div>
98+
);
99+
}

src/components/proposals/ProposalPreview.tsx

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ import { useProposalEligibilityContext } from "@/components/proposals/ProposalEl
2222
import { Alert, AlertDescription } from "@/components/ui/alert";
2323
import { Button } from "@/components/ui/button";
2424
import { Card, CardContent } from "@/components/ui/card";
25+
import { Checkbox } from "@/components/ui/checkbox";
2526
import { createProposalAction } from "@/app/[locale]/propose/actions";
2627
import { useProposalIndexing } from "@/hooks/use-proposal-indexing";
2728
import { useUserAddress } from "@/hooks/use-user-address";
2829
import { useWriteAccount } from "@/hooks/use-write-account";
30+
import { Link } from "@/i18n/navigation";
2931
import { DAO_ADDRESSES } from "@/lib/config";
3032
import { ipfsToGatewayUrl } from "@/lib/pinata";
3133
import { encodeTransactions } from "@/lib/proposal-utils";
@@ -107,6 +109,7 @@ export function ProposalPreview() {
107109
const {
108110
getValues,
109111
handleSubmit,
112+
setValue,
110113
formState: { errors },
111114
} = useFormContext<ProposalFormValues>();
112115
const [isActionPending, startTransition] = useTransition();
@@ -162,13 +165,26 @@ export function ProposalPreview() {
162165
}
163166
};
164167

165-
if (data.title && data.description) {
168+
// The prepared description includes the commitment footer, so it only
169+
// exists once the proposer has accepted — matching what goes onchain.
170+
if (data.title && data.description && data.termsAccepted) {
166171
generateDescription();
167172
}
168173
}, [data]);
169174

170175
const handleFormSubmit = async (formData: ProposalFormValues) => {
171176
console.log("handleFormSubmit called with data:", formData);
177+
178+
// The commitment is optional in the form schema (see schema.ts) so that
179+
// saving a transaction doesn't trip on it — enforce it here, at the only
180+
// point where it actually matters.
181+
if (formData.termsAccepted !== true) {
182+
const detail = t("preview.termsRequired");
183+
setValidationError(detail);
184+
toast.error(detail);
185+
return;
186+
}
187+
172188
setValidationError(null);
173189
setHash(undefined);
174190
setIsConfirming(false);
@@ -349,6 +365,7 @@ export function ProposalPreview() {
349365
eligibility.hasThreshold === true &&
350366
Boolean(data.title) &&
351367
(data.transactions?.length ?? 0) > 0 &&
368+
data.termsAccepted === true &&
352369
!isActionPending &&
353370
!isWalletPending &&
354371
!isConfirming &&
@@ -541,6 +558,41 @@ export function ProposalPreview() {
541558
</Alert>
542559
)}
543560

561+
<div className="mb-4 rounded-lg border p-4">
562+
<div className="flex items-start gap-3">
563+
<Checkbox
564+
id="terms-accepted"
565+
checked={data.termsAccepted === true}
566+
onCheckedChange={(checked) =>
567+
setValue("termsAccepted", checked === true, {
568+
shouldValidate: true,
569+
shouldDirty: true,
570+
})
571+
}
572+
className="mt-0.5"
573+
/>
574+
<div className="space-y-1">
575+
<label
576+
htmlFor="terms-accepted"
577+
className="text-sm font-medium leading-snug cursor-pointer"
578+
>
579+
{t.rich("preview.termsLabel", {
580+
link: (chunks) => (
581+
<Link
582+
href="/propose/terms"
583+
target="_blank"
584+
className="underline underline-offset-4 hover:text-primary"
585+
>
586+
{chunks}
587+
</Link>
588+
),
589+
})}
590+
</label>
591+
<p className="text-xs text-muted-foreground">{t("preview.termsHint")}</p>
592+
</div>
593+
</div>
594+
</div>
595+
544596
{isConnected && eligibility.hasThreshold === true && (
545597
<Button
546598
onClick={(e) => {

src/components/proposals/ProposalWizard.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function ProposalWizard() {
3838
description: "",
3939
bannerImage: undefined,
4040
transactions: [],
41+
termsAccepted: false,
4142
},
4243
mode: "onChange",
4344
});

src/components/proposals/schema.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,25 @@ export const proposalSchema = z.object({
200200
* TemplateDetailsForm mirrors these into `description` as compiled markdown.
201201
*/
202202
templateFields: z.record(z.string(), z.unknown()).optional(),
203+
/**
204+
* Proposal Commitment acknowledgment, checked on the preview step.
205+
*
206+
* Optional *here* on purpose: `ActionForms` saves each transaction through
207+
* this form's `handleSubmit`, so a required field would block adding the very
208+
* transactions the user needs before the checkbox is reachable. The hard
209+
* requirement lives in `proposalSubmissionSchema` below, which is what the
210+
* server action parses.
211+
*/
212+
termsAccepted: z.boolean().optional(),
213+
});
214+
215+
/**
216+
* Strict schema for the actual `propose()` call. Enforces the Proposal
217+
* Commitment acknowledgment so the server action cannot compose an onchain
218+
* description for a proposal that never opted in.
219+
*/
220+
export const proposalSubmissionSchema = proposalSchema.extend({
221+
termsAccepted: z.literal(true, { message: "You must accept the Proposal Commitment" }),
203222
});
204223

205224
// Types

0 commit comments

Comments
 (0)