diff --git a/Cargo.toml b/Cargo.toml index baecf21..0ab88dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ authors = ["Moonlight"] edition = "2021" license = "Apache-2.0" repository = "https://github.com/Moonlight-Protocol/soroban-core" -version = "0.2.1" +version = "0.3.0" [workspace.dependencies] diff --git a/contracts/channel-auth/src/contract.rs b/contracts/channel-auth/src/contract.rs index cb13257..048232c 100644 --- a/contracts/channel-auth/src/contract.rs +++ b/contracts/channel-auth/src/contract.rs @@ -29,6 +29,20 @@ pub struct ProviderRemoved { pub provider: Address, } +// UC6: the council's quorum-authorized record that an asset channel was enabled or disabled. +// The contract holds NO channel/asset state — this event is the only on-chain artifact. The +// council-platform DB (sole authoritative writer) and every provider converge on it: `enabled` +// distinguishes enable/re-enable (true) from disable (false). `channel` is the privacy-channel +// contract id; `asset` is its token contract id (a channel is single-asset, so this is self-describing). +#[contractevent(data_format = "single-value")] +pub struct ChannelStateChanged { + #[topic] + pub channel: Address, + #[topic] + pub asset: Address, + pub enabled: bool, +} + // MOON-09: emit a dedicated event on upgrade so the governance audit trail does not rely solely on // the raw Stellar transaction record. #[contractevent(data_format = "single-value")] @@ -110,6 +124,37 @@ impl ChannelAuthContract { } } +// UC6: asset-lifecycle. Quorum-gated, event-only — the contract stores no channel/asset state; +// it only emits the quorum-authorized record that the council DB and providers converge on. The +// owner is the council quorum account, so `enforce_owner_auth` is the quorum gate (mirrors +// add_provider/remove_provider). +#[contractimpl] +impl ChannelAuthContract { + /// Enable an asset `channel` for service. Also used to RE-ENABLE a previously disabled + /// channel — both resume full service, so both emit `ChannelStateChanged { enabled: true }`. + pub fn enable_channel(e: &Env, channel: Address, asset: Address) { + ownable::enforce_owner_auth(e); + ChannelStateChanged { + channel, + asset, + enabled: true, + } + .publish(e); + } + + /// Disable an asset `channel`. The channel becomes withdraw-only (new deposits/sends rejected); + /// that enforcement lives provider-side. Emits `ChannelStateChanged { enabled: false }`. + pub fn disable_channel(e: &Env, channel: Address, asset: Address) { + ownable::enforce_owner_auth(e); + ChannelStateChanged { + channel, + asset, + enabled: false, + } + .publish(e); + } +} + #[contractimpl] impl CustomAccountInterface for ChannelAuthContract { type Error = MoonlightError; diff --git a/contracts/channel-auth/src/tests/events.rs b/contracts/channel-auth/src/tests/events.rs index ee13a45..6f5cf78 100644 --- a/contracts/channel-auth/src/tests/events.rs +++ b/contracts/channel-auth/src/tests/events.rs @@ -5,7 +5,47 @@ use soroban_sdk::{ use super::tests::create_contract; use crate::contract::ChannelAuthContractClient; -use crate::contract::{ContractInitialized, ProviderAdded, ProviderRemoved}; +use crate::contract::{ChannelStateChanged, ContractInitialized, ProviderAdded, ProviderRemoved}; + +fn enable_channel_with_auth( + client: &ChannelAuthContractClient, + admin: &Address, + channel: &Address, + asset: &Address, + e: &Env, +) { + client + .mock_auths(&[MockAuth { + address: admin, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "enable_channel", + args: (channel, asset).into_val(e), + sub_invokes: &[], + }, + }]) + .enable_channel(channel, asset); +} + +fn disable_channel_with_auth( + client: &ChannelAuthContractClient, + admin: &Address, + channel: &Address, + asset: &Address, + e: &Env, +) { + client + .mock_auths(&[MockAuth { + address: admin, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "disable_channel", + args: (channel, asset).into_val(e), + sub_invokes: &[], + }, + }]) + .disable_channel(channel, asset); +} fn add_provider_with_auth( client: &ChannelAuthContractClient, @@ -103,3 +143,121 @@ fn test_provider_lifecycle_with_events() { remove_provider_with_auth(&client, &admin, &provider, &e); assert!(!client.is_provider(&provider)); } + +#[test] +fn test_enable_channel_emits_event() { + let e = Env::default(); + let (client, admin) = create_contract(&e); + let channel = Address::generate(&e); + let asset = Address::generate(&e); + + enable_channel_with_auth(&client, &admin, &channel, &asset, &e); + + let events = e.events().all(); + let last = events.events().last().unwrap(); + assert_eq!( + last, + &ChannelStateChanged { + channel, + asset, + enabled: true, + } + .to_xdr(&e, &client.address) + ); +} + +#[test] +fn test_disable_channel_emits_event() { + let e = Env::default(); + let (client, admin) = create_contract(&e); + let channel = Address::generate(&e); + let asset = Address::generate(&e); + + disable_channel_with_auth(&client, &admin, &channel, &asset, &e); + + let events = e.events().all(); + let last = events.events().last().unwrap(); + assert_eq!( + last, + &ChannelStateChanged { + channel, + asset, + enabled: false, + } + .to_xdr(&e, &client.address) + ); +} + +// Enable -> disable -> re-enable. Re-enable reuses enable_channel and emits the same +// `enabled: true` record, so providers and the council DB resume full service on it. +#[test] +fn test_channel_lifecycle_enable_disable_reenable() { + let e = Env::default(); + let (client, admin) = create_contract(&e); + let channel = Address::generate(&e); + let asset = Address::generate(&e); + + enable_channel_with_auth(&client, &admin, &channel, &asset, &e); + disable_channel_with_auth(&client, &admin, &channel, &asset, &e); + enable_channel_with_auth(&client, &admin, &channel, &asset, &e); + + let events = e.events().all(); + let last = events.events().last().unwrap(); + assert_eq!( + last, + &ChannelStateChanged { + channel, + asset, + enabled: true, + } + .to_xdr(&e, &client.address) + ); +} + +// Quorum gate: a call NOT authorized by the owner (council quorum) must be rejected, and emit +// no event. Mirrors the add_provider non-owner rejection in tests.rs. +#[test] +fn test_enable_channel_requires_owner_auth() { + let e = Env::default(); + let (client, _admin) = create_contract(&e); + let not_owner = Address::generate(&e); + let channel = Address::generate(&e); + let asset = Address::generate(&e); + + let res = client + .mock_auths(&[MockAuth { + address: ¬_owner, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "enable_channel", + args: (&channel, &asset).into_val(&e), + sub_invokes: &[], + }, + }]) + .try_enable_channel(&channel, &asset); + + assert!(res.is_err()); +} + +#[test] +fn test_disable_channel_requires_owner_auth() { + let e = Env::default(); + let (client, _admin) = create_contract(&e); + let not_owner = Address::generate(&e); + let channel = Address::generate(&e); + let asset = Address::generate(&e); + + let res = client + .mock_auths(&[MockAuth { + address: ¬_owner, + invoke: &MockAuthInvoke { + contract: &client.address, + fn_name: "disable_channel", + args: (&channel, &asset).into_val(&e), + sub_invokes: &[], + }, + }]) + .try_disable_channel(&channel, &asset); + + assert!(res.is_err()); +}