Skip to content

Latest commit

 

History

History
363 lines (261 loc) · 25.7 KB

File metadata and controls

363 lines (261 loc) · 25.7 KB

Lesson 5 — Treasury: a public-funds layer for the chain

Commit: 6e253cdAdd a treasury and stop burning the chain's income

Reading time: ~12 min.

Where we are. After lesson 4 we have OpenGov: anyone can submit a referendum, conviction-locked voters decide, approved calls execute via the scheduler. But governance with nothing to govern over is half a system. Slashed funds vanish (type Slash = ()). Transaction fees vanish. Reward dust from imperfect era payouts vanishes. The chain has economic security but no economic body — no account that the chain itself controls. This lesson adds it. Spec version 103 → 104.

Goal

The chain gains a single sovereign on-chain account — the treasury — and a set of revenue streams that route value into it instead of letting it disappear. Spending out of the treasury happens exclusively through OpenGov: two new tracks (Treasurer and BigSpender) authorize spends at different scales, gated by track-specific approval/support curves like every other governance decision.

The runtime grows from 17 to 18 pallets. The TRACKS_DATA array grows from 5 to 7. Three previously-() sinks in earlier configs (pallet_staking::Slash, pallet_staking::RewardRemainder, pallet_referenda::Slash) get wired to the treasury.

Background

What "treasury" means in substrate

A treasury in substrate is two things glued together:

  1. An account. Specifically, an account derived from a PalletId — a deterministic 8-byte tag (we use b"st/trsry"). into_account_truncating() prefixes it with b"modl", then zero-pads and truncates the result to the AccountId width — no hashing, so the bytes stay human-readable (you can literally read modl/st/trsry in the derived address). The pallet holds the private key in the sense that no one holds it: the only way to spend from this account is through pallet-treasury's own extrinsics, which gate spending behind SpendOrigin. The treasury account exists in pallet-balances like any other account, can receive transfers, can be displayed in block explorers, can show balance.

  2. A spending machine. pallet-treasury exposes spend (signed by SpendOrigin, schedules a future payout to a beneficiary) and payout (anyone can dispatch this to actually transfer the approved amount, once the payout-period delay has elapsed). It also runs periodic accounting at the end of each SpendPeriod — burning a configurable fraction of the unspent balance, processing any spend-funds hooks.

This is intentionally minimal. There's no bounty-curator state machine, no tipping flow, no asset-management. Those are separate pallets (pallet-bounties, pallet-tips, etc.) that plug into the treasury account; the core treasury is just "an account with a controlled payout extrinsic."

Revenue: where the money comes from

A treasury that only receives manual transfers is useless. Substrate gives several hooks where value naturally accumulates, all routable to treasury:

  • Slashed stake. When a validator equivocates or is otherwise slashed, the slashed balance has to go somewhere. pallet_staking::Config::Slash is the destination. Setting it to () burns; setting it to a treasury-resolving adapter routes there.
  • Reward dust. EraPayout returns the validator-payout share and a "remainder" — the inflation that exists but isn't reward (e.g., when validators are under-staked relative to ideal). pallet_staking::Config::RewardRemainder is the sink for that remainder.
  • Transaction fees. pallet_transaction_payment::Config::OnChargeTransaction receives the credit when fees are charged. The adapter can split it: author share, treasury share, burn share — any pure function of the incoming credit.
  • Tips and operational fees. Same OnChargeTransaction hook, but the transaction payment pallet calls the adapter's on_unbalanceds with a separate iterator for tips, so the adapter can route tips differently from base fees.
  • Forfeited deposits from rejected referenda. pallet_referenda::Config::Slash receives the slashed submission/decision deposit when a referendum is killed or rejected. Same ()-vs-treasury choice as staking slashes.

Adding all of these is mostly mechanical — change type X = (); to type X = ResolveTo<TreasuryAccount, Balances>; (or a custom adapter for fees). The interesting decisions are around the fee split and what we do with leftover treasury funds.

Two OnUnbalanced APIs, and why it matters

Substrate is mid-migration between two ways of representing "this pile of value just appeared, do something with it":

  • Legacy: Currency::NegativeImbalance. The old Currency trait represents accruals as a NegativeImbalance value — a debt that must be cleared by either crediting an account or being explicitly burned. Pallets that haven't migrated still expect this type.
  • Modern: fungibles::Credit. The new fungibles API uses Credit<AccountId, Asset> — same idea, but unified across native balance and arbitrary fungible assets. Pallets that have migrated expect Credit.

The relevance for treasury wiring: pallet_staking (modernized) wants something that implements OnUnbalanced<Credit<AccountId, Balances>>. pallet_referenda (not modernized for Slash) wants something that implements OnUnbalanced<NegativeImbalance<Runtime>>. The treasury account is the same in both cases, but the adapter you wire is different.

frame_support::traits::tokens::imbalance::ResolveTo<AccountId, Fungible> is the modern path — it implements OnUnbalanced<Credit<...>> and credits the named account. pallet_treasury::Pallet<Runtime> itself implements OnUnbalanced<NegativeImbalance<...>> (the legacy path) because the treasury pallet pre-dates the fungibles migration.

So: modern sinks → ResolveTo<TreasuryAccount, Balances>. Legacy sinks → Treasury (the pallet name) directly. Both end up at the same account.

SpendOrigin and the success-value pattern

pallet_treasury::Config::SpendOrigin is interesting because EnsureOrigin::Success matters: when spend is called, the origin's success value is the maximum amount the spend may pay out. The pattern:

type SpendOrigin = EitherOf<
    EnsureRootWithSuccess<AccountId, MaxBalance>,  // Root → Balance::MAX
    Spender,                                       // custom enum → per-variant balance
>;

EnsureRootWithSuccess takes a parameter type for the success value. We pass MaxBalance = Balance::MAX so Root can spend any amount. Spender is our custom origin combinator (defined in runtime/src/origins.rs) that maps the Treasurer track origin to Balance::MAX and the BigSpender track origin to 1_000_000 * UNIT. So Treasurer is functionally equivalent to Root for spending; BigSpender is a capped budget.

The capping is enforced inside pallet_treasury::Pallet::spend: it checks the amount against the success value returned by the origin check, and rejects with InsufficientPermission if the request exceeds the cap.

The path we chose

Fee split: 20% to treasury, 80% to author, 100% of tips to treasury. Implemented as a DealWithFees adapter that calls Imbalance::ration(20, 80) on the fee credit and routes each half separately. The author leg goes through a small ToAuthor adapter that looks up the current block author via pallet_authorship::Pallet::<Runtime>::author() and resolves the credit to that account; tips are merged into the treasury portion before routing.

Periodic 1% burn of unspent treasury, no burn destination. pallet_treasury::Config::Burn = Permill::from_percent(1) runs at each SpendPeriod boundary; BurnDestination = () means the burnt funds just disappear (deflationary). Polkadot does the same. Disabling the burn (Burn = Permill::zero()) is the alternative — useful if the chain is supposed to be a strict accumulator.

No bounties pallet, no tips pallet. pallet-bounties and pallet-tips are separate pallets that consume the treasury account; they add their own state machines (curator-managed staged payouts, tipper-collective fast-path payouts). For a chain whose first treasury use case is "OpenGov referendum approves a payout," both are optional and either can be added later. We skip both to keep the surface minimal.

Two new tracks: Treasurer (unlimited) and BigSpender (1,000,000 UNIT cap). Westend has a whole family of tip/spend tracks (SmallTipper, BigTipper, SmallSpender, MediumSpender, BigSpender) plus Treasurer. Two tracks is the minimum that gives us a graduated spending model — capped spends through BigSpender, whose much lower support threshold makes it easier to pass than the Root-strength Treasurer; unlimited or treasury-admin actions through Treasurer. (Both share the 28-day decision period; the difference is the required support, not the timing — BigSpender's approval curve is actually a touch stricter than Treasurer's, but its support requirement collapses to ~1%.) Adding more tracks later is ~30 lines each.

SpendPeriod = 24 * DAYS, PayoutSpendPeriod = 30 * DAYS. SpendPeriod controls the burn cadence; PayoutSpendPeriod controls how long an approved spend remains claimable before it expires. Both mirror Polkadot's defaults.

Code walkthrough

runtime/src/lib.rs — one new pallet

#[runtime::pallet_index(17)]
pub type Treasury = pallet_treasury;

And spec_version: 103 → 104.

runtime/src/origins.rs — the Spender combinator

Two new Origin variants:

#[pallet::origin]
pub enum Origin {
    // … existing four …
    BigSpender,
    Treasurer,
}

The unit-success Treasurer origin (returns ()) is generated alongside the existing four by the decl_unit_ensures! macro. That's what you use for things like RejectOrigin where the origin just needs to authorize the call, with no associated value.

Then a new decl_ensure! macro produces the Spender combinator — same shape as decl_unit_ensures!, but parametrized over the success type and per-variant success values:

decl_ensure! {
    pub type Spender: EnsureOrigin<Success = Balance> {
        BigSpender = 1_000_000 * UNIT,
        Treasurer = Balance::MAX,
    }
}

The macro expands to a struct Spender; with an EnsureOrigin impl whose try_origin matches the inbound origin against the named variants and returns the associated Balance value on a match. pallet_treasury::Pallet::spend then consumes that as the per-call spend cap.

This is the pattern Polkadot uses for the same purpose. See polkadot-fellows/runtimesrelay/polkadot/src/governance/origins.rs for the original.

runtime/src/tracks.rs — two new tracks

Two new curves:

// Treasurer: Root-like curves, used for very large spends or admin actions.
const APP_TREASURER: Curve = Curve::make_reciprocal(4, 28, percent(80), percent(50), percent(100));
const SUP_TREASURER: Curve = Curve::make_linear(28, 28, percent(0), percent(50));

// BigSpender: medium-strict thresholds, longer support tail than admin tracks.
const APP_BIG_SPENDER: Curve = Curve::make_linear(28, 28, percent(50), percent(100));
const SUP_BIG_SPENDER: Curve = Curve::make_reciprocal(20, 28, percent(1), percent(0), percent(50));

APP_TREASURER and SUP_TREASURER are identical to APP_ROOT / SUP_ROOT — the Treasurer track is as hard to pass as a Root referendum. BigSpender's approval curve is slightly stricter than Treasurer's for most of the window, but its support curve is far more lenient (a reciprocal that drops to ~1%), which is what makes a BigSpender referendum easier to carry in practice.

Two new Track entries (ids 5 and 6) in TRACKS_DATA:

pallet_referenda::Track {
    id: 5,
    info: pallet_referenda::TrackInfo {
        name: s("treasurer"),
        max_deciding: 10,
        decision_deposit: 100 * UNIT,
        prepare_period: 2 * HOURS,
        decision_period: 28 * DAYS,
        confirm_period: 12 * HOURS,
        min_enactment_period: 24 * HOURS,
        min_approval: APP_TREASURER,
        min_support: SUP_TREASURER,
    },
},
pallet_referenda::Track {
    id: 6,
    info: pallet_referenda::TrackInfo {
        name: s("big_spender"),
        max_deciding: 50,
        decision_deposit: 50 * UNIT,
        prepare_period: 4 * HOURS,
        decision_period: 28 * DAYS,
        confirm_period: 7 * DAYS,
        min_enactment_period: 24 * HOURS,
        min_approval: APP_BIG_SPENDER,
        min_support: SUP_BIG_SPENDER,
    },
},

And two new arms in track_for():

crate::origins::Origin::Treasurer => Ok(5),
crate::origins::Origin::BigSpender => Ok(6),

runtime/src/configs.rs — the treasury config + DealWithFees

The treasury Config impl:

parameter_types! {
    pub const TreasuryPalletId: PalletId = PalletId(*b"st/trsry");
    pub TreasuryAccount: AccountId = TreasuryPalletId::get().into_account_truncating();
    pub const SpendPeriod: BlockNumber = 24 * DAYS;
    pub const Burn: Permill = Permill::from_percent(1);
    pub const MaxApprovals: u32 = 100;
    pub const PayoutSpendPeriod: BlockNumber = 30 * DAYS;
    pub const MaxBalance: Balance = Balance::MAX;
}

impl pallet_treasury::Config for Runtime {
    type PalletId = TreasuryPalletId;
    type Currency = Balances;
    type RejectOrigin = EitherOfDiverse<EnsureRoot<AccountId>, Treasurer>;
    type SpendPeriod = SpendPeriod;
    type Burn = Burn;
    type BurnDestination = ();
    type SpendFunds = ();
    type MaxApprovals = MaxApprovals;
    type SpendOrigin = EitherOf<EnsureRootWithSuccess<AccountId, MaxBalance>, Spender>;
    type AssetKind = ();
    type Beneficiary = AccountId;
    type BeneficiaryLookup = IdentityLookup<AccountId>;
    type Paymaster = PayFromAccount<Balances, TreasuryAccount>;
    type BalanceConverter = UnityAssetBalanceConversion;
    type PayoutPeriod = PayoutSpendPeriod;
    // …
}

The interesting bits:

  • TreasuryPalletId = b"st/trsry". 8 bytes, turned into an account at runtime by into_account_truncating() (b"modl" prefix + zero-padding, no hash). b"st/" is our project tag.
  • AssetKind = () and Beneficiary = AccountId. We only support spending native balance to native accounts — no multi-asset treasury, no asset-id-tagged spends. () as the asset kind means "the only asset is the native balance."
  • Paymaster = PayFromAccount<Balances, TreasuryAccount>. The paymaster is the thing that actually performs the transfer when payout is called. PayFromAccount is the stock substrate impl that does Balances::transfer(TreasuryAccount, beneficiary, amount).
  • BalanceConverter = UnityAssetBalanceConversion. Since AssetKind = (), there's only one asset, so the "convert asset amount to native amount" function is identity.
  • RejectOrigin uses EitherOfDiverse (heterogeneous success types, both coerced to ()) because reject's origin only needs authorization, not a budget. SpendOrigin uses EitherOf (homogeneous success type) because both branches produce Balance.

The author-pay adapter:

pub struct ToAuthor;
impl OnUnbalanced<Credit<AccountId, Balances>> for ToAuthor {
    fn on_nonzero_unbalanced(credit: Credit<AccountId, Balances>) {
        if let Some(author) = pallet_authorship::Pallet::<Runtime>::author() {
            let _ = <Balances as Balanced<AccountId>>::resolve(&author, credit);
        }
    }
}

pallet_authorship::Pallet::author() returns the current block's author by delegating to the configured FindAuthor impl — in this runtime, pallet_session::FindAccountFromAuthorIndex<Self, Babe>, which reads the BABE digest, looks up the authority index, and resolves it to the session-keyed signing account. <Balances as Balanced<AccountId>>::resolve(&author, credit) then deposits the credit into that account. If the author lookup fails (e.g. during genesis), on_nonzero_unbalanced returns without consuming the credit, which then drops and burns — an acceptable edge case.

The fee splitter:

pub struct DealWithFees;
impl OnUnbalanced<Credit<AccountId, Balances>> for DealWithFees {
    fn on_unbalanceds(mut fees_then_tips: impl Iterator<Item = Credit<AccountId, Balances>>) {
        if let Some(fees) = fees_then_tips.next() {
            let mut split = fees.ration(20, 80);
            if let Some(tips) = fees_then_tips.next() {
                tips.merge_into(&mut split.0);
            }
            ResolveTo::<TreasuryAccount, Balances>::on_unbalanced(split.0);
            ToAuthor::on_unbalanced(split.1);
        }
    }
}

pallet-transaction-payment calls on_unbalanceds with an iterator that yields the base-fee credit first, then the tip credit if any. The adapter:

  1. Pulls the fee credit, splits it 20/80 via Imbalance::ration(20, 80). split.0 is the 20% treasury portion; split.1 is the 80% author portion.
  2. Pulls the tip credit (if any) and merges it into the treasury portion via Imbalance::merge_into.
  3. Routes the (treasury + tips) portion to the treasury account via ResolveTo.
  4. Routes the author portion to the current block author via ToAuthor.

merge_into is the in-place version of merge — it adds the second imbalance into the first, leaving you with a single combined credit and no intermediate Credit variable.

The structural pattern (split fees first, merge tips into one side, route both halves) mirrors polkadot/runtime/common/src/impls.rs exactly. We differ from Polkadot only in the proportions (Polkadot is 80%-to-treasury / 20%-to-author with tips going to the author; we're inverted) and in the merge target (Polkadot merges tips into author; we merge tips into treasury).

OnChargeTransaction is then wired through:

impl pallet_transaction_payment::Config for Runtime {
    type OnChargeTransaction = FungibleAdapter<Balances, DealWithFees>;
    // …
}

FungibleAdapter is the stock fungibles-API impl of OnChargeTransaction; it withdraws the fee from the payer, then hands the resulting Credit to DealWithFees.

The staking rewires:

impl pallet_staking::Config for Runtime {
    type RewardRemainder = ResolveTo<TreasuryAccount, Balances>;
    type Slash = ResolveTo<TreasuryAccount, Balances>;
    // …
}

Both were () in lesson 3.

The referenda rewire:

impl pallet_referenda::Config for Runtime {
    type Slash = Treasury;
    // …
}

Was () in lesson 4. Note this one uses Treasury (the pallet) directly, not ResolveTo. That's the legacy-Currency-API path described above: pallet_referenda::Slash requires OnUnbalanced<NegativeImbalance<...>>, which pallet_treasury::Pallet implements unconditionally. ResolveTo is on the fungibles-Credit side and wouldn't satisfy the trait bound.

Other paths we could have taken

Different fee splits

The 20/80 split is one parameter knob. Alternatives:

  • 100% to author. No treasury fee revenue. Maximum validator yield. Used by chains that fund treasury exclusively from slashing + endowment.
  • 100% to treasury, 0% to author. Validators are paid only via era inflation (pallet-staking rewards). Used by chains that want to fully decouple fee volume from validator economics.
  • 100% burn. Pure deflationary — fees aren't a revenue source for anyone. The chain's only treasury inflow is slashing + reward remainder. Some L1s do this.
  • Split across multiple destinations. Author + treasury + burn + a separate insurance fund. Same DealWithFees shape, just more arms.

Each is a 1-line change to DealWithFees. The economic implications are large and chain-specific.

Bounties + tips

pallet-bounties and pallet-tips are the conventional ways to spend treasury without going through full OpenGov referenda every time:

  • pallet-tips — a small group ("tippers") can tip a beneficiary up to a configured cap. Fast, lightweight, good for unbudgeted contributions. Polkadot retired tips in 2023 in favour of pure referenda; Kusama still has them.
  • pallet-bounties — multi-stage state machine: propose → fund-and-elect-curator → curator pays out on milestones. Designed for "we want $X work done, need oversight on whether it's done well." Polkadot uses bounties heavily.

We didn't add either. The argument against tips: with reasonable spend-track curves, small payouts can go through BigSpender with relatively fast confirm periods. The argument against bounties: their state machine duplicates work that could be a multi-call OpenGov sequence ("approve curator hire", "approve milestone payout"). Both are defensible but add 100+ lines and a chunk of state per pallet. Add when the chain has actual treasury-spending workflows that need them.

BurnDestination somewhere useful

type BurnDestination = (); burns the periodic burn fraction. Alternatives:

  • Route to a different sovereign account — e.g., an insurance fund, a community-controlled subaccount. Same ResolveTo shape.
  • Disable the burn entirely (Burn = Permill::zero()). The treasury becomes a strict accumulator. Useful if you'd rather grow the balance indefinitely than create deflationary pressure.
  • Route to an off-chain bridge (more complex; requires the destination to be an OnUnbalanced impl that initiates a bridge transfer).

Polkadot's choice (Burn = 1%, BurnDestination = ()) is the conservative default. We mirror it. (Westend, by contrast, disables the burn entirely.)

Multi-asset treasury

AssetKind = () means we only spend the native balance. With pallet-assets wired, you could set AssetKind = AssetIdOf<Runtime> and let the treasury hold arbitrary fungible assets — DOT-like multi-currency treasuries, asset-backed spending tracks, etc. The Polkadot Asset Hub uses exactly this. For Substrate Tutorial (no assets pallet yet), the unit asset is correct.

SpendFunds hook

SpendFunds = () is the no-op. Setting it to a SpendFunds-implementing aggregator lets adjacent pallets get a callback whenever the treasury cycles, before the burn — typically used to let pallet-bounties mark certain bounty-funding allocations against the treasury before it shrinks. Not relevant without bounties.

Trade-offs and caveats

  • Author lookup can fail at genesis. pallet_authorship::Pallet::<Runtime>::author() returns None until BABE has produced its first block with a valid digest. If a fee-charging extrinsic somehow executes before then (it shouldn't — the chain isn't taking transactions yet), the author's 80% portion is dropped and burned. Not a real production concern; flagged here because the Option branch is silent.
  • Treasurer track is as strict as Root. Same curves, same 28-day decision period. A 100-UNIT spend via Treasurer takes 28 days minimum. The BigSpender track is for routine spending; Treasurer is for very large or admin-tier actions. If your operational tempo requires faster small-spend turnaround than BigSpender's 28-day decision, add a SmallSpender track (Polkadot has one).
  • Periodic burn is unconditional. Even if the treasury has critical pending obligations, the 1% burn runs at every SpendPeriod. Mitigations: a track-controlled "skip burn" extrinsic (doesn't exist in stock pallet-treasury), or pre-approve enough spends to drain the account before the cycle.
  • MaxApprovals = 100. Once 100 spends are queued, further spend calls fail until existing ones either complete payout or expire. Mostly a DOS-protection knob; raise if your governance produces many concurrent spend approvals.
  • type SpendFunds = (). We're not exposing a hook for adjacent pallets to participate in the treasury cycle. If you add pallet-bounties later, you'd switch this to an aggregator type that includes bounties as a fund-spender.
  • Slash = Treasury for referenda uses the legacy Currency API. This is fine — pallet_treasury::Pallet implements OnUnbalanced<NegativeImbalance<R>> exactly for this purpose. But it's a hint that pallet-referenda's Slash hook is on the wrong side of the fungibles migration. A future polkadot-sdk version may move it; when it does, the wiring becomes ResolveTo<TreasuryAccount, Balances> like everything else.
  • Spec version 103 → 104. The pallet-index-17 addition changes the encoded transaction format and the storage layout. Existing dev DBs must be purged.

What's next

Lesson 6 adds the accounts-as-institutions layer: pallet-identity for human-readable accounts, pallet-utility for atomic batching and derived sub-accounts, pallet-multisig for M-of-N approvals, and pallet-proxy for delegated keys with scoped permissions. Together they turn raw key-pairs into actors that can act jointly, publish who they are, and delegate narrow authority — the missing piece between "treasury exists" and "a foundation can actually operate it".

Beyond that, see 99-where-to-next.md for assets, bounties/tips, smart contracts, and custom pallets.

References


Next up: Lesson 6 — Identity, utility, multisig, proxy.