794 lines
19 KiB
Vue
794 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, computed } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
|
||
import {
|
||
BaseWallet,
|
||
Bytes,
|
||
type Address,
|
||
} from '@xocash/stack';
|
||
|
||
import {
|
||
type WalletHDWatch,
|
||
type WalletP2PKHWatch,
|
||
} from '../xo-extensions/index.js';
|
||
|
||
import { binToHex } from '@bitauth/libauth';
|
||
|
||
import { useApp } from '../services/app.js';
|
||
|
||
//-----------------------------------------------------------------------------
|
||
// Types
|
||
//-----------------------------------------------------------------------------
|
||
|
||
type WalletType = 'p2pkh' | 'hd';
|
||
|
||
type AddressInfo = {
|
||
address: Address;
|
||
chainPath: number;
|
||
index: number;
|
||
cashAddr: string;
|
||
derivationPath: string;
|
||
lockscriptHex: string;
|
||
txCount: number;
|
||
utxoCount: number;
|
||
};
|
||
|
||
//-----------------------------------------------------------------------------
|
||
// App & Router
|
||
//-----------------------------------------------------------------------------
|
||
|
||
const app = useApp();
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
|
||
//-----------------------------------------------------------------------------
|
||
// State
|
||
//-----------------------------------------------------------------------------
|
||
|
||
/** The wallet key from the route param. */
|
||
const key = computed(() => route.params.key as string);
|
||
|
||
/** The account path from query params (for master xpubs). */
|
||
const accountPath = computed(() => route.query.accountPath as string | undefined);
|
||
|
||
/** The detected wallet type. */
|
||
const walletType = computed<WalletType | null>(() => {
|
||
if (isXPub(key.value)) return 'hd';
|
||
if (isPublicKey(key.value)) return 'p2pkh';
|
||
return null;
|
||
});
|
||
|
||
/** Loading state. */
|
||
const isLoading = ref(true);
|
||
|
||
/** Error message if wallet creation fails. */
|
||
const error = ref<string | null>(null);
|
||
|
||
/** Filter for chain path (0 = external, 1 = internal, null = all). */
|
||
const chainPathFilter = ref<number | null>(null);
|
||
|
||
/** Search query for filtering addresses. */
|
||
const searchQuery = ref('');
|
||
|
||
//-----------------------------------------------------------------------------
|
||
// Computed Wallet
|
||
//-----------------------------------------------------------------------------
|
||
|
||
/** The genesis data used to derive the wallet ID. */
|
||
const genesisData = computed(() => {
|
||
return walletType.value === 'hd'
|
||
? {
|
||
type: 'WalletHDWatch',
|
||
xpub: key.value,
|
||
derivationPath: accountPath.value ?? '',
|
||
} as const
|
||
: {
|
||
type: 'WalletP2PKHWatch',
|
||
publicKey: key.value,
|
||
} as const;
|
||
});
|
||
|
||
/** The wallet instance from the app's wallet manager. */
|
||
const wallet = computed(() => {
|
||
const walletId = BaseWallet.deriveId(genesisData.value);
|
||
return app.wallets.wallets[Bytes.from(walletId).toHex()];
|
||
});
|
||
|
||
//-----------------------------------------------------------------------------
|
||
// Wallet Type Detection
|
||
//-----------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Determines if a key is an xpub (extended public key).
|
||
*
|
||
* @param key - The key to check.
|
||
* @returns True if the key is an xpub.
|
||
*/
|
||
function isXPub(key: string): boolean {
|
||
return key.startsWith('xpub') || key.startsWith('tpub');
|
||
}
|
||
|
||
/**
|
||
* Determines if a key is a valid compressed public key.
|
||
*
|
||
* @param key - The key to check.
|
||
* @returns True if the key is a valid public key.
|
||
*/
|
||
function isPublicKey(key: string): boolean {
|
||
if (key.length !== 66) return false;
|
||
if (!key.startsWith('02') && !key.startsWith('03')) return false;
|
||
return /^[0-9a-fA-F]+$/.test(key);
|
||
}
|
||
|
||
//-----------------------------------------------------------------------------
|
||
// Wallet Initialization
|
||
//-----------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Creates and initializes the wallet if it doesn't exist.
|
||
*/
|
||
async function initializeWallet() {
|
||
isLoading.value = true;
|
||
error.value = null;
|
||
|
||
try {
|
||
if (!walletType.value) {
|
||
throw new Error(
|
||
'Invalid key format. Please provide a valid public key or xpub.'
|
||
);
|
||
}
|
||
|
||
// Create the wallet via the app's wallet manager.
|
||
await app.wallets.createWallet(genesisData.value);
|
||
} catch (err) {
|
||
error.value = err instanceof Error ? err.message : 'Failed to initialize wallet';
|
||
console.error('Wallet initialization error:', err);
|
||
} finally {
|
||
isLoading.value = false;
|
||
}
|
||
}
|
||
|
||
//-----------------------------------------------------------------------------
|
||
// Lifecycle
|
||
//-----------------------------------------------------------------------------
|
||
|
||
onMounted(() => {
|
||
initializeWallet();
|
||
});
|
||
|
||
//-----------------------------------------------------------------------------
|
||
// Computed Values
|
||
//-----------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Map of transaction counts per address lockscript.
|
||
* Key: lockscript hex, Value: number of transactions involving this address.
|
||
*/
|
||
const txCountsByLockscript = computed<Map<string, number>>(() => {
|
||
if (!wallet.value) return new Map();
|
||
|
||
const counts = new Map<string, number>();
|
||
|
||
// Iterate through all transactions in the wallet.
|
||
wallet.value.transactions.value.forEach((tx) => {
|
||
const seenLockscripts = new Set<string>();
|
||
|
||
// Check outputs for addresses belonging to this wallet.
|
||
tx.transaction.getOutputs().forEach((output) => {
|
||
const lockscriptHex = binToHex(output.lockingBytecode);
|
||
// Only count once per transaction.
|
||
if (!seenLockscripts.has(lockscriptHex)) {
|
||
seenLockscripts.add(lockscriptHex);
|
||
counts.set(lockscriptHex, (counts.get(lockscriptHex) ?? 0) + 1);
|
||
}
|
||
});
|
||
|
||
// Check inputs (source outputs) for addresses belonging to this wallet.
|
||
tx.sourceOutputs.forEach((sourceOutput) => {
|
||
const lockscriptHex = binToHex(sourceOutput.lockingBytecode);
|
||
// Only count once per transaction.
|
||
if (!seenLockscripts.has(lockscriptHex)) {
|
||
seenLockscripts.add(lockscriptHex);
|
||
counts.set(lockscriptHex, (counts.get(lockscriptHex) ?? 0) + 1);
|
||
}
|
||
});
|
||
});
|
||
|
||
return counts;
|
||
});
|
||
|
||
/**
|
||
* Map of UTXO counts per address lockscript.
|
||
* Key: lockscript hex, Value: number of unspent outputs for this address.
|
||
*/
|
||
const utxoCountsByLockscript = computed<Map<string, number>>(() => {
|
||
if (!wallet.value) return new Map();
|
||
|
||
const counts = new Map<string, number>();
|
||
|
||
// Iterate through all UTXOs in the wallet.
|
||
wallet.value.unspents.value.forEach((blockchainUtxo) => {
|
||
const lockscriptHex = binToHex(blockchainUtxo.utxo.output.lockingBytecode);
|
||
counts.set(lockscriptHex, (counts.get(lockscriptHex) ?? 0) + 1);
|
||
});
|
||
|
||
return counts;
|
||
});
|
||
|
||
/** Derived addresses with metadata built from the wallet. */
|
||
const addressList = computed<AddressInfo[]>(() => {
|
||
if (!wallet.value) return [];
|
||
|
||
const addresses: AddressInfo[] = [];
|
||
const baseWallet = wallet.value.wallet;
|
||
|
||
if (walletType.value === 'hd' && 'wallets' in baseWallet) {
|
||
// HD Wallet - iterate through child wallets.
|
||
const hdWallet = baseWallet as WalletHDWatch;
|
||
const basePath = hdWallet.genesisData.derivationPath;
|
||
|
||
for (const [chainPath, childWallets] of Object.entries(hdWallet.wallets)) {
|
||
childWallets.forEach((childWallet, index) => {
|
||
const address = childWallet.publicKey.deriveAddress();
|
||
const chainPathNum = Number(chainPath);
|
||
const lockscriptHex = address.toLockscriptHex();
|
||
addresses.push({
|
||
address,
|
||
chainPath: chainPathNum,
|
||
index,
|
||
cashAddr: address.toCashAddr(),
|
||
derivationPath: `${basePath}/${chainPathNum}/${index}`,
|
||
lockscriptHex,
|
||
txCount: txCountsByLockscript.value.get(lockscriptHex) ?? 0,
|
||
utxoCount: utxoCountsByLockscript.value.get(lockscriptHex) ?? 0,
|
||
});
|
||
});
|
||
}
|
||
} else {
|
||
// P2PKH Wallet - single address.
|
||
const p2pkhWallet = baseWallet as WalletP2PKHWatch;
|
||
const address = p2pkhWallet.publicKey.deriveAddress();
|
||
const lockscriptHex = address.toLockscriptHex();
|
||
addresses.push({
|
||
address,
|
||
chainPath: 0,
|
||
index: 0,
|
||
cashAddr: address.toCashAddr(),
|
||
derivationPath: '—',
|
||
lockscriptHex,
|
||
txCount: txCountsByLockscript.value.get(lockscriptHex) ?? 0,
|
||
utxoCount: utxoCountsByLockscript.value.get(lockscriptHex) ?? 0,
|
||
});
|
||
}
|
||
|
||
return addresses;
|
||
});
|
||
|
||
/** Filtered addresses based on chain path and search query. */
|
||
const filteredAddresses = computed(() => {
|
||
let result = addressList.value;
|
||
|
||
// Filter by chain path.
|
||
if (chainPathFilter.value !== null) {
|
||
result = result.filter((a) => a.chainPath === chainPathFilter.value);
|
||
}
|
||
|
||
// Filter by search query.
|
||
if (searchQuery.value) {
|
||
const query = searchQuery.value.toLowerCase();
|
||
result = result.filter((a) => a.cashAddr.toLowerCase().includes(query));
|
||
}
|
||
|
||
return result;
|
||
});
|
||
|
||
/** Count of external addresses. */
|
||
const externalCount = computed(() =>
|
||
addressList.value.filter((a) => a.chainPath === 0).length
|
||
);
|
||
|
||
/** Count of internal (change) addresses. */
|
||
const internalCount = computed(() =>
|
||
addressList.value.filter((a) => a.chainPath === 1).length
|
||
);
|
||
|
||
//-----------------------------------------------------------------------------
|
||
// Actions
|
||
//-----------------------------------------------------------------------------
|
||
|
||
function goBack() {
|
||
router.push({
|
||
name: 'wallet',
|
||
params: { key: key.value },
|
||
query: accountPath.value ? { accountPath: accountPath.value } : {},
|
||
});
|
||
}
|
||
|
||
function copyAddress(cashAddr: string) {
|
||
navigator.clipboard.writeText(cashAddr);
|
||
}
|
||
|
||
function getChainLabel(chainPath: number): string {
|
||
return chainPath === 0 ? 'External' : 'Change';
|
||
}
|
||
|
||
function setFilter(filter: number | null) {
|
||
chainPathFilter.value = filter;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="addresses-page">
|
||
<!-- Header -->
|
||
<header class="header">
|
||
<button class="back-button" @click="goBack">
|
||
← Back to Wallet
|
||
</button>
|
||
<div class="wallet-type-badge" v-if="walletType">
|
||
{{ walletType === 'hd' ? 'HD Wallet' : 'P2PKH Wallet' }}
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Loading State -->
|
||
<div v-if="isLoading" class="loading-state">
|
||
<div class="spinner"></div>
|
||
<p>Loading addresses...</p>
|
||
</div>
|
||
|
||
<!-- Error State -->
|
||
<div v-else-if="error" class="error-state">
|
||
<div class="error-icon">⚠️</div>
|
||
<h2>Error</h2>
|
||
<p>{{ error }}</p>
|
||
<button class="retry-button" @click="goBack">
|
||
Go Back
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Addresses Content -->
|
||
<div v-else class="addresses-content">
|
||
<h1 class="page-title">Addresses</h1>
|
||
|
||
<!-- Stats -->
|
||
<div class="stats-row" v-if="walletType === 'hd'">
|
||
<div class="stat-card">
|
||
<div class="stat-value">{{ addressList.length }}</div>
|
||
<div class="stat-label">Total</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">{{ externalCount }}</div>
|
||
<div class="stat-label">External</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value">{{ internalCount }}</div>
|
||
<div class="stat-label">Change</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filters -->
|
||
<div class="filters" v-if="walletType === 'hd'">
|
||
<div class="filter-buttons">
|
||
<button
|
||
:class="['filter-btn', { active: chainPathFilter === null }]"
|
||
@click="setFilter(null)"
|
||
>
|
||
All
|
||
</button>
|
||
<button
|
||
:class="['filter-btn', { active: chainPathFilter === 0 }]"
|
||
@click="setFilter(0)"
|
||
>
|
||
External
|
||
</button>
|
||
<button
|
||
:class="['filter-btn', { active: chainPathFilter === 1 }]"
|
||
@click="setFilter(1)"
|
||
>
|
||
Change
|
||
</button>
|
||
</div>
|
||
|
||
<input
|
||
v-model="searchQuery"
|
||
type="text"
|
||
placeholder="Search addresses..."
|
||
class="search-input"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Address List -->
|
||
<div class="address-list">
|
||
<div
|
||
v-for="addr in filteredAddresses"
|
||
:key="addr.cashAddr"
|
||
class="address-item"
|
||
>
|
||
<div class="address-info">
|
||
<div class="address-index">
|
||
<span class="chain-badge" :class="addr.chainPath === 0 ? 'external' : 'internal'">
|
||
{{ getChainLabel(addr.chainPath) }}
|
||
</span>
|
||
<span class="index-number">#{{ addr.index }}</span>
|
||
<span class="derivation-path">{{ addr.derivationPath }}</span>
|
||
</div>
|
||
<code class="address-value">{{ addr.cashAddr }}</code>
|
||
<div class="address-stats">
|
||
<span class="stat-badge" :class="{ inactive: addr.txCount === 0 }">
|
||
{{ addr.txCount }} tx{{ addr.txCount !== 1 ? 's' : '' }}
|
||
</span>
|
||
<span class="stat-badge" :class="{ inactive: addr.utxoCount === 0 }">
|
||
{{ addr.utxoCount }} UTXO{{ addr.utxoCount !== 1 ? 's' : '' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<button class="copy-button" @click="copyAddress(addr.cashAddr)" title="Copy address">
|
||
📋
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="filteredAddresses.length === 0" class="empty-state">
|
||
<p v-if="searchQuery">No addresses match your search.</p>
|
||
<p v-else>No addresses found.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.addresses-page {
|
||
min-height: 100vh;
|
||
background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%);
|
||
color: #fff;
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
/* Header */
|
||
.header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 2rem;
|
||
max-width: 900px;
|
||
margin-left: auto;
|
||
margin-right: auto;
|
||
}
|
||
|
||
.back-button {
|
||
padding: 0.5rem 1rem;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
border: 1px solid #2d3748;
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
color: #a0aec0;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.back-button:hover {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-color: #4a5568;
|
||
}
|
||
|
||
.wallet-type-badge {
|
||
padding: 0.375rem 0.75rem;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
border-radius: 9999px;
|
||
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 44, 191, 0.2));
|
||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||
color: #00d4ff;
|
||
}
|
||
|
||
/* Loading State */
|
||
.loading-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 60vh;
|
||
text-align: center;
|
||
}
|
||
|
||
.spinner {
|
||
width: 48px;
|
||
height: 48px;
|
||
border: 3px solid #2d3748;
|
||
border-top-color: #00d4ff;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.loading-state p {
|
||
color: #a0aec0;
|
||
font-size: 1.125rem;
|
||
}
|
||
|
||
/* Error State */
|
||
.error-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 60vh;
|
||
text-align: center;
|
||
}
|
||
|
||
.error-icon {
|
||
font-size: 3rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.error-state h2 {
|
||
font-size: 1.5rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.error-state p {
|
||
color: #fc8181;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.retry-button {
|
||
padding: 0.75rem 1.5rem;
|
||
font-size: 1rem;
|
||
font-weight: 500;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: #2d3748;
|
||
color: #fff;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.retry-button:hover {
|
||
background: #4a5568;
|
||
}
|
||
|
||
/* Content */
|
||
.addresses-content {
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.page-title {
|
||
font-size: 2rem;
|
||
font-weight: 700;
|
||
margin-bottom: 1.5rem;
|
||
background: linear-gradient(135deg, #00d4ff, #7b2cbf);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
|
||
/* Stats Row */
|
||
.stats-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 1rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.stat-card {
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border: 1px solid #2d3748;
|
||
border-radius: 12px;
|
||
padding: 1rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 1.5rem;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
color: #718096;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
/* Filters */
|
||
.filters {
|
||
display: flex;
|
||
gap: 1rem;
|
||
margin-bottom: 1.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.filter-buttons {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.filter-btn {
|
||
padding: 0.5rem 1rem;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
border: 1px solid #2d3748;
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
color: #a0aec0;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.filter-btn:hover {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.filter-btn.active {
|
||
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(123, 44, 191, 0.2));
|
||
border-color: rgba(0, 212, 255, 0.3);
|
||
color: #00d4ff;
|
||
}
|
||
|
||
.search-input {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
padding: 0.5rem 1rem;
|
||
font-size: 0.875rem;
|
||
border: 1px solid #2d3748;
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
color: #fff;
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
}
|
||
|
||
.search-input::placeholder {
|
||
color: #4a5568;
|
||
}
|
||
|
||
.search-input:focus {
|
||
outline: none;
|
||
border-color: #00d4ff;
|
||
}
|
||
|
||
/* Address List */
|
||
.address-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.address-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border: 1px solid #2d3748;
|
||
border-radius: 12px;
|
||
padding: 1rem 1.25rem;
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.address-item:hover {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-color: #4a5568;
|
||
}
|
||
|
||
.address-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.address-index {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.chain-badge {
|
||
padding: 0.125rem 0.5rem;
|
||
font-size: 0.625rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.chain-badge.external {
|
||
background: rgba(72, 187, 120, 0.2);
|
||
color: #48bb78;
|
||
}
|
||
|
||
.chain-badge.internal {
|
||
background: rgba(237, 137, 54, 0.2);
|
||
color: #ed8936;
|
||
}
|
||
|
||
.index-number {
|
||
font-size: 0.75rem;
|
||
color: #718096;
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
}
|
||
|
||
.derivation-path {
|
||
font-size: 0.7rem;
|
||
color: #4a5568;
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
margin-left: 0.5rem;
|
||
padding: 0.125rem 0.375rem;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.address-value {
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
font-size: 0.8rem;
|
||
color: #a0aec0;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.address-stats {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-top: 0.25rem;
|
||
}
|
||
|
||
.stat-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 0.125rem 0.5rem;
|
||
font-size: 0.625rem;
|
||
font-weight: 500;
|
||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||
border-radius: 4px;
|
||
background: rgba(0, 212, 255, 0.1);
|
||
color: #00d4ff;
|
||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||
}
|
||
|
||
.stat-badge.inactive {
|
||
background: rgba(255, 255, 255, 0.03);
|
||
color: #4a5568;
|
||
border-color: #2d3748;
|
||
}
|
||
|
||
.copy-button {
|
||
padding: 0.5rem;
|
||
font-size: 1rem;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.copy-button:hover {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 3rem;
|
||
color: #718096;
|
||
background: rgba(255, 255, 255, 0.02);
|
||
border-radius: 12px;
|
||
border: 1px dashed #2d3748;
|
||
}
|
||
</style>
|
||
|