diff --git a/contracts/vesting/src/lib.rs b/contracts/vesting/src/lib.rs index 911e30fa..1137276a 100644 --- a/contracts/vesting/src/lib.rs +++ b/contracts/vesting/src/lib.rs @@ -1,54 +1,6 @@ #![no_std] -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address, - Env, Map, Vec, -}; - -// --------------------------------------------------------------------------- -// Errors -// --------------------------------------------------------------------------- - -/// Typed contract errors for the vesting contract. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum VestingError { - /// `initialize` was called on a contract that is already initialized. - AlreadyInitialized = 1, - /// Operation attempted before `initialize` was called. - NotInitialized = 2, - /// The vesting contract is paused. - Paused = 3, - /// Amount is zero or negative where a positive value is required. - InvalidAmount = 4, - /// `end_ledger` is not strictly after `cliff_ledger`. - InvalidLedgerRange = 5, - /// `accept_admin` was called with no pending proposal. - NoPendingAdmin = 6, - /// Operation attempted on a revoked vesting schedule. - ScheduleRevoked = 7, - /// Schedule has already been revoked. - AlreadyRevoked = 8, - /// `release` was called but no vested tokens are available. - NothingToRelease = 9, - /// No schedule found for recipient. - ScheduleNotFound = 10, - /// Schedule index is out of bounds for recipient. - ScheduleIndexOutOfBounds = 11, - /// Batch schedules list is empty. - BatchEmpty = 12, - /// Batch schedules size exceeds maximum of 50. - BatchTooLarge = 13, - /// `extend_cliff` called after the cliff ledger has passed. - CliffPassed = 14, - /// New cliff ledger is not strictly later than the current cliff ledger. - CliffNotExtended = 15, - /// New cliff ledger is not strictly before the end ledger. - CliffAfterEnd = 16, - /// `prune_recipient` called for a recipient that is not tracked. - RecipientNotTracked = 17, -} +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, Map, Vec}; // --------------------------------------------------------------------------- // Constants @@ -92,6 +44,7 @@ const TTL_LEDGERS: u32 = { pub enum DataKey { Admin, PendingAdmin, + Locked, TokenContract, IsPaused, TotalCommitted, @@ -176,6 +129,7 @@ impl VestingContract { /// Accept the admin role. Must be called by the pending admin. pub fn accept_admin(env: Env) { + Self::_require_not_locked(&env); let pending: Address = env .storage() .instance() @@ -203,12 +157,7 @@ impl VestingContract { end_ledger: u32, ) { Self::_check_paused(&env); - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .unwrap_or_else(|| panic_with_error!(&env, VestingError::NotInitialized)); - admin.require_auth(); + Self::_require_admin(&env); Self::_validate_total_amount(total_amount); assert!( @@ -272,12 +221,7 @@ impl VestingContract { /// with a clear error rather than an opaque resource failure. pub fn create_schedules_batch(env: Env, schedules: Vec) -> u32 { Self::_check_paused(&env); - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .unwrap_or_else(|| panic_with_error!(&env, VestingError::NotInitialized)); - admin.require_auth(); + Self::_require_admin(&env); if schedules.len() == 0 { panic_with_error!(&env, VestingError::BatchEmpty); @@ -594,6 +538,52 @@ impl VestingContract { env.events().publish((symbol_short!("unpause"),), ()); } + /// Upgrade this contract's WASM code hash in place. Admin only. + /// + /// Security note: this preserves existing storage and contract state, so + /// new WASM must remain storage-compatible with previous deployments. + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { + Self::_require_admin(&env); + assert!( + new_wasm_hash != BytesN::from_array(&env, &[0; 32]), + "invalid wasm hash" + ); + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + env.events() + .publish((symbol_short!("upgrade"),), new_wasm_hash); + } + + /// Permanently revoke the admin role and lock the contract. + /// + /// After this call: + /// - No further `create_schedule`, `revoke`, `extend_cliff`, + /// `prune_recipient`, `propose_admin`, `accept_admin`, + /// `upgrade`, `pause`, or `unpause` operation can ever succeed. + /// - The Admin storage entry is removed and a `Locked` flag is set. + /// - `is_locked()` returns `true` from then on. + /// + /// Holders can still `release` and `keep_alive`. The contract + /// becomes effectively immutable. + /// + /// **This action is irreversible.** + pub fn revoke_admin(env: Env) { + Self::_require_admin(&env); + env.storage().instance().set(&DataKey::Locked, &true); + env.storage().instance().remove(&DataKey::Admin); + env.storage().instance().remove(&DataKey::PendingAdmin); + env.events().publish((symbol_short!("revoked"),), true); + } + + /// Returns `true` once `revoke_admin` has been called. Once locked, no + /// admin operation can ever succeed again. + pub fn is_locked(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Locked) + .unwrap_or(false) + } + /// Return the number of schedules stored for a recipient. pub fn get_schedule_count(env: Env, recipient: Address) -> u32 { Self::_schedule_count(&env, &recipient) @@ -818,6 +808,7 @@ impl VestingContract { // ── Internals ─────────────────────────────────────────────────────── fn _require_admin(env: &Env) { + Self::_require_not_locked(env); let admin: Address = env .storage() .instance() @@ -826,6 +817,17 @@ impl VestingContract { admin.require_auth(); } + fn _require_not_locked(env: &Env) { + let locked: bool = env + .storage() + .instance() + .get(&DataKey::Locked) + .unwrap_or(false); + if locked { + panic!("admin revoked: contract is locked"); + } + } + fn _check_paused(env: &Env) { if env .storage() @@ -1019,9 +1021,9 @@ mod test { // categories of activity. `scripts/generate_events_doc.py --check` // re-derives this same set directly from source and fails CI if it // and `docs/events.json` disagree. - const EXPECTED_TOPICS: [&str; 11] = [ + const EXPECTED_TOPICS: [&str; 13] = [ "init", "prop_adm", "acc_adm", "create", "batch", "release", "revoke", "clf_ext", "pause", - "unpause", "prune", + "unpause", "prune", "upgrade", "revoked", ]; /// Asserts the set of `symbol_short!("...")` topic-0 literals used in @@ -2225,6 +2227,7 @@ mod test { assert_eq!(token_client.balance(&admin), 250); } + // ── #359: initialize auth guard ─────────────────────────────────── #[test] @@ -2252,9 +2255,25 @@ mod test { } // ── Regression tests for issue #324: TTL clamp for long schedules ── + // ── Upgrade tests ─────────────────────────────────────────── + #[test] + fn test_upgrade_rejects_zero_hash() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + + let zero_hash = BytesN::from_array(&env, &[0; 32]); + client.upgrade(&zero_hash); + } #[test] - fn test_create_schedule_four_years_does_not_panic() { + fn test_upgrade_success() { let env = Env::default(); env.mock_all_auths(); @@ -2262,26 +2281,132 @@ mod test { let client = VestingContractClient::new(&env, &contract_id); let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + + let non_zero_hash = BytesN::from_array(&env, &[1; 32]); + client.upgrade(&non_zero_hash); + + // Verify the contract is still functional after upgrade + assert_eq!(client.get_admin(), admin); + } + + #[test] + #[should_panic] + fn test_non_admin_cannot_upgrade() { + let env = Env::default(); + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + + let non_zero_hash = BytesN::from_array(&env, &[1; 32]); + env.mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &user, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id, + fn_name: "upgrade", + args: (non_zero_hash.clone(),).into_val(&env), + sub_invokes: &[], + }, + }]); + + client.upgrade(&non_zero_hash); + } + + // ── Lock / revoke_admin tests ────────────────────────────── + + #[test] + fn test_revoke_admin_sets_locked_flag() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + + assert!(!client.is_locked()); + client.revoke_admin(); + assert!(client.is_locked()); + } + + #[test] + #[should_panic(expected = "admin revoked")] + fn test_admin_getter_after_revoke_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + client.revoke_admin(); + let _ = client.get_admin(); + } + + #[test] + #[should_panic(expected = "admin revoked: contract is locked")] + fn test_create_schedule_after_revoke_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + client.revoke_admin(); + let recipient = Address::generate(&env); - let token_addr = env.register_stellar_asset_contract(admin.clone()); - let asset_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr); + client.create_schedule(&recipient, &1000, &100, &200); + } - client.initialize(&admin, &token_addr); - asset_client.mint(&admin, &1_000_000); + #[test] + #[should_panic(expected = "admin revoked: contract is locked")] + fn test_revoke_after_revoke_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); - // ~4 years at ~5s ledgers: 4 * 365 * 24 * 60 * 60 / 5 - let four_years_ledgers: u32 = 4 * 365 * 24 * 60 * 60 / 5; + let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + client.revoke_admin(); + client.revoke_admin(); + } - // This must not panic even though the TTL would exceed the network's - // max_entry_ttl if applied verbatim (see _ttl_ledgers clamp). - client.create_schedule(&recipient, &1_000_000, &100u32, &four_years_ledgers); + #[test] + #[should_panic(expected = "admin revoked: contract is locked")] + fn test_upgrade_after_revoke_panics() { + let env = Env::default(); + env.mock_all_auths(); - let schedule = get_schedule_latest(&client, &recipient); - assert_eq!(schedule.end_ledger, four_years_ledgers); + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + client.revoke_admin(); + + let non_zero_hash = BytesN::from_array(&env, &[1; 32]); + client.upgrade(&non_zero_hash); } #[test] - fn test_keep_alive_refreshes_ttl_without_release() { + #[should_panic(expected = "admin revoked: contract is locked")] + fn test_extend_cliff_after_revoke_panics() { let env = Env::default(); env.mock_all_auths(); @@ -2289,43 +2414,102 @@ mod test { let client = VestingContractClient::new(&env, &contract_id); let (_, recipient) = setup_schedule(&env, &client); - // Should not panic, and should not release/transfer anything. - client.keep_alive(&recipient, &latest_index()); - assert_eq!(released_amount_latest(&client, &recipient), 0); + client.revoke_admin(); + extend_cliff_latest(&client, &recipient, 150u32); + } + + #[test] + #[should_panic(expected = "admin revoked: contract is locked")] + fn test_prune_recipient_after_revoke_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + let (_, recipient) = setup_schedule(&env, &client); + + client.revoke_admin(); + client.prune_recipient(&recipient); + } + + #[test] + #[should_panic(expected = "admin revoked: contract is locked")] + fn test_pause_after_revoke_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + client.revoke_admin(); + client.pause(); + } + + #[test] + #[should_panic(expected = "admin revoked: contract is locked")] + fn test_unpause_after_revoke_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + client.revoke_admin(); + client.unpause(); } - // ── Regression tests for issue #325: unbounded recipients list ───── + #[test] + #[should_panic(expected = "admin revoked: contract is locked")] + fn test_propose_admin_after_revoke_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + client.revoke_admin(); + + let other = Address::generate(&env); + client.propose_admin(&other); + } #[test] - fn test_recipients_scale_to_several_hundred() { + fn test_holder_release_still_works_after_revoke() { let env = Env::default(); env.mock_all_auths(); - env.budget().reset_unlimited(); let contract_id = env.register_contract(None, VestingContract); let client = VestingContractClient::new(&env, &contract_id); let admin = Address::generate(&env); + let recipient = Address::generate(&env); let token_addr = env.register_stellar_asset_contract(admin.clone()); + let token_client = soroban_sdk::token::Client::new(&env, &token_addr); let asset_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_addr); client.initialize(&admin, &token_addr); - asset_client.mint(&admin, &1_000_000); - - let n = 300u32; - for _ in 0..n { - let recipient = Address::generate(&env); - client.create_schedule(&recipient, &1000, &100, &200); - } + asset_client.mint(&admin, &1000); - assert_eq!(client.get_recipient_count(), n); + client.create_schedule(&recipient, &1000, &100, &200); + client.revoke_admin(); - let page = client.get_recipients_paginated(&0u32, &n); - assert_eq!(page.len(), n); + // Release should still work - holders can claim vested tokens + env.ledger().set_sequence_number(150); + release_latest(&client, &recipient); + assert_eq!(token_client.balance(&recipient), 500); } #[test] - fn test_prune_recipient_removes_from_paginated_list() { + fn test_keep_alive_still_works_after_revoke() { let env = Env::default(); env.mock_all_auths(); @@ -2333,15 +2517,47 @@ mod test { let client = VestingContractClient::new(&env, &contract_id); let (_, recipient) = setup_schedule(&env, &client); - assert_eq!(client.get_recipients_paginated(&0u32, &10u32).len(), 1); + client.revoke_admin(); - client.prune_recipient(&recipient); + // keep_alive should still work after revoke + env.ledger().set_sequence_number(50); + client.keep_alive(&recipient, &latest_index()); + } + + // ── Upgrade event tests ───────────────────────────────────── + + #[test] + fn test_upgrade_emits_event_with_new_hash() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); - assert_eq!(client.get_recipients_paginated(&0u32, &10u32).len(), 0); + let non_zero_hash = BytesN::from_array(&env, &[0xAB; 32]); + client.upgrade(&non_zero_hash); + + let events = env.events().all(); + let last_event = events.slice(events.len() - 1..); + assert_eq!( + last_event, + soroban_sdk::vec![ + &env, + ( + contract_id, + (symbol_short!("upgrade"),).into_val(&env), + non_zero_hash.into_val(&env) + ) + ] + ); } #[test] - fn test_prune_recipient_not_tracked_panics() { + fn test_revoke_admin_emits_event() { let env = Env::default(); env.mock_all_auths(); @@ -2352,12 +2568,55 @@ mod test { let token = Address::generate(&env); client.initialize(&admin, &token); - let stranger = Address::generate(&env); + client.revoke_admin(); + + let events = env.events().all(); + let last_event = events.slice(events.len() - 1..); assert_eq!( - client.try_prune_recipient(&stranger), - Err(Ok(VestingError::RecipientNotTracked.into())) + last_event, + soroban_sdk::vec![ + &env, + ( + contract_id, + (symbol_short!("revoked"),).into_val(&env), + true.into_val(&env) + ) + ] ); } + // ── Regression: existing vesting functionality unchanged ───── + + #[test] + fn test_initialize_still_works() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + + assert_eq!(client.get_admin(), admin); + assert_eq!(client.get_token_contract(), token); + assert!(!client.is_locked()); + } + + #[test] + fn test_is_locked_default_is_false() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, VestingContract); + let client = VestingContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + client.initialize(&admin, &token); + + assert!(!client.is_locked()); + } // ── #360: aggregate getters and release_all ──────────────────────── @@ -2563,4 +2822,4 @@ mod test { let all = client.get_all_schedules(&recipient); assert_eq!(all.len(), 0); } -} +} \ No newline at end of file diff --git a/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx b/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx index 8970a464..6a2764ec 100644 --- a/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx +++ b/frontend/app/dashboard/[contractId]/components/AdminPanel.tsx @@ -16,6 +16,7 @@ import { MintCard } from "./admin/MintCard"; import { SupplyCard } from "./admin/SupplyCard"; import { VestingCard } from "./admin/VestingCard"; import { ManageVestingCard } from "./admin/ManageVestingCard"; +import { VestingUpgradeCard } from "./admin/VestingUpgradeCard"; import { TransferAdminCard, RevokeAdminCard, @@ -181,6 +182,7 @@ export function AdminPanel({ + ({ + resolver: zodResolver(vestingUpgradeSchema), + }); + const [showConfirm, setShowConfirm] = useState(false); + + const onSubmit = form.handleSubmit(async (data) => { + if (!showConfirm) { + setShowConfirm(true); + return; + } + + if (await admin.run("vesting-upgrade", data)) { + form.reset(); + setShowConfirm(false); + } + }); + + return ( + + {locked ? ( +
+
+ ) : ( + <> +
+ Before upgrading:{" "} + ensure the new WASM has been reviewed and audited. This replaces + contract logic for every vesting schedule holder and cannot be undone + unless the new contract itself supports a further upgrade. +
+ +
+
+ + + {form.formState.errors.vestingContract && ( +

+ {form.formState.errors.vestingContract.message} +

+ )} +
+ +
+ + + {form.formState.errors.wasmHash && ( +

+ {form.formState.errors.wasmHash.message} +

+ )} +
+ + {showConfirm && ( +
+

+ Confirm upgrade +

+

+ Type the vesting contract address{" "} + + {form.watch("vestingContract") ?? "C..."} + {" "} + to confirm you understand this is irreversible. +

+ + {form.formState.errors.confirmSymbol && ( +

+ {form.formState.errors.confirmSymbol.message} +

+ )} +
+ )} + +
+ {showConfirm && ( + + )} + +
+
+ + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/app/dashboard/[contractId]/components/admin/adminActions.ts b/frontend/app/dashboard/[contractId]/components/admin/adminActions.ts index 799df590..d31c77bb 100644 --- a/frontend/app/dashboard/[contractId]/components/admin/adminActions.ts +++ b/frontend/app/dashboard/[contractId]/components/admin/adminActions.ts @@ -14,6 +14,7 @@ import type { ManageVestingData, MetadataUriData, UpgradeData, + VestingUpgradeData, WhaleCapData, ComplianceNodeData, AccountData, @@ -103,6 +104,7 @@ export interface AdminActionData { "revoke-auth": AccountData; revoke: EmptyData; upgrade: UpgradeData; + "vesting-upgrade": VestingUpgradeData; } export type AdminActionKey = keyof AdminActionData; @@ -369,7 +371,7 @@ export const ADMIN_ACTIONS: AdminActionRegistry = { /* ── Danger ──────────────────────────────────────────────────── */ - upgrade: { + upgrade: { label: "Upgrade contract", resolve: async (data, ctx) => ctx.tokenClient.upgrade({ new_wasm_hash: Buffer.from(data.wasmHash, "hex") @@ -381,4 +383,19 @@ export const ADMIN_ACTIONS: AdminActionRegistry = { "The contract WASM has been replaced. All holders are now on the new logic.", }, }, + + "vesting-upgrade": { + label: "Upgrade vesting contract", + resolve: (data) => ({ + contractId: data.vestingContract, + method: "upgrade", + args: [xdr.ScVal.scvBytes(Buffer.from(data.wasmHash, "hex"))], + }), + preflight: "none", + successToast: { + title: "Vesting contract upgraded", + message: + "The vesting contract WASM has been replaced. All vesting schedules are now on the new logic.", + }, + }, }; diff --git a/frontend/app/dashboard/[contractId]/components/admin/schemas.ts b/frontend/app/dashboard/[contractId]/components/admin/schemas.ts index 93f7ee1a..33d04b9d 100644 --- a/frontend/app/dashboard/[contractId]/components/admin/schemas.ts +++ b/frontend/app/dashboard/[contractId]/components/admin/schemas.ts @@ -90,6 +90,17 @@ export const upgradeSchema = z.object({ confirmSymbol: z.string().min(1, "Type the token symbol to confirm"), }); +export const vestingUpgradeSchema = z.object({ + vestingContract: contractAddress, + wasmHash: z + .string() + .regex( + /^[0-9a-fA-F]{64}$/, + "Must be a 64-character hex string (32-byte WASM hash)", + ), + confirmSymbol: z.string().min(1, "Type the symbol to confirm"), +}); + export const whaleCapSchema = z.object({ cap: z .string() @@ -115,6 +126,7 @@ export type VestingData = z.infer; export type ManageVestingData = z.infer; export type MetadataUriData = z.infer; export type UpgradeData = z.infer; +export type VestingUpgradeData = z.infer; export type WhaleCapData = z.infer; export type ComplianceNodeData = z.infer; export type AccountData = z.infer;