Commit:
6e253cd— Add a treasury and stop burning the chain's incomeReading 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.
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.
A treasury in substrate is two things glued together:
-
An account. Specifically, an account derived from a
PalletId— a deterministic 8-byte tag (we useb"st/trsry").into_account_truncating()prefixes it withb"modl", then zero-pads and truncates the result to theAccountIdwidth — no hashing, so the bytes stay human-readable (you can literally readmodl/st/trsryin 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 behindSpendOrigin. The treasury account exists inpallet-balanceslike any other account, can receive transfers, can be displayed in block explorers, can show balance. -
A spending machine.
pallet-treasuryexposesspend(signed bySpendOrigin, schedules a future payout to a beneficiary) andpayout(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 eachSpendPeriod— 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."
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::Slashis the destination. Setting it to()burns; setting it to a treasury-resolving adapter routes there. - Reward dust.
EraPayoutreturns 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::RewardRemainderis the sink for that remainder. - Transaction fees.
pallet_transaction_payment::Config::OnChargeTransactionreceives 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
OnChargeTransactionhook, but the transaction payment pallet calls the adapter'son_unbalancedswith a separate iterator for tips, so the adapter can route tips differently from base fees. - Forfeited deposits from rejected referenda.
pallet_referenda::Config::Slashreceives 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.
Substrate is mid-migration between two ways of representing "this pile of value just appeared, do something with it":
- Legacy:
Currency::NegativeImbalance. The oldCurrencytrait represents accruals as aNegativeImbalancevalue — 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 usesCredit<AccountId, Asset>— same idea, but unified across native balance and arbitrary fungible assets. Pallets that have migrated expectCredit.
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.
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.
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.
#[runtime::pallet_index(17)]
pub type Treasury = pallet_treasury;And spec_version: 103 → 104.
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/runtimes → relay/polkadot/src/governance/origins.rs for the original.
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),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 byinto_account_truncating()(b"modl"prefix + zero-padding, no hash).b"st/"is our project tag.AssetKind = ()andBeneficiary = 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 whenpayoutis called.PayFromAccountis the stock substrate impl that doesBalances::transfer(TreasuryAccount, beneficiary, amount).BalanceConverter = UnityAssetBalanceConversion. SinceAssetKind = (), there's only one asset, so the "convert asset amount to native amount" function is identity.RejectOriginusesEitherOfDiverse(heterogeneous success types, both coerced to()) because reject's origin only needs authorization, not a budget.SpendOriginusesEitherOf(homogeneous success type) because both branches produceBalance.
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:
- Pulls the fee credit, splits it 20/80 via
Imbalance::ration(20, 80).split.0is the 20% treasury portion;split.1is the 80% author portion. - Pulls the tip credit (if any) and merges it into the treasury portion via
Imbalance::merge_into. - Routes the (treasury + tips) portion to the treasury account via
ResolveTo. - 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.
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-stakingrewards). 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
DealWithFeesshape, just more arms.
Each is a 1-line change to DealWithFees. The economic implications are large and chain-specific.
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.
type BurnDestination = (); burns the periodic burn fraction. Alternatives:
- Route to a different sovereign account — e.g., an insurance fund, a community-controlled subaccount. Same
ResolveToshape. - 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
OnUnbalancedimpl 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.)
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 = () 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.
- Author lookup can fail at genesis.
pallet_authorship::Pallet::<Runtime>::author()returnsNoneuntil 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 theOptionbranch 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
SmallSpendertrack (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, furtherspendcalls 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 addpallet-bountieslater, you'd switch this to an aggregator type that includes bounties as a fund-spender.Slash = Treasuryfor referenda uses the legacy Currency API. This is fine —pallet_treasury::PalletimplementsOnUnbalanced<NegativeImbalance<R>>exactly for this purpose. But it's a hint thatpallet-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 becomesResolveTo<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.
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.
- polkadot-sdk
pallet-treasury— config, extrinsics, the spend/payout flow. - polkadot-fellows/runtimes — Polkadot relay treasury — the canonical
Spenderenum + spend-track set. - polkadot-sdk
pallet-transaction-payment::FungibleAdapter— the source of theon_unbalancedsiterator semantics. frame_support::traits::tokens::imbalance::ResolveTo— the modern-API treasury-routing adapter.Imbalance::ration— the proportional-split primitiveDealWithFeesuses.