diff --git a/creator-keys/src/curve_subscriptions_swaps.rs b/creator-keys/src/curve_subscriptions_swaps.rs index 7246fdf5..6f516633 100644 --- a/creator-keys/src/curve_subscriptions_swaps.rs +++ b/creator-keys/src/curve_subscriptions_swaps.rs @@ -1,8 +1,5 @@ -#![no_std] -use soroban_sdk::{ - contracttype, symbol_short, Address, Env, Symbol, Vec, -}; use crate::ContractError; +use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol}; #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] @@ -65,9 +62,10 @@ pub fn propose_curve_migration( is_executed: false, }; - env.storage() - .instance() - .set(&EmdevelopaDataKey::CurveMigration(creator.clone()), &proposal); + env.storage().instance().set( + &EmdevelopaDataKey::CurveMigration(creator.clone()), + &proposal, + ); env.events().publish( (CURVE_MIGRATION_PROPOSED_EVENT, creator.clone()), @@ -138,7 +136,11 @@ pub fn subscribe_key_access( env.storage().instance().set(&sub_key, &sub); env.events().publish( - (SUBSCRIPTION_GRANTED_EVENT, creator.clone(), subscriber.clone()), + ( + SUBSCRIPTION_GRANTED_EVENT, + creator.clone(), + subscriber.clone(), + ), expires_at, ); @@ -154,9 +156,9 @@ pub fn is_subscribed( ) -> bool { let sub_key = EmdevelopaDataKey::Subscription(creator.clone(), subscriber.clone()); if let Some(sub) = env.storage().instance().get::<_, KeySubscription>(&sub_key) { - if sub.is_active && - env.ledger().sequence() <= sub.expires_at_ledger && - subscriber_balance >= sub.min_keys_required + if sub.is_active + && env.ledger().sequence() <= sub.expires_at_ledger + && subscriber_balance >= sub.min_keys_required { return true; } @@ -165,6 +167,11 @@ pub fn is_subscribed( } /// #945: Execute cross-key atomic swap between two parties. +// Eight parameters, over clippy's limit of seven. Allowed rather than +// refactored: this is a pre-existing signature for the atomic-swap feature +// (#945), unrelated to the subscription work that registered this module, and +// reshaping another feature's public signature is not this change's business. +#[allow(clippy::too_many_arguments)] pub fn execute_atomic_swap( env: &Env, party_a: &Address, diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 58d275e0..71e532e7 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -8,6 +8,12 @@ use soroban_sdk::{ pub mod acl_dividend_twap_gov; pub mod acl_limits_merge_sunset; +/// Bonding-curve migration, key subscriptions, and atomic swaps. +/// +/// Declared here as part of Issue #953 — the file existed but had no `mod` +/// declaration, so `subscribe_key_access` and `is_subscribed` compiled nowhere +/// and could not be called. +pub mod curve_subscriptions_swaps; pub mod events; pub mod ratings_royalties_dividends; @@ -1768,6 +1774,10 @@ pub enum DataKey { CreatorCurveSlope(Address), /// (creator) -> number of completed curve resets -> `u32`. CurveResetCount(Address), + /// (creator) -> minimum key balance a wallet must hold to subscribe for + /// gated access (Issue #953). Absent means access gating is not configured + /// for that creator and `subscribe` rejects. + MinHoldForAccess(Address), } #[derive(Clone, Debug, PartialEq)] @@ -6875,6 +6885,139 @@ impl CreatorKeysContract { Self::get_key_balance(env, creator, wallet) } + // ── Subscription access gating (Issue #953) ──────────────────────────── + // + // `curve_subscriptions_swaps::subscribe_key_access` takes the subscriber's + // balance and the minimum as *parameters*. That is fine for an internal + // helper but must never be the contract's surface: a caller-supplied + // `subscriber_balance` makes the minimum-hold check self-attested, so any + // wallet could claim to hold enough and gate itself in. + // + // These entry points read both values from storage instead — the balance via + // `get_key_balance`, the minimum from `DataKey::MinHoldForAccess` — so the + // threshold is enforced against what the ledger actually says. + + /// Sets the minimum key balance a wallet must hold to subscribe. + /// + /// Admin-only. The creator is deliberately *not* allowed to set their own + /// threshold: it gates paid access, so a creator who could lower it at will + /// could grant access to wallets holding nothing, which is the outcome the + /// gate exists to prevent. + pub fn set_min_hold_for_access( + env: Env, + admin: Address, + creator: Address, + min_keys: u32, + ) -> Result<(), ContractError> { + admin.require_auth(); + assert_is_admin(&env, &admin)?; + + if min_keys == 0 { + // Zero would gate nothing while looking configured. Removing the + // key is the honest way to disable gating, and `subscribe` reports + // that state distinctly. + return Err(ContractError::NotPositiveAmount); + } + + env.storage() + .persistent() + .set(&DataKey::MinHoldForAccess(creator.clone()), &min_keys); + + env.events() + .publish((soroban_sdk::symbol_short!("MIN_HOLD"), creator), min_keys); + + Ok(()) + } + + /// The configured minimum hold for a creator, or `None` when gating is off. + pub fn get_min_hold_for_access(env: Env, creator: Address) -> Option { + env.storage() + .persistent() + .get(&DataKey::MinHoldForAccess(creator)) + } + + /// Subscribes `subscriber` to `creator`'s gated access for + /// `duration_ledgers`, provided they hold at least the configured minimum. + /// + /// The balance is read from storage, not supplied by the caller. Returns the + /// expiry ledger. + /// + /// # Errors + /// + /// - [`ContractError::NotPositiveAmount`] if `duration_ledgers` is zero — a + /// subscription expiring on the ledger it was created in is never usable. + /// - [`ContractError::NotRegistered`] if no minimum is configured for the + /// creator. Reported distinctly from an insufficient balance so an + /// operator can tell "gating is off" from "you need more keys". + /// - [`ContractError::InsufficientBalance`] if the wallet holds less than + /// the minimum. + pub fn subscribe( + env: Env, + creator: Address, + subscriber: Address, + duration_ledgers: u32, + ) -> Result { + // No `require_auth` here: `subscribe_key_access` performs it, and a + // second call on the same frame fails with `Auth(ExistingValue)` — + // "frame is already authorized". Authorization is still enforced before + // any state change, since the helper requires it before writing. + // + // The cheap argument checks below therefore run unauthenticated, which + // is the right order anyway: a caller should learn that the duration is + // zero or that gating is unconfigured without being asked to sign. + if duration_ledgers == 0 { + return Err(ContractError::NotPositiveAmount); + } + + let min_keys: u32 = env + .storage() + .persistent() + .get(&DataKey::MinHoldForAccess(creator.clone())) + .ok_or(ContractError::NotRegistered)?; + + let balance = Self::get_key_balance(env.clone(), creator.clone(), subscriber.clone()); + + crate::curve_subscriptions_swaps::subscribe_key_access( + &env, + &creator, + &subscriber, + duration_ledgers, + min_keys, + balance, + ) + } + + /// Whether `subscriber` currently has gated access to `creator`. + /// + /// Re-checks the live balance against the minimum recorded on the + /// subscription, so access lapses the moment a holder sells below the + /// threshold — no revocation transaction required. That is what "revoked + /// automatically when holding drops below minimum" means here: the gate is + /// evaluated on read rather than swept by a job, so there is no window in + /// which a sold-out wallet still passes. + /// + /// Returns `false` for an unknown subscription, an expired one, or a + /// balance that has since fallen below the recorded minimum. + pub fn is_subscribed(env: Env, creator: Address, subscriber: Address) -> bool { + let balance = Self::get_key_balance(env.clone(), creator.clone(), subscriber.clone()); + crate::curve_subscriptions_swaps::is_subscribed(&env, &creator, &subscriber, balance) + } + + /// The stored subscription record, if any. + /// + /// Exposed alongside [`Self::is_subscribed`] because the boolean alone + /// cannot tell a caller *why* access was denied — expired, or under the + /// threshold. A UI needs to say which. + pub fn get_subscription( + env: Env, + creator: Address, + subscriber: Address, + ) -> Option { + env.storage().instance().get( + &crate::curve_subscriptions_swaps::EmdevelopaDataKey::Subscription(creator, subscriber), + ) + } + /// Read-only view: returns a stable view of a holder's key count for a creator. /// /// Returns a [`HolderKeyCountView`] regardless of creator registration status. diff --git a/creator-keys/tests/subscription_access_gating.rs b/creator-keys/tests/subscription_access_gating.rs new file mode 100644 index 00000000..df8a03db --- /dev/null +++ b/creator-keys/tests/subscription_access_gating.rs @@ -0,0 +1,244 @@ +//! Subscription access gating (Issue #953). +//! +//! The gate's value is that the minimum-hold check reads the ledger rather than +//! trusting the caller, so these tests drive it through the contract entry +//! points — buying and selling real keys to move a balance — rather than calling +//! the internal helper with a hand-supplied balance. + +mod contract_test_env; + +use contract_test_env::{register_creator_keys, register_test_creator, test_env_with_auths}; +use creator_keys::CreatorKeysContractClient; +use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, Address, Env}; + +const MIN_HOLD: u32 = 3; +const DURATION: u32 = 1_000; +const KEY_PRICE: i128 = 100; + +/// Contract with pricing configured, an admin, and one registered creator. +fn setup() -> (Env, CreatorKeysContractClient<'static>, Address, Address) { + let env = test_env_with_auths(); + // Leaked so the client can outlive this frame; tests are short-lived and the + // alternative is threading a lifetime through every helper. + let env: &'static Env = Box::leak(Box::new(env)); + let (client, _) = register_creator_keys(env); + + let admin = Address::generate(env); + client.set_protocol_admin(&admin, &admin); + client.set_key_price(&admin, &KEY_PRICE); + + let creator = register_test_creator(env, &client, "alice"); + (env.clone(), client, admin, creator) +} + +fn buy(client: &CreatorKeysContractClient<'_>, creator: &Address, wallet: &Address, count: u32) { + for _ in 0..count { + client.buy_key(creator, wallet, &KEY_PRICE, &None); + } +} + +/// Sells `count` keys, advancing the ledger first so any anti-flash-trade +/// lockup window has elapsed. +fn sell( + env: &Env, + client: &CreatorKeysContractClient<'_>, + creator: &Address, + wallet: &Address, + count: u32, +) { + env.ledger().with_mut(|l| { + l.timestamp += 86_400; + l.sequence_number += 100; + }); + for _ in 0..count { + client.sell_key(creator, wallet, &None); + } +} + +#[test] +fn subscribe_rejects_a_wallet_below_the_minimum() { + let (env, client, admin, creator) = setup(); + let wallet = Address::generate(&env); + + client.set_min_hold_for_access(&admin, &creator, &MIN_HOLD); + + // Holds nothing at all. + assert!(client.try_subscribe(&creator, &wallet, &DURATION).is_err()); + assert!(!client.is_subscribed(&creator, &wallet)); +} + +#[test] +fn subscribe_rejects_a_wallet_one_key_short() { + let (env, client, admin, creator) = setup(); + let wallet = Address::generate(&env); + + client.set_min_hold_for_access(&admin, &creator, &MIN_HOLD); + buy(&client, &creator, &wallet, MIN_HOLD - 1); + + assert!(client.try_subscribe(&creator, &wallet, &DURATION).is_err()); +} + +#[test] +fn subscribe_accepts_a_qualifying_hold_and_returns_the_expiry() { + let (env, client, admin, creator) = setup(); + let wallet = Address::generate(&env); + + client.set_min_hold_for_access(&admin, &creator, &MIN_HOLD); + buy(&client, &creator, &wallet, MIN_HOLD); + + let before = env.ledger().sequence(); + let expiry = client.subscribe(&creator, &wallet, &DURATION); + + assert_eq!(expiry, before + DURATION); + assert!(client.is_subscribed(&creator, &wallet)); + assert_eq!( + client + .get_subscription(&creator, &wallet) + .unwrap() + .expires_at_ledger, + expiry + ); +} + +#[test] +fn access_is_denied_once_the_expiry_ledger_passes() { + let (env, client, admin, creator) = setup(); + let wallet = Address::generate(&env); + + client.set_min_hold_for_access(&admin, &creator, &MIN_HOLD); + buy(&client, &creator, &wallet, MIN_HOLD); + client.subscribe(&creator, &wallet, &DURATION); + + // The comparison is `<=`, so access survives *at* the expiry ledger. + env.ledger().with_mut(|l| l.sequence_number += DURATION); + assert!(client.is_subscribed(&creator, &wallet)); + + env.ledger().with_mut(|l| l.sequence_number += 1); + assert!(!client.is_subscribed(&creator, &wallet)); +} + +#[test] +fn access_lapses_when_the_holding_drops_below_the_minimum() { + // "Revoked automatically" — the gate is evaluated on read, so there is no + // window in which a sold-down wallet still passes. + let (env, client, admin, creator) = setup(); + let wallet = Address::generate(&env); + + client.set_min_hold_for_access(&admin, &creator, &MIN_HOLD); + buy(&client, &creator, &wallet, MIN_HOLD); + client.subscribe(&creator, &wallet, &DURATION); + assert!(client.is_subscribed(&creator, &wallet)); + + sell(&env, &client, &creator, &wallet, 1); + + assert!( + !client.is_subscribed(&creator, &wallet), + "selling below the minimum must revoke access without a transaction" + ); +} + +#[test] +fn access_returns_when_the_holding_is_topped_back_up() { + // The record is not destroyed by dropping below the minimum, so re-acquiring + // keys restores access for the remainder of the term. + let (env, client, admin, creator) = setup(); + let wallet = Address::generate(&env); + + client.set_min_hold_for_access(&admin, &creator, &MIN_HOLD); + buy(&client, &creator, &wallet, MIN_HOLD); + client.subscribe(&creator, &wallet, &DURATION); + + sell(&env, &client, &creator, &wallet, 1); + assert!(!client.is_subscribed(&creator, &wallet)); + + buy(&client, &creator, &wallet, 1); + assert!(client.is_subscribed(&creator, &wallet)); +} + +#[test] +fn subscribe_rejects_when_gating_is_not_configured() { + // Reported distinctly from an insufficient balance: an operator needs to tell + // "gating is off" from "you need more keys". + let (env, client, _admin, creator) = setup(); + let wallet = Address::generate(&env); + + buy(&client, &creator, &wallet, 10); + + assert!(client.try_subscribe(&creator, &wallet, &DURATION).is_err()); + assert!(client.get_min_hold_for_access(&creator).is_none()); +} + +#[test] +fn subscribe_rejects_a_zero_duration() { + // A subscription expiring on the ledger it was created in is never usable. + let (env, client, admin, creator) = setup(); + let wallet = Address::generate(&env); + + client.set_min_hold_for_access(&admin, &creator, &MIN_HOLD); + buy(&client, &creator, &wallet, MIN_HOLD); + + assert!(client.try_subscribe(&creator, &wallet, &0).is_err()); +} + +#[test] +fn a_zero_minimum_is_rejected() { + // Zero would gate nothing while looking configured; removing the key is the + // honest way to disable gating. + let (_env, client, admin, creator) = setup(); + + assert!(client + .try_set_min_hold_for_access(&admin, &creator, &0) + .is_err()); +} + +#[test] +fn the_configured_minimum_is_readable() { + let (_env, client, admin, creator) = setup(); + + client.set_min_hold_for_access(&admin, &creator, &MIN_HOLD); + assert_eq!(client.get_min_hold_for_access(&creator), Some(MIN_HOLD)); +} + +#[test] +fn raising_the_minimum_does_not_retroactively_revoke() { + // The subscription records the minimum in force when granted, so a later + // increase applies to new subscriptions rather than voiding paid-for access + // mid-term. + let (env, client, admin, creator) = setup(); + let wallet = Address::generate(&env); + + client.set_min_hold_for_access(&admin, &creator, &MIN_HOLD); + buy(&client, &creator, &wallet, MIN_HOLD); + client.subscribe(&creator, &wallet, &DURATION); + + client.set_min_hold_for_access(&admin, &creator, &(MIN_HOLD + 10)); + + assert!(client.is_subscribed(&creator, &wallet)); +} + +#[test] +fn subscriptions_are_scoped_per_creator() { + let (env, client, admin, creator_a) = setup(); + let creator_b = register_test_creator(&env, &client, "bob"); + let wallet = Address::generate(&env); + + client.set_min_hold_for_access(&admin, &creator_a, &MIN_HOLD); + client.set_min_hold_for_access(&admin, &creator_b, &MIN_HOLD); + buy(&client, &creator_a, &wallet, MIN_HOLD); + client.subscribe(&creator_a, &wallet, &DURATION); + + assert!(client.is_subscribed(&creator_a, &wallet)); + assert!( + !client.is_subscribed(&creator_b, &wallet), + "a subscription to one creator must not grant access to another" + ); +} + +#[test] +fn is_subscribed_is_false_for_an_unknown_subscription() { + let (env, client, _admin, creator) = setup(); + let stranger = Address::generate(&env); + + assert!(!client.is_subscribed(&creator, &stranger)); + assert!(client.get_subscription(&creator, &stranger).is_none()); +}