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> {
|
||||
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}")]
|
||||
MiningError(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Contract error: {0}")]
|
||||
ContractError(String),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,6 +213,19 @@ pub fn run() {
|
|||
commands::wallet_get_utxos,
|
||||
commands::wallet_get_network_info,
|
||||
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
|
||||
check_update,
|
||||
install_update,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import History from './pages/History';
|
|||
import Settings from './pages/Settings';
|
||||
import NodeDashboard from './pages/Node/NodeDashboard';
|
||||
import MiningDashboard from './pages/Mining/MiningDashboard';
|
||||
import ContractsDashboard from './pages/Contracts/ContractsDashboard';
|
||||
import TokensDashboard from './pages/Tokens/TokensDashboard';
|
||||
|
||||
function App() {
|
||||
const { isInitialized, isUnlocked } = useWalletStore();
|
||||
|
|
@ -100,6 +102,18 @@ function App() {
|
|||
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
|
||||
path="/settings"
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
WifiOff,
|
||||
Server,
|
||||
Hammer,
|
||||
FileCode2,
|
||||
Coins,
|
||||
} from 'lucide-react';
|
||||
import { useWalletStore } from '../store/wallet';
|
||||
import { useNodeStore } from '../store/node';
|
||||
|
|
@ -25,6 +27,8 @@ const navItems = [
|
|||
const advancedNavItems = [
|
||||
{ to: '/node', label: 'Node', icon: Server },
|
||||
{ to: '/mining', label: 'Mining', icon: Hammer },
|
||||
{ to: '/contracts', label: 'Contracts', icon: FileCode2 },
|
||||
{ to: '/tokens', label: 'Tokens', icon: Coins },
|
||||
{ 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,
|
||||
} 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