Issue 02 — freeze and unfreeze instructions
Plan: Stablecoin Plan 5 — Emergency Freeze
Depends on: Plan 1 (ProtocolParameters with is_frozen + freeze_authority_account_id fields, compute_protocol_parameters_pda / compute_protocol_parameters_pda_seed); issue 01 in this plan (the gate that gives freeze real effect).
Blocks: issue 03 (guest wiring + integration tests).
Goal: Add the Instruction::Freeze and Instruction::Unfreeze variants to stablecoin_core, implement both host functions in a new programs/stablecoin/src/freeze.rs module, and cover every code path with unit tests — happy paths for each, authorization rejections, freeze-authority handle mismatches, and idempotency (calling freeze when already frozen succeeds and the flag stays true; same shape for unfreeze).
Spec reference
Why
The freeze authority's job is to halt the protocol when something goes wrong (broken oracle, exploit in progress). Issue 01 landed the gate that makes a true value of is_frozen block risky ops; this issue lands the actual instructions that flip the bit. Together they make the kill switch real.
Both instructions are identical except for the target bit value, so bundling them in one issue (and one host module) keeps the two cases trivially in sync. The auth check pattern mirrors the admin auth check from Plan 4 issue 01 — same structural shape (assert!(authority.is_authorized) plus assert_eq!(authority.account_id, params.freeze_authority_account_id)), targeting freeze_authority_account_id instead of admin_account_id. We don't extract a shared trait between Plan 4 and Plan 5 for v1; if a third authority lands (RFP-001/002 wrappers), we can refactor then.
Architecture
A new programs/stablecoin/src/freeze.rs module exposes two pub fns:
pub fn freeze(
freeze_authority: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stablecoin_program_id: ProgramId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>);
pub fn unfreeze(
freeze_authority: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stablecoin_program_id: ProgramId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>);
Both share a private set_is_frozen(...) helper that takes a target_value: bool and the three inputs. This keeps the two public entry points one-liners around the helper and means the auth check, PDA check, and post-state shape only exist in one place.
Two new Instruction variants in stablecoin_core::Instruction: Freeze and Unfreeze, both unit variants (no scalar parameters). The 2-account input list comes from the guest entry signature; no instruction-level args needed.
Files
- Modify:
programs/stablecoin/core/src/lib.rs — add the two new Instruction variants with doc comments.
- Create:
programs/stablecoin/src/freeze.rs — the two public host functions + the private helper.
- Modify:
programs/stablecoin/src/lib.rs — pub mod freeze;.
- Create:
programs/stablecoin/src/tests/freeze_tests.rs — the unit tests.
- Modify:
programs/stablecoin/src/tests/mod.rs — register mod freeze_tests;.
Acceptance criteria
cargo test -p stablecoin_core is green (the two new Instruction variants serialize / deserialize cleanly).
cargo test -p stablecoin_program freeze_tests:: passes the full test list:
freeze_happy_path_sets_is_frozen_true
unfreeze_happy_path_sets_is_frozen_false
freeze_rejects_unauthorized_freeze_authority
unfreeze_rejects_unauthorized_freeze_authority
freeze_rejects_wrong_freeze_authority_account_id
unfreeze_rejects_wrong_freeze_authority_account_id
freeze_rejects_uninitialized_protocol_parameters
unfreeze_rejects_uninitialized_protocol_parameters
freeze_rejects_wrong_protocol_parameters_pda
unfreeze_rejects_wrong_protocol_parameters_pda
freeze_is_idempotent_when_already_frozen
unfreeze_is_idempotent_when_already_unfrozen
freeze_does_not_change_any_other_protocol_parameters_field
unfreeze_does_not_change_any_other_protocol_parameters_field
freeze_emits_no_chained_calls
unfreeze_emits_no_chained_calls
make clippy is green.
- Exactly 1 post-state per call (the
protocol_parameters account, claimed via Claim::Pda(compute_protocol_parameters_pda_seed())). Zero chained calls.
Implementation steps
In programs/stablecoin/core/src/lib.rs, after the existing Instruction variants (the position-lifecycle ones from Plan 3 and admin ones from Plan 4), add:
/// Set [`ProtocolParameters::is_frozen`] to `true`, halting risk-increasing
/// position operations until [`Instruction::Unfreeze`] flips it back.
/// Idempotent: calling `Freeze` when already frozen succeeds and leaves
/// `is_frozen = true`.
///
/// Required accounts (2):
/// - `freeze_authority` (authorized; `account_id` must equal
/// `ProtocolParameters.freeze_authority_account_id`)
/// - `protocol_parameters` (initialized, writable; PDA verified)
///
/// See spec §10.17 for the full contract.
Freeze,
/// Set [`ProtocolParameters::is_frozen`] to `false`, resuming normal
/// operation. Idempotent: calling `Unfreeze` when already unfrozen
/// succeeds and leaves `is_frozen = false`.
///
/// Required accounts (2):
/// - `freeze_authority` (authorized; `account_id` must equal
/// `ProtocolParameters.freeze_authority_account_id`)
/// - `protocol_parameters` (initialized, writable; PDA verified)
///
/// See spec §10.18 for the full contract.
Unfreeze,
Run cargo test -p stablecoin_core. Expected: still green; the two new variants serialize via the existing serde derive.
Create programs/stablecoin/src/freeze.rs:
//! Host-side implementation of `Instruction::Freeze` and `Instruction::Unfreeze`
//! (spec §10.17 / §10.18).
//!
//! Both entries flip `ProtocolParameters.is_frozen` to a fixed target value.
//! Idempotent — calling `freeze` when already frozen (or `unfreeze` when
//! already unfrozen) succeeds and leaves the flag at the target value.
use nssa_core::{
account::{Account, AccountWithMetadata, Data},
program::{AccountPostState, ChainedCall, Claim, ProgramId},
};
use stablecoin_core::{
compute_protocol_parameters_pda, compute_protocol_parameters_pda_seed, ProtocolParameters,
};
/// Set [`ProtocolParameters::is_frozen`] to `true`. See module-level docs and
/// spec §10.17 for the full contract.
///
/// # Panics
/// - `freeze_authority.is_authorized = false`.
/// - `freeze_authority.account_id` does not match
/// `ProtocolParameters.freeze_authority_account_id`.
/// - `protocol_parameters` is uninitialized or sits at the wrong PDA.
/// - `protocol_parameters.account.data` does not decode as
/// [`ProtocolParameters`].
pub fn freeze(
freeze_authority: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stablecoin_program_id: ProgramId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
set_is_frozen(freeze_authority, protocol_parameters, stablecoin_program_id, true)
}
/// Set [`ProtocolParameters::is_frozen`] to `false`. See module-level docs and
/// spec §10.18 for the full contract.
///
/// Panic conditions are identical to [`freeze`].
pub fn unfreeze(
freeze_authority: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stablecoin_program_id: ProgramId,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
set_is_frozen(freeze_authority, protocol_parameters, stablecoin_program_id, false)
}
fn set_is_frozen(
freeze_authority: AccountWithMetadata,
protocol_parameters: AccountWithMetadata,
stablecoin_program_id: ProgramId,
target_value: bool,
) -> (Vec<AccountPostState>, Vec<ChainedCall>) {
// 1. Freeze-authority authorization.
assert!(
freeze_authority.is_authorized,
"Freeze authority authorization is missing"
);
// 2. ProtocolParameters must be initialized and at the right PDA.
assert_ne!(
protocol_parameters.account,
Account::default(),
"ProtocolParameters account must be initialized"
);
assert_eq!(
protocol_parameters.account_id,
compute_protocol_parameters_pda(stablecoin_program_id),
"ProtocolParameters account ID does not match expected PDA derivation"
);
// 3. Decode and check the freeze-authority handle.
let mut params = ProtocolParameters::try_from(&protocol_parameters.account.data)
.expect("ProtocolParameters must decode");
assert_eq!(
freeze_authority.account_id, params.freeze_authority_account_id,
"Freeze authority account ID does not match the recorded freeze_authority_account_id"
);
// 4. Flip the bit. Idempotent by construction: setting target_value when
// is_frozen already equals target_value is a no-op write.
params.is_frozen = target_value;
// 5. Build the post-state.
let mut protocol_parameters_post = protocol_parameters.account;
protocol_parameters_post.data = Data::from(¶ms);
let post_states = vec![AccountPostState::new_claimed(
protocol_parameters_post,
Claim::Pda(compute_protocol_parameters_pda_seed()),
)];
(post_states, Vec::new())
}
Run cargo check -p stablecoin_program. Expected: clean.
In programs/stablecoin/src/lib.rs, add (alongside the existing pub mod declarations):
/// Emergency kill switch — flip [`stablecoin_core::ProtocolParameters::is_frozen`].
pub mod freeze;
Run cargo check -p stablecoin_program. Expected: clean.
Create programs/stablecoin/src/tests/freeze_tests.rs:
#![allow(
clippy::indexing_slicing,
clippy::panic,
clippy::unwrap_used,
reason = "tests deliberately panic on bad state via assert!/#[should_panic] and index fixed-size vectors"
)]
use nssa_core::{
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
program::{Claim, ProgramId},
};
use stablecoin_core::{
compute_protocol_parameters_pda, compute_protocol_parameters_pda_seed, ProtocolParameters,
};
use stablecoin_program::freeze::{freeze, unfreeze};
const STABLECOIN_PROGRAM_ID: ProgramId = [3u32; 8];
fn freeze_authority_id() -> AccountId {
AccountId::new([0xFE; 32])
}
fn admin_id() -> AccountId {
AccountId::new([0xA0; 32])
}
fn stablecoin_definition_id() -> AccountId {
AccountId::new([0x11; 32])
}
fn collateral_definition_id() -> AccountId {
AccountId::new([0x12; 32])
}
fn oracle_id() -> AccountId {
AccountId::new([0x13; 32])
}
fn protocol_parameters_id() -> AccountId {
compute_protocol_parameters_pda(STABLECOIN_PROGRAM_ID)
}
fn baseline_protocol_parameters(is_frozen: bool) -> ProtocolParameters {
ProtocolParameters {
admin_account_id: admin_id(),
freeze_authority_account_id: freeze_authority_id(),
stablecoin_definition_id: stablecoin_definition_id(),
collateral_definition_id: collateral_definition_id(),
market_price_oracle_id: oracle_id(),
stability_fee_per_millisecond: 1_000_000_000_000_000_000_000_000_001,
controller_proportional_gain: 0,
controller_integral_gain: 0,
minimum_collateralization_ratio: 1_500_000_000_000_000_000_000_000_000,
minimum_milliseconds_between_rate_updates: 300,
maximum_oracle_price_age_milliseconds: 900,
is_frozen,
}
}
fn protocol_parameters_account(is_frozen: bool) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: STABLECOIN_PROGRAM_ID,
balance: 0,
data: Data::from(&baseline_protocol_parameters(is_frozen)),
nonce: Nonce(0),
},
is_authorized: false,
account_id: protocol_parameters_id(),
}
}
fn authorized_freeze_authority_account() -> AccountWithMetadata {
AccountWithMetadata {
account: Account::default(),
is_authorized: true,
account_id: freeze_authority_id(),
}
}
Edit programs/stablecoin/src/tests/mod.rs to add:
Run cargo test -p stablecoin_program freeze_tests. Expected: 0 tests so far — module compiles.
Append to freeze_tests.rs:
#[test]
fn freeze_happy_path_sets_is_frozen_true() {
let (post_states, chained_calls) = freeze(
authorized_freeze_authority_account(),
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
);
assert_eq!(post_states.len(), 1);
assert!(chained_calls.is_empty());
let pp_post = &post_states[0];
assert_eq!(
pp_post.required_claim(),
Some(&Claim::Pda(compute_protocol_parameters_pda_seed()))
);
let decoded = ProtocolParameters::try_from(&pp_post.account().data).expect("decode");
assert!(decoded.is_frozen);
}
#[test]
fn unfreeze_happy_path_sets_is_frozen_false() {
let (post_states, chained_calls) = unfreeze(
authorized_freeze_authority_account(),
protocol_parameters_account(true),
STABLECOIN_PROGRAM_ID,
);
assert_eq!(post_states.len(), 1);
assert!(chained_calls.is_empty());
let pp_post = &post_states[0];
assert_eq!(
pp_post.required_claim(),
Some(&Claim::Pda(compute_protocol_parameters_pda_seed()))
);
let decoded = ProtocolParameters::try_from(&pp_post.account().data).expect("decode");
assert!(!decoded.is_frozen);
}
Run cargo test -p stablecoin_program freeze_tests::freeze_happy_path_sets_is_frozen_true freeze_tests::unfreeze_happy_path_sets_is_frozen_false. Expected: 2 PASS.
Append:
#[test]
#[should_panic(expected = "Freeze authority authorization is missing")]
fn freeze_rejects_unauthorized_freeze_authority() {
let mut freeze_authority = authorized_freeze_authority_account();
freeze_authority.is_authorized = false;
let _ = freeze(
freeze_authority,
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(expected = "Freeze authority authorization is missing")]
fn unfreeze_rejects_unauthorized_freeze_authority() {
let mut freeze_authority = authorized_freeze_authority_account();
freeze_authority.is_authorized = false;
let _ = unfreeze(
freeze_authority,
protocol_parameters_account(true),
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(
expected = "Freeze authority account ID does not match the recorded freeze_authority_account_id"
)]
fn freeze_rejects_wrong_freeze_authority_account_id() {
let mut freeze_authority = authorized_freeze_authority_account();
freeze_authority.account_id = AccountId::new([0xBA; 32]);
let _ = freeze(
freeze_authority,
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(
expected = "Freeze authority account ID does not match the recorded freeze_authority_account_id"
)]
fn unfreeze_rejects_wrong_freeze_authority_account_id() {
let mut freeze_authority = authorized_freeze_authority_account();
freeze_authority.account_id = AccountId::new([0xBA; 32]);
let _ = unfreeze(
freeze_authority,
protocol_parameters_account(true),
STABLECOIN_PROGRAM_ID,
);
}
Run cargo test -p stablecoin_program freeze_tests::. Expected: 6 PASS so far.
Append:
#[test]
#[should_panic(expected = "ProtocolParameters account must be initialized")]
fn freeze_rejects_uninitialized_protocol_parameters() {
let pp = AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: protocol_parameters_id(),
};
let _ = freeze(
authorized_freeze_authority_account(),
pp,
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(expected = "ProtocolParameters account must be initialized")]
fn unfreeze_rejects_uninitialized_protocol_parameters() {
let pp = AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: protocol_parameters_id(),
};
let _ = unfreeze(
authorized_freeze_authority_account(),
pp,
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(
expected = "ProtocolParameters account ID does not match expected PDA derivation"
)]
fn freeze_rejects_wrong_protocol_parameters_pda() {
let mut pp = protocol_parameters_account(false);
pp.account_id = AccountId::new([0xDE; 32]);
let _ = freeze(
authorized_freeze_authority_account(),
pp,
STABLECOIN_PROGRAM_ID,
);
}
#[test]
#[should_panic(
expected = "ProtocolParameters account ID does not match expected PDA derivation"
)]
fn unfreeze_rejects_wrong_protocol_parameters_pda() {
let mut pp = protocol_parameters_account(true);
pp.account_id = AccountId::new([0xDE; 32]);
let _ = unfreeze(
authorized_freeze_authority_account(),
pp,
STABLECOIN_PROGRAM_ID,
);
}
Run cargo test -p stablecoin_program freeze_tests::. Expected: 10 PASS so far.
Append:
#[test]
fn freeze_is_idempotent_when_already_frozen() {
let (post_states, chained_calls) = freeze(
authorized_freeze_authority_account(),
protocol_parameters_account(true), // already frozen
STABLECOIN_PROGRAM_ID,
);
assert_eq!(post_states.len(), 1);
assert!(chained_calls.is_empty());
let decoded = ProtocolParameters::try_from(&post_states[0].account().data).expect("decode");
assert!(
decoded.is_frozen,
"is_frozen must remain true when freeze is called on an already-frozen protocol"
);
}
#[test]
fn unfreeze_is_idempotent_when_already_unfrozen() {
let (post_states, chained_calls) = unfreeze(
authorized_freeze_authority_account(),
protocol_parameters_account(false), // already unfrozen
STABLECOIN_PROGRAM_ID,
);
assert_eq!(post_states.len(), 1);
assert!(chained_calls.is_empty());
let decoded = ProtocolParameters::try_from(&post_states[0].account().data).expect("decode");
assert!(
!decoded.is_frozen,
"is_frozen must remain false when unfreeze is called on an already-unfrozen protocol"
);
}
Run cargo test -p stablecoin_program freeze_tests::. Expected: 12 PASS so far.
Append:
#[test]
fn freeze_does_not_change_any_other_protocol_parameters_field() {
let original = baseline_protocol_parameters(false);
let (post_states, _) = freeze(
authorized_freeze_authority_account(),
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
);
let decoded = ProtocolParameters::try_from(&post_states[0].account().data).expect("decode");
// Every field except is_frozen must match the pre-state.
assert_eq!(decoded.admin_account_id, original.admin_account_id);
assert_eq!(
decoded.freeze_authority_account_id,
original.freeze_authority_account_id
);
assert_eq!(decoded.stablecoin_definition_id, original.stablecoin_definition_id);
assert_eq!(decoded.collateral_definition_id, original.collateral_definition_id);
assert_eq!(decoded.market_price_oracle_id, original.market_price_oracle_id);
assert_eq!(decoded.stability_fee_per_millisecond, original.stability_fee_per_millisecond);
assert_eq!(
decoded.controller_proportional_gain,
original.controller_proportional_gain
);
assert_eq!(decoded.controller_integral_gain, original.controller_integral_gain);
assert_eq!(
decoded.minimum_collateralization_ratio,
original.minimum_collateralization_ratio
);
assert_eq!(
decoded.minimum_milliseconds_between_rate_updates,
original.minimum_milliseconds_between_rate_updates
);
assert_eq!(
decoded.maximum_oracle_price_age_milliseconds,
original.maximum_oracle_price_age_milliseconds
);
// is_frozen IS the field that changed:
assert_ne!(decoded.is_frozen, original.is_frozen);
assert!(decoded.is_frozen);
}
#[test]
fn unfreeze_does_not_change_any_other_protocol_parameters_field() {
let original = baseline_protocol_parameters(true);
let (post_states, _) = unfreeze(
authorized_freeze_authority_account(),
protocol_parameters_account(true),
STABLECOIN_PROGRAM_ID,
);
let decoded = ProtocolParameters::try_from(&post_states[0].account().data).expect("decode");
assert_eq!(decoded.admin_account_id, original.admin_account_id);
assert_eq!(
decoded.freeze_authority_account_id,
original.freeze_authority_account_id
);
assert_eq!(decoded.stablecoin_definition_id, original.stablecoin_definition_id);
assert_eq!(decoded.collateral_definition_id, original.collateral_definition_id);
assert_eq!(decoded.market_price_oracle_id, original.market_price_oracle_id);
assert_eq!(decoded.stability_fee_per_millisecond, original.stability_fee_per_millisecond);
assert_eq!(
decoded.controller_proportional_gain,
original.controller_proportional_gain
);
assert_eq!(decoded.controller_integral_gain, original.controller_integral_gain);
assert_eq!(
decoded.minimum_collateralization_ratio,
original.minimum_collateralization_ratio
);
assert_eq!(
decoded.minimum_milliseconds_between_rate_updates,
original.minimum_milliseconds_between_rate_updates
);
assert_eq!(
decoded.maximum_oracle_price_age_milliseconds,
original.maximum_oracle_price_age_milliseconds
);
assert_ne!(decoded.is_frozen, original.is_frozen);
assert!(!decoded.is_frozen);
}
Run cargo test -p stablecoin_program freeze_tests::. Expected: 14 PASS so far.
Append:
#[test]
fn freeze_emits_no_chained_calls() {
let (_, chained_calls) = freeze(
authorized_freeze_authority_account(),
protocol_parameters_account(false),
STABLECOIN_PROGRAM_ID,
);
assert!(chained_calls.is_empty(), "freeze must not emit any chained calls");
}
#[test]
fn unfreeze_emits_no_chained_calls() {
let (_, chained_calls) = unfreeze(
authorized_freeze_authority_account(),
protocol_parameters_account(true),
STABLECOIN_PROGRAM_ID,
);
assert!(
chained_calls.is_empty(),
"unfreeze must not emit any chained calls"
);
}
Run cargo test -p stablecoin_program freeze_tests::. Expected: 16 PASS.
make clippy
RISC0_DEV_MODE=1 cargo test -p stablecoin_core
RISC0_DEV_MODE=1 cargo test -p stablecoin_program
Expected: all green.
git add programs/stablecoin/core/src/lib.rs \
programs/stablecoin/src/freeze.rs \
programs/stablecoin/src/lib.rs \
programs/stablecoin/src/tests/freeze_tests.rs \
programs/stablecoin/src/tests/mod.rs
cargo +nightly fmt --all
git add -u
git commit -m "feat(stablecoin): implement freeze and unfreeze host functions
Two new Instruction variants (Freeze, Unfreeze) plus the matching
host functions in programs/stablecoin/src/freeze.rs. Both share a
private set_is_frozen(target_value) helper so the auth check, PDA
check, and post-state shape exist in exactly one place.
Authorization mirrors the admin auth pattern: freeze_authority must be
is_authorized = true AND its account_id must equal the recorded
ProtocolParameters.freeze_authority_account_id. Both instructions are
idempotent — calling freeze when already frozen succeeds and leaves
is_frozen = true; same shape for unfreeze.
16 unit tests cover happy path (2), unauthorized freeze_authority (2),
freeze_authority handle mismatch (2), uninitialized ProtocolParameters
(2), wrong PDA (2), idempotency (2), other-fields-untouched (2), and
no-chained-calls (2)."
Notes for reviewer / future maintainers
- The auth check pattern (
is_authorized + account_id == recorded handle) deliberately mirrors the admin auth check landed in Plan 4 issue 01. We do NOT share a helper between them in v1 — two call sites are not enough indirection to justify a verify_authority(authority, recorded_id) abstraction, and the freeze authority's recorded field has a different name than the admin's. If a third authority lands (RFP-001/002 wrappers), revisit.
- The bundling of
freeze + unfreeze in one issue is justified: their host bodies differ by exactly one boolean argument, the auth check is identical, the post-state shape is identical. Splitting them would duplicate every test scaffold and obscure the symmetry.
- Idempotency is explicit (the
target_value is unconditionally assigned rather than only-on-change). This is intentional and the corresponding tests pin the behavior so a future "optimize: short-circuit on no-op" change can't silently turn an idempotent op into a panicking one.
- The post-state always claims the
ProtocolParameters PDA via Claim::Pda(...) even though the account is already claimed at initialize_program time. This matches the pattern other writers of ProtocolParameters (the admin set_* instructions in Plan 4) use. The runtime accepts re-claiming an already-PDA-claimed account.
- No chained calls.
freeze / unfreeze are a pure config flip — they do not touch the Token Program, the stability fee accumulator, or the redemption price state.
Dependencies
Depends on: Plan 1 (#156); #188.
Blocks: #190.
Issue 02 —
freezeandunfreezeinstructionsPlan: Stablecoin Plan 5 — Emergency Freeze
Depends on: Plan 1 (
ProtocolParameterswithis_frozen+freeze_authority_account_idfields,compute_protocol_parameters_pda/compute_protocol_parameters_pda_seed); issue 01 in this plan (the gate that givesfreezereal effect).Blocks: issue 03 (guest wiring + integration tests).
Goal: Add the
Instruction::FreezeandInstruction::Unfreezevariants tostablecoin_core, implement both host functions in a newprograms/stablecoin/src/freeze.rsmodule, and cover every code path with unit tests — happy paths for each, authorization rejections, freeze-authority handle mismatches, and idempotency (callingfreezewhen already frozen succeeds and the flag staystrue; same shape forunfreeze).Spec reference
§10.17–10.18 freeze / unfreeze— input list, output, panic conditions, idempotency.§3.1 ProtocolParameters— thefreeze_authority_account_idfield that gates these instructions and theis_frozenfield they flip.§9.5 Emergency— top-level taxonomy entry.Why
The freeze authority's job is to halt the protocol when something goes wrong (broken oracle, exploit in progress). Issue 01 landed the gate that makes a
truevalue ofis_frozenblock risky ops; this issue lands the actual instructions that flip the bit. Together they make the kill switch real.Both instructions are identical except for the target bit value, so bundling them in one issue (and one host module) keeps the two cases trivially in sync. The auth check pattern mirrors the admin auth check from Plan 4 issue 01 — same structural shape (
assert!(authority.is_authorized)plusassert_eq!(authority.account_id, params.freeze_authority_account_id)), targetingfreeze_authority_account_idinstead ofadmin_account_id. We don't extract a shared trait between Plan 4 and Plan 5 for v1; if a third authority lands (RFP-001/002 wrappers), we can refactor then.Architecture
A new
programs/stablecoin/src/freeze.rsmodule exposes twopub fns:Both share a private
set_is_frozen(...)helper that takes atarget_value: booland the three inputs. This keeps the two public entry points one-liners around the helper and means the auth check, PDA check, and post-state shape only exist in one place.Two new
Instructionvariants instablecoin_core::Instruction:FreezeandUnfreeze, both unit variants (no scalar parameters). The 2-account input list comes from the guest entry signature; no instruction-level args needed.Files
programs/stablecoin/core/src/lib.rs— add the two newInstructionvariants with doc comments.programs/stablecoin/src/freeze.rs— the two public host functions + the private helper.programs/stablecoin/src/lib.rs—pub mod freeze;.programs/stablecoin/src/tests/freeze_tests.rs— the unit tests.programs/stablecoin/src/tests/mod.rs— registermod freeze_tests;.Acceptance criteria
cargo test -p stablecoin_coreis green (the two newInstructionvariants serialize / deserialize cleanly).cargo test -p stablecoin_program freeze_tests::passes the full test list:freeze_happy_path_sets_is_frozen_trueunfreeze_happy_path_sets_is_frozen_falsefreeze_rejects_unauthorized_freeze_authorityunfreeze_rejects_unauthorized_freeze_authorityfreeze_rejects_wrong_freeze_authority_account_idunfreeze_rejects_wrong_freeze_authority_account_idfreeze_rejects_uninitialized_protocol_parametersunfreeze_rejects_uninitialized_protocol_parametersfreeze_rejects_wrong_protocol_parameters_pdaunfreeze_rejects_wrong_protocol_parameters_pdafreeze_is_idempotent_when_already_frozenunfreeze_is_idempotent_when_already_unfrozenfreeze_does_not_change_any_other_protocol_parameters_fieldunfreeze_does_not_change_any_other_protocol_parameters_fieldfreeze_emits_no_chained_callsunfreeze_emits_no_chained_callsmake clippyis green.protocol_parametersaccount, claimed viaClaim::Pda(compute_protocol_parameters_pda_seed())). Zero chained calls.Implementation steps
InstructionvariantsIn
programs/stablecoin/core/src/lib.rs, after the existingInstructionvariants (the position-lifecycle ones from Plan 3 and admin ones from Plan 4), add:Run
cargo test -p stablecoin_core. Expected: still green; the two new variants serialize via the existing serde derive.freeze.rshost moduleCreate
programs/stablecoin/src/freeze.rs:Run
cargo check -p stablecoin_program. Expected: clean.stablecoin_programIn
programs/stablecoin/src/lib.rs, add (alongside the existingpub moddeclarations):Run
cargo check -p stablecoin_program. Expected: clean.Create
programs/stablecoin/src/tests/freeze_tests.rs:Edit
programs/stablecoin/src/tests/mod.rsto add:Run
cargo test -p stablecoin_program freeze_tests. Expected: 0 tests so far — module compiles.Append to
freeze_tests.rs:Run
cargo test -p stablecoin_program freeze_tests::freeze_happy_path_sets_is_frozen_true freeze_tests::unfreeze_happy_path_sets_is_frozen_false. Expected: 2 PASS.Append:
Run
cargo test -p stablecoin_program freeze_tests::. Expected: 6 PASS so far.Append:
Run
cargo test -p stablecoin_program freeze_tests::. Expected: 10 PASS so far.Append:
Run
cargo test -p stablecoin_program freeze_tests::. Expected: 12 PASS so far.Append:
Run
cargo test -p stablecoin_program freeze_tests::. Expected: 14 PASS so far.Append:
Run
cargo test -p stablecoin_program freeze_tests::. Expected: 16 PASS.Expected: all green.
git add programs/stablecoin/core/src/lib.rs \ programs/stablecoin/src/freeze.rs \ programs/stablecoin/src/lib.rs \ programs/stablecoin/src/tests/freeze_tests.rs \ programs/stablecoin/src/tests/mod.rs cargo +nightly fmt --all git add -u git commit -m "feat(stablecoin): implement freeze and unfreeze host functions Two new Instruction variants (Freeze, Unfreeze) plus the matching host functions in programs/stablecoin/src/freeze.rs. Both share a private set_is_frozen(target_value) helper so the auth check, PDA check, and post-state shape exist in exactly one place. Authorization mirrors the admin auth pattern: freeze_authority must be is_authorized = true AND its account_id must equal the recorded ProtocolParameters.freeze_authority_account_id. Both instructions are idempotent — calling freeze when already frozen succeeds and leaves is_frozen = true; same shape for unfreeze. 16 unit tests cover happy path (2), unauthorized freeze_authority (2), freeze_authority handle mismatch (2), uninitialized ProtocolParameters (2), wrong PDA (2), idempotency (2), other-fields-untouched (2), and no-chained-calls (2)."Notes for reviewer / future maintainers
is_authorized+account_id == recorded handle) deliberately mirrors the admin auth check landed in Plan 4 issue 01. We do NOT share a helper between them in v1 — two call sites are not enough indirection to justify averify_authority(authority, recorded_id)abstraction, and the freeze authority's recorded field has a different name than the admin's. If a third authority lands (RFP-001/002 wrappers), revisit.freeze+unfreezein one issue is justified: their host bodies differ by exactly one boolean argument, the auth check is identical, the post-state shape is identical. Splitting them would duplicate every test scaffold and obscure the symmetry.target_valueis unconditionally assigned rather than only-on-change). This is intentional and the corresponding tests pin the behavior so a future "optimize: short-circuit on no-op" change can't silently turn an idempotent op into a panicking one.ProtocolParametersPDA viaClaim::Pda(...)even though the account is already claimed atinitialize_programtime. This matches the pattern other writers ofProtocolParameters(the adminset_*instructions in Plan 4) use. The runtime accepts re-claiming an already-PDA-claimed account.freeze/unfreezeare a pure config flip — they do not touch the Token Program, the stability fee accumulator, or the redemption price state.Dependencies
Depends on: Plan 1 (#156); #188.
Blocks: #190.