feat(desktop-wallet): add smart contracts and tokens UI
Add complete frontend and backend implementation for smart contracts and tokens management: Backend (Rust/Tauri): - Add contract_deploy, contract_call, contract_read, contract_get_info commands - Add token_create, token_transfer, token_get_info, token_get_balance commands - Add token_list_balances, token_mint, token_burn commands - Add new error variants for validation and contract errors Frontend (React/TypeScript): - Add contracts Zustand store with persistence - Add tokens Zustand store with persistence - Add ContractsDashboard page with deploy/interact tabs - Add TokensDashboard page with balances/create/transfer/manage tabs - Update navigation sidebar with Contracts and Tokens links - Add routes for new pages
This commit is contained in:
parent
88b09914c3
commit
a5e4fc1c21
10 changed files with 2555 additions and 0 deletions
|
|
@ -774,3 +774,456 @@ pub async fn wallet_get_fee_estimate(
|
||||||
) -> Result<crate::rpc_client::FeeEstimate> {
|
) -> Result<crate::rpc_client::FeeEstimate> {
|
||||||
app_state.rpc_client.get_fee_estimate().await
|
app_state.rpc_client.get_fee_estimate().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Smart Contract Commands
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Contract deployment request
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeployContractRequest {
|
||||||
|
/// Contract bytecode (hex)
|
||||||
|
pub bytecode: String,
|
||||||
|
/// Constructor arguments (encoded)
|
||||||
|
pub constructor_args: Option<String>,
|
||||||
|
/// Gas limit
|
||||||
|
pub gas_limit: u64,
|
||||||
|
/// Initial value to send (in sompi)
|
||||||
|
pub value: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract deployment response
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeployContractResponse {
|
||||||
|
/// Transaction ID
|
||||||
|
pub tx_id: String,
|
||||||
|
/// Contract address (available after confirmation)
|
||||||
|
pub contract_address: Option<String>,
|
||||||
|
/// Gas used
|
||||||
|
pub gas_used: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deploy a smart contract
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn contract_deploy(
|
||||||
|
wallet_state: State<'_, WalletState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
request: DeployContractRequest,
|
||||||
|
) -> Result<DeployContractResponse> {
|
||||||
|
if !wallet_state.is_unlocked().await {
|
||||||
|
return Err(Error::WalletLocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = app_state.node_manager.connection_mode().await;
|
||||||
|
if matches!(mode, ConnectionMode::Disconnected) {
|
||||||
|
return Err(Error::NotConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Build and sign contract deployment transaction
|
||||||
|
// 1. Create transaction with contract bytecode in payload
|
||||||
|
// 2. Sign with wallet key
|
||||||
|
// 3. Broadcast to network
|
||||||
|
|
||||||
|
// For now, return a placeholder
|
||||||
|
Ok(DeployContractResponse {
|
||||||
|
tx_id: "pending".to_string(),
|
||||||
|
contract_address: None,
|
||||||
|
gas_used: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract call request
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CallContractRequest {
|
||||||
|
/// Contract address
|
||||||
|
pub contract_address: String,
|
||||||
|
/// Method to call (encoded)
|
||||||
|
pub method: String,
|
||||||
|
/// Arguments (encoded)
|
||||||
|
pub args: Option<String>,
|
||||||
|
/// Gas limit
|
||||||
|
pub gas_limit: u64,
|
||||||
|
/// Value to send (in sompi)
|
||||||
|
pub value: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract call response
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CallContractResponse {
|
||||||
|
/// Transaction ID (for state-changing calls)
|
||||||
|
pub tx_id: Option<String>,
|
||||||
|
/// Return data (for view calls)
|
||||||
|
pub result: Option<String>,
|
||||||
|
/// Gas used
|
||||||
|
pub gas_used: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call a smart contract method
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn contract_call(
|
||||||
|
wallet_state: State<'_, WalletState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
request: CallContractRequest,
|
||||||
|
) -> Result<CallContractResponse> {
|
||||||
|
if !wallet_state.is_unlocked().await {
|
||||||
|
return Err(Error::WalletLocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = app_state.node_manager.connection_mode().await;
|
||||||
|
if matches!(mode, ConnectionMode::Disconnected) {
|
||||||
|
return Err(Error::NotConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Build and execute contract call
|
||||||
|
Ok(CallContractResponse {
|
||||||
|
tx_id: None,
|
||||||
|
result: None,
|
||||||
|
gas_used: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract read request (view function, no transaction needed)
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReadContractRequest {
|
||||||
|
/// Contract address
|
||||||
|
pub contract_address: String,
|
||||||
|
/// Method to call (encoded)
|
||||||
|
pub method: String,
|
||||||
|
/// Arguments (encoded)
|
||||||
|
pub args: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read from a smart contract (view function)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn contract_read(
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
request: ReadContractRequest,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mode = app_state.node_manager.connection_mode().await;
|
||||||
|
if matches!(mode, ConnectionMode::Disconnected) {
|
||||||
|
return Err(Error::NotConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Execute view call via RPC
|
||||||
|
Ok("0x".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contract info
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ContractInfo {
|
||||||
|
/// Contract address
|
||||||
|
pub address: String,
|
||||||
|
/// Contract name (if known)
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Contract type (e.g., "ERC20", "Custom")
|
||||||
|
pub contract_type: String,
|
||||||
|
/// Deployment transaction ID
|
||||||
|
pub deploy_tx_id: String,
|
||||||
|
/// Creation timestamp
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get contract information
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn contract_get_info(
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
address: String,
|
||||||
|
) -> Result<ContractInfo> {
|
||||||
|
let mode = app_state.node_manager.connection_mode().await;
|
||||||
|
if matches!(mode, ConnectionMode::Disconnected) {
|
||||||
|
return Err(Error::NotConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Query contract info from node
|
||||||
|
Ok(ContractInfo {
|
||||||
|
address,
|
||||||
|
name: None,
|
||||||
|
contract_type: "Unknown".to_string(),
|
||||||
|
deploy_tx_id: "".to_string(),
|
||||||
|
created_at: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Token Commands
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Token creation request
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateTokenRequest {
|
||||||
|
/// Token name
|
||||||
|
pub name: String,
|
||||||
|
/// Token symbol (e.g., "SYN")
|
||||||
|
pub symbol: String,
|
||||||
|
/// Decimal places (usually 8 or 18)
|
||||||
|
pub decimals: u8,
|
||||||
|
/// Initial supply (in smallest units)
|
||||||
|
pub initial_supply: String,
|
||||||
|
/// Maximum supply (optional, for capped tokens)
|
||||||
|
pub max_supply: Option<String>,
|
||||||
|
/// Is mintable (can create more tokens later)
|
||||||
|
pub mintable: bool,
|
||||||
|
/// Is burnable (can destroy tokens)
|
||||||
|
pub burnable: bool,
|
||||||
|
/// Is pausable (can pause transfers)
|
||||||
|
pub pausable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token creation response
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateTokenResponse {
|
||||||
|
/// Deployment transaction ID
|
||||||
|
pub tx_id: String,
|
||||||
|
/// Token contract address (available after confirmation)
|
||||||
|
pub token_address: Option<String>,
|
||||||
|
/// Token ID (unique identifier)
|
||||||
|
pub token_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new token
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn token_create(
|
||||||
|
wallet_state: State<'_, WalletState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
request: CreateTokenRequest,
|
||||||
|
) -> Result<CreateTokenResponse> {
|
||||||
|
if !wallet_state.is_unlocked().await {
|
||||||
|
return Err(Error::WalletLocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = app_state.node_manager.connection_mode().await;
|
||||||
|
if matches!(mode, ConnectionMode::Disconnected) {
|
||||||
|
return Err(Error::NotConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token parameters
|
||||||
|
if request.name.is_empty() || request.name.len() > 64 {
|
||||||
|
return Err(Error::Validation("Token name must be 1-64 characters".to_string()));
|
||||||
|
}
|
||||||
|
if request.symbol.is_empty() || request.symbol.len() > 8 {
|
||||||
|
return Err(Error::Validation("Token symbol must be 1-8 characters".to_string()));
|
||||||
|
}
|
||||||
|
if request.decimals > 18 {
|
||||||
|
return Err(Error::Validation("Decimals must be 0-18".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Deploy standard token contract
|
||||||
|
// 1. Use pre-compiled token contract bytecode
|
||||||
|
// 2. Encode constructor args (name, symbol, decimals, supply, etc.)
|
||||||
|
// 3. Deploy contract
|
||||||
|
// 4. Return token address
|
||||||
|
|
||||||
|
let token_id = format!(
|
||||||
|
"{}:{}",
|
||||||
|
request.symbol.to_uppercase(),
|
||||||
|
hex::encode([0u8; 4]) // Placeholder
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(CreateTokenResponse {
|
||||||
|
tx_id: "pending".to_string(),
|
||||||
|
token_address: None,
|
||||||
|
token_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token information
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TokenInfo {
|
||||||
|
/// Token contract address
|
||||||
|
pub address: String,
|
||||||
|
/// Token name
|
||||||
|
pub name: String,
|
||||||
|
/// Token symbol
|
||||||
|
pub symbol: String,
|
||||||
|
/// Decimal places
|
||||||
|
pub decimals: u8,
|
||||||
|
/// Total supply
|
||||||
|
pub total_supply: String,
|
||||||
|
/// Maximum supply (if capped)
|
||||||
|
pub max_supply: Option<String>,
|
||||||
|
/// Your balance
|
||||||
|
pub balance: String,
|
||||||
|
/// Is verified/trusted
|
||||||
|
pub is_verified: bool,
|
||||||
|
/// Logo URL (if available)
|
||||||
|
pub logo_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get token information
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn token_get_info(
|
||||||
|
wallet_state: State<'_, WalletState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
token_address: String,
|
||||||
|
) -> Result<TokenInfo> {
|
||||||
|
let _wallet_state = wallet_state; // Silence unused warning
|
||||||
|
let mode = app_state.node_manager.connection_mode().await;
|
||||||
|
if matches!(mode, ConnectionMode::Disconnected) {
|
||||||
|
return Err(Error::NotConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Query token contract for info
|
||||||
|
// Call name(), symbol(), decimals(), totalSupply(), balanceOf(user)
|
||||||
|
|
||||||
|
Ok(TokenInfo {
|
||||||
|
address: token_address,
|
||||||
|
name: "Unknown Token".to_string(),
|
||||||
|
symbol: "???".to_string(),
|
||||||
|
decimals: 8,
|
||||||
|
total_supply: "0".to_string(),
|
||||||
|
max_supply: None,
|
||||||
|
balance: "0".to_string(),
|
||||||
|
is_verified: false,
|
||||||
|
logo_url: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Token transfer request
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TransferTokenRequest {
|
||||||
|
/// Token contract address
|
||||||
|
pub token_address: String,
|
||||||
|
/// Recipient address
|
||||||
|
pub to: String,
|
||||||
|
/// Amount to transfer (in smallest units)
|
||||||
|
pub amount: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer tokens
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn token_transfer(
|
||||||
|
wallet_state: State<'_, WalletState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
request: TransferTokenRequest,
|
||||||
|
) -> Result<String> {
|
||||||
|
if !wallet_state.is_unlocked().await {
|
||||||
|
return Err(Error::WalletLocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = app_state.node_manager.connection_mode().await;
|
||||||
|
if matches!(mode, ConnectionMode::Disconnected) {
|
||||||
|
return Err(Error::NotConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _request = request; // Silence unused warning
|
||||||
|
|
||||||
|
// TODO: Call token contract's transfer(to, amount) function
|
||||||
|
// 1. Encode transfer function call
|
||||||
|
// 2. Build and sign transaction
|
||||||
|
// 3. Broadcast
|
||||||
|
|
||||||
|
Ok("pending".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get token balance for an address
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn token_get_balance(
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
token_address: String,
|
||||||
|
owner_address: String,
|
||||||
|
) -> Result<String> {
|
||||||
|
let mode = app_state.node_manager.connection_mode().await;
|
||||||
|
if matches!(mode, ConnectionMode::Disconnected) {
|
||||||
|
return Err(Error::NotConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_token_address, _owner_address) = (token_address, owner_address); // Silence unused
|
||||||
|
|
||||||
|
// TODO: Call token contract's balanceOf(owner) function
|
||||||
|
Ok("0".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List tokens held by wallet
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TokenBalance {
|
||||||
|
/// Token address
|
||||||
|
pub address: String,
|
||||||
|
/// Token name
|
||||||
|
pub name: String,
|
||||||
|
/// Token symbol
|
||||||
|
pub symbol: String,
|
||||||
|
/// Decimals
|
||||||
|
pub decimals: u8,
|
||||||
|
/// Balance in smallest units
|
||||||
|
pub balance: String,
|
||||||
|
/// Balance formatted (e.g., "1,000.00")
|
||||||
|
pub balance_formatted: String,
|
||||||
|
/// USD value (if available)
|
||||||
|
pub usd_value: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all token balances for the wallet
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn token_list_balances(
|
||||||
|
wallet_state: State<'_, WalletState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<TokenBalance>> {
|
||||||
|
let _wallet_state = wallet_state; // Silence unused warning
|
||||||
|
let mode = app_state.node_manager.connection_mode().await;
|
||||||
|
if matches!(mode, ConnectionMode::Disconnected) {
|
||||||
|
return Err(Error::NotConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Query indexed token transfers to find held tokens
|
||||||
|
// Then query each token's balance
|
||||||
|
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint tokens (if authorized)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn token_mint(
|
||||||
|
wallet_state: State<'_, WalletState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
token_address: String,
|
||||||
|
to: String,
|
||||||
|
amount: String,
|
||||||
|
) -> Result<String> {
|
||||||
|
if !wallet_state.is_unlocked().await {
|
||||||
|
return Err(Error::WalletLocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = app_state.node_manager.connection_mode().await;
|
||||||
|
if matches!(mode, ConnectionMode::Disconnected) {
|
||||||
|
return Err(Error::NotConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_token_address, _to, _amount) = (token_address, to, amount); // Silence unused
|
||||||
|
|
||||||
|
// TODO: Call token contract's mint(to, amount) function
|
||||||
|
Ok("pending".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Burn tokens
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn token_burn(
|
||||||
|
wallet_state: State<'_, WalletState>,
|
||||||
|
app_state: State<'_, AppState>,
|
||||||
|
token_address: String,
|
||||||
|
amount: String,
|
||||||
|
) -> Result<String> {
|
||||||
|
if !wallet_state.is_unlocked().await {
|
||||||
|
return Err(Error::WalletLocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = app_state.node_manager.connection_mode().await;
|
||||||
|
if matches!(mode, ConnectionMode::Disconnected) {
|
||||||
|
return Err(Error::NotConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_token_address, _amount) = (token_address, amount); // Silence unused
|
||||||
|
|
||||||
|
// TODO: Call token contract's burn(amount) function
|
||||||
|
Ok("pending".to_string())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,12 @@ pub enum Error {
|
||||||
#[error("Mining error: {0}")]
|
#[error("Mining error: {0}")]
|
||||||
MiningError(String),
|
MiningError(String),
|
||||||
|
|
||||||
|
#[error("Validation error: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
|
#[error("Contract error: {0}")]
|
||||||
|
ContractError(String),
|
||||||
|
|
||||||
#[error("Internal error: {0}")]
|
#[error("Internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,19 @@ pub fn run() {
|
||||||
commands::wallet_get_utxos,
|
commands::wallet_get_utxos,
|
||||||
commands::wallet_get_network_info,
|
commands::wallet_get_network_info,
|
||||||
commands::wallet_get_fee_estimate,
|
commands::wallet_get_fee_estimate,
|
||||||
|
// Smart contracts
|
||||||
|
commands::contract_deploy,
|
||||||
|
commands::contract_call,
|
||||||
|
commands::contract_read,
|
||||||
|
commands::contract_get_info,
|
||||||
|
// Tokens
|
||||||
|
commands::token_create,
|
||||||
|
commands::token_get_info,
|
||||||
|
commands::token_transfer,
|
||||||
|
commands::token_get_balance,
|
||||||
|
commands::token_list_balances,
|
||||||
|
commands::token_mint,
|
||||||
|
commands::token_burn,
|
||||||
// Updates
|
// Updates
|
||||||
check_update,
|
check_update,
|
||||||
install_update,
|
install_update,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import History from './pages/History';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
import NodeDashboard from './pages/Node/NodeDashboard';
|
import NodeDashboard from './pages/Node/NodeDashboard';
|
||||||
import MiningDashboard from './pages/Mining/MiningDashboard';
|
import MiningDashboard from './pages/Mining/MiningDashboard';
|
||||||
|
import ContractsDashboard from './pages/Contracts/ContractsDashboard';
|
||||||
|
import TokensDashboard from './pages/Tokens/TokensDashboard';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isInitialized, isUnlocked } = useWalletStore();
|
const { isInitialized, isUnlocked } = useWalletStore();
|
||||||
|
|
@ -100,6 +102,18 @@ function App() {
|
||||||
isUnlocked ? <MiningDashboard /> : <Navigate to="/unlock" replace />
|
isUnlocked ? <MiningDashboard /> : <Navigate to="/unlock" replace />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/contracts"
|
||||||
|
element={
|
||||||
|
isUnlocked ? <ContractsDashboard /> : <Navigate to="/unlock" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/tokens"
|
||||||
|
element={
|
||||||
|
isUnlocked ? <TokensDashboard /> : <Navigate to="/unlock" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/settings"
|
path="/settings"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
WifiOff,
|
WifiOff,
|
||||||
Server,
|
Server,
|
||||||
Hammer,
|
Hammer,
|
||||||
|
FileCode2,
|
||||||
|
Coins,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useWalletStore } from '../store/wallet';
|
import { useWalletStore } from '../store/wallet';
|
||||||
import { useNodeStore } from '../store/node';
|
import { useNodeStore } from '../store/node';
|
||||||
|
|
@ -25,6 +27,8 @@ const navItems = [
|
||||||
const advancedNavItems = [
|
const advancedNavItems = [
|
||||||
{ to: '/node', label: 'Node', icon: Server },
|
{ to: '/node', label: 'Node', icon: Server },
|
||||||
{ to: '/mining', label: 'Mining', icon: Hammer },
|
{ to: '/mining', label: 'Mining', icon: Hammer },
|
||||||
|
{ to: '/contracts', label: 'Contracts', icon: FileCode2 },
|
||||||
|
{ to: '/tokens', label: 'Tokens', icon: Coins },
|
||||||
{ to: '/settings', label: 'Settings', icon: Settings },
|
{ to: '/settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
563
apps/desktop-wallet/src/pages/Contracts/ContractsDashboard.tsx
Normal file
563
apps/desktop-wallet/src/pages/Contracts/ContractsDashboard.tsx
Normal file
|
|
@ -0,0 +1,563 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
FileCode2,
|
||||||
|
Plus,
|
||||||
|
Play,
|
||||||
|
BookOpen,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useContractsStore, truncateAddress } from '../../store/contracts';
|
||||||
|
import { useNodeStore } from '../../store/node';
|
||||||
|
|
||||||
|
export default function ContractsDashboard() {
|
||||||
|
const {
|
||||||
|
deployedContracts,
|
||||||
|
isDeploying,
|
||||||
|
isCalling,
|
||||||
|
isReading,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
deployContract,
|
||||||
|
callContract,
|
||||||
|
readContract,
|
||||||
|
removeDeployedContract,
|
||||||
|
} = useContractsStore();
|
||||||
|
|
||||||
|
const nodeStatus = useNodeStore((state) => state.status);
|
||||||
|
|
||||||
|
// View state
|
||||||
|
const [activeTab, setActiveTab] = useState<'deploy' | 'interact'>('deploy');
|
||||||
|
const [selectedContract, setSelectedContract] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Deploy form state
|
||||||
|
const [deployForm, setDeployForm] = useState({
|
||||||
|
bytecode: '',
|
||||||
|
constructorArgs: '',
|
||||||
|
gasLimit: '1000000',
|
||||||
|
value: '0',
|
||||||
|
name: '',
|
||||||
|
abi: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interaction form state
|
||||||
|
const [interactForm, setInteractForm] = useState({
|
||||||
|
method: '',
|
||||||
|
args: '',
|
||||||
|
gasLimit: '100000',
|
||||||
|
value: '0',
|
||||||
|
isRead: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Results
|
||||||
|
const [interactResult, setInteractResult] = useState<string | null>(null);
|
||||||
|
const [expandedContracts, setExpandedContracts] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Clear error on tab change
|
||||||
|
useEffect(() => {
|
||||||
|
clearError();
|
||||||
|
setInteractResult(null);
|
||||||
|
}, [activeTab, selectedContract, clearError]);
|
||||||
|
|
||||||
|
const handleDeploy = async () => {
|
||||||
|
if (!deployForm.bytecode) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deployContract(
|
||||||
|
{
|
||||||
|
bytecode: deployForm.bytecode,
|
||||||
|
constructorArgs: deployForm.constructorArgs || undefined,
|
||||||
|
gasLimit: parseInt(deployForm.gasLimit),
|
||||||
|
value: parseInt(deployForm.value),
|
||||||
|
},
|
||||||
|
deployForm.name || undefined,
|
||||||
|
deployForm.abi || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear form and switch to interact tab
|
||||||
|
setDeployForm({
|
||||||
|
bytecode: '',
|
||||||
|
constructorArgs: '',
|
||||||
|
gasLimit: '1000000',
|
||||||
|
value: '0',
|
||||||
|
name: '',
|
||||||
|
abi: '',
|
||||||
|
});
|
||||||
|
setSelectedContract(result.contractAddress);
|
||||||
|
setActiveTab('interact');
|
||||||
|
} catch {
|
||||||
|
// Error is handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInteract = async () => {
|
||||||
|
if (!selectedContract || !interactForm.method) return;
|
||||||
|
|
||||||
|
setInteractResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (interactForm.isRead) {
|
||||||
|
const result = await readContract(
|
||||||
|
selectedContract,
|
||||||
|
interactForm.method,
|
||||||
|
interactForm.args || undefined
|
||||||
|
);
|
||||||
|
setInteractResult(result);
|
||||||
|
} else {
|
||||||
|
const result = await callContract({
|
||||||
|
contractAddress: selectedContract,
|
||||||
|
method: interactForm.method,
|
||||||
|
args: interactForm.args || undefined,
|
||||||
|
gasLimit: parseInt(interactForm.gasLimit),
|
||||||
|
value: parseInt(interactForm.value),
|
||||||
|
});
|
||||||
|
setInteractResult(`TX: ${result.txHash}\nGas Used: ${result.gasUsed}${result.result ? `\nResult: ${result.result}` : ''}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error is handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleContractExpanded = (address: string) => {
|
||||||
|
const newExpanded = new Set(expandedContracts);
|
||||||
|
if (newExpanded.has(address)) {
|
||||||
|
newExpanded.delete(address);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(address);
|
||||||
|
}
|
||||||
|
setExpandedContracts(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<FileCode2 size={28} />
|
||||||
|
Smart Contracts
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Deploy and interact with smart contracts on Synor
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Not connected warning */}
|
||||||
|
{!nodeStatus.isConnected && (
|
||||||
|
<div className="p-4 rounded-lg bg-yellow-900/30 border border-yellow-800 text-yellow-400">
|
||||||
|
Please connect to a node to deploy and interact with contracts
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-lg bg-red-900/30 border border-red-800 text-red-400 flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content grid */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* Left: Deployed Contracts List */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<FileCode2 size={20} />
|
||||||
|
Deployed Contracts
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{deployedContracts.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-sm">No contracts deployed yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deployedContracts.map((contract) => (
|
||||||
|
<div
|
||||||
|
key={contract.address}
|
||||||
|
className={`p-3 rounded-lg border transition-colors cursor-pointer ${
|
||||||
|
selectedContract === contract.address
|
||||||
|
? 'bg-synor-900/30 border-synor-700'
|
||||||
|
: 'bg-gray-800 border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
onClick={() => toggleContractExpanded(contract.address)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{expandedContracts.has(contract.address) ? (
|
||||||
|
<ChevronDown size={16} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={16} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
{contract.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 font-mono">
|
||||||
|
{truncateAddress(contract.address)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyToClipboard(contract.address);
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-white"
|
||||||
|
title="Copy address"
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedContract(contract.address);
|
||||||
|
setActiveTab('interact');
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-synor-400"
|
||||||
|
title="Interact"
|
||||||
|
>
|
||||||
|
<Play size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedContracts.has(contract.address) && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-700 space-y-2">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-gray-500">Deployed:</span>
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{new Date(contract.deployedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-gray-500">TX:</span>
|
||||||
|
<span className="text-gray-400 font-mono">
|
||||||
|
{truncateAddress(contract.txHash, 6)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => removeDeployedContract(contract.address)}
|
||||||
|
className="w-full mt-2 text-xs text-red-400 hover:text-red-300 flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Deploy / Interact Tabs */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('deploy')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
activeTab === 'deploy'
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
Deploy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('interact')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
activeTab === 'interact'
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Play size={18} />
|
||||||
|
Interact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deploy Tab */}
|
||||||
|
{activeTab === 'deploy' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Contract Name (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deployForm.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDeployForm({ ...deployForm, name: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="My Contract"
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Bytecode *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={deployForm.bytecode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDeployForm({ ...deployForm, bytecode: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="0x608060405234801561001057600080fd5b50..."
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Constructor Arguments (ABI-encoded, optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deployForm.constructorArgs}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDeployForm({ ...deployForm, constructorArgs: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="0x0000000000000000000000000000000000000000000000000000000000000001"
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Gas Limit
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={deployForm.gasLimit}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDeployForm({ ...deployForm, gasLimit: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Value (in smallest unit)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={deployForm.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDeployForm({ ...deployForm, value: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
ABI JSON (optional, for interaction)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={deployForm.abi}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDeployForm({ ...deployForm, abi: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='[{"inputs": [], "name": "getValue", "outputs": [...]}]'
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDeploy}
|
||||||
|
disabled={isDeploying || !deployForm.bytecode || !nodeStatus.isConnected}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isDeploying ? (
|
||||||
|
<RefreshCw size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus size={18} />
|
||||||
|
)}
|
||||||
|
Deploy Contract
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Interact Tab */}
|
||||||
|
{activeTab === 'interact' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Contract Address
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedContract || ''}
|
||||||
|
onChange={(e) => setSelectedContract(e.target.value || null)}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
|
||||||
|
>
|
||||||
|
<option value="">Select a contract...</option>
|
||||||
|
{deployedContracts.map((contract) => (
|
||||||
|
<option key={contract.address} value={contract.address}>
|
||||||
|
{contract.name} - {truncateAddress(contract.address)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Or paste a contract address directly
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Method Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={interactForm.method}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInteractForm({ ...interactForm, method: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="transfer"
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Arguments (ABI-encoded, optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={interactForm.args}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInteractForm({ ...interactForm, args: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="0x..."
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={interactForm.isRead}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInteractForm({ ...interactForm, isRead: e.target.checked })
|
||||||
|
}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 bg-gray-800 text-synor-500 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
Read-only (no transaction)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!interactForm.isRead && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Gas Limit
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={interactForm.gasLimit}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInteractForm({ ...interactForm, gasLimit: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Value (in smallest unit)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={interactForm.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInteractForm({ ...interactForm, value: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleInteract}
|
||||||
|
disabled={
|
||||||
|
(isCalling || isReading) ||
|
||||||
|
!selectedContract ||
|
||||||
|
!interactForm.method ||
|
||||||
|
!nodeStatus.isConnected
|
||||||
|
}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{(isCalling || isReading) ? (
|
||||||
|
<RefreshCw size={18} className="animate-spin" />
|
||||||
|
) : interactForm.isRead ? (
|
||||||
|
<BookOpen size={18} />
|
||||||
|
) : (
|
||||||
|
<Play size={18} />
|
||||||
|
)}
|
||||||
|
{interactForm.isRead ? 'Read' : 'Execute'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Result display */}
|
||||||
|
{interactResult && (
|
||||||
|
<div className="mt-4 p-4 rounded-lg bg-gray-800 border border-gray-700">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-400">Result</span>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(interactResult)}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="text-sm text-white font-mono whitespace-pre-wrap break-all">
|
||||||
|
{interactResult}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help Section */}
|
||||||
|
<div className="p-6 rounded-xl bg-gray-900/50 border border-gray-800">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-400 mb-3 flex items-center gap-2">
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
Getting Started
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-500">
|
||||||
|
<li>• Compile your contract using a Solidity compiler to get the bytecode</li>
|
||||||
|
<li>• Constructor arguments must be ABI-encoded if your constructor has parameters</li>
|
||||||
|
<li>• Use "Read-only" mode for view/pure functions that don't modify state</li>
|
||||||
|
<li>• Save the ABI JSON for easier interaction with complex contracts</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
828
apps/desktop-wallet/src/pages/Tokens/TokensDashboard.tsx
Normal file
828
apps/desktop-wallet/src/pages/Tokens/TokensDashboard.tsx
Normal file
|
|
@ -0,0 +1,828 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Coins,
|
||||||
|
Plus,
|
||||||
|
Send,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Flame,
|
||||||
|
Sparkles,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useTokensStore,
|
||||||
|
formatTokenAmount,
|
||||||
|
parseTokenAmount,
|
||||||
|
TokenBalance,
|
||||||
|
} from '../../store/tokens';
|
||||||
|
import { useNodeStore } from '../../store/node';
|
||||||
|
import { useWalletStore } from '../../store/wallet';
|
||||||
|
import { truncateAddress } from '../../store/contracts';
|
||||||
|
|
||||||
|
type TabType = 'balances' | 'create' | 'transfer' | 'manage';
|
||||||
|
|
||||||
|
export default function TokensDashboard() {
|
||||||
|
const {
|
||||||
|
trackedTokens,
|
||||||
|
balances,
|
||||||
|
isCreating,
|
||||||
|
isTransferring,
|
||||||
|
isMinting,
|
||||||
|
isBurning,
|
||||||
|
isLoadingBalances,
|
||||||
|
error,
|
||||||
|
clearError,
|
||||||
|
createToken,
|
||||||
|
transferToken,
|
||||||
|
mintToken,
|
||||||
|
burnToken,
|
||||||
|
refreshBalances,
|
||||||
|
removeTrackedToken,
|
||||||
|
addTrackedToken,
|
||||||
|
getTokenInfo,
|
||||||
|
} = useTokensStore();
|
||||||
|
|
||||||
|
const nodeStatus = useNodeStore((state) => state.status);
|
||||||
|
const addresses = useWalletStore((state) => state.addresses);
|
||||||
|
const currentAddress = addresses[0]?.address || '';
|
||||||
|
|
||||||
|
// View state
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('balances');
|
||||||
|
const [expandedTokens, setExpandedTokens] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Create form state
|
||||||
|
const [createForm, setCreateForm] = useState({
|
||||||
|
name: '',
|
||||||
|
symbol: '',
|
||||||
|
decimals: '18',
|
||||||
|
initialSupply: '',
|
||||||
|
maxSupply: '',
|
||||||
|
mintable: true,
|
||||||
|
burnable: true,
|
||||||
|
pausable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transfer form state
|
||||||
|
const [transferForm, setTransferForm] = useState({
|
||||||
|
contractAddress: '',
|
||||||
|
toAddress: '',
|
||||||
|
amount: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manage form state
|
||||||
|
const [manageForm, setManageForm] = useState({
|
||||||
|
contractAddress: '',
|
||||||
|
mintTo: '',
|
||||||
|
mintAmount: '',
|
||||||
|
burnAmount: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import token form
|
||||||
|
const [importAddress, setImportAddress] = useState('');
|
||||||
|
|
||||||
|
// Refresh balances on mount and when address changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentAddress && nodeStatus.isConnected) {
|
||||||
|
refreshBalances(currentAddress);
|
||||||
|
}
|
||||||
|
}, [currentAddress, nodeStatus.isConnected, refreshBalances]);
|
||||||
|
|
||||||
|
// Clear error on tab change
|
||||||
|
useEffect(() => {
|
||||||
|
clearError();
|
||||||
|
}, [activeTab, clearError]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!createForm.name || !createForm.symbol || !createForm.initialSupply) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createToken({
|
||||||
|
name: createForm.name,
|
||||||
|
symbol: createForm.symbol,
|
||||||
|
decimals: parseInt(createForm.decimals),
|
||||||
|
initialSupply: parseTokenAmount(createForm.initialSupply, parseInt(createForm.decimals)),
|
||||||
|
maxSupply: createForm.maxSupply
|
||||||
|
? parseTokenAmount(createForm.maxSupply, parseInt(createForm.decimals))
|
||||||
|
: undefined,
|
||||||
|
mintable: createForm.mintable,
|
||||||
|
burnable: createForm.burnable,
|
||||||
|
pausable: createForm.pausable,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear form
|
||||||
|
setCreateForm({
|
||||||
|
name: '',
|
||||||
|
symbol: '',
|
||||||
|
decimals: '18',
|
||||||
|
initialSupply: '',
|
||||||
|
maxSupply: '',
|
||||||
|
mintable: true,
|
||||||
|
burnable: true,
|
||||||
|
pausable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to balances and refresh
|
||||||
|
setActiveTab('balances');
|
||||||
|
if (currentAddress) {
|
||||||
|
refreshBalances(currentAddress);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransfer = async () => {
|
||||||
|
if (!transferForm.contractAddress || !transferForm.toAddress || !transferForm.amount) return;
|
||||||
|
|
||||||
|
const token = trackedTokens.find((t) => t.contractAddress === transferForm.contractAddress);
|
||||||
|
const decimals = token?.decimals || 18;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transferToken(
|
||||||
|
transferForm.contractAddress,
|
||||||
|
transferForm.toAddress,
|
||||||
|
parseTokenAmount(transferForm.amount, decimals)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear form and refresh
|
||||||
|
setTransferForm({
|
||||||
|
contractAddress: '',
|
||||||
|
toAddress: '',
|
||||||
|
amount: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentAddress) {
|
||||||
|
refreshBalances(currentAddress);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMint = async () => {
|
||||||
|
if (!manageForm.contractAddress || !manageForm.mintTo || !manageForm.mintAmount) return;
|
||||||
|
|
||||||
|
const token = trackedTokens.find((t) => t.contractAddress === manageForm.contractAddress);
|
||||||
|
const decimals = token?.decimals || 18;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mintToken(
|
||||||
|
manageForm.contractAddress,
|
||||||
|
manageForm.mintTo,
|
||||||
|
parseTokenAmount(manageForm.mintAmount, decimals)
|
||||||
|
);
|
||||||
|
|
||||||
|
setManageForm({ ...manageForm, mintAmount: '' });
|
||||||
|
if (currentAddress) {
|
||||||
|
refreshBalances(currentAddress);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBurn = async () => {
|
||||||
|
if (!manageForm.contractAddress || !manageForm.burnAmount) return;
|
||||||
|
|
||||||
|
const token = trackedTokens.find((t) => t.contractAddress === manageForm.contractAddress);
|
||||||
|
const decimals = token?.decimals || 18;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await burnToken(
|
||||||
|
manageForm.contractAddress,
|
||||||
|
parseTokenAmount(manageForm.burnAmount, decimals)
|
||||||
|
);
|
||||||
|
|
||||||
|
setManageForm({ ...manageForm, burnAmount: '' });
|
||||||
|
if (currentAddress) {
|
||||||
|
refreshBalances(currentAddress);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportToken = async () => {
|
||||||
|
if (!importAddress) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await getTokenInfo(importAddress);
|
||||||
|
addTrackedToken({
|
||||||
|
contractAddress: importAddress,
|
||||||
|
name: info.name,
|
||||||
|
symbol: info.symbol,
|
||||||
|
decimals: info.decimals,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
isCreatedByUser: false,
|
||||||
|
});
|
||||||
|
setImportAddress('');
|
||||||
|
if (currentAddress) {
|
||||||
|
refreshBalances(currentAddress);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTokenExpanded = (address: string) => {
|
||||||
|
const newExpanded = new Set(expandedTokens);
|
||||||
|
if (newExpanded.has(address)) {
|
||||||
|
newExpanded.delete(address);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(address);
|
||||||
|
}
|
||||||
|
setExpandedTokens(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBalanceForToken = (contractAddress: string): TokenBalance | undefined => {
|
||||||
|
return balances.find((b) => b.contractAddress === contractAddress);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<Coins size={28} />
|
||||||
|
Tokens
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Create, manage, and transfer custom tokens
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => currentAddress && refreshBalances(currentAddress)}
|
||||||
|
disabled={isLoadingBalances || !nodeStatus.isConnected}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-white transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={isLoadingBalances ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Not connected warning */}
|
||||||
|
{!nodeStatus.isConnected && (
|
||||||
|
<div className="p-4 rounded-lg bg-yellow-900/30 border border-yellow-800 text-yellow-400">
|
||||||
|
Please connect to a node to manage tokens
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-lg bg-red-900/30 border border-red-800 text-red-400 flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button onClick={clearError} className="text-red-400 hover:text-red-300">
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{[
|
||||||
|
{ id: 'balances' as TabType, icon: Coins, label: 'Balances' },
|
||||||
|
{ id: 'create' as TabType, icon: Plus, label: 'Create Token' },
|
||||||
|
{ id: 'transfer' as TabType, icon: Send, label: 'Transfer' },
|
||||||
|
{ id: 'manage' as TabType, icon: Sparkles, label: 'Manage' },
|
||||||
|
].map(({ id, icon: Icon, label }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setActiveTab(id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
activeTab === id
|
||||||
|
? 'bg-synor-600 text-white'
|
||||||
|
: 'bg-gray-800 text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="p-6 rounded-xl bg-gray-900 border border-gray-800">
|
||||||
|
{/* Balances Tab */}
|
||||||
|
{activeTab === 'balances' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-white">Your Token Balances</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import token */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={importAddress}
|
||||||
|
onChange={(e) => setImportAddress(e.target.value)}
|
||||||
|
placeholder="Import token by contract address..."
|
||||||
|
className="flex-1 px-4 py-2 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleImportToken}
|
||||||
|
disabled={!importAddress || !nodeStatus.isConnected}
|
||||||
|
className="px-4 py-2 rounded-lg bg-synor-600 hover:bg-synor-700 text-white transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trackedTokens.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Coins size={48} className="mx-auto text-gray-600 mb-4" />
|
||||||
|
<p className="text-gray-500">No tokens tracked yet</p>
|
||||||
|
<p className="text-gray-600 text-sm mt-1">
|
||||||
|
Create a new token or import an existing one
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{trackedTokens.map((token) => {
|
||||||
|
const balance = getBalanceForToken(token.contractAddress);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={token.contractAddress}
|
||||||
|
className="p-4 rounded-lg bg-gray-800 border border-gray-700"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between cursor-pointer"
|
||||||
|
onClick={() => toggleTokenExpanded(token.contractAddress)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{expandedTokens.has(token.contractAddress) ? (
|
||||||
|
<ChevronDown size={18} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={18} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
<div className="w-10 h-10 rounded-full bg-synor-600/30 flex items-center justify-center">
|
||||||
|
<span className="text-synor-400 font-bold text-sm">
|
||||||
|
{token.symbol.slice(0, 2).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">{token.name}</p>
|
||||||
|
<p className="text-gray-500 text-sm">{token.symbol}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{balance
|
||||||
|
? formatTokenAmount(balance.balance, token.decimals)
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
{truncateAddress(token.contractAddress)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedTokens.has(token.contractAddress) && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-700 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Contract:</span>
|
||||||
|
<p className="text-gray-300 font-mono text-xs break-all">
|
||||||
|
{token.contractAddress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Decimals:</span>
|
||||||
|
<p className="text-gray-300">{token.decimals}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyToClipboard(token.contractAddress);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded bg-gray-700 text-gray-300 hover:text-white text-sm"
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
Copy Address
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setTransferForm({
|
||||||
|
...transferForm,
|
||||||
|
contractAddress: token.contractAddress,
|
||||||
|
});
|
||||||
|
setActiveTab('transfer');
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded bg-synor-600 text-white text-sm"
|
||||||
|
>
|
||||||
|
<Send size={14} />
|
||||||
|
Transfer
|
||||||
|
</button>
|
||||||
|
{token.isCreatedByUser && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setManageForm({
|
||||||
|
...manageForm,
|
||||||
|
contractAddress: token.contractAddress,
|
||||||
|
});
|
||||||
|
setActiveTab('manage');
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded bg-gray-700 text-gray-300 hover:text-white text-sm"
|
||||||
|
>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeTrackedToken(token.contractAddress);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded bg-red-900/30 text-red-400 hover:text-red-300 text-sm"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Token Tab */}
|
||||||
|
{activeTab === 'create' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white">Create New Token</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Token Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={createForm.name}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||||
|
placeholder="My Token"
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Symbol *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={createForm.symbol}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, symbol: e.target.value.toUpperCase() })
|
||||||
|
}
|
||||||
|
placeholder="TKN"
|
||||||
|
maxLength={8}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 uppercase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Decimals</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={createForm.decimals}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, decimals: e.target.value })}
|
||||||
|
min="0"
|
||||||
|
max="18"
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Standard: 18</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Initial Supply *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={createForm.initialSupply}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, initialSupply: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="1000000"
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">
|
||||||
|
Max Supply (optional, leave blank for unlimited)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={createForm.maxSupply}
|
||||||
|
onChange={(e) => setCreateForm({ ...createForm, maxSupply: e.target.value })}
|
||||||
|
placeholder="10000000"
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token features */}
|
||||||
|
<div className="p-4 rounded-lg bg-gray-800">
|
||||||
|
<p className="text-sm text-gray-400 mb-3">Token Features</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createForm.mintable}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, mintable: e.target.checked })
|
||||||
|
}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-synor-500 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-300">Mintable</span>
|
||||||
|
<span className="text-gray-500 text-sm">
|
||||||
|
- Owner can create new tokens
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createForm.burnable}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, burnable: e.target.checked })
|
||||||
|
}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-synor-500 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-300">Burnable</span>
|
||||||
|
<span className="text-gray-500 text-sm">
|
||||||
|
- Holders can destroy their tokens
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createForm.pausable}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateForm({ ...createForm, pausable: e.target.checked })
|
||||||
|
}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-synor-500 focus:ring-synor-500"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-300">Pausable</span>
|
||||||
|
<span className="text-gray-500 text-sm">
|
||||||
|
- Owner can pause transfers
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={
|
||||||
|
isCreating ||
|
||||||
|
!createForm.name ||
|
||||||
|
!createForm.symbol ||
|
||||||
|
!createForm.initialSupply ||
|
||||||
|
!nodeStatus.isConnected
|
||||||
|
}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<RefreshCw size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus size={18} />
|
||||||
|
)}
|
||||||
|
Create Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transfer Tab */}
|
||||||
|
{activeTab === 'transfer' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white">Transfer Tokens</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Token *</label>
|
||||||
|
<select
|
||||||
|
value={transferForm.contractAddress}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTransferForm({ ...transferForm, contractAddress: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
|
||||||
|
>
|
||||||
|
<option value="">Select token...</option>
|
||||||
|
{trackedTokens.map((token) => {
|
||||||
|
const balance = getBalanceForToken(token.contractAddress);
|
||||||
|
return (
|
||||||
|
<option key={token.contractAddress} value={token.contractAddress}>
|
||||||
|
{token.symbol} - {token.name}
|
||||||
|
{balance
|
||||||
|
? ` (${formatTokenAmount(balance.balance, token.decimals)})`
|
||||||
|
: ''}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Recipient Address *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={transferForm.toAddress}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTransferForm({ ...transferForm, toAddress: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="synor1..."
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Amount *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={transferForm.amount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTransferForm({ ...transferForm, amount: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="0.0"
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleTransfer}
|
||||||
|
disabled={
|
||||||
|
isTransferring ||
|
||||||
|
!transferForm.contractAddress ||
|
||||||
|
!transferForm.toAddress ||
|
||||||
|
!transferForm.amount ||
|
||||||
|
!nodeStatus.isConnected
|
||||||
|
}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 rounded-lg bg-synor-600 hover:bg-synor-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isTransferring ? (
|
||||||
|
<RefreshCw size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send size={18} />
|
||||||
|
)}
|
||||||
|
Transfer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manage Tab */}
|
||||||
|
{activeTab === 'manage' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white">Manage Token</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-2">Token *</label>
|
||||||
|
<select
|
||||||
|
value={manageForm.contractAddress}
|
||||||
|
onChange={(e) =>
|
||||||
|
setManageForm({
|
||||||
|
...manageForm,
|
||||||
|
contractAddress: e.target.value,
|
||||||
|
mintTo: currentAddress,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-gray-800 border border-gray-700 text-white focus:outline-none focus:border-synor-500"
|
||||||
|
>
|
||||||
|
<option value="">Select token...</option>
|
||||||
|
{trackedTokens
|
||||||
|
.filter((t) => t.isCreatedByUser)
|
||||||
|
.map((token) => (
|
||||||
|
<option key={token.contractAddress} value={token.contractAddress}>
|
||||||
|
{token.symbol} - {token.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{trackedTokens.filter((t) => t.isCreatedByUser).length === 0 && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Only tokens you created can be managed
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{manageForm.contractAddress && (
|
||||||
|
<>
|
||||||
|
{/* Mint Section */}
|
||||||
|
<div className="p-4 rounded-lg bg-gray-800 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-synor-400">
|
||||||
|
<Sparkles size={18} />
|
||||||
|
<span className="font-medium">Mint New Tokens</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">
|
||||||
|
Recipient
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manageForm.mintTo}
|
||||||
|
onChange={(e) =>
|
||||||
|
setManageForm({ ...manageForm, mintTo: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="synor1..."
|
||||||
|
className="w-full px-3 py-2 rounded bg-gray-700 border border-gray-600 text-white text-sm font-mono focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Amount</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manageForm.mintAmount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setManageForm({ ...manageForm, mintAmount: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="1000"
|
||||||
|
className="w-full px-3 py-2 rounded bg-gray-700 border border-gray-600 text-white text-sm focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleMint}
|
||||||
|
disabled={
|
||||||
|
isMinting ||
|
||||||
|
!manageForm.mintTo ||
|
||||||
|
!manageForm.mintAmount ||
|
||||||
|
!nodeStatus.isConnected
|
||||||
|
}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded bg-synor-600 hover:bg-synor-700 text-white text-sm transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isMinting ? (
|
||||||
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles size={16} />
|
||||||
|
)}
|
||||||
|
Mint Tokens
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Burn Section */}
|
||||||
|
<div className="p-4 rounded-lg bg-gray-800 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-red-400">
|
||||||
|
<Flame size={18} />
|
||||||
|
<span className="font-medium">Burn Tokens</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">
|
||||||
|
Amount to Burn
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manageForm.burnAmount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setManageForm({ ...manageForm, burnAmount: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="100"
|
||||||
|
className="w-full px-3 py-2 rounded bg-gray-700 border border-gray-600 text-white text-sm focus:outline-none focus:border-synor-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleBurn}
|
||||||
|
disabled={isBurning || !manageForm.burnAmount || !nodeStatus.isConnected}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded bg-red-600 hover:bg-red-700 text-white text-sm transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isBurning ? (
|
||||||
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Flame size={16} />
|
||||||
|
)}
|
||||||
|
Burn Tokens
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Warning: Burned tokens are permanently destroyed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help Section */}
|
||||||
|
<div className="p-6 rounded-xl bg-gray-900/50 border border-gray-800">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-400 mb-3 flex items-center gap-2">
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
About Tokens
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-500">
|
||||||
|
<li>• Tokens are fungible assets that live on the Synor blockchain</li>
|
||||||
|
<li>• Created tokens follow the standard token interface (similar to ERC-20)</li>
|
||||||
|
<li>• Mintable tokens allow the owner to create more supply</li>
|
||||||
|
<li>• Burnable tokens allow holders to permanently destroy tokens</li>
|
||||||
|
<li>• Import existing tokens by their contract address</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
apps/desktop-wallet/src/store/contracts.ts
Normal file
271
apps/desktop-wallet/src/store/contracts.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitized error logging
|
||||||
|
*/
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
const errorType = error instanceof Error ? error.name : 'Unknown';
|
||||||
|
console.error(`[Contracts] ${context}: ${errorType}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Contracts] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DeployContractRequest {
|
||||||
|
bytecode: string;
|
||||||
|
constructorArgs?: string;
|
||||||
|
gasLimit: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployContractResponse {
|
||||||
|
txHash: string;
|
||||||
|
contractAddress: string;
|
||||||
|
gasUsed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallContractRequest {
|
||||||
|
contractAddress: string;
|
||||||
|
method: string;
|
||||||
|
args?: string;
|
||||||
|
gasLimit: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CallContractResponse {
|
||||||
|
txHash: string;
|
||||||
|
gasUsed: number;
|
||||||
|
result?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractInfo {
|
||||||
|
address: string;
|
||||||
|
codeHash: string;
|
||||||
|
balance: string;
|
||||||
|
creator: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastInteraction: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployedContract {
|
||||||
|
address: string;
|
||||||
|
name: string;
|
||||||
|
deployedAt: number;
|
||||||
|
txHash: string;
|
||||||
|
abi?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Store
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface ContractsState {
|
||||||
|
// Deployed contracts (persisted)
|
||||||
|
deployedContracts: DeployedContract[];
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
isDeploying: boolean;
|
||||||
|
isCalling: boolean;
|
||||||
|
isReading: boolean;
|
||||||
|
|
||||||
|
// Last results
|
||||||
|
lastDeployResult: DeployContractResponse | null;
|
||||||
|
lastCallResult: CallContractResponse | null;
|
||||||
|
lastReadResult: string | null;
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
clearError: () => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
addDeployedContract: (contract: DeployedContract) => void;
|
||||||
|
removeDeployedContract: (address: string) => void;
|
||||||
|
updateContractName: (address: string, name: string) => void;
|
||||||
|
updateContractAbi: (address: string, abi: string) => void;
|
||||||
|
|
||||||
|
// Async actions
|
||||||
|
deployContract: (
|
||||||
|
request: DeployContractRequest,
|
||||||
|
name?: string,
|
||||||
|
abi?: string
|
||||||
|
) => Promise<DeployContractResponse>;
|
||||||
|
callContract: (request: CallContractRequest) => Promise<CallContractResponse>;
|
||||||
|
readContract: (
|
||||||
|
contractAddress: string,
|
||||||
|
method: string,
|
||||||
|
args?: string
|
||||||
|
) => Promise<string>;
|
||||||
|
getContractInfo: (contractAddress: string) => Promise<ContractInfo>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useContractsStore = create<ContractsState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
deployedContracts: [],
|
||||||
|
isDeploying: false,
|
||||||
|
isCalling: false,
|
||||||
|
isReading: false,
|
||||||
|
lastDeployResult: null,
|
||||||
|
lastCallResult: null,
|
||||||
|
lastReadResult: null,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Sync actions
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
|
||||||
|
addDeployedContract: (contract) =>
|
||||||
|
set((state) => ({
|
||||||
|
deployedContracts: [contract, ...state.deployedContracts],
|
||||||
|
})),
|
||||||
|
|
||||||
|
removeDeployedContract: (address) =>
|
||||||
|
set((state) => ({
|
||||||
|
deployedContracts: state.deployedContracts.filter(
|
||||||
|
(c) => c.address !== address
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
|
updateContractName: (address, name) =>
|
||||||
|
set((state) => ({
|
||||||
|
deployedContracts: state.deployedContracts.map((c) =>
|
||||||
|
c.address === address ? { ...c, name } : c
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
|
updateContractAbi: (address, abi) =>
|
||||||
|
set((state) => ({
|
||||||
|
deployedContracts: state.deployedContracts.map((c) =>
|
||||||
|
c.address === address ? { ...c, abi } : c
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
|
// Async actions
|
||||||
|
deployContract: async (request, name, abi) => {
|
||||||
|
set({ isDeploying: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await invoke<DeployContractResponse>('contract_deploy', {
|
||||||
|
bytecode: request.bytecode,
|
||||||
|
constructorArgs: request.constructorArgs,
|
||||||
|
gasLimit: request.gasLimit,
|
||||||
|
value: request.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to deployed contracts list
|
||||||
|
const contract: DeployedContract = {
|
||||||
|
address: result.contractAddress,
|
||||||
|
name: name || `Contract ${result.contractAddress.slice(0, 8)}`,
|
||||||
|
deployedAt: Date.now(),
|
||||||
|
txHash: result.txHash,
|
||||||
|
abi,
|
||||||
|
};
|
||||||
|
get().addDeployedContract(contract);
|
||||||
|
|
||||||
|
set({ lastDeployResult: result, isDeploying: false });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg =
|
||||||
|
error instanceof Error ? error.message : 'Deploy failed';
|
||||||
|
logError('deployContract', error);
|
||||||
|
set({ error: errorMsg, isDeploying: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
callContract: async (request) => {
|
||||||
|
set({ isCalling: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await invoke<CallContractResponse>('contract_call', {
|
||||||
|
contractAddress: request.contractAddress,
|
||||||
|
method: request.method,
|
||||||
|
args: request.args,
|
||||||
|
gasLimit: request.gasLimit,
|
||||||
|
value: request.value,
|
||||||
|
});
|
||||||
|
set({ lastCallResult: result, isCalling: false });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg =
|
||||||
|
error instanceof Error ? error.message : 'Call failed';
|
||||||
|
logError('callContract', error);
|
||||||
|
set({ error: errorMsg, isCalling: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
readContract: async (contractAddress, method, args) => {
|
||||||
|
set({ isReading: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await invoke<string>('contract_read', {
|
||||||
|
contractAddress,
|
||||||
|
method,
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
set({ lastReadResult: result, isReading: false });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg =
|
||||||
|
error instanceof Error ? error.message : 'Read failed';
|
||||||
|
logError('readContract', error);
|
||||||
|
set({ error: errorMsg, isReading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getContractInfo: async (contractAddress) => {
|
||||||
|
try {
|
||||||
|
const info = await invoke<ContractInfo>('contract_get_info', {
|
||||||
|
contractAddress,
|
||||||
|
});
|
||||||
|
return info;
|
||||||
|
} catch (error) {
|
||||||
|
logError('getContractInfo', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'synor-contracts-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
deployedContracts: state.deployedContracts,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Hooks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all deployed contracts
|
||||||
|
*/
|
||||||
|
export function useDeployedContracts(): DeployedContract[] {
|
||||||
|
return useContractsStore((state) => state.deployedContracts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a specific contract by address
|
||||||
|
*/
|
||||||
|
export function useContract(address: string): DeployedContract | undefined {
|
||||||
|
return useContractsStore((state) =>
|
||||||
|
state.deployedContracts.find((c) => c.address === address)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncates an address for display
|
||||||
|
*/
|
||||||
|
export function truncateAddress(address: string, chars: number = 8): string {
|
||||||
|
if (address.length <= chars * 2 + 2) return address;
|
||||||
|
return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`;
|
||||||
|
}
|
||||||
|
|
@ -13,3 +13,33 @@ export {
|
||||||
formatHashrate,
|
formatHashrate,
|
||||||
} from './mining';
|
} from './mining';
|
||||||
export type { MiningStatus, MiningStats, BlockFoundEvent } from './mining';
|
export type { MiningStatus, MiningStats, BlockFoundEvent } from './mining';
|
||||||
|
|
||||||
|
export {
|
||||||
|
useContractsStore,
|
||||||
|
useDeployedContracts,
|
||||||
|
useContract,
|
||||||
|
truncateAddress,
|
||||||
|
} from './contracts';
|
||||||
|
export type {
|
||||||
|
DeployContractRequest,
|
||||||
|
DeployContractResponse,
|
||||||
|
CallContractRequest,
|
||||||
|
CallContractResponse,
|
||||||
|
ContractInfo,
|
||||||
|
DeployedContract,
|
||||||
|
} from './contracts';
|
||||||
|
|
||||||
|
export {
|
||||||
|
useTokensStore,
|
||||||
|
useTrackedTokens,
|
||||||
|
useTokenBalances,
|
||||||
|
formatTokenAmount,
|
||||||
|
parseTokenAmount,
|
||||||
|
} from './tokens';
|
||||||
|
export type {
|
||||||
|
CreateTokenRequest,
|
||||||
|
CreateTokenResponse,
|
||||||
|
TokenInfo,
|
||||||
|
TokenBalance,
|
||||||
|
TrackedToken,
|
||||||
|
} from './tokens';
|
||||||
|
|
|
||||||
373
apps/desktop-wallet/src/store/tokens.ts
Normal file
373
apps/desktop-wallet/src/store/tokens.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitized error logging
|
||||||
|
*/
|
||||||
|
function logError(context: string, error: unknown): void {
|
||||||
|
if (import.meta.env.PROD) {
|
||||||
|
const errorType = error instanceof Error ? error.name : 'Unknown';
|
||||||
|
console.error(`[Tokens] ${context}: ${errorType}`);
|
||||||
|
} else {
|
||||||
|
console.error(`[Tokens] ${context}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CreateTokenRequest {
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
decimals: number;
|
||||||
|
initialSupply: string;
|
||||||
|
maxSupply?: string;
|
||||||
|
mintable: boolean;
|
||||||
|
burnable: boolean;
|
||||||
|
pausable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTokenResponse {
|
||||||
|
txHash: string;
|
||||||
|
contractAddress: string;
|
||||||
|
tokenId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenInfo {
|
||||||
|
contractAddress: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
decimals: number;
|
||||||
|
totalSupply: string;
|
||||||
|
maxSupply?: string;
|
||||||
|
owner: string;
|
||||||
|
mintable: boolean;
|
||||||
|
burnable: boolean;
|
||||||
|
pausable: boolean;
|
||||||
|
paused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenBalance {
|
||||||
|
contractAddress: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
decimals: number;
|
||||||
|
balance: string;
|
||||||
|
formattedBalance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackedToken {
|
||||||
|
contractAddress: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
decimals: number;
|
||||||
|
addedAt: number;
|
||||||
|
isCreatedByUser: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Store
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TokensState {
|
||||||
|
// Tracked tokens (persisted)
|
||||||
|
trackedTokens: TrackedToken[];
|
||||||
|
|
||||||
|
// Balances cache
|
||||||
|
balances: TokenBalance[];
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
isCreating: boolean;
|
||||||
|
isTransferring: boolean;
|
||||||
|
isMinting: boolean;
|
||||||
|
isBurning: boolean;
|
||||||
|
isLoadingBalances: boolean;
|
||||||
|
|
||||||
|
// Last results
|
||||||
|
lastCreateResult: CreateTokenResponse | null;
|
||||||
|
lastTransferTxHash: string | null;
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
clearError: () => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
addTrackedToken: (token: TrackedToken) => void;
|
||||||
|
removeTrackedToken: (contractAddress: string) => void;
|
||||||
|
setBalances: (balances: TokenBalance[]) => void;
|
||||||
|
|
||||||
|
// Async actions
|
||||||
|
createToken: (request: CreateTokenRequest) => Promise<CreateTokenResponse>;
|
||||||
|
getTokenInfo: (contractAddress: string) => Promise<TokenInfo>;
|
||||||
|
transferToken: (
|
||||||
|
contractAddress: string,
|
||||||
|
toAddress: string,
|
||||||
|
amount: string
|
||||||
|
) => Promise<string>;
|
||||||
|
getTokenBalance: (
|
||||||
|
contractAddress: string,
|
||||||
|
address: string
|
||||||
|
) => Promise<string>;
|
||||||
|
listBalances: (address: string) => Promise<TokenBalance[]>;
|
||||||
|
mintToken: (
|
||||||
|
contractAddress: string,
|
||||||
|
toAddress: string,
|
||||||
|
amount: string
|
||||||
|
) => Promise<string>;
|
||||||
|
burnToken: (contractAddress: string, amount: string) => Promise<string>;
|
||||||
|
refreshBalances: (address: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTokensStore = create<TokensState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
trackedTokens: [],
|
||||||
|
balances: [],
|
||||||
|
isCreating: false,
|
||||||
|
isTransferring: false,
|
||||||
|
isMinting: false,
|
||||||
|
isBurning: false,
|
||||||
|
isLoadingBalances: false,
|
||||||
|
lastCreateResult: null,
|
||||||
|
lastTransferTxHash: null,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Sync actions
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
|
||||||
|
addTrackedToken: (token) =>
|
||||||
|
set((state) => {
|
||||||
|
// Don't add duplicates
|
||||||
|
if (state.trackedTokens.find((t) => t.contractAddress === token.contractAddress)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
trackedTokens: [token, ...state.trackedTokens],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeTrackedToken: (contractAddress) =>
|
||||||
|
set((state) => ({
|
||||||
|
trackedTokens: state.trackedTokens.filter(
|
||||||
|
(t) => t.contractAddress !== contractAddress
|
||||||
|
),
|
||||||
|
balances: state.balances.filter(
|
||||||
|
(b) => b.contractAddress !== contractAddress
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
|
setBalances: (balances) => set({ balances }),
|
||||||
|
|
||||||
|
// Async actions
|
||||||
|
createToken: async (request) => {
|
||||||
|
set({ isCreating: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await invoke<CreateTokenResponse>('token_create', {
|
||||||
|
name: request.name,
|
||||||
|
symbol: request.symbol,
|
||||||
|
decimals: request.decimals,
|
||||||
|
initialSupply: request.initialSupply,
|
||||||
|
maxSupply: request.maxSupply,
|
||||||
|
mintable: request.mintable,
|
||||||
|
burnable: request.burnable,
|
||||||
|
pausable: request.pausable,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add to tracked tokens
|
||||||
|
const token: TrackedToken = {
|
||||||
|
contractAddress: result.contractAddress,
|
||||||
|
name: request.name,
|
||||||
|
symbol: request.symbol,
|
||||||
|
decimals: request.decimals,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
isCreatedByUser: true,
|
||||||
|
};
|
||||||
|
get().addTrackedToken(token);
|
||||||
|
|
||||||
|
set({ lastCreateResult: result, isCreating: false });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg =
|
||||||
|
error instanceof Error ? error.message : 'Token creation failed';
|
||||||
|
logError('createToken', error);
|
||||||
|
set({ error: errorMsg, isCreating: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getTokenInfo: async (contractAddress) => {
|
||||||
|
try {
|
||||||
|
const info = await invoke<TokenInfo>('token_get_info', {
|
||||||
|
contractAddress,
|
||||||
|
});
|
||||||
|
return info;
|
||||||
|
} catch (error) {
|
||||||
|
logError('getTokenInfo', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
transferToken: async (contractAddress, toAddress, amount) => {
|
||||||
|
set({ isTransferring: true, error: null });
|
||||||
|
try {
|
||||||
|
const txHash = await invoke<string>('token_transfer', {
|
||||||
|
contractAddress,
|
||||||
|
toAddress,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
set({ lastTransferTxHash: txHash, isTransferring: false });
|
||||||
|
return txHash;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg =
|
||||||
|
error instanceof Error ? error.message : 'Transfer failed';
|
||||||
|
logError('transferToken', error);
|
||||||
|
set({ error: errorMsg, isTransferring: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getTokenBalance: async (contractAddress, address) => {
|
||||||
|
try {
|
||||||
|
const balance = await invoke<string>('token_get_balance', {
|
||||||
|
contractAddress,
|
||||||
|
address,
|
||||||
|
});
|
||||||
|
return balance;
|
||||||
|
} catch (error) {
|
||||||
|
logError('getTokenBalance', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listBalances: async (address) => {
|
||||||
|
set({ isLoadingBalances: true });
|
||||||
|
try {
|
||||||
|
const balances = await invoke<TokenBalance[]>('token_list_balances', {
|
||||||
|
address,
|
||||||
|
});
|
||||||
|
set({ balances, isLoadingBalances: false });
|
||||||
|
return balances;
|
||||||
|
} catch (error) {
|
||||||
|
logError('listBalances', error);
|
||||||
|
set({ isLoadingBalances: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mintToken: async (contractAddress, toAddress, amount) => {
|
||||||
|
set({ isMinting: true, error: null });
|
||||||
|
try {
|
||||||
|
const txHash = await invoke<string>('token_mint', {
|
||||||
|
contractAddress,
|
||||||
|
toAddress,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
set({ isMinting: false });
|
||||||
|
return txHash;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg =
|
||||||
|
error instanceof Error ? error.message : 'Mint failed';
|
||||||
|
logError('mintToken', error);
|
||||||
|
set({ error: errorMsg, isMinting: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
burnToken: async (contractAddress, amount) => {
|
||||||
|
set({ isBurning: true, error: null });
|
||||||
|
try {
|
||||||
|
const txHash = await invoke<string>('token_burn', {
|
||||||
|
contractAddress,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
set({ isBurning: false });
|
||||||
|
return txHash;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg =
|
||||||
|
error instanceof Error ? error.message : 'Burn failed';
|
||||||
|
logError('burnToken', error);
|
||||||
|
set({ error: errorMsg, isBurning: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshBalances: async (address) => {
|
||||||
|
try {
|
||||||
|
const balances = await get().listBalances(address);
|
||||||
|
set({ balances });
|
||||||
|
} catch (error) {
|
||||||
|
logError('refreshBalances', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'synor-tokens-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
trackedTokens: state.trackedTokens,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all tracked tokens
|
||||||
|
*/
|
||||||
|
export function useTrackedTokens(): TrackedToken[] {
|
||||||
|
return useTokensStore((state) => state.trackedTokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all token balances
|
||||||
|
*/
|
||||||
|
export function useTokenBalances(): TokenBalance[] {
|
||||||
|
return useTokensStore((state) => state.balances);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a token amount based on decimals
|
||||||
|
*/
|
||||||
|
export function formatTokenAmount(
|
||||||
|
amount: string,
|
||||||
|
decimals: number,
|
||||||
|
maxDecimals: number = 6
|
||||||
|
): string {
|
||||||
|
const value = BigInt(amount);
|
||||||
|
const divisor = BigInt(10 ** decimals);
|
||||||
|
const wholePart = value / divisor;
|
||||||
|
const fractionalPart = value % divisor;
|
||||||
|
|
||||||
|
if (fractionalPart === BigInt(0)) {
|
||||||
|
return wholePart.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fractionalStr = fractionalPart
|
||||||
|
.toString()
|
||||||
|
.padStart(decimals, '0')
|
||||||
|
.slice(0, maxDecimals)
|
||||||
|
.replace(/0+$/, '');
|
||||||
|
|
||||||
|
if (fractionalStr === '') {
|
||||||
|
return wholePart.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${wholePart}.${fractionalStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a token amount string to raw units
|
||||||
|
*/
|
||||||
|
export function parseTokenAmount(amount: string, decimals: number): string {
|
||||||
|
const [whole, fraction = ''] = amount.split('.');
|
||||||
|
const paddedFraction = fraction.padEnd(decimals, '0').slice(0, decimals);
|
||||||
|
const combined = whole + paddedFraction;
|
||||||
|
return BigInt(combined).toString();
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue