This commit is contained in:
2026-08-28 17:31:02 +02:00
commit 2b30e8bd39
694 changed files with 49243 additions and 0 deletions
+312
View File
@@ -0,0 +1,312 @@
<template>
<div v-loading="loading" class="detail-loading-host" element-loading-text="Loading wallet…">
<el-empty v-if="!loading && loadError" description="Failed to load wallet status" />
<template v-if="!loading && !loadError && walletStatus">
<div v-if="walletStatus.syncStatus !== MoneroWalletSyncStatus.Synced" class="mb-16">
<el-alert type="warning" :closable="false" show-icon title="Wallet is syncing. Please wait." />
</div>
<el-card class="mb-24" shadow="never">
<template #header>
<div class="flex items-center justify-between gap-16">
<span>Status</span>
<el-button type="default" :loading="refreshing" @click="refreshStatus">Refresh</el-button>
</div>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="Network">{{ walletStatus.network }}</el-descriptions-item>
<el-descriptions-item label="RPC version">{{ walletStatus.rpcVersion }}</el-descriptions-item>
<el-descriptions-item label="Wallet height">{{ walletStatus.walletHeight }}</el-descriptions-item>
<el-descriptions-item label="Daemon height">
{{ walletStatus.daemonHeight ?? 'Unavailable' }}
</el-descriptions-item>
<el-descriptions-item label="Sync">
<el-tag :type="resolveMoneroWalletSyncStatusTagType(walletStatus.syncStatus)" size="small">
{{ resolveMoneroWalletSyncStatusLabel(walletStatus.syncStatus) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Total balance">{{ walletStatus.balanceXmr }} XMR</el-descriptions-item>
<el-descriptions-item label="Unlocked balance">
{{ walletStatus.unlockedBalanceXmr }} XMR
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="mb-24" shadow="never">
<template #header>
<span>Withdraw all</span>
</template>
<el-form
ref="withdrawFormRef"
label-position="top"
:model="withdrawForm"
:rules="withdrawFormRules"
@submit.prevent="onWithdrawSubmit"
>
<el-form-item label="Destination address" prop="destinationAddress">
<el-input
v-model="withdrawForm.destinationAddress"
autocomplete="off"
:placeholder="`${walletStatus.network} Monero address`"
:disabled="withdrawing"
@input="withdrawFormRef?.clearValidate('destinationAddress')"
/>
</el-form-item>
<el-button type="primary" native-type="submit" :loading="withdrawing">
Withdraw all unlocked funds
</el-button>
</el-form>
</el-card>
<el-card shadow="never">
<template #header>
<span>Recovery seed</span>
</template>
<p class="m-0 mb-16 secondary-text">
This shop is non-custodial. You control the wallet seed. Anyone with the seed can spend all funds.
Store it offline and never share it.
</p>
<el-button type="danger" plain :loading="revealingSeed" @click="onRevealSeedClick">
Reveal seed
</el-button>
</el-card>
</template>
<el-dialog v-model="seedDialogVisible" title="Recovery seed" width="560px" destroy-on-close @closed="clearSeed">
<el-alert type="error" :closable="false" show-icon title="Store this offline. Do not share it." />
<el-input v-model="revealedMnemonic" class="mt-16" type="textarea" :rows="4" readonly autocomplete="off" />
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { computed, onBeforeMount, reactive, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { MoneroWalletSyncStatus } from '@/types/moneroWallet/MoneroWalletSyncStatus';
import { useMoneroWalletStore } from '@/stores/moneroWallet';
import { isMoneroStandardAddress } from '@/utils/monero/isMoneroStandardAddress';
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
const moneroWalletStore = useMoneroWalletStore();
const { status: walletStatus } = storeToRefs(moneroWalletStore);
const { fetchStatus, withdrawAll, revealSeed } = moneroWalletStore;
const loading = ref(true);
const loadError = ref(false);
const refreshing = ref(false);
const withdrawing = ref(false);
const revealingSeed = ref(false);
const withdrawFormRef = ref<FormInstance>();
const withdrawForm = reactive({
destinationAddress: ''
});
const seedDialogVisible = ref(false);
const revealedMnemonic = ref('');
onBeforeMount(async () => {
loading.value = true;
await loadWalletStatus();
loading.value = false;
});
const withdrawFormRules = computed<FormRules>(() => ({
destinationAddress: [
{
validator: (_rule, value, callback) => {
if (walletStatus.value?.syncStatus !== MoneroWalletSyncStatus.Synced) {
callback(new Error('Wait until the wallet finishes syncing before withdrawing'));
return;
}
if (typeof value !== 'string' || !value.trim()) {
callback(new Error('Enter a destination address'));
return;
}
const network = walletStatus.value?.network;
if (!network || !isMoneroStandardAddress(value, network)) {
callback(new Error(`Enter a valid ${network ?? 'Monero'} address.`));
return;
}
callback();
},
trigger: ['blur', 'change']
}
]
}));
const loadWalletStatus = async (): Promise<void> => {
loadError.value = false;
try {
await fetchStatus();
} catch (error) {
loadError.value = true;
ElMessage.error(resolveAxiosErrorMessage(error, 'Failed to load wallet status'));
}
};
const refreshStatus = async (): Promise<void> => {
refreshing.value = true;
try {
await loadWalletStatus();
ElMessage.success('Wallet status refreshed');
} finally {
refreshing.value = false;
}
};
const promptForPassword = async (title: string): Promise<string | null> => {
try {
const { value } = await ElMessageBox.prompt('Enter your CMS password to continue.', title, {
confirmButtonText: 'Continue',
cancelButtonText: 'Cancel',
inputType: 'password',
inputValidator: value => (value.trim().length > 0 ? true : 'Password is required')
});
return value.trim();
} catch {
return null;
}
};
const onWithdrawSubmit = async (): Promise<void> => {
const formEl = withdrawFormRef.value;
if (!formEl) {
return;
}
try {
await formEl.validate();
} catch {
return;
}
const trimmedAddress = withdrawForm.destinationAddress.trim();
try {
await ElMessageBox.confirm(`Withdraw all unlocked funds to:\n${trimmedAddress}`, 'Confirm withdrawal', {
confirmButtonText: 'Continue',
cancelButtonText: 'Cancel',
type: 'warning'
});
} catch {
return;
}
const password = await promptForPassword('Confirm withdrawal');
if (!password) {
return;
}
withdrawing.value = true;
try {
const result = await withdrawAll({
destinationAddress: trimmedAddress,
password
});
ElMessage.success(`Withdrew ${result.amountXmr} XMR`);
if (result.txHashes.length > 0) {
await ElMessageBox.alert(result.txHashes.join('\n'), 'Transaction hash(es)', {
confirmButtonText: 'OK'
});
}
withdrawForm.destinationAddress = '';
withdrawFormRef.value?.clearValidate();
await loadWalletStatus();
} catch (error) {
ElMessage.error({ message: resolveAxiosErrorMessage(error, 'Withdrawal failed'), duration: 5000 });
} finally {
withdrawing.value = false;
}
};
const onRevealSeedClick = async (): Promise<void> => {
try {
await ElMessageBox.confirm(
'The recovery seed grants full control over this shop wallet. Store it offline. Never share it or enter it on untrusted sites.',
'Reveal recovery seed?',
{
confirmButtonText: 'I understand',
cancelButtonText: 'Cancel',
type: 'warning'
}
);
} catch {
return;
}
const password = await promptForPassword('Reveal recovery seed');
if (!password) {
return;
}
revealingSeed.value = true;
try {
const result = await revealSeed({ password });
revealedMnemonic.value = result.mnemonic;
seedDialogVisible.value = true;
} catch (error) {
ElMessage.error(resolveAxiosErrorMessage(error, 'Could not reveal seed'));
} finally {
revealingSeed.value = false;
}
};
const clearSeed = (): void => {
revealedMnemonic.value = '';
};
const resolveMoneroWalletSyncStatusLabel = (syncStatus: MoneroWalletSyncStatus): string => {
switch (syncStatus) {
case MoneroWalletSyncStatus.Synced:
return 'Synced';
case MoneroWalletSyncStatus.Syncing:
return 'Syncing';
default:
return 'Unknown';
}
};
const resolveMoneroWalletSyncStatusTagType = (syncStatus: MoneroWalletSyncStatus): 'success' | 'warning' | 'info' => {
switch (syncStatus) {
case MoneroWalletSyncStatus.Synced:
return 'success';
case MoneroWalletSyncStatus.Syncing:
return 'warning';
default:
return 'info';
}
};
</script>