diff --git a/MigrationDashboard.tsx b/MigrationDashboard.tsx new file mode 100644 index 00000000..aee54552 --- /dev/null +++ b/MigrationDashboard.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useEffect, useState } from 'react'; + +// --- Mocks & Placeholders --- +// Replace these with your actual Soroban contract interaction library +const sorobanUtils = { + getOldTokenBalance: async (userAddress: string) => "1000.0000000", + getAllowance: async (userAddress: string, spender: string) => "0.0000000", + getMigrationDeadline: async () => Date.now() / 1000 + 86400 * 7, // 7 days from now + getTotalMigrated: async () => "500000.0000000", + approve: async (spender: string, amount: string) => { + console.log(`Approving ${spender} for ${amount}`); + await new Promise(res => setTimeout(res, 2000)); + return true; + }, + migrate: async (amount: string) => { + console.log(`Migrating ${amount}`); + await new Promise(res => setTimeout(res, 2000)); + return true; + }, +}; + +const MIGRATION_CONTRACT_ID = "CD...YOUR_MIGRATION_CONTRACT_ID"; +const USER_ADDRESS = "GD...YOUR_USER_ADDRESS"; // This would come from a wallet connection + +const Countdown = ({ deadline }: { deadline: number }) => { + const [timeLeft, setTimeLeft] = useState(""); + + useEffect(() => { + const interval = setInterval(() => { + const now = Date.now() / 1000; + const remaining = deadline - now; + + if (remaining <= 0) { + setTimeLeft("Migration period has ended."); + clearInterval(interval); + return; + } + + const days = Math.floor(remaining / 86400); + const hours = Math.floor((remaining % 86400) / 3600); + const minutes = Math.floor((remaining % 3600) / 60); + const seconds = Math.floor(remaining % 60); + + setTimeLeft(`${days}d ${hours}h ${minutes}m ${seconds}s`); + }, 1000); + + return () => clearInterval(interval); + }, [deadline]); + + return
{timeLeft}
; +}; + +export default function MigrationDashboard() { + const [amount, setAmount] = useState(''); + const [status, setStatus] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + // Data from blockchain + const [oldTokenBalance, setOldTokenBalance] = useState("0"); + const [allowance, setAllowance] = useState("0"); + const [deadline, setDeadline] = useState(0); + const [totalMigrated, setTotalMigrated] = useState("0"); + + const needsApproval = parseFloat(allowance) < parseFloat(amount || "0"); + + useEffect(() => { + const fetchData = async () => { + try { + const [balance, allowance, deadline, totalMigrated] = await Promise.all([ + sorobanUtils.getOldTokenBalance(USER_ADDRESS), + sorobanUtils.getAllowance(USER_ADDRESS, MIGRATION_CONTRACT_ID), + sorobanUtils.getMigrationDeadline(), + sorobanUtils.getTotalMigrated(), + ]); + setOldTokenBalance(balance); + setAllowance(allowance); + setDeadline(deadline); + setTotalMigrated(totalMigrated); + } catch (error) { + console.error("Failed to fetch migration data:", error); + setStatus("Error: Could not load migration data."); + } + }; + fetchData(); + }, []); + + const handleApprove = async () => { + setIsLoading(true); + setStatus(`Approving migration contract to spend ${amount} legacy tokens...`); + try { + await sorobanUtils.approve(MIGRATION_CONTRACT_ID, amount); + setAllowance(amount); // Optimistically update allowance + setStatus("Approval successful! You can now migrate."); + } catch (error) { + console.error(error); + setStatus("Error: Approval failed."); + } finally { + setIsLoading(false); + } + }; + + const handleMigrate = async () => { + setIsLoading(true); + setStatus(`Migrating ${amount} legacy tokens...`); + try { + await sorobanUtils.migrate(amount); + setStatus("Migration successful! Your new tokens have been minted."); + // Refetch data after migration + const newBalance = await sorobanUtils.getOldTokenBalance(USER_ADDRESS); + setOldTokenBalance(newBalance); + setAmount(''); + } catch (error) { + console.error(error); + setStatus("Error: Migration failed."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Token Migration Tool

+

+ Seamlessly swap your legacy RST tokens for the new, upgraded version. +

+ + {/* Stats Section */} +
+
+

Time Remaining

+ {deadline > 0 ? :
Loading...
} +
+
+

Total Tokens Migrated

+
{parseFloat(totalMigrated).toLocaleString()}
+
+
+ + {/* Swap Interface */} +
+
+ +
+ setAmount(e.target.value)} + placeholder="0.0" + className="w-full p-3 bg-gray-900 border border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:outline-none" + disabled={isLoading} + /> + +
+

Your Balance: {parseFloat(oldTokenBalance).toLocaleString()} Legacy RST

+
+ + {/* Action Buttons */} +
+ {needsApproval ? ( + + ) : ( +
✓ Approved
+ )} + + +
+
+ + {/* Status Message */} + {status && ( +
+

{status}

+
+ )} +
+ ); +} diff --git a/legacy_deprecation.rs b/legacy_deprecation.rs new file mode 100644 index 00000000..56a50e7a --- /dev/null +++ b/legacy_deprecation.rs @@ -0,0 +1,53 @@ +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String}; + +/// This module is intended to be integrated into an existing token contract +/// via a contract upgrade. It cannot function as a standalone contract. + +#[contracttype] +#[derive(Clone, Default)] +pub struct DeprecationStatus { + pub is_deprecated: bool, + pub migration_contract: Option
, + pub deprecation_timestamp: u64, +} + +#[contracttype] +pub enum DeprecationDataKey { + Status, +} + +pub trait DeprecationTrait { + /// Deprecates the contract, allowing transfers only to the migration contract. + /// Must only be callable by the contract admin. + fn deprecate(env: Env, migration_contract: Address); + + /// Helper function to check transfer validity during deprecation. + fn check_deprecation_status(env: &Env, to: &Address); +} + +pub fn deprecate(env: Env, migration_contract: Address) { + // This function assumes it's called from within the legacy token contract + // and that admin authorization has already been checked. + let status = DeprecationStatus { + is_deprecated: true, + migration_contract: Some(migration_contract.clone()), + deprecation_timestamp: env.ledger().timestamp(), + }; + env.storage().instance().set(&DeprecationDataKey::Status, &status); + + env.events().publish( + (String::from_slice(&env, "deprecated"),), + (migration_contract,), + ); +} + +pub fn check_deprecation_status(env: &Env, to: &Address) { + if let Some(status) = env.storage().instance().get::<_, DeprecationStatus>(&DeprecationDataKey::Status) { + if status.is_deprecated { + let migration_target = status.migration_contract.unwrap(); + if to != &migration_target { + panic!("Contract deprecated; transfers only allowed to the migration contract"); + } + } + } +} diff --git a/token_migration.rs b/token_migration.rs new file mode 100644 index 00000000..3cd77a86 --- /dev/null +++ b/token_migration.rs @@ -0,0 +1,217 @@ +use soroban_sdk::{ + contract, contractimpl, contracttype, token, Address, Env, String, +}; + +use crate::legacy_deprecation::TokenClient as LegacyTokenClient; +use crate::token::Client as NewTokenClient; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MigrationConfig { + pub admin: Address, + pub old_token: Address, + pub new_token: Address, + pub ratio_new_per_old: u32, // e.g., 10 means 1 old -> 10 new + pub deadline: u64, +} + +#[contracttype] +enum DataKey { + Config, + UserMigrated(Address), + TotalMigrated, +} + +#[contract] +pub struct TokenMigrationContract; + +#[contractimpl] +impl TokenMigrationContract { + /// Initializes the migration contract. + /// + /// # Arguments + /// * `admin` - The admin address, who can withdraw remaining legacy tokens after the deadline. + /// * `old_token` - The address of the legacy token contract. + /// * `new_token` - The address of the new token contract. This migration contract must have minting rights on it. + /// * `ratio_new_per_old` - The number of new tokens minted for 1 old token (in its smallest unit). + /// * `deadline` - A Unix timestamp after which migrations are no longer possible. + pub fn initialize( + env: Env, + admin: Address, + old_token: Address, + new_token: Address, + ratio_new_per_old: u32, + deadline: u64, + ) { + if env.storage().instance().has(&DataKey::Config) { + panic!("Already initialized"); + } + + let config = MigrationConfig { + admin, + old_token, + new_token, + ratio_new_per_old, + deadline, + }; + + env.storage().instance().set(&DataKey::Config, &config); + env.storage().instance().set(&DataKey::TotalMigrated, &0i128); + + // Set TTL for all data + env.storage().instance().extend_ttl(100_000, 100_000); + } + + /// Migrates a user's legacy tokens to new tokens. + /// The user must first approve this contract to spend their `old_token`. + /// + /// # Arguments + /// * `caller` - The user performing the migration. + /// * `amount` - The amount of old tokens to migrate. + pub fn migrate(env: Env, caller: Address, amount: i128) { + caller.require_auth(); + + let config: MigrationConfig = env.storage().instance().get(&DataKey::Config).unwrap(); + + if env.ledger().timestamp() > config.deadline { + panic!("Migration period has ended"); + } + + if amount <= 0 { + panic!("Amount must be positive"); + } + + // Calculate the amount of new tokens to mint + let new_amount = amount + .checked_mul(config.ratio_new_per_old as i128) + .expect("Amount overflow"); + + // Transfer old tokens from the user to this contract + let old_token_client = LegacyTokenClient::new(&env, &config.old_token); + old_token_client.transfer_from(&env.current_contract_address(), &caller, &env.current_contract_address(), &amount); + + // Mint new tokens to the user + let new_token_client = NewTokenClient::new(&env, &config.new_token); + new_token_client.mint(&caller, &new_amount); + + // Update user's migrated amount + let user_key = DataKey::UserMigrated(caller.clone()); + let user_migrated: i128 = env.storage().persistent().get(&user_key).unwrap_or(0); + let new_user_migrated = user_migrated.checked_add(amount).expect("User amount overflow"); + env.storage().persistent().set(&user_key, &new_user_migrated); + env.storage().persistent().extend_ttl(&user_key, 100_000, 100_000); + + // Update total migrated amount + let total_migrated: i128 = env.storage().instance().get(&DataKey::TotalMigrated).unwrap(); + let new_total_migrated = total_migrated.checked_add(amount).expect("Total amount overflow"); + env.storage().instance().set(&DataKey::TotalMigrated, &new_total_migrated); + + // Emit event + env.events().publish( + (String::from_slice(&env, "migrated"), caller), + (amount, new_amount), + ); + } + + /// Allows the admin to withdraw any remaining legacy tokens after the deadline. + /// This is for cleanup and to recover any tokens sent here by mistake. + pub fn withdraw_legacy(env: Env) { + let config: MigrationConfig = env.storage().instance().get(&DataKey::Config).unwrap(); + config.admin.require_auth(); + + if env.ledger().timestamp() <= config.deadline { + panic!("Cannot withdraw before deadline"); + } + + let old_token_client = token::Client::new(&env, &config.old_token); + let balance = old_token_client.balance(&env.current_contract_address()); + + if balance > 0 { + old_token_client.transfer(&env.current_contract_address(), &config.admin, &balance); + } + } + + // --- View Functions --- + + /// Returns the migration configuration. + pub fn get_config(env: Env) -> MigrationConfig { + env.storage().instance().get(&DataKey::Config).unwrap() + } + + /// Returns the total amount of old tokens a specific user has migrated. + pub fn get_user_migrated(env: Env, user: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::UserMigrated(user)) + .unwrap_or(0) + } + + /// Returns the total amount of old tokens migrated across all users. + pub fn get_total_migrated(env: Env) -> i128 { + env.storage().instance().get(&DataKey::TotalMigrated).unwrap() + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{testutils::{Address as _, Events}, BytesN, IntoVal}; + + // Note: Full integration tests would require setting up three contracts: + // 1. Legacy Token + // 2. New Token + // 3. This Migration Contract + // The tests here are simplified to illustrate the logic. + + #[test] + fn test_initialization_and_migration() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::random(&env); + let user = Address::random(&env); + let old_token_address = Address::random(&env); + let new_token_address = Address::random(&env); + let migration_contract_id = env.register_contract(None, TokenMigrationContract); + let client = TokenMigrationContractClient::new(&env, &migration_contract_id); + + let deadline = env.ledger().timestamp() + 1000; + let ratio = 1; // 1:1 ratio + + client.initialize(&admin, &old_token_address, &new_token_address, &ratio, &deadline); + + // --- Mock contract calls --- + // This is a simplified mock. In a real test, you'd register token contracts. + let expected_new_amount = 100 * ratio as i128; + + // Mock the call to `transfer_from` on the old token + env.mock_contract_call( + &old_token_address, + "transfer_from", + (&migration_contract_id, &user, &migration_contract_id, &100i128).into_val(&env), + Ok(().into()), + ); + // Mock the call to `mint` on the new token + env.mock_contract_call( + &new_token_address, + "mint", + (&user, &expected_new_amount).into_val(&env), + Ok(().into()), + ); + + client.migrate(&user, &100); + + assert_eq!(client.get_user_migrated(&user), 100); + assert_eq!(client.get_total_migrated(), 100); + + let event = env.events().all().last().unwrap(); + assert_eq!( + event, + ( + migration_contract_id.clone(), + (String::from_slice(&env, "migrated"), user.clone()).into_val(&env), + (100i128, expected_new_amount).into_val(&env) + ) + ); + } +}