Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

admin.gno

10.04 Kb · 202 lines
  1package valopers
  2
  3import (
  4	"gno.land/p/moul/authz/v0"
  5	"gno.land/p/nt/ufmt/v0"
  6	"gno.land/r/gov/dao"
  7)
  8
  9var auth *authz.Authorizer
 10
 11// Auth returns a read-only description of the realm's current governance
 12// authority (for rendering / inspection). It renders as
 13//
 14//	contract_authority[contract=gno.land/r/gov/dao,proposer=contract-identity]
 15//
 16// i.e. "GovDAO governs this realm, and only GovDAO may drive it" — a
 17// statement an on-chain reader can act on. The proposer half is
 18// load-bearing: without it the string is byte-identical whether the
 19// authority is gated or wide open (see ContractAuthority.String).
 20//
 21// It deliberately does NOT return the live *authz.Authorizer: an
 22// exported handle to the authority would let any realm reach its
 23// mutators (Transfer, DoByPrevious, ...) directly. Returning a
 24// description instead of the handle keeps that surface unreachable from
 25// here in the first place. Valoper.AuthOwner follows the same rule for
 26// per-operator auth lists.
 27func Auth() string {
 28	return auth.String()
 29}
 30
 31// updateInstructions is the realm's only privileged write. It authorizes
 32// as the realm that CROSSED INTO valopers, not as valopers itself.
 33//
 34// Why DoByPrevious and not DoByCurrent: own-path + DoByCurrent is a
 35// tautology (see NewContractAuthority's godoc for the mechanism, once).
 36// `rlm.Previous()` is the caller — on the legitimate path r/gov/dao's
 37// executor frame, which is what init.gno's authority is pointed at.
 38//
 39// What it buys: it is the code-level guard against this realm's most
 40// likely regression, a maintainer later adding an exported crossing
 41// entrypoint that reaches here. Under DoByCurrent such an entrypoint
 42// authorizes for any caller; under DoByPrevious the caller's own address
 43// is the principal and the gate rejects it. Pinned by
 44// filetests/z_govdao_only_principal_filetest.gno.
 45//
 46// Two things it does NOT cover on its own, both closed elsewhere:
 47// re-exporting the privileged closure (guarded by the seal in
 48// NewInstructionsProposalRequest), and exporting a function whose
 49// signature is assignable to dao's `func(realm) error` callback type,
 50// which the proxy would invoke on an attacker's behalf. The second was a
 51// live bypass of this gate until SimpleExecutor.Execute began rejecting
 52// invocation from outside r/gov/dao (r/gov/dao/types.gno); keep BOTH
 53// conditions in mind before adding an exported symbol here.
 54//
 55// Returns the authorization error rather than panicking on it. That matters
 56// on the governance path: impl.ExecuteProposal turns an executor ERROR into
 57// status Denied plus a DeniedReason, but it cannot see a panic — a panic
 58// aborts the whole ExecuteProposal transaction, burning its gas and leaving
 59// the proposal stuck Accepted, retryable forever and never deniable. The
 60// refusal is currently unreachable on the approved route (Previous() there
 61// is always r/gov/dao), but init.gno's handler is explicitly offered as the
 62// seam for an intent check (timelock, quorum, audit trail), and the first
 63// such check to return an error would hit exactly that.
 64func updateInstructions(_ int, rlm realm, newInstructions string) error {
 65	return auth.DoByPrevious(0, rlm, "update-instructions", func() error {
 66		instructions = newInstructions
 67		return nil
 68	})
 69}
 70
 71// NewInstructionsProposalRequest builds the GovDAO proposal that rewrites
 72// this realm's instructions. It replaces the previously exported
 73// NewInstructionsProposalCallback.
 74//
 75// SECURITY: the privileged closure must never leave this package.
 76//
 77// The VM mints a crossing frame's `cur` from the CALLEE's declaring
 78// package, so a closure declared here always runs with valopers'
 79// identity no matter who invokes it — and whoever holds the closure
 80// chooses its Previous() by wrapping it in an executor of their own.
 81// Handing such a closure to a caller therefore hands out the ability to
 82// satisfy this realm's gate whichever principal it is pointed at: the
 83// handler in init.gno runs the action and `instructions` is rewritten
 84// with no proposal and no vote. That is what the exported callback did.
 85//
 86// Returning a dao.ProposalRequest instead seals the capability: the
 87// executor is an unexported field with no accessor (dao.ProposalRequest
 88// exposes only Title/Description/Filter), so the only way to reach the
 89// closure is for GovDAO to execute the proposal that contains it.
 90//
 91// Note this is deliberately stronger than returning a dao.Executor —
 92// even a dao.NewSafeExecutor-wrapped one. Executor.Execute is an
 93// exported method, so a returned Executor stays directly invocable by
 94// its holder, and SafeExecutor's only gate (InAllowedDAOs) FAILS OPEN
 95// while the allowedDAOs list is empty, which is the documented
 96// bootstrap state (see r/gov/dao/loader/v0). A sealed request has no
 97// such conditional.
 98//
 99// The rule for this realm: no exported function may return a crossing
100// closure, a dao.Executor, or anything else that carries valopers'
101// frame identity — and no exported function may itself BE such a thing,
102// i.e. have a signature assignable to dao's `func(realm) error` callback
103// type, since the proxy would then invoke it on an attacker's behalf.
104// filetests/z_foreign_realm_capability_filetest.gno pins the first;
105// filetests/z_govdao_only_principal_filetest.gno pins the second.
106func NewInstructionsProposalRequest(cur realm, newInstructions string) dao.ProposalRequest {
107	cb := func(cur realm) error {
108		return updateInstructions(0, cur, newInstructions)
109	}
110
111	title := instructionsProposalTitle
112	description := ufmt.Sprintf("Update the instructions to: \n\n%s", newInstructions)
113
114	return dao.NewProposalRequest(title, description, dao.NewSimpleExecutor(0, cur, cb, ""))
115}
116
117// instructionsProposalTitle is the on-chain title of the instructions
118// proposal, named as a constant so a test can pin it: it is rendered to
119// GovDAO voters and is the string off-chain tooling matches on. It was
120// "/p/gnops/valopers: ..." before this realm's proposal construction moved
121// here; the "/p/" was simply wrong (valopers is an /r/ realm).
122const instructionsProposalTitle = "/r/gnops/valopers: Update instructions"
123
124// rotateAuthority replaces this realm's governance authority.
125//
126// Unexported, and reachable only through the sealed request built by
127// NewAuthorityRotationProposalRequest — same discipline as
128// updateInstructions, for the same reason.
129//
130// authz.Authorizer.Transfer derives its principal as
131// rlm.Previous().Address(), so inside a GovDAO-executed callback declared
132// here Previous() is r/gov/dao — exactly the principal init.gno's authority
133// asserts. No same-package cross() trick is needed.
134func rotateAuthority(_ int, rlm realm, newContractPath string) error {
135	return auth.Transfer(0, rlm, authz.NewContractAuthority(
136		newContractPath,
137		func(_ string, action authz.PrivilegedAction) error {
138			return action()
139		},
140	))
141}
142
143// NewAuthorityRotationProposalRequest builds the GovDAO proposal that
144// re-points this realm's governance authority at `newContractPath`.
145//
146// WHY THIS EXISTS. Without it the authority installed by init.gno is
147// permanently frozen: nothing else calls auth.Transfer, Auth() returns a
148// description rather than the live Authorizer, and Transfer routes through
149// the authority's own gate — so if the asserted principal ever stops being
150// presentable, `instructions` becomes unwritable with no on-chain recovery.
151// That is not hypothetical: the r/gov/dao proxy path is a governance
152// artifact that can be superseded, and a well-formed but wrong path (say
153// the impl path instead of the proxy) is accepted at construction and dead
154// forever. Redeploying instead is expensive — r/sys/validators/v0/cache.gno
155// hardcodes `const valopersRealmPath`, so a new pkgpath means editing a
156// second genesis realm and re-registering every valoper. It must exist
157// before deploy, because code cannot be added to a deployed realm.
158//
159// WHY A PATH AND NOT AN authz.Authority. Taking an Authority would make
160// this an open-interface input (Class-3 impl-substitution): a proposal
161// could install an always-approve or always-deny authority, and readers of
162// Auth() could not tell from the rendered description alone. Taking a path
163// keeps the installed authority canonical by construction — always a
164// ContractAuthority with the pass-through handler and the contract-identity
165// gate — so the only thing governance can change is WHICH principal is
166// asserted. The path is validated by authz.NewContractAuthority, which
167// panics on a malformed one, aborting the proposal execution rather than
168// bricking the realm.
169//
170// This remains a privileged surface: a majority that can pass this proposal
171// can point the authority at a principal it controls. That is the same
172// power a majority already has over `instructions`, and it is the price of
173// being recoverable at all.
174func NewAuthorityRotationProposalRequest(cur realm, newContractPath string) dao.ProposalRequest {
175	cb := func(cur realm) error {
176		return rotateAuthority(0, cur, newContractPath)
177	}
178
179	title := "/r/gnops/valopers: Rotate governance authority"
180	description := ufmt.Sprintf(
181		"Re-point the valopers governance authority at: \n\n%s\n\n"+
182			"The authority stays a contract-identity ContractAuthority; only the asserted principal changes.",
183		newContractPath,
184	)
185
186	return dao.NewProposalRequest(title, description, dao.NewSimpleExecutor(0, cur, cb, ""))
187}
188
189// NewInstructionsProposalCallback was removed, not deprecated: it was the
190// capability leak described above, so an inert shim would be pointless and
191// a working one would reopen the hole. Precedent + replay check: the
192// min-fee callback below was already removed the same way even though it
193// HAD been used on gnoland1 (GovDAO Prop #19), whereas the instructions
194// callback has no on-chain trace at all — the live realm's instructions
195// are byte-identical to the genesis default and none of gnoland1's 26
196// GovDAO proposals is an "Update instructions" one.
197//
198// The min-fee callback was removed: the fee now lives in sysparams
199// under node:valoper:register_fee, and
200// proposal.ProposeNewMinFeeProposalRequest delegates to
201// sys/params.NewSysParamUint64PropRequest. Removing avoids the
202// forward-compat hazard of a no-op shim with no caller-auth gating.