Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ All coding guidelines, testing practices, and project conventions are documented

- **Subscription System (AI Guide):** [docs/AI_SUBSCRIPTION_GUIDE.md](docs/AI_SUBSCRIPTION_GUIDE.md) - Architecture, key files, payment flows, fee calculations, and implementation details
- **Pricing Reference:** [docs/PRICING.md](docs/PRICING.md) - Plan tiers, features, and database schema
- **Custom Proposal Templates:** [docs/CUSTOM_PROPOSAL_TEMPLATES.md](docs/CUSTOM_PROPOSAL_TEMPLATES.md) - the manifest DSL, architecture, args-first authoring, and ChangePolicy gating.
216 changes: 216 additions & 0 deletions docs/CUSTOM_PROPOSAL_TEMPLATES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
# Custom proposal templates

trezu creates the everyday treasury proposals — payments, transfers, staking — natively,
but a SputnikDAO can call **any method on any contract**. trezu could already *display*
those proposals; it just had no way to *create* them. **Custom proposal templates** close
that gap: a DAO authors a reusable form once, and members fill it to file an arbitrary
SputnikDAO **`FunctionCall`** proposal — no hand-written base64 args, no developer needed.

The form definition is a JSON **manifest** (the DSL described below). The proposal a
template produces still passes the DAO's normal permissions and approvals, so a manifest
never grants authority by itself.

This doc is the canonical reference for the manifest DSL, the architecture, and the
authoring UI. The in-app `/<dao>/custom-templates/about` page derives from it.

## What it produces

Filling a template builds one SputnikDAO `FunctionCall` proposal:

```
receiver_id: <binding.receiver_id>
actions: [ FunctionCall {
method_name: <binding.method_name>,
args: <args with every {{placeholder}} substituted, base64-encoded>,
deposit: <binding.deposit>, // yoctoNEAR
gas: <binding.gas>,
} ]
description: "<title>\n\n[trezu-tmpl:<id>]" // the tag is the provenance marker
```

Member-supplied values flow only into `args` (and the human-readable `summary`); the
contract, method, deposit, and gas are fixed by the template author.

## The manifest DSL

A manifest is a JSON object. `version` is `1` (the only shape this schema describes).

| Key | Required | Notes |
|-----|----------|-------|
| `version` | yes | literal `1` |
| `id` | yes | tag-safe slug `[A-Za-z0-9_-]`, unique per DAO. The page URL (`/custom-templates/<id>`) **and** the `[trezu-tmpl:<id>]` description tag. Reserved: `create`, `new`, `about`. |
| `title` | yes | non-blank |
| `description` | no | non-blank if present |
| `icon` | no | non-blank if present |
| `summary` | no | human one-liner shown while filing; may use `{{placeholders}}` |
| `binding` | yes | the fixed on-chain call (below) |
| `fields` | yes | array of form inputs (below) |
| `args` | yes | the call arguments — a JSON object; string values may embed `{{placeholders}}` |

### `binding` — the on-chain call

```json
"binding": {
"receiver_id": "guestbook.near", // the contract
"method_name": "add_message", // the method
"deposit": "1", // yoctoNEAR, integer string (u128-safe)
"gas": "30000000000000" // gas, integer string
}
```

`deposit`/`gas` are **integer strings** in base units — there are no decimals at the
contract boundary (1.1 NEAR = `1100000000000000000000000` yocto). They exceed 2^53, so
they must be strings, never JSON numbers.

### `fields` — the form inputs

Each field is `{ name, label, type, ... }`:

| Field key | Notes |
|-----------|-------|
| `name` | `[A-Za-z0-9_]` — must be `{{placeholder}}`-safe; unique within the manifest |
| `label` | non-blank; shown above the input |
| `type` | one of the types below |
| `required` | optional bool; **not allowed on `bool`** (a toggle always submits) |
| `default` | optional; must match `type` (string for text-ish, number for `number`, boolean for `bool`, an option for `select`, any JSON for `json`) |
| `help` | optional; shown under the input |
| `options` | required for `select`, forbidden otherwise; array of strings |
| `validation` | optional `{ min, max, pattern }` (below) |

Types:

| `type` | Renders / validates as |
|--------|------------------------|
| `account` | a NEAR account id (validated shape) |
| `token` | an omni token id, e.g. `base-0x…` (free text) |
| `uint` | a whole-number string, u128-safe |
| `amount` | a whole-number string, u128-safe (base units — no decimals) |
| `number` | a numeric value (JS number / f64 — for counts/ratios, **not** amounts) |
| `text` | free text |
| `select` | dropdown of `options` |
| `bool` | a toggle |
| `json` | JSON text |

`validation`:
- `min` / `max` — integer strings; only on numeric types (`uint`/`amount`/`number`).
- `pattern` — a regular expression; only on `text` / `number`. Must compile.

> u128 safety: `deposit`/`gas`/`validation.min`/`max` and `uint`/`amount` field values
> stay digit strings end-to-end, never JS numbers, so NEAR amounts (which exceed 2^53)
> survive untruncated.

### `args` — the call arguments

`args` is a JSON object — the literal method arguments. Each string value may contain
`{{name}}` placeholders that reference a declared field by `name`. At fill time the
engine substitutes each placeholder with the member's value:

```json
"args": {
"app": "trezu", // static
"text": "{{message}}", // direct member value
"amount": "{{tip}}", // direct member value (u128)
"meta": "{\"by\":\"{{author}}\"}" // composed: a field inside a string
}
```

A value need not be a string. `args` may hold any JSON — numbers, booleans, `null`,
nested objects, arrays (the visual builder offers these as static value types). Non-string
values are sent to the contract **verbatim**; `{{placeholders}}` are only ever resolved
inside string values, so a number/bool/object passes through exactly as written.

Rules:
- **Every `{{placeholder}}` must reference a declared field.** A dangling reference is
a validation error (attributed to `args` or `summary`).
- An escaped `{{{{literal}}}}` collapses to a literal `{{literal}}` and is never treated
as a placeholder.
- Concatenation / composition is just multiple placeholders in one string
(`"{{first}}.{{last}}"` → `"alice.near"`).
- Amounts stay digit strings, so u128 values never lose precision.

### A minimal example

```json
{
"version": 1,
"id": "set-greeting",
"title": "Set Greeting",
"binding": {
"receiver_id": "guestbook.near",
"method_name": "set_greeting",
"deposit": "0",
"gas": "30000000000000"
},
"fields": [
{ "name": "greeting", "label": "Greeting", "type": "text", "required": true }
],
"args": { "greeting": "{{greeting}}" },
"summary": "Set greeting to {{greeting}}"
}
```

## Authoring (the UI)

`/<dao>/custom-templates/create` (and `…/<slug>/edit`) offer **Visual** and **Code**
modes over the same manifest; both validate through the one `parseManifest`.

### Visual mode is args-first

The call is the unit. Each **argument** is either:

- **Static** — a fixed value (text/number/bool/null/object/array; text may embed
`{{field}}`), or
- **Member input (dynamic)** — its value becomes `{{key}}` and the row expands to that
input's config inline (label, type, required, help, validation). The input's **name
is the argument key**; renaming the key renames the input.

Inputs are **derived from placeholders**: anything you reference (`{{x}}` in a static
value or in `summary`) auto-creates input `x`. Inputs referenced only inside a composed
value — or added manually — appear under **Other inputs**; an input no argument
references is flagged **Unused** (collected but not sent — used for acknowledgment
toggles or forward-compat).

### Code mode

A JSON textarea over the same manifest, with live validation. Useful for paste and
power edits. Switching Code → Visual needs only valid JSON (a partial manifest hydrates
with blanks); Visual → Code serializes the draft back.

## Build one with an AI assistant

Non-developers can generate a manifest by describing the proposal in plain English. The
self-contained, distributable skill at
[`docs/trezu-custom-proposal-template/`](./trezu-custom-proposal-template/SKILL.md)
installs into Claude Code / Claude.ai / Codex (see its `README`) and produces a manifest
to paste into the **Code** tab — it needs no repo access.

## Permissions

- **Authoring** (create / update / delete a template) is gated on the DAO's on-chain
**`ChangePolicy`** permission — the governance bar, not mere membership.
- **Listing / filling** a template is gated on **membership**.
- Filing the proposal still goes through the DAO's normal `add_proposal` →
approvals → execution. A template grants no authority.

## Where the code lives

Backend (`nt-be`):
- `src/handlers/proposal_templates.rs` — CRUD at `/api/treasury/{dao_id}/proposal-templates`,
`validate_manifest` (structural + slug/reserved checks), ChangePolicy-gated writes.
- Migration: `proposal_templates` table with a `manifest_id` generated column
(`manifest->>'id'`), unique per DAO.

Frontend (`nt-fe/features/proposal-templates/`):
- `manifest.ts` — the zod schema + `parseManifest`, `manifestPlaceholders`,
`substitutePlaceholders`, `manifestIdOf`, reserved slugs.
- `build-proposal.ts` — the engine: manifest + values → `{ kind, description }`.
- `form-schema.ts` — manifest → react-hook-form zod schema (the fill form).
- `components/manifest-form.tsx` — the fill-form renderer.
- `draft.ts` — the visual builder's editable model (`ManifestDraft` ↔ manifest,
`normalizeFields`, the `ArgNode` args tree).
- `args-node.ts`, `error-map.ts` — args value-type helpers; per-input error routing.
- `components/{template-editor,visual-builder,fields-builder,args-tree-editor}.tsx` —
the Code/Visual authoring UI.

Routes (`nt-fe/app/(treasury)/[treasuryId]/custom-templates/`):
`index` · `[slug]` (fill + file) · `create` · `[slug]/edit` · `about` (in-app DSL docs).
41 changes: 41 additions & 0 deletions docs/trezu-custom-proposal-template/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# trezu custom-proposal-template skill

A portable AI **skill** that helps you build a custom proposal template for
[trezu](https://trezu.app) (a SputnikDAO treasury UI on NEAR) by describing what you
want in plain English. The assistant produces the template **manifest** (JSON) you paste
into trezu — no coding.

Everything the assistant needs is in [`SKILL.md`](SKILL.md); it's self-contained, so it
works without access to the trezu codebase.

## Install / use it

**Claude Code**
- Copy this folder into your skills directory:
- all projects: `~/.claude/skills/trezu-custom-proposal-template/`
- one project: `<project>/.claude/skills/trezu-custom-proposal-template/`
- Then just ask: *"Build a trezu custom proposal template that pays USDC to a recipient
each month."* Claude matches the skill by its description and follows it.

**Claude.ai / Claude in your workspace (Skills)**
- Where Skills can be uploaded, add this folder (or `SKILL.md`) as a custom skill, then
ask the same way.

**Claude chat (no Skills feature) / Codex / other assistants**
- Paste the contents of `SKILL.md` into the chat (or your assistant's custom-instructions
/ system-prompt slot), then describe the proposal you want. The file is plain Markdown
and works as a self-contained prompt anywhere.

## What you'll get, and what to do with it

The assistant asks for the contract + method, which arguments are fixed vs member-filled,
and the deposit/gas — then returns a manifest like:

```json
{ "version": 1, "id": "usdc-payout", "title": "USDC Payout", "...": "..." }
```

In trezu: open your DAO → **Custom → New template → Code tab**, paste the manifest, and
**Save** (use the **Visual** tab to review it as a form first). Authoring a template
requires your DAO's `ChangePolicy` permission — if you don't have it, hand the manifest
to a member who does.
Loading