feat: Add comprehensive staking interface to PaperclipWallet
- Add staking RPC methods for validators, delegation, and rewards - Implement complete staking UI with validator selection and delegation - Add reward claiming functionality and validator creation interface - Include professional staking dashboard with real-time data - Integrate staking navigation into existing wallet interface
This commit is contained in:
@@ -25,6 +25,9 @@ class MainGUI {
|
||||
case "transactions":
|
||||
$("#mainNavBtnTransactionsWrapper").addClass("iconSelected");
|
||||
break;
|
||||
case "staking":
|
||||
$("#mainNavBtnStakingWrapper").addClass("iconSelected");
|
||||
break;
|
||||
case "markets":
|
||||
$("#mainNavBtnMarketsWrapper").addClass("iconSelected");
|
||||
break;
|
||||
@@ -122,6 +125,11 @@ $("#mainNavBtnTransactions").click(function () {
|
||||
EthoTransactions.renderTransactions();
|
||||
});
|
||||
|
||||
$("#mainNavBtnStaking").click(function () {
|
||||
EthoMainGUI.changeAppState("staking");
|
||||
PaperclipStaking.showStakingPage();
|
||||
});
|
||||
|
||||
$("#mainNavBtnAddressBoook").click(function () {
|
||||
EthoMainGUI.changeAppState("addressBook");
|
||||
EthoAddressBook.renderAddressBook();
|
||||
|
||||
520
renderer/staking.js
Normal file
520
renderer/staking.js
Normal file
@@ -0,0 +1,520 @@
|
||||
// PaperclipChain Staking Interface
|
||||
// Handles all staking-related functionality in the wallet
|
||||
|
||||
const { ipcRenderer } = require('electron');
|
||||
|
||||
class StakingManager {
|
||||
constructor() {
|
||||
this.validators = [];
|
||||
this.stakingInfo = null;
|
||||
this.totalStaked = 0;
|
||||
this.pendingRewards = 0;
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
|
||||
async init() {
|
||||
console.log('Initializing staking manager...');
|
||||
await this.loadStakingData();
|
||||
this.setupEventHandlers();
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
async loadStakingData() {
|
||||
try {
|
||||
// Load validators
|
||||
this.validators = await ipcRenderer.invoke('paperclip-get-validators');
|
||||
|
||||
// Load total staked
|
||||
this.totalStaked = await ipcRenderer.invoke('paperclip-get-total-staked');
|
||||
|
||||
// Load user staking info if wallet is selected
|
||||
if (ClipsWallet.addressList.length > 0) {
|
||||
const currentAddress = ClipsWallet.addressList[0]; // Use first wallet as current
|
||||
this.stakingInfo = await ipcRenderer.invoke('paperclip-get-staking-info', currentAddress);
|
||||
this.pendingRewards = await ipcRenderer.invoke('paperclip-get-staking-rewards', currentAddress);
|
||||
}
|
||||
|
||||
this.updateUI();
|
||||
} catch (error) {
|
||||
console.error('Failed to load staking data:', error);
|
||||
this.showError('Failed to load staking data: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
// Update overview cards
|
||||
$('#totalStakedAmount').text(this.formatClips(this.totalStaked));
|
||||
$('#pendingRewards').text(this.formatClips(this.pendingRewards));
|
||||
$('#activeValidators').text(this.validators.filter(v => v.active).length);
|
||||
|
||||
// Update validators table
|
||||
this.updateValidatorsTable();
|
||||
|
||||
// Update progress bars
|
||||
this.updateProgressBars();
|
||||
}
|
||||
|
||||
updateValidatorsTable() {
|
||||
const tbody = $('#validatorsTableBody');
|
||||
tbody.empty();
|
||||
|
||||
this.validators.forEach(validator => {
|
||||
const apy = this.calculateValidatorAPY(validator);
|
||||
const userStake = this.getUserStakeForValidator(validator.address);
|
||||
const status = validator.active ?
|
||||
'<span class="badge badge-success">Active</span>' :
|
||||
'<span class="badge badge-danger">Inactive</span>';
|
||||
|
||||
const row = `
|
||||
<tr data-validator="${validator.address}">
|
||||
<td>
|
||||
<div class="validator-info">
|
||||
<strong>${validator.address.substring(0, 12)}...</strong>
|
||||
<br><small class="text-muted">${validator.address}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>${status}</td>
|
||||
<td>${this.formatClips(validator.stakedAmount || 0)}</td>
|
||||
<td>${this.formatClips(validator.delegatedAmount || 0)}</td>
|
||||
<td>${(validator.commission / 100).toFixed(2)}%</td>
|
||||
<td>~${apy.toFixed(2)}%</td>
|
||||
<td>${this.formatClips(userStake)}</td>
|
||||
<td>
|
||||
<div class="btn-group-sm">
|
||||
<button class="btn btn-sm btn-clips btn-delegate" data-validator="${validator.address}" ${!validator.active ? 'disabled' : ''}>
|
||||
Delegate
|
||||
</button>
|
||||
${userStake > 0 ? `<button class="btn btn-sm btn-warning btn-undelegate" data-validator="${validator.address}">Undelegate</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
tbody.append(row);
|
||||
});
|
||||
|
||||
// Initialize DataTable if not already initialized
|
||||
if (!$.fn.DataTable.isDataTable('#validatorsTable')) {
|
||||
$('#validatorsTable').DataTable({
|
||||
pageLength: 10,
|
||||
responsive: true,
|
||||
order: [[2, 'desc']] // Sort by self stake desc
|
||||
});
|
||||
} else {
|
||||
$('#validatorsTable').DataTable().draw();
|
||||
}
|
||||
}
|
||||
|
||||
updateProgressBars() {
|
||||
// Calculate total network stake percentage (mock calculation)
|
||||
const networkSupply = 1000000000; // 1B CLIPS total supply (mock)
|
||||
const stakedPercent = (this.totalStaked / networkSupply) * 100;
|
||||
$('#totalStakedProgress').css('width', Math.min(stakedPercent, 100) + '%');
|
||||
|
||||
// Update rewards progress (mock)
|
||||
const rewardsPercent = Math.min((this.pendingRewards / 1000) * 100, 100);
|
||||
$('#rewardsProgress').css('width', rewardsPercent + '%');
|
||||
}
|
||||
|
||||
setupEventHandlers() {
|
||||
// Navigation
|
||||
$('#mainNavBtnStaking').off('click').on('click', () => {
|
||||
this.showStakingPage();
|
||||
});
|
||||
|
||||
// Refresh button
|
||||
$('#btnRefreshValidators').off('click').on('click', () => {
|
||||
this.loadStakingData();
|
||||
});
|
||||
|
||||
// Quick action buttons
|
||||
$('#btnDelegate').off('click').on('click', () => {
|
||||
this.showDelegateModal();
|
||||
});
|
||||
|
||||
$('#btnUndelegate').off('click').on('click', () => {
|
||||
this.showUndelegateModal();
|
||||
});
|
||||
|
||||
$('#btnClaimRewards').off('click').on('click', () => {
|
||||
this.claimRewards();
|
||||
});
|
||||
|
||||
$('#btnCreateValidator').off('click').on('click', () => {
|
||||
this.showCreateValidatorModal();
|
||||
});
|
||||
|
||||
// Table action buttons (delegated event handling)
|
||||
$(document).off('click', '.btn-delegate').on('click', '.btn-delegate', (e) => {
|
||||
const validatorAddress = $(e.target).data('validator');
|
||||
this.showDelegateModal(validatorAddress);
|
||||
});
|
||||
|
||||
$(document).off('click', '.btn-undelegate').on('click', '.btn-undelegate', (e) => {
|
||||
const validatorAddress = $(e.target).data('validator');
|
||||
this.showUndelegateModal(validatorAddress);
|
||||
});
|
||||
|
||||
// Modal handlers
|
||||
$('#btnDelegateConfirm').off('click').on('click', () => {
|
||||
this.performDelegation();
|
||||
});
|
||||
|
||||
$('#btnUndelegateConfirm').off('click').on('click', () => {
|
||||
this.performUndelegation();
|
||||
});
|
||||
|
||||
$('#btnCreateValidatorConfirm').off('click').on('click', () => {
|
||||
this.createValidator();
|
||||
});
|
||||
|
||||
// Modal cancel buttons
|
||||
$('#btnDelegateCancel, #btnUndelegateCancel, #btnCreateValidatorCancel').off('click').on('click', function() {
|
||||
$(this).closest('.modalDialog').iziModal('close');
|
||||
});
|
||||
}
|
||||
|
||||
showStakingPage() {
|
||||
EthoMainGUI.renderTemplate('staking.html', {});
|
||||
this.loadStakingData();
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
showDelegateModal(validatorAddress = null) {
|
||||
// Populate validator dropdown
|
||||
const validatorSelect = $('#delegateValidator');
|
||||
validatorSelect.empty().append('<option value="">Select a validator...</option>');
|
||||
|
||||
this.validators.filter(v => v.active).forEach(validator => {
|
||||
const option = `<option value="${validator.address}">${validator.address.substring(0, 20)}... (${(validator.commission / 100).toFixed(2)}% commission)</option>`;
|
||||
validatorSelect.append(option);
|
||||
});
|
||||
|
||||
if (validatorAddress) {
|
||||
validatorSelect.val(validatorAddress);
|
||||
}
|
||||
|
||||
// Populate wallet dropdown
|
||||
this.populateWalletDropdown('#delegateWallet');
|
||||
|
||||
// Update available balance
|
||||
this.updateAvailableBalance('#availableBalance');
|
||||
|
||||
$('#dlgDelegate').iziModal('open');
|
||||
}
|
||||
|
||||
showUndelegateModal(validatorAddress = null) {
|
||||
// Populate validator dropdown with only validators user has stake with
|
||||
const validatorSelect = $('#undelegateValidator');
|
||||
validatorSelect.empty().append('<option value="">Select a validator...</option>');
|
||||
|
||||
this.validators.forEach(validator => {
|
||||
const userStake = this.getUserStakeForValidator(validator.address);
|
||||
if (userStake > 0) {
|
||||
const option = `<option value="${validator.address}">${validator.address.substring(0, 20)}... (${this.formatClips(userStake)} staked)</option>`;
|
||||
validatorSelect.append(option);
|
||||
}
|
||||
});
|
||||
|
||||
if (validatorAddress) {
|
||||
validatorSelect.val(validatorAddress);
|
||||
this.updateYourStakeAmount(validatorAddress);
|
||||
}
|
||||
|
||||
// Populate wallet dropdown
|
||||
this.populateWalletDropdown('#undelegateWallet');
|
||||
|
||||
// Event handler for validator selection change
|
||||
validatorSelect.off('change').on('change', (e) => {
|
||||
this.updateYourStakeAmount(e.target.value);
|
||||
});
|
||||
|
||||
$('#dlgUndelegate').iziModal('open');
|
||||
}
|
||||
|
||||
showCreateValidatorModal() {
|
||||
// Populate wallet dropdown
|
||||
this.populateWalletDropdown('#validatorWallet');
|
||||
|
||||
$('#dlgCreateValidator').iziModal('open');
|
||||
}
|
||||
|
||||
async performDelegation() {
|
||||
try {
|
||||
const validatorAddress = $('#delegateValidator').val();
|
||||
const amount = parseFloat($('#delegateAmount').val());
|
||||
const walletAddress = $('#delegateWallet').val();
|
||||
|
||||
if (!validatorAddress || !amount || !walletAddress || amount < 100) {
|
||||
this.showError('Please fill all fields correctly. Minimum delegation is 100 CLIPS.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ClipsWallet.getAddressExists(walletAddress)) {
|
||||
this.showError('Selected wallet not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create delegation transaction
|
||||
const txData = {
|
||||
sender: walletAddress,
|
||||
receiver: validatorAddress,
|
||||
amount: Math.floor(amount * 1000000), // Convert to micro-CLIPS
|
||||
txType: 'delegate',
|
||||
gas: 30000,
|
||||
gasPrice: 1
|
||||
};
|
||||
|
||||
// Sign and broadcast transaction
|
||||
const result = await this.sendStakingTransaction(txData, { address: walletAddress });
|
||||
|
||||
if (result.success) {
|
||||
this.showSuccess('Delegation successful!');
|
||||
$('#dlgDelegate').iziModal('close');
|
||||
await this.loadStakingData();
|
||||
} else {
|
||||
this.showError('Delegation failed: ' + result.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Delegation failed:', error);
|
||||
this.showError('Delegation failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async performUndelegation() {
|
||||
try {
|
||||
const validatorAddress = $('#undelegateValidator').val();
|
||||
const amount = parseFloat($('#undelegateAmount').val());
|
||||
const walletAddress = $('#undelegateWallet').val();
|
||||
|
||||
if (!validatorAddress || !amount || !walletAddress || amount <= 0) {
|
||||
this.showError('Please fill all fields correctly.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ClipsWallet.getAddressExists(walletAddress)) {
|
||||
this.showError('Selected wallet not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create undelegation transaction
|
||||
const txData = {
|
||||
sender: walletAddress,
|
||||
receiver: validatorAddress,
|
||||
amount: Math.floor(amount * 1000000), // Convert to micro-CLIPS
|
||||
txType: 'undelegate',
|
||||
gas: 30000,
|
||||
gasPrice: 1
|
||||
};
|
||||
|
||||
// Sign and broadcast transaction
|
||||
const result = await this.sendStakingTransaction(txData, { address: walletAddress });
|
||||
|
||||
if (result.success) {
|
||||
this.showSuccess('Undelegation successful!');
|
||||
$('#dlgUndelegate').iziModal('close');
|
||||
await this.loadStakingData();
|
||||
} else {
|
||||
this.showError('Undelegation failed: ' + result.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Undelegation failed:', error);
|
||||
this.showError('Undelegation failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async createValidator() {
|
||||
try {
|
||||
const stakeAmount = parseFloat($('#validatorStake').val());
|
||||
const commission = parseFloat($('#validatorCommission').val());
|
||||
const walletAddress = $('#validatorWallet').val();
|
||||
|
||||
if (!stakeAmount || !walletAddress || stakeAmount < 10000 || commission < 0 || commission > 50) {
|
||||
this.showError('Please fill all fields correctly. Minimum stake is 10,000 CLIPS, commission must be 0-50%.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ClipsWallet.getAddressExists(walletAddress)) {
|
||||
this.showError('Selected wallet not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create validator creation transaction
|
||||
const txData = {
|
||||
sender: walletAddress,
|
||||
receiver: '',
|
||||
amount: Math.floor(stakeAmount * 1000000), // Convert to micro-CLIPS
|
||||
txType: 'create_validator',
|
||||
gas: 30000,
|
||||
gasPrice: 1,
|
||||
data: JSON.stringify({ commission: Math.floor(commission * 100) }) // Convert to basis points
|
||||
};
|
||||
|
||||
// Sign and broadcast transaction
|
||||
const result = await this.sendStakingTransaction(txData, { address: walletAddress });
|
||||
|
||||
if (result.success) {
|
||||
this.showSuccess('Validator created successfully!');
|
||||
$('#dlgCreateValidator').iziModal('close');
|
||||
await this.loadStakingData();
|
||||
} else {
|
||||
this.showError('Validator creation failed: ' + result.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Validator creation failed:', error);
|
||||
this.showError('Validator creation failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async claimRewards() {
|
||||
try {
|
||||
if (ClipsWallet.addressList.length === 0) {
|
||||
this.showError('No wallet available.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pendingRewards <= 0) {
|
||||
this.showError('No rewards to claim.');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentAddress = ClipsWallet.addressList[0];
|
||||
|
||||
// Create claim rewards transaction
|
||||
const txData = {
|
||||
sender: currentAddress,
|
||||
receiver: '',
|
||||
amount: 0,
|
||||
txType: 'claim_rewards',
|
||||
gas: 30000,
|
||||
gasPrice: 1
|
||||
};
|
||||
|
||||
// Sign and broadcast transaction
|
||||
const result = await this.sendStakingTransaction(txData, { address: currentAddress });
|
||||
|
||||
if (result.success) {
|
||||
this.showSuccess('Rewards claimed successfully!');
|
||||
await this.loadStakingData();
|
||||
} else {
|
||||
this.showError('Claim failed: ' + result.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Claim failed:', error);
|
||||
this.showError('Claim failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async sendStakingTransaction(txData, wallet) {
|
||||
try {
|
||||
// Get current nonce
|
||||
const nonce = await ipcRenderer.invoke('paperclip-get-nonce', wallet.address);
|
||||
txData.nonce = nonce + 1;
|
||||
|
||||
// Sign transaction (this would use the actual wallet signing)
|
||||
const signedTx = await ClipsWallet.signTransaction(txData, wallet);
|
||||
|
||||
// Broadcast transaction
|
||||
const result = await ipcRenderer.invoke('paperclip-broadcast-tx', signedTx);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
populateWalletDropdown(selector) {
|
||||
const walletSelect = $(selector);
|
||||
walletSelect.empty();
|
||||
|
||||
ClipsWallet.addressList.forEach((address, index) => {
|
||||
const option = `<option value="${address}">Wallet ${index + 1} (${address.substring(0, 12)}...)</option>`;
|
||||
walletSelect.append(option);
|
||||
});
|
||||
}
|
||||
|
||||
async updateAvailableBalance(selector) {
|
||||
if (ClipsWallet.addressList.length > 0) {
|
||||
try {
|
||||
const currentAddress = ClipsWallet.addressList[0];
|
||||
const balance = await ipcRenderer.invoke('paperclip-get-balance', currentAddress);
|
||||
$(selector).text(this.formatClips(balance));
|
||||
} catch (error) {
|
||||
$(selector).text('0 CLIPS');
|
||||
}
|
||||
} else {
|
||||
$(selector).text('0 CLIPS');
|
||||
}
|
||||
}
|
||||
|
||||
updateYourStakeAmount(validatorAddress) {
|
||||
const userStake = this.getUserStakeForValidator(validatorAddress);
|
||||
$('#yourStakeAmount').text(this.formatClips(userStake));
|
||||
}
|
||||
|
||||
getUserStakeForValidator(validatorAddress) {
|
||||
if (!this.stakingInfo || !this.stakingInfo.delegations) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const delegation = this.stakingInfo.delegations.find(d => d.validator === validatorAddress);
|
||||
return delegation ? delegation.amount : 0;
|
||||
}
|
||||
|
||||
calculateValidatorAPY(validator) {
|
||||
// Simplified APY calculation
|
||||
const baseAPY = 8.0; // 8% base APY
|
||||
const totalStake = (validator.stakedAmount || 0) + (validator.delegatedAmount || 0);
|
||||
if (totalStake === 0) return 0;
|
||||
|
||||
// Adjust APY based on commission
|
||||
const adjustedAPY = baseAPY * (1 - (validator.commission || 0) / 10000);
|
||||
return Math.max(adjustedAPY, 0);
|
||||
}
|
||||
|
||||
formatClips(microClips) {
|
||||
return (microClips / 1000000).toFixed(6) + ' CLIPS';
|
||||
}
|
||||
|
||||
startAutoRefresh() {
|
||||
// Refresh staking data every 30 seconds
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.loadStakingData();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
stopAutoRefresh() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
iziToast.error({
|
||||
title: 'Staking Error',
|
||||
message: message,
|
||||
position: 'topRight'
|
||||
});
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
iziToast.success({
|
||||
title: 'Staking Success',
|
||||
message: message,
|
||||
position: 'topRight'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize staking manager
|
||||
const PaperclipStaking = new StakingManager();
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = PaperclipStaking;
|
||||
}
|
||||
Reference in New Issue
Block a user