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:
Gulshan Yadav 2026-02-02 09:03:58 +05:30
parent 88b09914c3
commit a5e4fc1c21
10 changed files with 2555 additions and 0 deletions

View file

@ -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())
}

View file

@ -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),
} }

View file

@ -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,

View file

@ -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={

View file

@ -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 },
]; ];

View 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>
);
}

View 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>
);
}

View 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)}`;
}

View file

@ -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';

View 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();
}