Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions creator-keys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ pub mod emergency_pause;
pub mod events;
pub mod lp_reward;
pub mod price_alerts;
pub mod proxy;
pub mod ratings_royalties_dividends;
pub use proxy::{BondingCurveProxy, BondingCurveProxyClient};

pub mod test_feature_impl;
pub mod test_new_features;
Expand Down Expand Up @@ -20348,3 +20350,6 @@ mod test_issue_998;

#[cfg(test)]
mod test_issue_1000;

#[cfg(test)]
mod test_proxy;
184 changes: 184 additions & 0 deletions creator-keys/src/proxy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! Upgrade proxy contract for bonding curve logic migration (#1055).
//!
//! Wraps the bonding curve (`creator-keys`) contract behind an upgrade proxy pattern,
//! allowing the admin to migrate to a new implementation while preserving all state,
//! balances, and continuous trading operations.

use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Symbol, Val,
Vec,
};

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum ProxyError {
AlreadyInitialized = 1,
NotInitialized = 2,
Unauthorized = 3,
InvalidInterface = 4,
ContractPaused = 5,
}

#[derive(Clone)]
#[contracttype]
pub enum ProxyDataKey {
Admin,
Implementation,
IsPaused,
Initialized,
}

/// Event emitted when an implementation upgrade is executed.
#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct UpgradeExecuted {
pub old_implementation: Address,
pub new_implementation: Address,
pub executed_at: u64,
}

pub const UPGRADE_EXECUTED_EVENT_NAME: Symbol = symbol_short!("upg_exec");

#[contract]
pub struct BondingCurveProxy;

fn assert_admin(env: &Env, caller: &Address) -> Result<(), ProxyError> {
caller.require_auth();
let admin: Address = env
.storage()
.instance()
.get(&ProxyDataKey::Admin)
.ok_or(ProxyError::NotInitialized)?;
if *caller != admin {
return Err(ProxyError::Unauthorized);
}
Ok(())
}

fn validate_interface(env: &Env, implementation: &Address) -> Result<(), ProxyError> {
// Interface verification: ensure implementation responds to bonding curve contract calls
let fn_name = Symbol::new(env, "get_timelock_delay");
let res = env.try_invoke_contract::<Val, Val>(implementation, &fn_name, Vec::new(env));
if res.is_err() {
let alt_fn = Symbol::new(env, "get_current_fee");
let alt_res = env.try_invoke_contract::<Val, Val>(implementation, &alt_fn, Vec::new(env));
if alt_res.is_err() {
return Err(ProxyError::InvalidInterface);
}
}
Ok(())
}

fn emit_upgrade_executed(env: &Env, old_impl: &Address, new_impl: &Address) {
let payload = UpgradeExecuted {
old_implementation: old_impl.clone(),
new_implementation: new_impl.clone(),
executed_at: env.ledger().timestamp(),
};
env.events()
.publish((UPGRADE_EXECUTED_EVENT_NAME, old_impl, new_impl), payload);
}

#[contractimpl]
impl BondingCurveProxy {
/// Initializes the proxy with an admin and the initial bonding curve implementation contract.
pub fn initialize(
env: Env,
admin: Address,
initial_implementation: Address,
) -> Result<(), ProxyError> {
let storage = env.storage().instance();
if storage
.get::<_, bool>(&ProxyDataKey::Initialized)
.unwrap_or(false)
{
return Err(ProxyError::AlreadyInitialized);
}

validate_interface(&env, &initial_implementation)?;

storage.set(&ProxyDataKey::Admin, &admin);
storage.set(&ProxyDataKey::Implementation, &initial_implementation);
storage.set(&ProxyDataKey::IsPaused, &false);
storage.set(&ProxyDataKey::Initialized, &true);

emit_upgrade_executed(&env, &initial_implementation, &initial_implementation);

Ok(())
}

/// Upgrades the bonding curve contract to a new implementation.
///
/// Requires admin authorization and performs interface validation against the new contract.
/// Emits `UpgradeExecuted` event upon successful upgrade.
pub fn upgrade_implementation(
env: Env,
caller: Address,
new_implementation: Address,
) -> Result<(), ProxyError> {
assert_admin(&env, &caller)?;

let storage = env.storage().instance();
let old_impl: Address = storage
.get(&ProxyDataKey::Implementation)
.ok_or(ProxyError::NotInitialized)?;

validate_interface(&env, &new_implementation)?;

storage.set(&ProxyDataKey::Implementation, &new_implementation);

emit_upgrade_executed(&env, &old_impl, &new_implementation);

Ok(())
}

/// Emergency pause for the proxy to guard trading operations during sensitive migrations.
pub fn set_paused(env: Env, caller: Address, paused: bool) -> Result<(), ProxyError> {
assert_admin(&env, &caller)?;
env.storage()
.instance()
.set(&ProxyDataKey::IsPaused, &paused);
Ok(())
}

/// Returns whether the proxy is currently paused.
pub fn is_paused(env: Env) -> bool {
env.storage()
.instance()
.get(&ProxyDataKey::IsPaused)
.unwrap_or(false)
}

/// Returns the current active implementation address.
pub fn get_implementation(env: Env) -> Result<Address, ProxyError> {
env.storage()
.instance()
.get(&ProxyDataKey::Implementation)
.ok_or(ProxyError::NotInitialized)
}

/// Returns the current admin address.
pub fn get_admin(env: Env) -> Result<Address, ProxyError> {
env.storage()
.instance()
.get(&ProxyDataKey::Admin)
.ok_or(ProxyError::NotInitialized)
}

/// Fallback dispatcher: delegates call to current implementation.
/// Rejects calls if the proxy is paused or not initialized.
pub fn dispatch(env: Env, function: Symbol, args: Vec<Val>) -> Result<Val, ProxyError> {
let storage = env.storage().instance();
let is_paused: bool = storage.get(&ProxyDataKey::IsPaused).unwrap_or(false);
if is_paused {
return Err(ProxyError::ContractPaused);
}

let impl_addr: Address = storage
.get(&ProxyDataKey::Implementation)
.ok_or(ProxyError::NotInitialized)?;

Ok(env.invoke_contract(&impl_addr, &function, args))
}
}
109 changes: 109 additions & 0 deletions creator-keys/src/test_proxy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#![cfg(test)]

use crate::proxy::{BondingCurveProxy, BondingCurveProxyClient, ProxyError};
use crate::CreatorKeysContract;
use soroban_sdk::{
testutils::{Address as _, Events as _},
Address, Env, IntoVal, Symbol, Vec,
};

#[test]
fn test_proxy_initialization_and_access_control() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let impl_id = env.register(CreatorKeysContract, ());

let proxy_id = env.register(BondingCurveProxy, ());
let client = BondingCurveProxyClient::new(&env, &proxy_id);

client.initialize(&admin, &impl_id);

assert_eq!(client.get_admin(), admin);
assert_eq!(client.get_implementation(), impl_id);
assert!(!client.is_paused());

// Already initialized rejection
let res = client.try_initialize(&admin, &impl_id);
assert_eq!(res, Err(Ok(ProxyError::AlreadyInitialized)));
}

#[test]
fn test_proxy_upgrade_restricted_to_admin_with_interface_validation() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let non_admin = Address::generate(&env);
let impl_1 = env.register(CreatorKeysContract, ());
let impl_2 = env.register(CreatorKeysContract, ());

let proxy_id = env.register(BondingCurveProxy, ());
let client = BondingCurveProxyClient::new(&env, &proxy_id);
client.initialize(&admin, &impl_1);

// Non-admin upgrade fails with Unauthorized
let res = client.try_upgrade_implementation(&non_admin, &impl_2);
assert_eq!(res, Err(Ok(ProxyError::Unauthorized)));

// Invalid implementation (random address without contract interface) fails with InvalidInterface
let dummy_addr = Address::generate(&env);
let res_invalid = client.try_upgrade_implementation(&admin, &dummy_addr);
assert_eq!(res_invalid, Err(Ok(ProxyError::InvalidInterface)));

// Admin upgrade to valid implementation succeeds
client.upgrade_implementation(&admin, &impl_2);
let events = env.events().all();
assert!(!events.is_empty());
assert_eq!(client.get_implementation(), impl_2);
}

#[test]
fn test_proxy_pause_and_unpause() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let non_admin = Address::generate(&env);
let impl_id = env.register(CreatorKeysContract, ());

let proxy_id = env.register(BondingCurveProxy, ());
let client = BondingCurveProxyClient::new(&env, &proxy_id);
client.initialize(&admin, &impl_id);

// Non-admin cannot pause
let pause_res = client.try_set_paused(&non_admin, &true);
assert_eq!(pause_res, Err(Ok(ProxyError::Unauthorized)));

// Admin can pause
client.set_paused(&admin, &true);
assert!(client.is_paused());

// Admin can unpause
client.set_paused(&admin, &false);
assert!(!client.is_paused());
}

#[test]
fn test_proxy_delegation_dispatch() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let impl_id = env.register(CreatorKeysContract, ());

let proxy_id = env.register(BondingCurveProxy, ());
let client = BondingCurveProxyClient::new(&env, &proxy_id);
client.initialize(&admin, &impl_id);

// Call get_timelock_delay via proxy dispatch
let delay_res = client.dispatch(&Symbol::new(&env, "get_timelock_delay"), &Vec::new(&env));
let delay_val: u64 = delay_res.into_val(&env);
assert_eq!(delay_val, 172800u64);

// When paused, dispatch returns ContractPaused
client.set_paused(&admin, &true);
let paused_res = client.try_dispatch(&Symbol::new(&env, "get_timelock_delay"), &Vec::new(&env));
assert!(matches!(paused_res, Err(Ok(ProxyError::ContractPaused))));
}
Loading