Add Bitcoin wallet withdraw and seed reveal UI in CMS.

Wire admin actions to the backend API with client-side address validation and configurable max fee rate.
This commit is contained in:
2026-09-07 02:08:17 +02:00
parent 626f2ea4f0
commit de52534271
7 changed files with 316 additions and 5 deletions
+254 -4
View File
@@ -7,7 +7,7 @@
<el-alert type="warning" :closable="false" show-icon title="Wallet is syncing. Please wait." /> <el-alert type="warning" :closable="false" show-icon title="Wallet is syncing. Please wait." />
</div> </div>
<el-card shadow="never"> <el-card class="mb-24" shadow="never">
<template #header> <template #header>
<div class="flex items-center justify-between gap-16"> <div class="flex items-center justify-between gap-16">
<span>Status</span> <span>Status</span>
@@ -35,29 +35,105 @@
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </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"
class="withdraw-address-input"
autocomplete="off"
:placeholder="`${walletStatus.network} Bitcoin address`"
:disabled="withdrawing"
@input="withdrawFormRef?.clearValidate('destinationAddress')"
/>
</el-form-item>
<el-form-item label="Fee rate (sat/vbyte)" prop="feeRateSatVbyte">
<el-input-number
v-model="withdrawForm.feeRateSatVbyte"
class="fee-rate-input"
:min="1"
:max="maxBitcoinWithdrawFeeRateSatVbyte"
:step="1"
:disabled="withdrawing"
/>
</el-form-item>
<el-button type="primary" native-type="submit" :loading="withdrawing">
Withdraw all confirmed 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> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage } from 'element-plus'; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { onBeforeMount, ref } from 'vue'; import { computed, onBeforeMount, reactive, ref } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { config } from '@/config';
import { WalletSyncStatus } from '@/types/wallet/WalletSyncStatus'; import { WalletSyncStatus } from '@/types/wallet/WalletSyncStatus';
import { useBitcoinWalletStore } from '@/stores/bitcoinWallet'; import { useBitcoinWalletStore } from '@/stores/bitcoinWallet';
import { isBitcoinAddress } from '@/utils/bitcoin/isBitcoinAddress';
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage'; import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
import { resolveWalletSyncStatusLabel } from '@/utils/wallet/resolveWalletSyncStatusLabel'; import { resolveWalletSyncStatusLabel } from '@/utils/wallet/resolveWalletSyncStatusLabel';
import { resolveWalletSyncStatusTagType } from '@/utils/wallet/resolveWalletSyncStatusTagType'; import { resolveWalletSyncStatusTagType } from '@/utils/wallet/resolveWalletSyncStatusTagType';
const bitcoinWalletStore = useBitcoinWalletStore(); const bitcoinWalletStore = useBitcoinWalletStore();
const {
validation: { bitcoinWithdrawMaxFeeRateSatVbyte: maxBitcoinWithdrawFeeRateSatVbyte }
} = config;
const { status: walletStatus } = storeToRefs(bitcoinWalletStore); const { status: walletStatus } = storeToRefs(bitcoinWalletStore);
const { fetchStatus } = bitcoinWalletStore; const { fetchStatus, withdrawAll, revealSeed } = bitcoinWalletStore;
const loading = ref(true); const loading = ref(true);
const loadError = ref(false); const loadError = ref(false);
const refreshing = ref(false); const refreshing = ref(false);
const withdrawing = ref(false);
const revealingSeed = ref(false);
const withdrawFormRef = ref<FormInstance>();
const withdrawForm = reactive({
destinationAddress: '',
feeRateSatVbyte: 3
});
const seedDialogVisible = ref(false);
const revealedMnemonic = ref('');
onBeforeMount(async () => { onBeforeMount(async () => {
loading.value = true; loading.value = true;
@@ -67,6 +143,53 @@ onBeforeMount(async () => {
loading.value = false; loading.value = false;
}); });
const withdrawFormRules = computed<FormRules>(() => ({
destinationAddress: [
{
validator: (_rule, value, callback) => {
if (walletStatus.value?.syncStatus !== WalletSyncStatus.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 || !isBitcoinAddress(value, network)) {
callback(new Error(`Enter a valid ${network ?? 'Bitcoin'} address.`));
return;
}
callback();
},
trigger: ['blur', 'change']
}
],
feeRateSatVbyte: [
{
validator: (_rule, value, callback) => {
if (!Number.isInteger(value) || value < 1 || value > maxBitcoinWithdrawFeeRateSatVbyte) {
callback(
new Error(`Enter a fee rate between 1 and ${maxBitcoinWithdrawFeeRateSatVbyte} sat/vbyte`)
);
return;
}
callback();
},
trigger: ['blur', 'change']
}
]
}));
const loadWalletStatus = async (): Promise<void> => { const loadWalletStatus = async (): Promise<void> => {
loadError.value = false; loadError.value = false;
@@ -90,4 +213,131 @@ const refreshStatus = async (): Promise<void> => {
refreshing.value = false; 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 confirmed funds at ${withdrawForm.feeRateSatVbyte} sat/vbyte 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,
feeRateSatVbyte: withdrawForm.feeRateSatVbyte,
password
});
ElMessage.success(`Withdrew ${result.amountBtc} BTC`);
await ElMessageBox.alert(result.txHash, 'Transaction hash', {
confirmButtonText: 'OK'
});
withdrawFormRef.value?.resetFields();
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 with anyone.',
'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) {
const errorMessage = resolveAxiosErrorMessage(error, 'Could not reveal seed');
ElMessage.error(errorMessage);
} finally {
revealingSeed.value = false;
}
};
const clearSeed = (): void => {
revealedMnemonic.value = '';
};
</script> </script>
<style scoped>
.withdraw-address-input {
width: 100%;
max-width: min(560px, 100%);
}
.fee-rate-input {
width: 120px;
}
</style>
+19 -1
View File
@@ -1,7 +1,11 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import { api } from '@/plugins/axios'; import { api } from '@/plugins/axios';
import type { BitcoinWalletRevealSeedPayload } from '@/types/bitcoinWallet/BitcoinWalletRevealSeedPayload';
import type { BitcoinWalletRevealSeedResult } from '@/types/bitcoinWallet/BitcoinWalletRevealSeedResult';
import type { BitcoinWalletStatus } from '@/types/bitcoinWallet/BitcoinWalletStatus'; import type { BitcoinWalletStatus } from '@/types/bitcoinWallet/BitcoinWalletStatus';
import type { BitcoinWalletWithdrawPayload } from '@/types/bitcoinWallet/BitcoinWalletWithdrawPayload';
import type { BitcoinWalletWithdrawResult } from '@/types/bitcoinWallet/BitcoinWalletWithdrawResult';
export const useBitcoinWalletStore = defineStore('bitcoinWallet', () => { export const useBitcoinWalletStore = defineStore('bitcoinWallet', () => {
const status = ref<BitcoinWalletStatus | null>(null); const status = ref<BitcoinWalletStatus | null>(null);
@@ -14,8 +18,22 @@ export const useBitcoinWalletStore = defineStore('bitcoinWallet', () => {
return data; return data;
}; };
const withdrawAll = async (payload: BitcoinWalletWithdrawPayload): Promise<BitcoinWalletWithdrawResult> => {
const { data } = await api.post<BitcoinWalletWithdrawResult>('/bitcoin-wallet/withdraw', payload);
return data;
};
const revealSeed = async (payload: BitcoinWalletRevealSeedPayload): Promise<BitcoinWalletRevealSeedResult> => {
const { data } = await api.post<BitcoinWalletRevealSeedResult>('/bitcoin-wallet/reveal-seed', payload);
return data;
};
return { return {
status, status,
fetchStatus fetchStatus,
withdrawAll,
revealSeed
}; };
}); });
@@ -0,0 +1,3 @@
export interface BitcoinWalletRevealSeedPayload {
password: string;
}
@@ -0,0 +1,3 @@
export interface BitcoinWalletRevealSeedResult {
mnemonic: string;
}
@@ -0,0 +1,5 @@
export interface BitcoinWalletWithdrawPayload {
destinationAddress: string;
feeRateSatVbyte: number;
password: string;
}
@@ -0,0 +1,4 @@
export interface BitcoinWalletWithdrawResult {
txHash: string;
amountBtc: string;
}
+28
View File
@@ -0,0 +1,28 @@
import type { BitcoinNetwork } from '@/types/bitcoinWallet/BitcoinNetwork';
// Soft validation only: prefix/length checks to reject obvious garbage and wrong-network
// addresses early. Checksums and spendability are validated by Electrum on payto.
const BASE58 = '[1-9A-HJ-NP-Za-km-z]';
const NETWORK_ADDRESS_PATTERNS: Record<BitcoinNetwork, RegExp[]> = {
mainnet: [
new RegExp(`^1${BASE58}{25,34}$`),
new RegExp(`^3${BASE58}{25,34}$`),
/^bc1[a-z0-9]{25,87}$/
],
testnet4: [
new RegExp(`^[mn]${BASE58}{25,34}$`),
new RegExp(`^2${BASE58}{25,34}$`),
/^(?:tb1|bcrt1)[a-z0-9]{25,87}$/
]
};
export const isBitcoinAddress = (value: unknown, network: BitcoinNetwork): boolean => {
if (typeof value !== 'string') {
return false;
}
const trimmed = value.trim();
return NETWORK_ADDRESS_PATTERNS[network].some(pattern => pattern.test(trimmed));
};