From 2332e78bc46e6382a5adf4b8b7124d2551cacd8b Mon Sep 17 00:00:00 2001 From: adenarc <334235131+adenarc@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:21:56 +0530 Subject: [PATCH 1/4] docs: Node DAO policy recipe for member proposals (#412) Draft recipe for both decision A variants: which roles change, the AddProposal permission forms for decision B's kinds, the bond and when Sputnik returns or keeps it, and how to file it as ChangePolicy or as smaller proposals from Trezu. Each Sputnik claim cites the contract at 5434a59d. Waits on #407's mainnet run and decisions A and B. --- docs/node-dao-member-proposals.md | 219 ++++++++++++++++++++++++++---- 1 file changed, 190 insertions(+), 29 deletions(-) diff --git a/docs/node-dao-member-proposals.md b/docs/node-dao-member-proposals.md index 66b3eb35..f5bf2511 100644 --- a/docs/node-dao-member-proposals.md +++ b/docs/node-dao-member-proposals.md @@ -1,53 +1,81 @@ # Letting members propose to a Node DAO -> **Draft.** The recipe below hasn't been validated on mainnet yet. Spike #407 runs it and records the transaction, decision A picks the role and decision B picks the kinds (epic #408). Until then, both decision A variants are described, and the kinds are the candidates from decision B. +> **Draft, not validated.** Nobody has run this recipe on mainnet yet. Spike #407 runs it and records the transaction. Decision A picks the role members propose under, and decision B picks the proposal kinds (epic #408). Until #407 has run and both decisions are recorded, this page describes both decision A variants side by side, and the kinds are decision B's candidates. Don't hand it to a DAO's signers as final. -Each Node DAO makes one policy change so that its members can add proposals and the bond is low enough for a member to pay, ideally 0. The DAO's signers propose and approve that change in their existing tools (Trezu). City Node doesn't change DAO policy and has no screen for it. It only reads the result (see [What City Node reads](#what-city-node-reads)). +| Check | Status | +| --- | --- | +| #407 mainnet transaction | _pending #407: add the transaction link here_ | +| Decision A (Everyone or a Members group) | _open_ | +| Decision B (which kinds members propose) | _open_ | -Validated transaction: _pending #407_. +Each Node DAO makes one policy change that does two things: it lets its members add proposals, and it lowers the proposal bond to an amount a member can pay, ideally 0. The DAO's signers propose and approve that change in the tool they already use (Trezu). City Node doesn't change DAO policy and has no screen for it. It only reads the result (see [What City Node reads](#what-city-node-reads)). -## What changes +Sputnik claims below cite [near-daos/sputnik-dao-contract][sdc] at `5434a59d`, the commit #412 was code-read against. All paths are under `sputnikdao2/src/`. -One `ChangePolicy` proposal, so the role and the bond change together. It replaces the whole policy, so start from the DAO's current `get_policy` and change only: +## How Sputnik decides who may propose -1. **The role members propose under** (decision A, one of the two variants below). -2. **`proposal_bond`**, ideally `"0"`. +- `add_proposal` asks the policy whether the caller may perform `AddProposal` on the proposal's kind, and panics with `ERR_PERMISSION_DENIED` if not ([proposals.rs:566-576][add-perm]). +- The caller matches every role whose kind fits them: `Everyone` always matches, `Group` matches listed accounts, and `Member` matches a delegated token weight at or above the threshold ([policy.rs:24-32][match-user]). The comment on `Everyone` says "who is not matched by other roles" ([policy.rs:16][everyone-doc]), but the code matches every account, council members included. +- A matched role allows the action if its `permissions` contain any of four strings ([policy.rs:333-359][can-execute]): + 1. `:AddProposal` + 2. `:*` + 3. `*:AddProposal` + 4. `*:*` +- `` is the proposal kind's policy label ([proposals.rs:125-147][labels]). A permission for one kind never authorizes another. +- The default policy gives an `all` role (kind `Everyone`) the permission `*:AddProposal`, and a `council` group everything except `RemoveProposal` ([policy.rs:179-216][default-policy]). -Leave the council and approver roles, their vote policies and `proposal_period` as they are. Members never get a `VoteApprove`, `VoteReject`, `VoteRemove`, `RemoveProposal` or `Finalize` permission. Proposing isn't voting: every member proposal still needs the council's approval under the installed threshold. +### Permission strings for decision B's candidates -Only an account whose role has `policy:AddProposal` (or `policy:*`, `*:AddProposal`, `*:*`) can add the `ChangePolicy` proposal, and it passes under the vote policy for `policy` proposals. The proposer attaches the DAO's current bond as the deposit. +| Decision B candidate | Sputnik proposal kind | Policy label | Exact permission | +| --- | --- | --- | --- | +| Polls | `Vote` | `vote` | `vote:AddProposal` | +| Community page changes | `FunctionCall` (config write) | `call` | `call:AddProposal` | +| House of Stake votes | `FunctionCall` (vote with the snapshot proof) | `call` | `call:AddProposal` | -### Permissions for decision B's kinds +Labels: `Vote` is `vote` ([proposals.rs:138][labels]) and `FunctionCall` is `call` ([proposals.rs:131][labels]). -Sputnik checks `:AddProposal`, `:*`, `*:AddProposal` and `*:*`, where `` is the proposal kind's policy label (`sputnikdao2/src/policy.rs`, `can_execute_action`). Grant the exact `:AddProposal` for each kind decision B picks, never a wildcard: +What each of the four forms would grant a member role, for kind `vote` (the same holds for `call`): -| Decision B candidate | Sputnik proposal kind | Permission | -| --- | --- | --- | -| Polls | `Vote` | `vote:AddProposal` | -| Community page changes | `FunctionCall` (config write) | `call:AddProposal` | -| House of Stake votes | `FunctionCall` (vote with the snapshot proof) | `call:AddProposal` | +| Form | Example | What it grants | Use for members? | +| --- | --- | --- | --- | +| `:AddProposal` | `vote:AddProposal` | Adding polls only | **Yes.** Grant one per kind decision B picks | +| `:*` | `vote:*` | Adding polls, and also voting on, removing and finalizing them | No: members would approve their own proposals | +| `*:AddProposal` | `*:AddProposal` | Adding every kind, including `policy`, `transfer` and `upgrade_self` | No: the default Everyone grant, and broader than decision B | +| `*:*` | `*:*` | Every action on every kind | Never | + +`call:AddProposal` lets a member propose any function call, not only the ones City Node builds. Sputnik can't scope a permission by receiver or method. The council's approval is what stops other calls, so approvers should check what a call does before approving it. -`call:AddProposal` lets a member propose any function call, not only the ones City Node builds. The council's approval is what stops the rest, so approvers should check what a call does before approving it. +Members never get `VoteApprove`, `VoteReject`, `VoteRemove`, `RemoveProposal` or `Finalize`. Proposing isn't voting: every member proposal still passes only under the council's installed threshold. -## Decision A, variant 1: a Members group +## Decision A, side by side + +| | Variant 1: a Members group | Variant 2: Everyone | +| --- | --- | --- | +| Roles that change | Add a `members` group role. Remove the overlapping `AddProposal` grant from the Everyone role (or remove that role). | Narrow (or add) the Everyone role to the chosen kinds only. | +| Who can propose | Only accounts listed in the group | Any NEAR account | +| Per join | Each new member's Passkey Wallet account is added by an `AddMemberToRole` proposal the council approves (label `add_member_to_role`, [proposals.rs:129][labels]; executed by [policy.rs:292-305][add-member]) | Nothing | +| Spam exposure | Low: only members | Anyone can file polls and calls. With a zero bond nothing discourages it, and the council rejects or removes them | +| What #407 checks | No overlapping Everyone grant, and an unrelated account is refused | An unrelated account succeeds, and the exposure difference | +| Council and thresholds | Unchanged | Unchanged | -Add a role: +### Variant 1: a Members group + +The role to add: ```json { - "name": "Members", + "name": "members", "kind": { "Group": ["member-one.near", "member-two.near"] }, "permissions": ["vote:AddProposal", "call:AddProposal"], "vote_policy": {} } ``` -- Only listed accounts can propose. A member's Passkey Wallet account has to be added before they can, through an `AddMemberToRole` proposal the council approves (one per join, or batched). -- The Everyone role must not grant `AddProposal` for the same kinds, otherwise anyone can still propose. The default Sputnik policy gives Everyone `*:AddProposal`, so remove that permission (or the role) in the same change. +The Everyone role must not grant `AddProposal` for the same kinds in any of the four forms, or anyone can still propose them. The default `all` role has `*:AddProposal`, which covers every kind, so remove that permission (leaving `"permissions": []`) or remove the role in the same change. -## Decision A, variant 2: Everyone +### Variant 2: Everyone -Add the kinds' permissions to the Everyone role (or add an Everyone role with only those permissions): +The Everyone role, narrowed from the default `*:AddProposal` to the chosen kinds: ```json { @@ -58,15 +86,148 @@ Add the kinds' permissions to the Everyone role (or add an Everyone role with on } ``` -- Any NEAR account can propose those kinds, not only the community's members, and no per-join policy change is needed. -- With a zero bond nothing discourages spam proposals. The council removes or rejects them. +If the DAO has no Everyone role, add this one. Leaving the default `*:AddProposal` in place would also work for decision B's kinds, but it lets any account propose transfers, policy changes and upgrades too. ## Bond -`add_proposal` requires an attached deposit exactly equal to `proposal_bond` (1 NEAR in the default policy). A DAO may keep a bond above zero. The member then pays it from their Passkey Wallet, and the proposal screen shows the amount before they sign. A new wallet starts empty, so it has to be funded first. +`add_proposal` requires an attached deposit **exactly** equal to `proposal_bond`, more or less fails with `ERR_MIN_BOND` ([proposals.rs:540-544][add-bond]). The default policy's bond is 1 NEAR ([policy.rs:211][default-policy]). The deposit is added to the DAO's locked amount ([proposals.rs:587][add-locked]). + +| What happens to the proposal | The bond | Source | +| --- | --- | --- | +| Approved, and it executes without a cross-contract call (`Vote`, policy changes, config) | Returned at once | [proposals.rs:431, 461-470][execute] | +| Approved, and its call or transfer succeeds | Returned in the callback | [proposals.rs:473-490][callback-ok], [698-722][callback] | +| Approved, but its call or transfer fails | Kept locked; the proposal is `Failed` | [proposals.rs:492-498][callback-fail] | +| `Failed`, then finalized | Re-executed if still approved (returned on success); returned if it has expired | [proposals.rs:658-685][finalize] | +| Rejected by vote | Returned | [proposals.rs:649-652][vote-outcome], [500-510][reject] | +| Expired, then finalized | Returned | [proposals.rs:676-679][finalize] | +| Expired, not yet finalized | Kept locked until someone with `Finalize` finalizes it. Members don't have that permission, so the council does | [proposals.rs:665-685][finalize] | +| Removed by vote (`VoteRemove`) | Forfeited; the proposal is deleted | [proposals.rs:644-648][vote-outcome] | +| Deleted with `RemoveProposal` | Forfeited | [proposals.rs:620-623][remove] | + +**The DAO returns its current bond, not the one the proposer paid.** `internal_return_bonds` transfers `policy.proposal_bond` from the policy at the time of return ([proposals.rs:339-349][return-bonds]). The policy change itself is safe: it's executed with the policy read before it ran ([proposals.rs:607, 641][act]), so its proposer gets the old bond back. But a proposal that was pending when the bond dropped from 1 NEAR to 0 gets 0 back later. Approve, reject or finalize pending proposals before lowering the bond. + +**A positive bond means the member's Passkey Wallet must hold that NEAR**, plus gas, before they sign. A new wallet starts empty. Per the product owner (2026-10-23), a DAO may keep a bond above zero; the proposal screen (#413) then shows the amount and the wallet's address before the member signs, and City Node doesn't fund wallets. + +## Submitting the change from Trezu + +A signer whose role allows the proposal kind files it, attaching the DAO's **current** bond as the deposit. It passes under that kind's vote policy, the same as any other policy change. Use the policy-change or custom-proposal option in Trezu. If Trezu's form can't express the change, file `add_proposal` with the arguments below. #407 records which Trezu path it used. + +### Recommended: one `ChangePolicy` + +One proposal changes the role and the bond together, as #407 tests ("one policy change for role plus bond"). `ChangePolicy` (label `policy`) **replaces the whole policy** ([proposals.rs:363-366][execute]), so: + +1. Read the DAO's current policy with `get_policy` on `example.sputnik-dao.near`. +2. Copy it, and change only the roles for your decision A variant and `proposal_bond`. Keep the council roles, vote policies, `proposal_period` and bounty settings exactly as they are. +3. File it. Sputnik accepts only a full policy object here, not the council-list shorthand ([proposals.rs:546-551][add-validate]). Policy, role and vote policy JSON reject unknown fields ([policy.rs:14, 68, 124, 152][policy-types]), so keep the field names exact. + +The proposer needs `policy:AddProposal` (or one of the other three forms covering `policy`). + +Example `add_proposal` arguments, variant 1, starting from a default-shaped policy with a two-signer council and lowering the bond to 0. Attach the current bond, 1 NEAR (`1000000000000000000000000` yoctoNEAR), as the deposit: + +```json +{ + "proposal": { + "description": "Let members propose polls and function calls, and lower the proposal bond to 0", + "kind": { + "ChangePolicy": { + "policy": { + "roles": [ + { + "name": "all", + "kind": "Everyone", + "permissions": [], + "vote_policy": {} + }, + { + "name": "council", + "kind": { "Group": ["signer-one.near", "signer-two.near"] }, + "permissions": [ + "*:AddProposal", + "*:VoteApprove", + "*:VoteReject", + "*:VoteRemove", + "*:Finalize" + ], + "vote_policy": {} + }, + { + "name": "members", + "kind": { "Group": ["member-one.near", "member-two.near"] }, + "permissions": ["vote:AddProposal", "call:AddProposal"], + "vote_policy": {} + } + ], + "default_vote_policy": { + "weight_kind": "RoleWeight", + "quorum": "0", + "threshold": [1, 2] + }, + "proposal_bond": "0", + "proposal_period": "604800000000000", + "bounty_bond": "1000000000000000000000000", + "bounty_forgiveness_period": "86400000000000" + } + } + } + } +} +``` + +For variant 2, the `roles` list is the `all` role from [Variant 2](#variant-2-everyone) plus the unchanged `council` role, with no `members` role. + +### Alternative: smaller proposals + +If the signers would rather not resubmit the whole policy, the same result takes two or three proposals, each voted on separately: + +- **The role:** `ChangePolicyAddOrUpdateRole` (label `policy_add_or_update_role`). A role with an existing name replaces it, otherwise it's added ([policy.rs:245-259][add-or-update]). Variant 1 needs two: add `members`, and replace `all` with empty permissions. Variant 2 needs one: replace (or add) `all`. + + ```json + { "ChangePolicyAddOrUpdateRole": { "role": { "name": "members", "kind": { "Group": ["member-one.near"] }, "permissions": ["vote:AddProposal", "call:AddProposal"], "vote_policy": {} } } } + ``` + +- **The bond:** `ChangePolicyUpdateParameters` (label `policy_update_parameters`). It changes only the parameters it's given ([proposals.rs:55-60][parameters], [policy.rs:276-290][update-parameters]; executed at [proposals.rs:454-459][execute]). + + ```json + { "ChangePolicyUpdateParameters": { "parameters": { "proposal_bond": "0" } } } + ``` -Sputnik returns the bond after a successful approval executes, when the proposal is rejected, or when an expired proposal is finalized. A pending proposal or a failed execution keeps it locked, and removal forfeits it. +Between the proposals the DAO is half changed. With variant 1, file the Everyone change last, so no member is shut out in between. The proposer needs `AddProposal` for each of those labels. ## What City Node reads -`proposalPermission(policy, accountId, kind, delegationBalance?)` in `ui/src/lib/sputnik-proposals.ts` evaluates the DAO's `get_policy` result the way Sputnik does. It matches Everyone, Group and token-weighted Member roles and the four permission forms. It returns whether the account may add that kind and the bond in yoctoNEAR. A missing or unreadable policy, or a missing or malformed `proposal_bond`, never allows submission. A malformed bond is never read as zero. +`ui/src/lib/sputnik-proposals.ts` (from #435) reads the result; nothing in City Node writes it. + +- `fetchSputnikPolicy(daoAccountId)` (`:164`) reads `get_policy` over public RPC, or returns `null`. +- `proposalPermission(policy, accountId, kind, delegationBalance?)` (`:301-315`) evaluates that policy the way `can_execute_action` does. It matches Everyone, Group and token-weighted Member roles (`accountInRoles`, `:217-230`) and the four permission forms (`permits`, `:195-208`) for one `SputnikProposalKindLabel` (`:176-193`). It returns `{ canPropose, reason, bondYocto }`, where `reason` is `allowed`, `no-account`, `no-policy`, `no-permission` or `bond-unavailable`, and `bondYocto` is the exact deposit in yoctoNEAR. +- A missing or unreadable policy, or a missing or malformed `proposal_bond`, never allows submission, and a malformed bond is never read as zero (`proposalBondYocto`, `:288-291`). Pass `delegationBalance` (`readDelegationBalance`, `:146`) only when the DAO has token-weighted Member roles. +- `proposerRoles(policy, kind)` (`:239-245`) and `canAccountPropose` (`:247-254`) are the older helpers; `canAccountPropose` still checks `call` only. + +For the proposal screen (#413): read the policy with `fetchSputnikPolicy`, then call `proposalPermission(policy, walletAccountId, "vote")` or `"call"` for each kind decision B picks, and show the bond from `bondYocto`. Don't take the bond from `fetchProposalBond` (`:393-398`): it returns `"0"` when the read fails, and `proposeAsSession` (`:406-429`) attaches that value as the deposit. + +The tests in `ui/src/lib/sputnik-proposal-permission.test.ts` cover both decision A variants, each permission form, a permission for another kind, and zero, positive, missing and malformed bonds. + +[sdc]: https://github.com/near-daos/sputnik-dao-contract/tree/5434a59dec23ee0c49df8c8c4350c208a7e75b0b +[everyone-doc]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/policy.rs#L16 +[match-user]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/policy.rs#L24-L32 +[policy-types]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/policy.rs#L65-L166 +[default-policy]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/policy.rs#L179-L216 +[add-or-update]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/policy.rs#L245-L259 +[update-parameters]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/policy.rs#L276-L290 +[add-member]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/policy.rs#L292-L305 +[can-execute]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/policy.rs#L333-L359 +[parameters]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L55-L60 +[labels]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L125-L147 +[return-bonds]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L339-L349 +[execute]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L352-L471 +[callback-ok]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L473-L490 +[callback-fail]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L492-L498 +[reject]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L500-L520 +[add-bond]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L540-L544 +[add-validate]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L546-L551 +[add-perm]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L566-L576 +[add-locked]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L587 +[act]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L594-L696 +[remove]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L620-L623 +[vote-outcome]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L637-L656 +[finalize]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L658-L685 +[callback]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L698-L722 From 14eb63879e6e1c0dce2f4783b48dc64670c8ce74 Mon Sep 17 00:00:00 2001 From: adenarc <334235131+adenarc@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:26:46 +0530 Subject: [PATCH 2/4] docs: keep signers able to propose and approve the bond change last (#412) Add a check that a signer role other than Everyone still grants AddProposal before Everyone is narrowed, and order the smaller-proposals path so the bond change is approved after every other proposal, since Sputnik refunds the bond in force at refund time. --- docs/node-dao-member-proposals.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/node-dao-member-proposals.md b/docs/node-dao-member-proposals.md index f5bf2511..382daf38 100644 --- a/docs/node-dao-member-proposals.md +++ b/docs/node-dao-member-proposals.md @@ -58,6 +58,12 @@ Members never get `VoteApprove`, `VoteReject`, `VoteRemove`, `RemoveProposal` or | What #407 checks | No overlapping Everyone grant, and an unrelated account is refused | An unrelated account succeeds, and the exposure difference | | Council and thresholds | Unchanged | Unchanged | +### First, keep the signers able to propose + +Both variants take `AddProposal` away from the Everyone role. In the default policy the council keeps its own `*:AddProposal` ([policy.rs:194-208][default-policy]), so nothing else changes. But a DAO that isn't default-shaped may let its signers propose only through Everyone, for example a council with vote and `Finalize` permissions and no `AddProposal`. Sputnik only grants what a role the caller matches lists ([policy.rs:333-359][can-execute]), so narrowing Everyone there would leave nobody able to add a proposal, including the `ChangePolicy` that would undo it. + +Before narrowing Everyone, check the current `get_policy`: a signer role other than Everyone must grant `AddProposal` for `policy` and every other kind the signers file, in one of the four forms. If none does, add that grant (`*:AddProposal`, as the default council has) to the signer role in the same change. + ### Variant 1: a Members group The role to add: @@ -71,7 +77,7 @@ The role to add: } ``` -The Everyone role must not grant `AddProposal` for the same kinds in any of the four forms, or anyone can still propose them. The default `all` role has `*:AddProposal`, which covers every kind, so remove that permission (leaving `"permissions": []`) or remove the role in the same change. +The Everyone role must not grant `AddProposal` for the same kinds in any of the four forms, or anyone can still propose them. The default `all` role has `*:AddProposal`, which covers every kind, so remove that permission (leaving `"permissions": []`) or remove the role in the same change, after the [signer check](#first-keep-the-signers-able-to-propose). ### Variant 2: Everyone @@ -86,7 +92,7 @@ The Everyone role, narrowed from the default `*:AddProposal` to the chosen kinds } ``` -If the DAO has no Everyone role, add this one. Leaving the default `*:AddProposal` in place would also work for decision B's kinds, but it lets any account propose transfers, policy changes and upgrades too. +Run the [signer check](#first-keep-the-signers-able-to-propose) before narrowing it. If the DAO has no Everyone role, add this one. Leaving the default `*:AddProposal` in place would also work for decision B's kinds, but it lets any account propose transfers, policy changes and upgrades too. ## Bond @@ -117,7 +123,7 @@ A signer whose role allows the proposal kind files it, attaching the DAO's **cur One proposal changes the role and the bond together, as #407 tests ("one policy change for role plus bond"). `ChangePolicy` (label `policy`) **replaces the whole policy** ([proposals.rs:363-366][execute]), so: 1. Read the DAO's current policy with `get_policy` on `example.sputnik-dao.near`. -2. Copy it, and change only the roles for your decision A variant and `proposal_bond`. Keep the council roles, vote policies, `proposal_period` and bounty settings exactly as they are. +2. Copy it, and change only the roles for your decision A variant and `proposal_bond`. First run the [signer check](#first-keep-the-signers-able-to-propose): if no signer role other than Everyone grants `AddProposal` for `policy` and the other kinds the signers file, add that grant to the signer role in this change. Otherwise keep the council roles, vote policies, `proposal_period` and bounty settings exactly as they are. 3. File it. Sputnik accepts only a full policy object here, not the council-list shorthand ([proposals.rs:546-551][add-validate]). Policy, role and vote policy JSON reject unknown fields ([policy.rs:14, 68, 124, 152][policy-types]), so keep the field names exact. The proposer needs `policy:AddProposal` (or one of the other three forms covering `policy`). @@ -191,7 +197,13 @@ If the signers would rather not resubmit the whole policy, the same result takes { "ChangePolicyUpdateParameters": { "parameters": { "proposal_bond": "0" } } } ``` -Between the proposals the DAO is half changed. With variant 1, file the Everyone change last, so no member is shut out in between. The proposer needs `AddProposal` for each of those labels. +Between the proposals the DAO is half changed, so the order in which the council approves them matters: + +1. Any signer-role grant the [signer check](#first-keep-the-signers-able-to-propose) calls for. +2. The member role. With variant 1, approve `members` before the Everyone change, so no member is shut out in between. +3. **The bond change, last**, once every other proposal from this change, and any other pending proposal, has been approved or rejected. Sputnik refunds the bond in force when it pays the refund ([proposals.rs:339-349][return-bonds]), so a role proposal approved after the bond drops to 0 refunds its proposer 0 and the 1 NEAR they attached stays in the DAO. The bond proposal itself runs under the policy read before it executes ([proposals.rs:607, 641][act]), so its own proposer still gets the old bond back. + +The proposer needs `AddProposal` for each of those labels. ## What City Node reads From 32c63ab5ada272ce80d802c6cec7431e2b90d178 Mon Sep 17 00:00:00 2001 From: adenarc <334235131+adenarc@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:31:36 +0530 Subject: [PATCH 3/4] docs: finalize expired and failed proposals before the bond change (#412) --- docs/node-dao-member-proposals.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/node-dao-member-proposals.md b/docs/node-dao-member-proposals.md index 382daf38..8612b5ee 100644 --- a/docs/node-dao-member-proposals.md +++ b/docs/node-dao-member-proposals.md @@ -201,7 +201,7 @@ Between the proposals the DAO is half changed, so the order in which the council 1. Any signer-role grant the [signer check](#first-keep-the-signers-able-to-propose) calls for. 2. The member role. With variant 1, approve `members` before the Everyone change, so no member is shut out in between. -3. **The bond change, last**, once every other proposal from this change, and any other pending proposal, has been approved or rejected. Sputnik refunds the bond in force when it pays the refund ([proposals.rs:339-349][return-bonds]), so a role proposal approved after the bond drops to 0 refunds its proposer 0 and the 1 NEAR they attached stays in the DAO. The bond proposal itself runs under the policy read before it executes ([proposals.rs:607, 641][act]), so its own proposer still gets the old bond back. +3. **The bond change, last**, once every other proposal from this change, and any other pending proposal, has been settled: approved or rejected, and any expired or `Failed` one finalized ([proposals.rs:658-685][finalize]). Sputnik refunds the bond in force when it pays the refund ([proposals.rs:339-349][return-bonds]), so a role proposal approved after the bond drops to 0 refunds its proposer 0 and the 1 NEAR they attached stays in the DAO. The bond proposal itself runs under the policy read before it executes ([proposals.rs:607, 641][act]), so its own proposer still gets the old bond back. The proposer needs `AddProposal` for each of those labels. From bedb9e029c09f8cdfbbb130d8be568c2ac128589 Mon Sep 17 00:00:00 2001 From: adenarc <334235131+adenarc@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:46:25 +0530 Subject: [PATCH 4/4] docs: recipe for the decided Contributors group (#412) Decisions A and B are recorded: members propose and vote on polls as Contributors, and ask to join through an Everyone add_member_to_role grant, or signers add them. Replace the two-variant comparison with that policy, matched to #412, and keep the bond rules and the earlier review fixes. Still a draft until #407's observed run. --- docs/node-dao-member-proposals.md | 213 ++++++++++++++++-------------- 1 file changed, 115 insertions(+), 98 deletions(-) diff --git a/docs/node-dao-member-proposals.md b/docs/node-dao-member-proposals.md index 8612b5ee..cd726e8c 100644 --- a/docs/node-dao-member-proposals.md +++ b/docs/node-dao-member-proposals.md @@ -1,98 +1,83 @@ # Letting members propose to a Node DAO -> **Draft, not validated.** Nobody has run this recipe on mainnet yet. Spike #407 runs it and records the transaction. Decision A picks the role members propose under, and decision B picks the proposal kinds (epic #408). Until #407 has run and both decisions are recorded, this page describes both decision A variants side by side, and the kinds are decision B's candidates. Don't hand it to a DAO's signers as final. +> **Draft, not validated.** Decisions A and B are recorded (epic #408, 2026-11-02): members propose and vote on polls as **Contributors**, a group in each Node DAO's policy. Nobody has run this recipe on mainnet yet. #407's observed run (James and Rhyme) still has to run it and record the transaction. Don't hand it to a DAO's signers as final until then. | Check | Status | | --- | --- | -| #407 mainnet transaction | _pending #407: add the transaction link here_ | -| Decision A (Everyone or a Members group) | _open_ | -| Decision B (which kinds members propose) | _open_ | +| #407 observed mainnet run | _pending (James and Rhyme): add the transaction link here_ | +| Decision A: who proposes | Contributors, a group in the Node DAO's policy | +| Decision B: what they propose | Polls first; other kinds later | -Each Node DAO makes one policy change that does two things: it lets its members add proposals, and it lowers the proposal bond to an amount a member can pay, ideally 0. The DAO's signers propose and approve that change in the tool they already use (Trezu). City Node doesn't change DAO policy and has no screen for it. It only reads the result (see [What City Node reads](#what-city-node-reads)). +Anyone who joined a community (by QR or invite) is a member. A member who wants to propose asks to become a Contributor, or the DAO's signers add them directly. Contributors can then file polls and vote on them. -Sputnik claims below cite [near-daos/sputnik-dao-contract][sdc] at `5434a59d`, the commit #412 was code-read against. All paths are under `sputnikdao2/src/`. - -## How Sputnik decides who may propose +Each Node DAO makes one policy change for this. Its signers propose and approve it in the tool they already use (Trezu). City Node doesn't change DAO policy and has no screen for it. It only reads the result (see [What City Node reads](#what-city-node-reads)). -- `add_proposal` asks the policy whether the caller may perform `AddProposal` on the proposal's kind, and panics with `ERR_PERMISSION_DENIED` if not ([proposals.rs:566-576][add-perm]). -- The caller matches every role whose kind fits them: `Everyone` always matches, `Group` matches listed accounts, and `Member` matches a delegated token weight at or above the threshold ([policy.rs:24-32][match-user]). The comment on `Everyone` says "who is not matched by other roles" ([policy.rs:16][everyone-doc]), but the code matches every account, council members included. -- A matched role allows the action if its `permissions` contain any of four strings ([policy.rs:333-359][can-execute]): - 1. `:AddProposal` - 2. `:*` - 3. `*:AddProposal` - 4. `*:*` -- `` is the proposal kind's policy label ([proposals.rs:125-147][labels]). A permission for one kind never authorizes another. -- The default policy gives an `all` role (kind `Everyone`) the permission `*:AddProposal`, and a `council` group everything except `RemoveProposal` ([policy.rs:179-216][default-policy]). +Sputnik claims below cite [near-daos/sputnik-dao-contract][sdc] at `5434a59d`, the commit #412 was code-read against. All paths are under `sputnikdao2/src/`. -### Permission strings for decision B's candidates +## What changes -| Decision B candidate | Sputnik proposal kind | Policy label | Exact permission | -| --- | --- | --- | --- | -| Polls | `Vote` | `vote` | `vote:AddProposal` | -| Community page changes | `FunctionCall` (config write) | `call` | `call:AddProposal` | -| House of Stake votes | `FunctionCall` (vote with the snapshot proof) | `call` | `call:AddProposal` | +Node DAOs set up in Trezu don't use Sputnik's default policy. Trezu's roles are `Requestor` (files transfers and function calls), `Approver` (shown as "Financial", votes on them) and `Admin` (shown as "Governance", handles membership and policy, including `add_member_to_role`). None of them has an Everyone role or a `vote` permission today. The change: -Labels: `Vote` is `vote` ([proposals.rs:138][labels]) and `FunctionCall` is `call` ([proposals.rs:131][labels]). +1. **Adds a `Contributors` group**, empty or with the first Contributors, that may add polls and vote approve or reject on them, with a poll threshold the group can reach. +2. **Lets members ask to become Contributors**, by granting `add_member_to_role:AddProposal` to an Everyone role (#477). +3. **Sets `proposal_bond`.** It doesn't have to be 0 (product owner, 2026-11-02); see [Bond](#bond). -What each of the four forms would grant a member role, for kind `vote` (the same holds for `call`): +Every other role, vote policy, `proposal_period` and bounty setting stays as it is. Use the role name `Contributors` in every Node DAO, because a member's request names the role it adds them to. -| Form | Example | What it grants | Use for members? | -| --- | --- | --- | --- | -| `:AddProposal` | `vote:AddProposal` | Adding polls only | **Yes.** Grant one per kind decision B picks | -| `:*` | `vote:*` | Adding polls, and also voting on, removing and finalizing them | No: members would approve their own proposals | -| `*:AddProposal` | `*:AddProposal` | Adding every kind, including `policy`, `transfer` and `upgrade_self` | No: the default Everyone grant, and broader than decision B | -| `*:*` | `*:*` | Every action on every kind | Never | +## How Sputnik checks permissions -`call:AddProposal` lets a member propose any function call, not only the ones City Node builds. Sputnik can't scope a permission by receiver or method. The council's approval is what stops other calls, so approvers should check what a call does before approving it. +- `add_proposal` asks the policy whether the caller may perform `AddProposal` on the proposal's kind, and panics with `ERR_PERMISSION_DENIED` if not ([proposals.rs:566-576][add-perm]). Votes go through the same check with `VoteApprove`, `VoteReject` or `VoteRemove` ([proposals.rs:609-612][act]). +- The caller matches every role whose kind fits them: `Everyone` always matches, `Group` matches listed accounts, and `Member` matches a delegated token weight at or above the threshold ([policy.rs:24-32][match-user]). The comment on `Everyone` says "who is not matched by other roles" ([policy.rs:16][everyone-doc]), but the code matches every account, signers included. +- A matched role allows an action if its `permissions` contain any of four strings ([policy.rs:333-359][can-execute]): `:`, `:*`, `*:` or `*:*`. +- `` is the proposal kind's policy label ([proposals.rs:125-147][labels]). A poll (`Vote`) is `vote` and a membership request (`AddMemberToRole`) is `add_member_to_role`. A permission for one kind never authorizes another. -Members never get `VoteApprove`, `VoteReject`, `VoteRemove`, `RemoveProposal` or `Finalize`. Proposing isn't voting: every member proposal still passes only under the council's installed threshold. +## The Contributors role -## Decision A, side by side +```json +{ + "name": "Contributors", + "kind": { "Group": [] }, + "permissions": ["vote:AddProposal", "vote:VoteApprove", "vote:VoteReject"], + "vote_policy": { + "vote": { "weight_kind": "RoleWeight", "quorum": "0", "threshold": [1, 2] } + } +} +``` -| | Variant 1: a Members group | Variant 2: Everyone | -| --- | --- | --- | -| Roles that change | Add a `members` group role. Remove the overlapping `AddProposal` grant from the Everyone role (or remove that role). | Narrow (or add) the Everyone role to the chosen kinds only. | -| Who can propose | Only accounts listed in the group | Any NEAR account | -| Per join | Each new member's Passkey Wallet account is added by an `AddMemberToRole` proposal the council approves (label `add_member_to_role`, [proposals.rs:129][labels]; executed by [policy.rs:292-305][add-member]) | Nothing | -| Spam exposure | Low: only members | Anyone can file polls and calls. With a zero bond nothing discourages it, and the council rejects or removes them | -| What #407 checks | No overlapping Everyone grant, and an unrelated account is refused | An unrelated account succeeds, and the exposure difference | -| Council and thresholds | Unchanged | Unchanged | +- **Exact permissions only.** `vote:*` would also give `VoteRemove` and `Finalize`, and `*:AddProposal` would let Contributors file transfers and policy changes. Contributors never get `VoteRemove` (removing a poll deletes it and forfeits its bond, [proposals.rs:644-648][vote-outcome]) or `Finalize`. +- **A threshold the group can reach.** Votes are counted per role, and a poll is approved or rejected as soon as one role's count reaches that role's threshold for the `vote` label ([policy.rs:378-434][proposal-status], [proposals.rs:256-283][update-votes]). The vote policy above is the default ratio: more than half of the group. A ratio grows with the group, so a large group where few people vote leaves polls to expire. A fixed weight such as `"threshold": "3"` needs that many votes, capped at the group's size ([policy.rs:96-107][weight]). #407 records which one it ran. +- **A poll only records the outcome.** Approving a `Vote` proposal executes nothing ([proposals.rs:431][execute]); its bond is returned at once. +- **Leave polls to Contributors.** A signer role that can vote on polls, including through `*:VoteApprove` or `*:*`, has its own threshold, and a small signer group would settle a poll before the Contributors do. Don't add `vote` grants to signer roles, and check that no signer wildcard covers `vote`. -### First, keep the signers able to propose +## Becoming a Contributor -Both variants take `AddProposal` away from the Everyone role. In the default policy the council keeps its own `*:AddProposal` ([policy.rs:194-208][default-policy]), so nothing else changes. But a DAO that isn't default-shaped may let its signers propose only through Everyone, for example a council with vote and `Finalize` permissions and no `AddProposal`. Sputnik only grants what a role the caller matches lists ([policy.rs:333-359][can-execute]), so narrowing Everyone there would leave nobody able to add a proposal, including the `ChangePolicy` that would undo it. +There are two ways, and the Node DAO approves both (#477): -Before narrowing Everyone, check the current `get_policy`: a signer role other than Everyone must grant `AddProposal` for `policy` and every other kind the signers file, in one of the four forms. If none does, add that grant (`*:AddProposal`, as the default council has) to the signer role in the same change. +- **The member asks.** Their Passkey Wallet files an `AddMemberToRole` proposal for the `Contributors` role. That needs an Everyone role with `add_member_to_role:AddProposal`: -### Variant 1: a Members group + ```json + { + "name": "all", + "kind": "Everyone", + "permissions": ["add_member_to_role:AddProposal"], + "vote_policy": {} + } + ``` -The role to add: +- **Signers add them.** A Governance (`Admin`) signer files the same proposal in Trezu. Trezu's `Admin` role already has `add_member_to_role`. -```json -{ - "name": "members", - "kind": { "Group": ["member-one.near", "member-two.near"] }, - "permissions": ["vote:AddProposal", "call:AddProposal"], - "vote_policy": {} -} -``` +The proposal is `{ "AddMemberToRole": { "member_id": "member-one.near", "role": "Contributors" } }` ([proposals.rs:72-73][add-member-kind]). On approval it adds the account to the group ([policy.rs:292-305][add-member]). It passes under the signer roles' vote policy for `add_member_to_role`, as it does today. -The Everyone role must not grant `AddProposal` for the same kinds in any of the four forms, or anyone can still propose them. The default `all` role has `*:AddProposal`, which covers every kind, so remove that permission (leaving `"permissions": []`) or remove the role in the same change, after the [signer check](#first-keep-the-signers-able-to-propose). +Watch for two things: -### Variant 2: Everyone +- **Anyone can file a request.** The Everyone grant lets any NEAR account file one, not only members. With a bond of 0 that's free, so requests from accounts that aren't members can pile up. A positive bond discourages that, but the member pays it. +- **A request can name any role.** `add_member_to_role:AddProposal` doesn't limit the role. Before approving, check that the role is `Contributors` and that the account is the member's Passkey Wallet. Never approve a request that adds someone to a signer role. -The Everyone role, narrowed from the default `*:AddProposal` to the chosen kinds: +### First, keep the signers able to propose -```json -{ - "name": "all", - "kind": "Everyone", - "permissions": ["vote:AddProposal", "call:AddProposal"], - "vote_policy": {} -} -``` +If a DAO already has an Everyone role, don't replace it with the one above without checking. A DAO whose signers can only propose through Everyone, for example through the default `*:AddProposal` ([policy.rs:185-192][default-policy]), would leave nobody able to add a proposal, including the `ChangePolicy` that would undo it. Sputnik only grants what a role the caller matches lists ([policy.rs:333-359][can-execute]). -Run the [signer check](#first-keep-the-signers-able-to-propose) before narrowing it. If the DAO has no Everyone role, add this one. Leaving the default `*:AddProposal` in place would also work for decision B's kinds, but it lets any account propose transfers, policy changes and upgrades too. +Before narrowing an existing Everyone role, check the current `get_policy`: a signer role other than Everyone must grant `AddProposal` for `policy` and every other kind the signers file, in one of the four forms. If none does, add that grant to the signer role in the same change. Narrowing it is still right: a leftover `*:AddProposal` on Everyone would let any account file polls, transfers and policy changes. If the DAO has no Everyone role, as in Trezu's setup, just add the one above. ## Bond @@ -106,60 +91,84 @@ Run the [signer check](#first-keep-the-signers-able-to-propose) before narrowing | `Failed`, then finalized | Re-executed if still approved (returned on success); returned if it has expired | [proposals.rs:658-685][finalize] | | Rejected by vote | Returned | [proposals.rs:649-652][vote-outcome], [500-510][reject] | | Expired, then finalized | Returned | [proposals.rs:676-679][finalize] | -| Expired, not yet finalized | Kept locked until someone with `Finalize` finalizes it. Members don't have that permission, so the council does | [proposals.rs:665-685][finalize] | +| Expired, not yet finalized | Kept locked until someone with `Finalize` finalizes it. Contributors don't have that permission, so a signer does | [proposals.rs:665-685][finalize] | | Removed by vote (`VoteRemove`) | Forfeited; the proposal is deleted | [proposals.rs:644-648][vote-outcome] | | Deleted with `RemoveProposal` | Forfeited | [proposals.rs:620-623][remove] | -**The DAO returns its current bond, not the one the proposer paid.** `internal_return_bonds` transfers `policy.proposal_bond` from the policy at the time of return ([proposals.rs:339-349][return-bonds]). The policy change itself is safe: it's executed with the policy read before it ran ([proposals.rs:607, 641][act]), so its proposer gets the old bond back. But a proposal that was pending when the bond dropped from 1 NEAR to 0 gets 0 back later. Approve, reject or finalize pending proposals before lowering the bond. +**The DAO returns its current bond, not the one the proposer paid.** `internal_return_bonds` transfers `policy.proposal_bond` from the policy at the time of return ([proposals.rs:339-349][return-bonds]). The policy change itself is safe: it's executed with the policy read before it ran ([proposals.rs:607, 641][act]), so its proposer gets the old bond back. But a proposal that was pending when the bond changed gets the new amount back: one filed at 1 NEAR gets 0 if the bond dropped to 0. Approve, reject or finalize pending proposals before changing the bond. -**A positive bond means the member's Passkey Wallet must hold that NEAR**, plus gas, before they sign. A new wallet starts empty. Per the product owner (2026-10-23), a DAO may keep a bond above zero; the proposal screen (#413) then shows the amount and the wallet's address before the member signs, and City Node doesn't fund wallets. +**A positive bond means the member's Passkey Wallet must hold that NEAR**, plus gas, before they sign. A new wallet starts empty. The bond doesn't have to be 0 (product owner, 2026-11-02). Node DAOs set up in Trezu have 0 today. A positive bond discourages spam requests (see [Becoming a Contributor](#becoming-a-contributor)), but members pay it. With a positive bond, the proposal screen (#413) shows the amount and the wallet's address before the member signs, and City Node doesn't fund wallets. ## Submitting the change from Trezu -A signer whose role allows the proposal kind files it, attaching the DAO's **current** bond as the deposit. It passes under that kind's vote policy, the same as any other policy change. Use the policy-change or custom-proposal option in Trezu. If Trezu's form can't express the change, file `add_proposal` with the arguments below. #407 records which Trezu path it used. +A Governance (`Admin`) signer files it, attaching the DAO's **current** bond as the deposit. It passes under that role's vote policy, the same as any other policy change. Use the policy-change or custom-proposal option in Trezu. If Trezu's form can't express the change, file `add_proposal` with the arguments below. #407 records which Trezu path it used. ### Recommended: one `ChangePolicy` -One proposal changes the role and the bond together, as #407 tests ("one policy change for role plus bond"). `ChangePolicy` (label `policy`) **replaces the whole policy** ([proposals.rs:363-366][execute]), so: +One proposal adds both roles and sets the bond together. `ChangePolicy` (label `policy`) **replaces the whole policy** ([proposals.rs:363-366][execute]), so: 1. Read the DAO's current policy with `get_policy` on `example.sputnik-dao.near`. -2. Copy it, and change only the roles for your decision A variant and `proposal_bond`. First run the [signer check](#first-keep-the-signers-able-to-propose): if no signer role other than Everyone grants `AddProposal` for `policy` and the other kinds the signers file, add that grant to the signer role in this change. Otherwise keep the council roles, vote policies, `proposal_period` and bounty settings exactly as they are. +2. Copy it, and add the `Contributors` and Everyone roles and set `proposal_bond`. If the DAO already has an Everyone role, run the [signer check](#first-keep-the-signers-able-to-propose) first. Keep every other role, vote policy, `proposal_period` and bounty setting exactly as it is. 3. File it. Sputnik accepts only a full policy object here, not the council-list shorthand ([proposals.rs:546-551][add-validate]). Policy, role and vote policy JSON reject unknown fields ([policy.rs:14, 68, 124, 152][policy-types]), so keep the field names exact. The proposer needs `policy:AddProposal` (or one of the other three forms covering `policy`). -Example `add_proposal` arguments, variant 1, starting from a default-shaped policy with a two-signer council and lowering the bond to 0. Attach the current bond, 1 NEAR (`1000000000000000000000000` yoctoNEAR), as the deposit: +Example `add_proposal` arguments. The `Requestor`, `Approver` and `Admin` roles stand in for whatever the DAO's `get_policy` returns; copy those, don't use these. The bond stays 0 here, so the deposit is 0: ```json { "proposal": { - "description": "Let members propose polls and function calls, and lower the proposal bond to 0", + "description": "Add the Contributors group for polls, and let members ask to join it", "kind": { "ChangePolicy": { "policy": { "roles": [ { - "name": "all", - "kind": "Everyone", - "permissions": [], + "name": "Requestor", + "kind": { "Group": ["signer-one.near", "signer-two.near"] }, + "permissions": ["call:AddProposal", "transfer:AddProposal"], "vote_policy": {} }, { - "name": "council", + "name": "Approver", "kind": { "Group": ["signer-one.near", "signer-two.near"] }, "permissions": [ - "*:AddProposal", - "*:VoteApprove", - "*:VoteReject", - "*:VoteRemove", - "*:Finalize" + "call:VoteApprove", + "call:VoteReject", + "call:VoteRemove", + "transfer:VoteApprove", + "transfer:VoteReject", + "transfer:VoteRemove" ], "vote_policy": {} }, { - "name": "members", - "kind": { "Group": ["member-one.near", "member-two.near"] }, - "permissions": ["vote:AddProposal", "call:AddProposal"], + "name": "Admin", + "kind": { "Group": ["signer-one.near", "signer-two.near"] }, + "permissions": [ + "config:*", + "policy:*", + "add_member_to_role:*", + "remove_member_from_role:*", + "policy_add_or_update_role:*", + "policy_remove_role:*", + "policy_update_default_vote_policy:*", + "policy_update_parameters:*" + ], + "vote_policy": {} + }, + { + "name": "Contributors", + "kind": { "Group": [] }, + "permissions": ["vote:AddProposal", "vote:VoteApprove", "vote:VoteReject"], + "vote_policy": { + "vote": { "weight_kind": "RoleWeight", "quorum": "0", "threshold": [1, 2] } + } + }, + { + "name": "all", + "kind": "Everyone", + "permissions": ["add_member_to_role:AddProposal"], "vote_policy": {} } ], @@ -179,29 +188,27 @@ Example `add_proposal` arguments, variant 1, starting from a default-shaped poli } ``` -For variant 2, the `roles` list is the `all` role from [Variant 2](#variant-2-everyone) plus the unchanged `council` role, with no `members` role. - ### Alternative: smaller proposals If the signers would rather not resubmit the whole policy, the same result takes two or three proposals, each voted on separately: -- **The role:** `ChangePolicyAddOrUpdateRole` (label `policy_add_or_update_role`). A role with an existing name replaces it, otherwise it's added ([policy.rs:245-259][add-or-update]). Variant 1 needs two: add `members`, and replace `all` with empty permissions. Variant 2 needs one: replace (or add) `all`. +- **The roles:** `ChangePolicyAddOrUpdateRole` (label `policy_add_or_update_role`), once for `Contributors` and once for `all`. A role with an existing name replaces it, otherwise it's added ([policy.rs:245-259][add-or-update]). ```json - { "ChangePolicyAddOrUpdateRole": { "role": { "name": "members", "kind": { "Group": ["member-one.near"] }, "permissions": ["vote:AddProposal", "call:AddProposal"], "vote_policy": {} } } } + { "ChangePolicyAddOrUpdateRole": { "role": { "name": "Contributors", "kind": { "Group": [] }, "permissions": ["vote:AddProposal", "vote:VoteApprove", "vote:VoteReject"], "vote_policy": { "vote": { "weight_kind": "RoleWeight", "quorum": "0", "threshold": [1, 2] } } } } } ``` -- **The bond:** `ChangePolicyUpdateParameters` (label `policy_update_parameters`). It changes only the parameters it's given ([proposals.rs:55-60][parameters], [policy.rs:276-290][update-parameters]; executed at [proposals.rs:454-459][execute]). +- **The bond**, only if it changes: `ChangePolicyUpdateParameters` (label `policy_update_parameters`). It changes only the parameters it's given ([proposals.rs:55-60][parameters], [policy.rs:276-290][update-parameters]; executed at [proposals.rs:454-459][execute]). ```json { "ChangePolicyUpdateParameters": { "parameters": { "proposal_bond": "0" } } } ``` -Between the proposals the DAO is half changed, so the order in which the council approves them matters: +Between the proposals the DAO is half changed, so the order in which the signers approve them matters: 1. Any signer-role grant the [signer check](#first-keep-the-signers-able-to-propose) calls for. -2. The member role. With variant 1, approve `members` before the Everyone change, so no member is shut out in between. -3. **The bond change, last**, once every other proposal from this change, and any other pending proposal, has been settled: approved or rejected, and any expired or `Failed` one finalized ([proposals.rs:658-685][finalize]). Sputnik refunds the bond in force when it pays the refund ([proposals.rs:339-349][return-bonds]), so a role proposal approved after the bond drops to 0 refunds its proposer 0 and the 1 NEAR they attached stays in the DAO. The bond proposal itself runs under the policy read before it executes ([proposals.rs:607, 641][act]), so its own proposer still gets the old bond back. +2. `Contributors`, then `all`, so a member's request has a role to name. +3. **The bond change, last**, once every other proposal from this change, and any other pending proposal, has been settled: approved or rejected, and any expired or `Failed` one finalized ([proposals.rs:658-685][finalize]). Sputnik refunds the bond in force when it pays the refund ([proposals.rs:339-349][return-bonds]), so a proposal settled after the bond changes refunds the new amount, not what its proposer attached. The bond proposal itself runs under the policy read before it executes ([proposals.rs:607, 641][act]), so its own proposer gets back what they paid. The proposer needs `AddProposal` for each of those labels. @@ -210,13 +217,19 @@ The proposer needs `AddProposal` for each of those labels. `ui/src/lib/sputnik-proposals.ts` (from #435) reads the result; nothing in City Node writes it. - `fetchSputnikPolicy(daoAccountId)` (`:164`) reads `get_policy` over public RPC, or returns `null`. -- `proposalPermission(policy, accountId, kind, delegationBalance?)` (`:301-315`) evaluates that policy the way `can_execute_action` does. It matches Everyone, Group and token-weighted Member roles (`accountInRoles`, `:217-230`) and the four permission forms (`permits`, `:195-208`) for one `SputnikProposalKindLabel` (`:176-193`). It returns `{ canPropose, reason, bondYocto }`, where `reason` is `allowed`, `no-account`, `no-policy`, `no-permission` or `bond-unavailable`, and `bondYocto` is the exact deposit in yoctoNEAR. +- `proposalPermission(policy, accountId, kind, delegationBalance?)` (`:301-315`) evaluates that policy the way `can_execute_action` does for `AddProposal`. It matches Everyone, Group and token-weighted Member roles (`accountInRoles`, `:217-230`) and the four permission forms (`permits`, `:195-208`) for one `SputnikProposalKindLabel` (`:176-193`). It returns `{ canPropose, reason, bondYocto }`, where `reason` is `allowed`, `no-account`, `no-policy`, `no-permission` or `bond-unavailable`, and `bondYocto` is the exact deposit in yoctoNEAR. - A missing or unreadable policy, or a missing or malformed `proposal_bond`, never allows submission, and a malformed bond is never read as zero (`proposalBondYocto`, `:288-291`). Pass `delegationBalance` (`readDelegationBalance`, `:146`) only when the DAO has token-weighted Member roles. - `proposerRoles(policy, kind)` (`:239-245`) and `canAccountPropose` (`:247-254`) are the older helpers; `canAccountPropose` still checks `call` only. -For the proposal screen (#413): read the policy with `fetchSputnikPolicy`, then call `proposalPermission(policy, walletAccountId, "vote")` or `"call"` for each kind decision B picks, and show the bond from `bondYocto`. Don't take the bond from `fetchProposalBond` (`:393-398`): it returns `"0"` when the read fails, and `proposeAsSession` (`:406-429`) attaches that value as the deposit. +For the Proposals tab: + +- **Can this member ask to become a Contributor?** `proposalPermission(policy, walletAccountId, "add_member_to_role")` (#477). +- **Is this member a Contributor, and can they file a poll?** `proposalPermission(policy, walletAccountId, "vote")` (#413). Show the bond from `bondYocto`. +- **Can they vote on a poll?** #412 now also asks the helper for `VoteApprove` and `VoteReject` on a kind. That read isn't on main yet. + +Don't take the bond from `fetchProposalBond` (`:393-398`): it returns `"0"` when the read fails, and `proposeAsSession` (`:406-429`) attaches that value as the deposit. -The tests in `ui/src/lib/sputnik-proposal-permission.test.ts` cover both decision A variants, each permission form, a permission for another kind, and zero, positive, missing and malformed bonds. +The tests in `ui/src/lib/sputnik-proposal-permission.test.ts` cover a group role, Everyone with and without `AddProposal`, each permission form, a permission for another kind, and zero, positive, missing and malformed bonds. [sdc]: https://github.com/near-daos/sputnik-dao-contract/tree/5434a59dec23ee0c49df8c8c4350c208a7e75b0b [everyone-doc]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/policy.rs#L16 @@ -243,3 +256,7 @@ The tests in `ui/src/lib/sputnik-proposal-permission.test.ts` cover both decisio [vote-outcome]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L637-L656 [finalize]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L658-L685 [callback]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L698-L722 +[weight]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/policy.rs#L96-L107 +[proposal-status]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/policy.rs#L378-L434 +[add-member-kind]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L72-L73 +[update-votes]: https://github.com/near-daos/sputnik-dao-contract/blob/5434a59dec23ee0c49df8c8c4350c208a7e75b0b/sputnikdao2/src/proposals.rs#L256-L283