Skip to content

[Plan 1 · 02] ProtocolParameters account type #158

Description

@gravityblast

Issue 02 — ProtocolParameters account type

Plan: Stablecoin Plan 1 — Foundation + Bootstrap
Depends on: none (can land in parallel with 01, 03, 04, 05, 06)
Blocks: 07 (initialize_program host function) and every Plan 2+ instruction that reads protocol parameters.

Goal: Add the ProtocolParameters account type to stablecoin_core — the single-instance PDA that stores every configurable knob of the protocol — with serde, Borsh, From/TryFrom<&Data>, and a PDA derivation helper.

Spec reference

§3.1 ProtocolParameters and §4.1 ProtocolParameters. The struct lists every field, its meaning, and its mutability.

Why

ProtocolParameters is the canonical configuration. Every instruction that reads admin / freeze / oracle / fee / ratio / timing values reads from this one account. Defining it once with all the fields up front (including ones used only by Plan 2+ instructions like the controller gains) means downstream code references a stable type instead of accumulating field-by-field additions.

Architecture

Add a new struct in stablecoin_core with the #[account_type] macro and the standard derives. Conversion to/from nssa_core::account::Data follows the existing Position pattern (borsh::object_length for buffer sizing, BorshSerialize into a Vec<u8>, Data::try_from).

The PDA derivation uses a constant seed string, hashed against the program id via the existing PDA convention (the PdaSeed::new(hash_bytes) pattern already used by compute_position_pda_seed). We follow it for consistency.

Files

  • Create: programs/stablecoin/core/src/protocol_parameters.rs
  • Modify: programs/stablecoin/core/src/lib.rs (re-export ProtocolParameters + the PDA helper)

Acceptance criteria

  • cargo test -p stablecoin_core --lib protocol_parameters:: passes (tests below).
  • Borsh roundtrip preserves every field.
  • compute_protocol_parameters_pda(program_id) is deterministic for fixed program_id.
  • Data::from(&ProtocolParameters{..}) succeeds (doesn't panic) for any field values within u128 / i128 bounds.
  • ProtocolParameters::try_from(&Data::from(&pp)) round-trips.

Implementation steps

  • Step 1: Create the file with the struct

programs/stablecoin/core/src/protocol_parameters.rs:

//! Single-instance configuration account for the Stablecoin program.
//!
//! Created at [`initialize_program`] time, written by admin `set_*`
//! instructions and by `freeze` / `unfreeze`. Read by nearly every other
//! instruction.

use borsh::{BorshDeserialize, BorshSerialize};
use nssa_core::{
    account::{AccountId, Data},
    program::{PdaSeed, ProgramId},
};
use serde::{Deserialize, Serialize};
use spel_framework_macros::account_type;

/// PDA seed for the single-instance [`ProtocolParameters`] account.
const PROTOCOL_PARAMETERS_PDA_DOMAIN: &[u8; 32] = b"STABLECOIN__PROTOCOL_PARAMS_____";

/// Single source of truth for the protocol's configurable knobs.
///
/// See the spec, §4.1 for field semantics. Immutability comments mirror the
/// rationale documented there.
#[account_type]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub struct ProtocolParameters {
    /// Authority required for every parameter-update and admin-rotation
    /// instruction.
    pub admin_account_id: AccountId,
    /// Authority required for `freeze` / `unfreeze`.
    pub freeze_authority_account_id: AccountId,
    /// The stablecoin's `TokenDefinition` PDA. IMMUTABLE — changing it would
    /// break supply accounting against the existing on-chain stablecoin
    /// float.
    pub stablecoin_definition_id: AccountId,
    /// The single accepted collateral's `TokenDefinition`. IMMUTABLE —
    /// changing it would orphan every position vault.
    pub collateral_definition_id: AccountId,
    /// Producer of the market price observation used by
    /// `update_redemption_rate` and the oracle staleness gate in
    /// `generate_debt`.
    pub market_price_oracle_id: AccountId,
    /// Per-millisecond stability fee multiplier in fixed-point, stored as
    /// `(1 + r_per_millisecond) * FIXED_POINT_ONE`.
    pub stability_fee_per_millisecond: u128,
    /// PI controller `Kp`. Signed.
    pub controller_proportional_gain: i128,
    /// PI controller `Ki`. Signed.
    pub controller_integral_gain: i128,
    /// Minimum collateralization ratio in fixed-point (e.g.
    /// `1.5 * FIXED_POINT_ONE` = 150%).
    pub minimum_collateralization_ratio: u128,
    /// Minimum milliseconds between successful `update_redemption_rate` calls.
    pub minimum_milliseconds_between_rate_updates: u64,
    /// Reject oracle observations older than this (RFP R3 staleness gate).
    pub maximum_oracle_price_age_milliseconds: u64,
    /// `true` blocks `open_position`, `generate_debt`, `withdraw_collateral`.
    pub is_frozen: bool,
}

impl TryFrom<&Data> for ProtocolParameters {
    type Error = std::io::Error;

    fn try_from(data: &Data) -> Result<Self, Self::Error> {
        Self::try_from_slice(data.as_ref())
    }
}

impl From<&ProtocolParameters> for Data {
    fn from(params: &ProtocolParameters) -> Self {
        let len = borsh::object_length(params)
            .expect("ProtocolParameters length must be known");
        let mut buf = Vec::with_capacity(len);
        BorshSerialize::serialize(params, &mut buf)
            .expect("ProtocolParameters serialization should not fail");
        Self::try_from(buf)
            .expect("ProtocolParameters encoded data should fit into Data")
    }
}

/// PDA seed for [`ProtocolParameters`] (single-instance — no per-deployment
/// uniqueness needed; the program id already discriminates).
#[must_use]
pub fn compute_protocol_parameters_pda_seed() -> PdaSeed {
    use risc0_zkvm::sha::{Impl, Sha256 as _};

    let mut out = [0u8; 32];
    out.copy_from_slice(Impl::hash_bytes(PROTOCOL_PARAMETERS_PDA_DOMAIN).as_bytes());
    PdaSeed::new(out)
}

/// Account id of the [`ProtocolParameters`] PDA under the given stablecoin
/// program.
#[must_use]
pub fn compute_protocol_parameters_pda(stablecoin_program_id: ProgramId) -> AccountId {
    AccountId::for_public_pda(
        &stablecoin_program_id,
        &compute_protocol_parameters_pda_seed(),
    )
}
  • Step 2: Re-export from stablecoin_core::lib

Edit programs/stablecoin/core/src/lib.rs — add at the top after the existing pub mod math; line (from issue 01):

pub mod protocol_parameters;

pub use protocol_parameters::{
    compute_protocol_parameters_pda, compute_protocol_parameters_pda_seed, ProtocolParameters,
};

Run cargo check -p stablecoin_core. Expected: clean compile.

  • Step 3: Add Borsh roundtrip test

Append to programs/stablecoin/core/src/protocol_parameters.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use crate::math::FIXED_POINT_ONE;

    fn sample_params() -> ProtocolParameters {
        ProtocolParameters {
            admin_account_id: AccountId::new([0xAA; 32]),
            freeze_authority_account_id: AccountId::new([0xFF; 32]),
            stablecoin_definition_id: AccountId::new([0x01; 32]),
            collateral_definition_id: AccountId::new([0x02; 32]),
            market_price_oracle_id: AccountId::new([0x03; 32]),
            stability_fee_per_millisecond: FIXED_POINT_ONE + 1_500_000_000_000_000, // ε ≈ 1.5×10^15 → ~5% annual
            controller_proportional_gain: -123_456_789,
            controller_integral_gain: 987_654_321,
            minimum_collateralization_ratio: FIXED_POINT_ONE * 3 / 2,
            minimum_milliseconds_between_rate_updates: 300_000,
            maximum_oracle_price_age_milliseconds: 900_000,
            is_frozen: false,
        }
    }

    #[test]
    fn borsh_roundtrip_preserves_every_field() {
        let params = sample_params();
        let data: Data = (&params).into();
        let decoded = ProtocolParameters::try_from(&data).expect("decode");
        assert_eq!(decoded, params);
    }

    #[test]
    fn borsh_roundtrip_with_is_frozen_true() {
        let mut params = sample_params();
        params.is_frozen = true;
        let data: Data = (&params).into();
        let decoded = ProtocolParameters::try_from(&data).expect("decode");
        assert!(decoded.is_frozen);
        assert_eq!(decoded, params);
    }
}

Run cargo test -p stablecoin_core protocol_parameters::tests::borsh. Expected: 2 PASS.

  • Step 4: Add PDA determinism tests

Append in the same mod tests:

    #[test]
    fn pda_is_deterministic_for_fixed_program_id() {
        let program_id: ProgramId = [42u32; 8];
        let first = compute_protocol_parameters_pda(program_id);
        let second = compute_protocol_parameters_pda(program_id);
        assert_eq!(first, second);
    }

    #[test]
    fn pda_differs_for_different_program_ids() {
        let id_a: ProgramId = [1u32; 8];
        let id_b: ProgramId = [2u32; 8];
        assert_ne!(
            compute_protocol_parameters_pda(id_a),
            compute_protocol_parameters_pda(id_b),
        );
    }

    #[test]
    fn seed_is_deterministic() {
        let seed_a = compute_protocol_parameters_pda_seed();
        let seed_b = compute_protocol_parameters_pda_seed();
        assert_eq!(seed_a, seed_b);
    }

Run cargo test -p stablecoin_core protocol_parameters::tests. Expected: 5 PASS.

  • Step 5: Lint check
make clippy

Expected: green.

  • Step 6: Commit
git add programs/stablecoin/core/src/protocol_parameters.rs programs/stablecoin/core/src/lib.rs
cargo +nightly fmt --all
git add -u
git commit -m "feat(stablecoin): add ProtocolParameters account type

Single-instance PDA storing every configurable knob of the protocol:
admin / freeze handles, fee / gains / ratio, timing thresholds, the
two bound definitions, the configurable oracle id, and the frozen flag.
Field set matches spec §4.1 exactly; immutability rationale lives there.

Includes Borsh+serde derives, Data <-> ProtocolParameters conversions,
the single-instance PDA derivation (constant domain seed under the
stablecoin program), and 5 unit tests covering Borsh roundtrip and PDA
determinism."

Notes for reviewer / future maintainers

  • PROTOCOL_PARAMETERS_PDA_DOMAIN is b"STABLECOIN__PROTOCOL_PARAMS_____" — exactly 32 bytes for natural hash-input alignment. Don't change this string post-deploy; it's part of the on-chain address derivation. Issues 03 and 04 follow the same convention for their respective accounts.
  • The i128 controller-gain fields use Borsh's default little-endian little-endian signed encoding. No conversion shenanigans.
  • is_frozen is a bool, not a u8 — Borsh handles it cleanly.

Dependencies

Depends on: none — parallels #157, #159, #160, #161, #162.

Blocks: #163 and every Plan 2+ instruction that reads protocol parameters.

Metadata

Metadata

Assignees

Type

Fields

No fields configured for Task.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions