diff --git a/docker/Dockerfile b/docker/Dockerfile index 6c05a767..af6ed03f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -53,7 +53,7 @@ WORKDIR /app # Set to production ENV NODE_ENV=production \ - PORT=3000 \ + PORT=8080 \ HOSTNAME="0.0.0.0" # Copy built files from builder @@ -68,11 +68,11 @@ RUN chown -R nextjs:nodejs /app USER nextjs # Expose port -EXPOSE 3000 +EXPOSE 8080 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD wget -q --spider http://localhost:3000/api/health || exit 1 + CMD wget -q --spider http://localhost:8080/api/health || exit 1 # Use dumb-init to handle signals properly ENTRYPOINT ["dumb-init", "--"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 04150d4b..84e8d717 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -16,12 +16,12 @@ services: restart: unless-stopped ports: - - "${PORT:-3000}:3000" + - "${HOST_PORT:-3000}:8080" environment: # Application - NODE_ENV=production - - PORT=3000 + - PORT=8080 # Steem Blockchain - STEEM_RPC_URL=${STEEM_RPC_URL:-https://api.steemit.com} @@ -57,7 +57,7 @@ services: # Health check healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"] + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/health"] interval: 30s timeout: 10s retries: 3 diff --git a/package.json b/package.json index 9759f989..9223d27f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --hostname 0.0.0.0 --port 3000", "build": "next build", "start": "next start", "lint": "eslint", diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 41f6cb28..e06664a1 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -19,8 +19,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: 'Steem Wallet', - description: 'Modern Steem blockchain wallet', + title: 'Steemit Wallet', + description: 'Steemit Wallet is an online wallet for managing Steem accounts.', icons: { icon: [ { url: '/favicon.ico' }, diff --git a/src/components/wallet/balance-rows.tsx b/src/components/wallet/balance-rows.tsx index b37306ae..1339ad8b 100644 --- a/src/components/wallet/balance-rows.tsx +++ b/src/components/wallet/balance-rows.tsx @@ -166,9 +166,7 @@ export function BalanceRows({ }, { label: 'Power Up', - walletAction: 'transfer', - asset: 'STEEM', - type: 'power_up', + walletAction: 'powerUp', }, { label: 'Trade', external: true, href: 'https://www.poloniex.com/zh-CN/trade/STEEM_USDT?type=spot' }, { label: 'Market', link: '/market' }, @@ -205,6 +203,9 @@ export function BalanceRows({ items={[ { label: 'Delegate', walletAction: 'delegate' }, { label: 'Power Down', walletAction: 'powerDown' }, + ...(isPoweringDown + ? [{ label: 'Cancel Power Down', walletAction: 'cancelPowerDown' as const }] + : []), { label: 'Advanced Routes', walletAction: 'advanced' }, ]} /> @@ -334,7 +335,7 @@ export function BalanceRows({ ? new Date(balance.next_vesting_withdrawal).toLocaleDateString() : '---'} {' '} - (~{powerDownRate} STEEM). + (~{powerDownRate} SP). )} diff --git a/src/components/wallet/cancel-power-down-handler.tsx b/src/components/wallet/cancel-power-down-handler.tsx new file mode 100644 index 00000000..68029752 --- /dev/null +++ b/src/components/wallet/cancel-power-down-handler.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useSelector } from 'react-redux'; +import type { RootState } from '@/lib/store'; +import { useActiveSigningKey } from '@/hooks/use-auth'; +import { SteemSigner, apiClient } from '@/lib/steem/client'; + +export interface CancelPowerDownHandlerProps { + onSuccess: () => void; + onCancel: () => void; +} + +/** Immediately broadcasts cancel power down (0 VESTS), matching wallet-legacy menu behavior. */ +export function CancelPowerDownHandler({ onSuccess, onCancel }: CancelPowerDownHandlerProps) { + const t = useTranslations('powerDown'); + const tCommon = useTranslations('common'); + const username = useSelector((state: RootState) => state.auth.username); + const signingKey = useActiveSigningKey(); + const [error, setError] = useState(null); + const started = useRef(false); + + useEffect(() => { + if (started.current || !username || !signingKey) return; + started.current = true; + + void (async () => { + try { + const signedTx = await SteemSigner.signPowerDown(username, '0.000000 VESTS', signingKey); + const response = await apiClient.broadcastPowerDown(signedTx, username); + if (!response.success) { + setError(response.error || t('cancelError')); + return; + } + onSuccess(); + } catch (err) { + console.error('Cancel power down error:', err); + setError(t('cancelError')); + } + })(); + }, [username, signingKey, onSuccess, t]); + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return

{tCommon('loading')}

; +} diff --git a/src/components/wallet/delegate-form.tsx b/src/components/wallet/delegate-form.tsx index 86d58f89..49664ee1 100644 --- a/src/components/wallet/delegate-form.tsx +++ b/src/components/wallet/delegate-form.tsx @@ -6,7 +6,9 @@ import { useTranslations } from 'next-intl'; import { useSelector } from 'react-redux'; import type { RootState } from '@/lib/store'; import { useActiveSigningKey } from '@/hooks/use-auth'; +import { useGlobalProps } from '@/hooks/use-global-props'; import { SteemSigner, apiClient } from '@/lib/steem/client'; +import { steemPowerToVestsAsset } from '@/lib/wallet/vest-steem'; import { Button } from '@/components/ui/button'; import { ModalFormActions, @@ -31,11 +33,13 @@ export function DelegateForm({ onCancel, }: DelegateFormProps) { const t = useTranslations('wallet'); + const tDelegations = useTranslations('delegations'); const tCommon = useTranslations('common'); const router = useRouter(); const username = useSelector((state: RootState) => state.auth.username); const signingKey = useActiveSigningKey(); const [isPending, startTransition] = useTransition(); + const { globalProps, loading: globalPropsLoading } = useGlobalProps(); const [delegatee, setDelegatee] = useState(''); const [shares, setShares] = useState(''); @@ -53,26 +57,32 @@ export function DelegateForm({ return; } + if (!globalProps) { + setError('Unable to load chain properties. Please try again.'); + setIsLoading(false); + return; + } + try { if (!delegatee.trim()) { - setError('Please enter a delegatee username'); + setError(tDelegations('invalidDelegatee')); setIsLoading(false); return; } const shareValue = parseFloat(shares); if (!shares || isNaN(shareValue) || shareValue <= 0) { - setError('Please enter a valid amount'); + setError(tDelegations('invalidAmount')); setIsLoading(false); return; } - const vests = `${shareValue.toFixed(6)} VESTS`; + const vests = steemPowerToVestsAsset(shareValue, globalProps); const signedTx = await SteemSigner.signDelegate(username, delegatee.trim(), vests, signingKey); const response = await apiClient.broadcastDelegate(signedTx, username); if (!response.success) { - setError(response.error || 'Failed to delegate'); + setError(response.error || tDelegations('delegateError')); setIsLoading(false); return; } @@ -86,7 +96,7 @@ export function DelegateForm({ }); } catch (err) { console.error('Delegate error:', err); - setError('Failed to process delegation'); + setError(tDelegations('delegateError')); setIsLoading(false); } }; @@ -100,7 +110,7 @@ export function DelegateForm({
setDelegatee(e.target.value)} required - placeholder="Enter username to delegate to" + placeholder={tDelegations('delegateePlaceholder')} disabled={isLoading || isPending} />
setShares(e.target.value)} - step="0.000001" + step="0.001" min="0" required - placeholder="Enter VESTS amount" - disabled={isLoading || isPending} + placeholder={tDelegations('spPlaceholder')} + disabled={isLoading || isPending || globalPropsLoading} /> -

Use format: 6 decimal places (e.g., 1000000.000000)

+

{tDelegations('formatHint')}

{error && ( @@ -140,10 +150,10 @@ export function DelegateForm({ - {isPoweringDown && ( - - )} - -
- + {note.text} + + ))} + + )} + + + + + ); if (variant === 'dialog') { return (
-

{t('powerDown')}

+

{t('title')}

{formInner}
); @@ -203,7 +292,7 @@ export function PowerDownForm({ variant = 'page', onSuccess }: PowerDownFormProp
- {t('powerDown')} + {tWallet('powerDown')} {formInner} diff --git a/src/components/wallet/power-up-form.tsx b/src/components/wallet/power-up-form.tsx new file mode 100644 index 00000000..c21993c5 --- /dev/null +++ b/src/components/wallet/power-up-form.tsx @@ -0,0 +1,313 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useSelector } from 'react-redux'; +import type { RootState } from '@/lib/store'; +import { useActiveSigningKey } from '@/hooks/use-auth'; +import { useAccountData } from '@/hooks/use-account-data'; +import { SteemSigner, apiClient } from '@/lib/steem/client'; +import { Button } from '@/components/ui/button'; +import { + ModalFormActions, + modalFormActionButtonClassName, +} from '@/components/ui/modal-form-actions'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { transfersPathForUsername } from '@/lib/wallet/wallet-modal-search-params'; + +export type PowerUpFormVariant = 'page' | 'dialog'; + +export interface PowerUpFormProps { + variant?: PowerUpFormVariant; + onSuccess?: () => void; + onCancel?: () => void; +} + +const STEEM_ACCOUNT_RE = /^[a-z][a-z0-9.-]{2,15}$/; + +function parseSteemBalance(assetStr: string | undefined): number { + if (!assetStr) return 0; + const m = assetStr.match(/^([\d.]+)/); + return m && m[1] ? parseFloat(m[1]) : 0; +} + +function countDecimals(value: string): number { + const parts = value.split('.'); + return parts.length > 1 && parts[1] ? parts[1].length : 0; +} + +export function PowerUpForm({ + variant = 'page', + onSuccess, + onCancel, +}: PowerUpFormProps) { + const t = useTranslations('powerUp'); + const tCommon = useTranslations('common'); + const router = useRouter(); + const username = useSelector((state: RootState) => state.auth.username); + const signingKey = useActiveSigningKey(); + const [isPending, startTransition] = useTransition(); + const { data: account, refetch } = useAccountData(); + + const [advanced, setAdvanced] = useState(false); + const [to, setTo] = useState(''); + const [amount, setAmount] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const steemBalance = parseSteemBalance(account?.balance); + + const fillMaxBalance = () => { + if (steemBalance > 0) { + setAmount(steemBalance.toFixed(3)); + setError(''); + } + }; + + const toggleAdvanced = (e: React.MouseEvent) => { + e.preventDefault(); + if (username) setTo(username); + setAdvanced((prev) => !prev); + setError(''); + }; + + const finishSuccess = () => { + setIsLoading(false); + void refetch(); + startTransition(() => { + if (onSuccess) onSuccess(); + else if (username) { + router.push(transfersPathForUsername(username)); + } + }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + if (!username || !signingKey) { + setError(t('notAuthenticated')); + setIsLoading(false); + return; + } + + const recipientName = advanced ? to.trim().toLowerCase() : username; + + if (!recipientName) { + setError(t('invalidRecipient')); + setIsLoading(false); + return; + } + + if (!STEEM_ACCOUNT_RE.test(recipientName)) { + setError(t('invalidRecipient')); + setIsLoading(false); + return; + } + + const amountMatch = amount.match(/^([\d.]+)$/); + if (!amountMatch || !amountMatch[1]) { + setError(t('invalidAmount')); + setIsLoading(false); + return; + } + + const amountValue = parseFloat(amountMatch[1]); + if (amountValue <= 0 || isNaN(amountValue)) { + setError(t('amountMustBePositive')); + setIsLoading(false); + return; + } + + if (countDecimals(amountMatch[1]) > 3) { + setError(t('precisionError')); + setIsLoading(false); + return; + } + + if (amountValue > steemBalance + 1e-9) { + setError(t('insufficientFunds')); + setIsLoading(false); + return; + } + + try { + const amountStr = `${amountValue.toFixed(3)} STEEM`; + const signedTx = await SteemSigner.signTransferToVesting( + username, + recipientName, + amountStr, + signingKey + ); + const response = await apiClient.broadcastTransfer(signedTx, username); + + if (!response.success) { + setError(response.error || t('powerUpError')); + setIsLoading(false); + return; + } + + finishSuccess(); + } catch (err) { + console.error('Power up error:', err); + setError(t('powerUpError')); + setIsLoading(false); + } + }; + + const handleCancel = () => { + if (onCancel) onCancel(); + else router.back(); + }; + + const infoBlock = ( +
+

{t('influenceToken')}

+

{t('nonTransferable')}

+
+ ); + + const formInner = ( + <> + {infoBlock} +
+
+ +
+ + @ + + +
+
+ + {advanced && ( +
+ + { + setTo(e.target.value); + setError(''); + }} + placeholder={t('recipientPlaceholder')} + disabled={isLoading || isPending} + autoComplete="off" + autoCorrect="off" + spellCheck={false} + /> +

{t('advancedRecipientHint')}

+
+ )} + +
+ +
+ { + setAmount(e.target.value); + setError(''); + }} + required + step="0.001" + min="0" + placeholder={t('amountPlaceholder')} + disabled={isLoading || isPending} + className="flex-1" + /> + + {t('steemPowerAsset')} + +
+ +
+ + {error && ( +
+

{error}

+
+ )} + + + + + + {onCancel && variant === 'page' && ( +
+ +
+ )} +
+ + ); + + if (variant === 'dialog') { + return ( +
+

{t('title')}

+ {formInner} +
+ ); + } + + return ( +
+ + + {t('title')} + + {formInner} + +
+ ); +} diff --git a/src/components/wallet/transfer-form.tsx b/src/components/wallet/transfer-form.tsx index e62cad1f..031179bb 100644 --- a/src/components/wallet/transfer-form.tsx +++ b/src/components/wallet/transfer-form.tsx @@ -27,7 +27,7 @@ export interface TransferFormProps { variant?: TransferFormVariant; /** Initial asset from URL / balance row (STEEM, SBD, or VESTS for power-up entry). */ initialAsset?: 'STEEM' | 'SBD' | 'VESTS'; - /** transfer = to another account; savings / savings_withdraw / power_up = self operations. */ + /** transfer = to another account; savings / savings_withdraw = self operations. */ initialTransferType?: WalletTransferType; onSuccess?: () => void; onCancel?: () => void; @@ -67,8 +67,7 @@ export function TransferForm({ setError(''); }; - const amountSuffix = - transferType === 'power_up' ? 'STEEM' : asset === 'SBD' ? 'SBD' : 'STEEM'; + const amountSuffix = asset === 'SBD' ? 'SBD' : 'STEEM'; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -129,13 +128,6 @@ export function TransferForm({ requestId, signingKey ); - } else if (transferType === 'power_up') { - signedTx = await SteemSigner.signTransferToVesting( - username, - username, - amountStr, - signingKey - ); } else { setError('Unsupported operation'); setIsLoading(false); @@ -180,9 +172,7 @@ export function TransferForm({ ? 'Transfer to savings' : transferType === 'savings_withdraw' ? 'Withdraw from savings' - : transferType === 'power_up' - ? 'Power up' - : t('title'); + : t('title'); const formBody = (
diff --git a/src/components/wallet/wallet-transfers-modals.tsx b/src/components/wallet/wallet-transfers-modals.tsx index 2cc5d826..3cf7e5d3 100644 --- a/src/components/wallet/wallet-transfers-modals.tsx +++ b/src/components/wallet/wallet-transfers-modals.tsx @@ -28,6 +28,8 @@ import { } from '@/components/ui/dialog'; import { TransferForm } from '@/components/wallet/transfer-form'; import { PowerDownForm } from '@/components/wallet/power-down-form'; +import { CancelPowerDownHandler } from '@/components/wallet/cancel-power-down-handler'; +import { PowerUpForm } from '@/components/wallet/power-up-form'; import { DelegateForm } from '@/components/wallet/delegate-form'; import { WithdrawRoutesForm } from '@/components/wallet/withdraw-routes-form'; import { ConvertSbdForm } from '@/components/wallet/convert-sbd-form'; @@ -76,18 +78,21 @@ export function WalletTransfersModals({ onWalletDataChanged }: WalletTransfersMo clearWalletQuery(); }, [onWalletDataChanged, clearWalletQuery]); - const open = walletAction !== null; + const isPowerUpLegacyUrl = + walletAction === 'transfer' && transferType === 'power_up'; + const showPowerUp = walletAction === 'powerUp' || isPowerUpLegacyUrl; + const open = walletAction !== null || isPowerUpLegacyUrl; const handleOpenChange = (next: boolean) => { if (!next) clearWalletQuery(); }; useEffect(() => { - if (walletAction === null || !accountUsername) return; + if ((walletAction === null && !isPowerUpLegacyUrl) || !accountUsername) return; if (!canManageBalance) { clearWalletQuery(); } - }, [walletAction, accountUsername, canManageBalance, clearWalletQuery]); + }, [walletAction, isPowerUpLegacyUrl, accountUsername, canManageBalance, clearWalletQuery]); const needsWalletReauth = open && @@ -116,7 +121,7 @@ export function WalletTransfersModals({ onWalletDataChanged }: WalletTransfersMo /> )} - {!needsWalletReauth && walletAction === 'transfer' && ( + {!needsWalletReauth && walletAction === 'transfer' && !isPowerUpLegacyUrl && ( <> Transfer @@ -132,6 +137,19 @@ export function WalletTransfersModals({ onWalletDataChanged }: WalletTransfersMo /> )} + {!needsWalletReauth && showPowerUp && ( + <> + + Power up + {t('powerUp')} + + + + )} {!needsWalletReauth && walletAction === 'powerDown' && ( <> @@ -141,6 +159,15 @@ export function WalletTransfersModals({ onWalletDataChanged }: WalletTransfersMo )} + {!needsWalletReauth && walletAction === 'cancelPowerDown' && ( + <> + + Cancel power down + {t('cancelPowerDown')} + + + + )} {!needsWalletReauth && walletAction === 'delegate' && ( <> diff --git a/src/hooks/use-global-props.ts b/src/hooks/use-global-props.ts new file mode 100644 index 00000000..a394b501 --- /dev/null +++ b/src/hooks/use-global-props.ts @@ -0,0 +1,31 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { cachedFetch } from '@/lib/cache/client-fetch'; +import type { GlobalPropsData } from '@/lib/wallet/wallet-balance-types'; + +export function useGlobalProps() { + const [globalProps, setGlobalProps] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchProps = async () => { + try { + setLoading(true); + const { data } = await cachedFetch<{ props: GlobalPropsData; error?: string }>( + '/api/query/global-props', + { staleMs: 3_000, maxAgeMs: 30_000 } + ); + setGlobalProps(data?.props ?? null); + } catch { + setGlobalProps(null); + } finally { + setLoading(false); + } + }; + + void fetchProps(); + }, []); + + return { globalProps, loading }; +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 00e32230..138f167a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -53,6 +53,8 @@ "balances": "Balances", "transfer": "Transfer", "powerDown": "Power Down", + "powerUp": "Power Up", + "cancelPowerDown": "Cancel Power Down", "delegations": "Delegations", "keysAndPermissions": "Keys & Permissions", "changePassword": "Change Password", @@ -90,7 +92,7 @@ "backupHint": "Back it up by storing it in your password manager or a text file.", "generateButton": "Click to generate password", "reEnterPassword": "Re-enter generated password", - "understandNoRecovery": "I understand that Steem Wallet cannot recover lost passwords.", + "understandNoRecovery": "I understand that Steemit Wallet cannot recover lost passwords.", "savedConfirm": "I have securely saved my generated password.", "updatePassword": "Update password", "needPasswordOrKey": "You need a private password or key (not a public key).", @@ -102,9 +104,9 @@ "broadcastFailed": "Password update failed. Check your current password and try again.", "successMessage": "Your password was updated successfully. Sign in with your new password.", "continueToSignIn": "Continue to sign in", - "rule1": "The first rule of Steem Wallet is: Do not lose your password.", - "rule2": "The second rule of Steem Wallet is: Do not lose your password.", - "rule3": "The third rule of Steem Wallet is: We cannot recover your password.", + "rule1": "The first rule of Steemit Wallet is: Do not lose your password.", + "rule2": "The second rule of Steemit Wallet is: Do not lose your password.", + "rule3": "The third rule of Steemit Wallet is: We cannot recover your password.", "rule4": "The fourth rule: If you can remember the password, it's not secure.", "rule5": "The fifth rule: Use only randomly-generated passwords.", "rule6": "The sixth rule: Do not tell anyone your password.", @@ -171,17 +173,17 @@ "vestingShares": "VESTING Shares", "recentActivity": "Recent Activity", "welcome": "Welcome", - "availableVESTS": "Available VESTS", + "availableVESTS": "Available SP", "poweredDown": "Powered Down", "nextWithdrawal": "Next Withdrawal", "withdrawRate": "Withdraw Rate", "noActivity": "No recent activity", "activityLoading": "Loading activity...", "activityError": "Failed to load activity", - "totalVests": "Total VESTS", + "totalVests": "Total SP", "effectiveSP": "Effective Steem Power", - "receivedVests": "Received VESTS", - "delegatedVests": "Delegated VESTS", + "receivedVests": "Received SP", + "delegatedVests": "Delegated SP", "outgoingDelegations": "Outgoing Delegations", "expiringDelegations": "Expiring Delegations", "delegatee": "Delegatee", @@ -526,33 +528,55 @@ }, "powerDown": { "title": "Power Down", - "startPowerDown": "Start Power Down", - "cancelPowerDown": "Cancel Power Down", - "vestsToPowerDown": "VESTS to Power Down", - "vestsPlaceholder": "Enter VESTS amount", + "amount": "Amount", + "powerDownButton": "Power Down", + "alreadyPowerDown": "You are already powering down {amount} {liquidTicker} ({withdrawn} {liquidTicker} paid out so far). Note that if you change the power down amount the payout schedule will reset.", + "delegating": "You are delegating {amount} {liquidTicker}. That amount is locked up and not available to power down until the delegation is removed and 5 days have passed.", + "perWeek": "That's ~{amount} {liquidTicker} per week.", + "reserveWarning": "Leaving less than {amount} {vestingToken} in your account is not recommended and can leave your account in a unusable state.", + "error": "Unable to power down (ERROR: {message})", "powerDownSuccess": "Power down successful", "powerDownError": "Power down failed", - "cancelSuccess": "Power down cancelled", "cancelError": "Failed to cancel power down", - "currentlyActive": "Power down is currently active", - "formatHint": "Use format: 6 decimal places (e.g., 1000000.000000)", - "powerDownInfo": "Power down converts your VESTS to STEEM over 13 weeks", - "confirmCancel": "Are you sure you want to cancel your power down?", + "notAuthenticated": "Not authenticated", + "loadError": "Unable to load account data. Please try again." + }, + "powerUp": { + "title": "Convert to STEEM POWER", + "from": "From", + "to": "To", + "amount": "Amount", + "steemPowerAsset": "Steem Power", + "powerUpButton": "Power Up", + "basic": "Basic", + "advanced": "Advanced", + "influenceToken": "Influence tokens which give you more control over post payouts and allow you to earn on curation rewards.", + "nonTransferable": "STEEM POWER is non-transferable and requires 1 month (4 payments) to convert back to STEEM.", + "advancedRecipientHint": "Converted STEEM POWER can be sent to yourself or someone else but cannot transfer again without converting back to STEEM.", + "recipientPlaceholder": "Enter recipient username", + "amountPlaceholder": "e.g. 1.000", + "balance": "Balance: {balance} STEEM", + "powerUpSuccess": "Power up successful", + "powerUpError": "Power up failed", + "notAuthenticated": "Not authenticated", + "invalidRecipient": "Please enter a valid recipient username", "invalidAmount": "Please enter a valid amount", - "amountMustBePositive": "Amount must be greater than 0" + "amountMustBePositive": "Amount must be greater than 0", + "precisionError": "Use only 3 digits of precision", + "insufficientFunds": "Insufficient funds" }, "delegations": { "title": "Delegations", - "delegateVESTS": "Delegate VESTS", + "delegateSP": "Delegate SP", "delegateeUsername": "Delegatee Username", - "vestsToDelegate": "VESTS to Delegate", + "spToDelegate": "SP to Delegate", "delegateePlaceholder": "Enter username to delegate to", - "vestsPlaceholder": "Enter VESTS amount", + "spPlaceholder": "Enter SP amount", "delegateButton": "Delegate", "delegateSuccess": "Delegation successful", "delegateError": "Delegation failed", "revokeHint": "Set to 0 to revoke your delegation", - "formatHint": "Use format: 6 decimal places (e.g., 1000000.000000)", + "formatHint": "Amount in SP (Steem Power), e.g. 10.000", "invalidDelegatee": "Please enter a delegatee username", "invalidAmount": "Please enter a valid amount", "amountMustBePositive": "Amount must be greater than 0" diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index a7d74987..1b0dacf3 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -53,6 +53,8 @@ "balances": "Saldos", "transfer": "Transferir", "powerDown": "Bajar Energía", + "powerUp": "Power Up", + "cancelPowerDown": "Cancelar Power Down", "delegations": "Delegaciones", "keysAndPermissions": "Claves y permisos", "changePassword": "Cambiar contraseña", @@ -90,7 +92,7 @@ "backupHint": "Guárdela en un gestor de contraseñas o en un archivo de texto.", "generateButton": "Haga clic para generar contraseña", "reEnterPassword": "Vuelva a introducir la contraseña generada", - "understandNoRecovery": "Entiendo que Steem Wallet no puede recuperar contraseñas perdidas.", + "understandNoRecovery": "Entiendo que Steemit Wallet no puede recuperar contraseñas perdidas.", "savedConfirm": "He guardado de forma segura mi contraseña generada.", "updatePassword": "Actualizar contraseña", "needPasswordOrKey": "Necesita una contraseña privada o clave (no una clave pública).", @@ -102,9 +104,9 @@ "broadcastFailed": "Error al actualizar la contraseña. Compruebe su contraseña actual e inténtelo de nuevo.", "successMessage": "Su contraseña se actualizó correctamente. Inicie sesión con la nueva contraseña.", "continueToSignIn": "Continuar al inicio de sesión", - "rule1": "La primera regla de Steem Wallet es: no pierda su contraseña.", - "rule2": "La segunda regla de Steem Wallet es: no pierda su contraseña.", - "rule3": "La tercera regla de Steem Wallet es: no podemos recuperar su contraseña.", + "rule1": "La primera regla de Steemit Wallet es: no pierda su contraseña.", + "rule2": "La segunda regla de Steemit Wallet es: no pierda su contraseña.", + "rule3": "La tercera regla de Steemit Wallet es: no podemos recuperar su contraseña.", "rule4": "La cuarta regla: si puede recordar la contraseña, no es segura.", "rule5": "La quinta regla: use solo contraseñas generadas aleatoriamente.", "rule6": "La sexta regla: no le diga su contraseña a nadie.", @@ -171,17 +173,17 @@ "vestingShares": "Acciones VESTING", "recentActivity": "Actividad Reciente", "welcome": "Bienvenido", - "availableVESTS": "VESTS Disponibles", + "availableVESTS": "SP Disponible", "poweredDown": "Bajado", "nextWithdrawal": "Próximo Retiro", "withdrawRate": "Tasa de Retiro", "noActivity": "Sin actividad reciente", "activityLoading": "Cargando actividad...", "activityError": "Error al cargar actividad", - "totalVests": "VESTS Totales", + "totalVests": "SP Total", "effectiveSP": "Steem Power Efectivo", - "receivedVests": "VESTS Recibidos", - "delegatedVests": "VESTS Delegados", + "receivedVests": "SP Recibido", + "delegatedVests": "SP Delegado", "outgoingDelegations": "Delegaciones Enviadas", "expiringDelegations": "Delegaciones por Expirar", "delegatee": "Delegatario", @@ -516,34 +518,56 @@ "memoNotEncrypted": "Memo no está encriptado" }, "powerDown": { - "title": "Bajar Energía", - "startPowerDown": "Iniciar Bajada", - "cancelPowerDown": "Cancelar Bajada", - "vestsToPowerDown": "VESTS a Bajar", - "vestsPlaceholder": "Ingrese monto de VESTS", - "powerDownSuccess": "Bajada exitosa", - "powerDownError": "Bajada fallida", - "cancelSuccess": "Bajada cancelada", - "cancelError": "Error al cancelar bajada", - "currentlyActive": "La bajada está actualmente activa", - "formatHint": "Use formato: 6 decimales (ej: 1000000.000000)", - "powerDownInfo": "Bajar energía convierte sus VESTS a STEEM en 13 semanas", - "confirmCancel": "¿Está seguro de que desea cancelar su bajada de energía?", + "title": "Power Down", + "amount": "Monto", + "powerDownButton": "Power Down", + "alreadyPowerDown": "Ya está realizando un power down de {amount} {liquidTicker} ({withdrawn} {liquidTicker} pagados hasta ahora). Si cambia el monto, el calendario de pagos se reiniciará.", + "delegating": "Está delegando {amount} {liquidTicker}. Ese monto está bloqueado y no está disponible para power down hasta que se elimine la delegación y pasen 5 días.", + "perWeek": "Eso es ~{amount} {liquidTicker} por semana.", + "reserveWarning": "Dejar menos de {amount} {vestingToken} en su cuenta no se recomienda y puede dejar su cuenta en un estado inutilizable.", + "error": "No se pudo realizar el power down (ERROR: {message})", + "powerDownSuccess": "Power down exitoso", + "powerDownError": "Power down fallido", + "cancelError": "Error al cancelar power down", + "notAuthenticated": "No autenticado", + "loadError": "No se pudieron cargar los datos de la cuenta. Inténtelo de nuevo." + }, + "powerUp": { + "title": "Convertir a STEEM POWER", + "from": "De", + "to": "Para", + "amount": "Monto", + "steemPowerAsset": "Steem Power", + "powerUpButton": "Power Up", + "basic": "Básico", + "advanced": "Avanzado", + "influenceToken": "Los tokens de influencia le dan más control sobre los pagos de publicaciones y le permiten ganar recompensas de curación.", + "nonTransferable": "STEEM POWER no es transferible y requiere 1 mes (4 pagos) para convertirse de nuevo a STEEM.", + "advancedRecipientHint": "El STEEM POWER convertido puede enviarse a usted mismo o a otra persona, pero no puede transferirse de nuevo sin convertirse de vuelta a STEEM.", + "recipientPlaceholder": "Ingrese nombre de usuario del destinatario", + "amountPlaceholder": "ej. 1.000", + "balance": "Saldo: {balance} STEEM", + "powerUpSuccess": "Power up exitoso", + "powerUpError": "Power up fallido", + "notAuthenticated": "No autenticado", + "invalidRecipient": "Ingrese un nombre de usuario válido", "invalidAmount": "Ingrese un monto válido", - "amountMustBePositive": "El monto debe ser mayor a 0" + "amountMustBePositive": "El monto debe ser mayor a 0", + "precisionError": "Use solo 3 dígitos de precisión", + "insufficientFunds": "Fondos insuficientes" }, "delegations": { "title": "Delegaciones", - "delegateVESTS": "Delegar VESTS", + "delegateSP": "Delegar SP", "delegateeUsername": "Nombre de Usuario Delegado", - "vestsToDelegate": "VESTS a Delegar", + "spToDelegate": "SP a Delegar", "delegateePlaceholder": "Ingrese nombre de usuario para delegar", - "vestsPlaceholder": "Ingrese monto de VESTS", + "spPlaceholder": "Ingrese monto de SP", "delegateButton": "Delegar", "delegateSuccess": "Delegación exitosa", "delegateError": "Delegación fallida", "revokeHint": "Establezca en 0 para revocar su delegación", - "formatHint": "Use formato: 6 decimales (ej: 1000000.000000)", + "formatHint": "Monto en SP (Steem Power), ej. 10.000", "invalidDelegatee": "Ingrese un nombre de usuario delegado", "invalidAmount": "Ingrese un monto válido", "amountMustBePositive": "El monto debe ser mayor a 0" diff --git a/src/i18n/messages/zh.json b/src/i18n/messages/zh.json index 2b30b90c..c2ba3a73 100644 --- a/src/i18n/messages/zh.json +++ b/src/i18n/messages/zh.json @@ -53,6 +53,8 @@ "balances": "余额", "transfer": "转账", "powerDown": "能量下调", + "powerUp": "Power Up", + "cancelPowerDown": "取消能量下调", "delegations": "委托", "keysAndPermissions": "密钥与权限", "changePassword": "修改密码", @@ -90,7 +92,7 @@ "backupHint": "请保存到密码管理器或文本文件中备份。", "generateButton": "点击生成密码", "reEnterPassword": "再次输入生成的密码", - "understandNoRecovery": "我理解 Steem Wallet 无法恢复丢失的密码。", + "understandNoRecovery": "我理解 Steemit Wallet 无法恢复丢失的密码。", "savedConfirm": "我已安全保存生成的密码。", "updatePassword": "更新密码", "needPasswordOrKey": "需要私钥或主密码(不能是公钥)。", @@ -102,9 +104,9 @@ "broadcastFailed": "密码更新失败。请检查当前密码后重试。", "successMessage": "密码已成功更新,请使用新密码登录。", "continueToSignIn": "前往登录", - "rule1": "Steem Wallet 第一条规则:不要丢失密码。", - "rule2": "Steem Wallet 第二条规则:不要丢失密码。", - "rule3": "Steem Wallet 第三条规则:我们无法恢复您的密码。", + "rule1": "Steemit Wallet 第一条规则:不要丢失密码。", + "rule2": "Steemit Wallet 第二条规则:不要丢失密码。", + "rule3": "Steemit Wallet 第三条规则:我们无法恢复您的密码。", "rule4": "第四条规则:若能记住密码,说明它不够安全。", "rule5": "第五条规则:只使用随机生成的密码。", "rule6": "第六条规则:不要告诉任何人您的密码。", @@ -171,17 +173,17 @@ "vestingShares": "VESTING 份额", "recentActivity": "最近活动", "welcome": "欢迎", - "availableVESTS": "可用 VESTS", + "availableVESTS": "可用 SP", "poweredDown": "已下调", "nextWithdrawal": "下次提取", "withdrawRate": "提取速率", "noActivity": "暂无活动", "activityLoading": "加载活动中...", "activityError": "加载活动失败", - "totalVests": "总 VESTS", + "totalVests": "总 SP", "effectiveSP": "有效 Steem Power", - "receivedVests": "收到的 VESTS", - "delegatedVests": "委托的 VESTS", + "receivedVests": "收到的 SP", + "delegatedVests": "委托的 SP", "outgoingDelegations": "发出委托", "expiringDelegations": "即将到期", "delegatee": "受托人", @@ -526,33 +528,55 @@ }, "powerDown": { "title": "能量下调", - "startPowerDown": "开始下调", - "cancelPowerDown": "取消下调", - "vestsToPowerDown": "要下调的 VESTS", - "vestsPlaceholder": "输入 VESTS 金额", + "amount": "金额", + "powerDownButton": "能量下调", + "alreadyPowerDown": "您已在进行能量下调 {amount} {liquidTicker}(目前已支付 {withdrawn} {liquidTicker})。若更改下调金额,支付计划将重置。", + "delegating": "您正在委托 {amount} {liquidTicker}。该部分已锁定,需取消委托并等待 5 天后才能下调。", + "perWeek": "约每周 {amount} {liquidTicker}。", + "reserveWarning": "账户中保留少于 {amount} {vestingToken} 不推荐,可能导致账户无法正常使用。", + "error": "无法执行能量下调(错误:{message})", "powerDownSuccess": "下调成功", "powerDownError": "下调失败", - "cancelSuccess": "已取消下调", "cancelError": "取消下调失败", - "currentlyActive": "下调正在进行中", - "formatHint": "使用格式:6 位小数(如:1000000.000000)", - "powerDownInfo": "能量下调会在 13 周内将您的 VESTS 转换为 STEEM", - "confirmCancel": "确定要取消能量下调吗?", + "notAuthenticated": "未登录", + "loadError": "无法加载账户数据,请重试。" + }, + "powerUp": { + "title": "转换为 STEEM POWER", + "from": "从", + "to": "到", + "amount": "金额", + "steemPowerAsset": "Steem Power", + "powerUpButton": "Power Up", + "basic": "基础", + "advanced": "高级", + "influenceToken": "影响力代币可让您更好地控制帖子奖励,并赚取策展奖励。", + "nonTransferable": "STEEM POWER 不可转让,需要 1 个月(4 次支付)才能转回 STEEM。", + "advancedRecipientHint": "转换后的 STEEM POWER 可以发送给自己或他人,但在转回 STEEM 之前无法再次转让。", + "recipientPlaceholder": "输入收款用户名", + "amountPlaceholder": "例如 1.000", + "balance": "余额:{balance} STEEM", + "powerUpSuccess": "Power Up 成功", + "powerUpError": "Power Up 失败", + "notAuthenticated": "未登录", + "invalidRecipient": "请输入有效的收款用户名", "invalidAmount": "请输入有效金额", - "amountMustBePositive": "金额必须大于 0" + "amountMustBePositive": "金额必须大于 0", + "precisionError": "最多使用 3 位小数", + "insufficientFunds": "余额不足" }, "delegations": { "title": "委托", - "delegateVESTS": "委托 VESTS", + "delegateSP": "委托 SP", "delegateeUsername": "受让人用户名", - "vestsToDelegate": "要委托的 VESTS", + "spToDelegate": "要委托的 SP", "delegateePlaceholder": "输入要委托给的用户名", - "vestsPlaceholder": "输入 VESTS 金额", + "spPlaceholder": "输入 SP 金额", "delegateButton": "委托", "delegateSuccess": "委托成功", "delegateError": "委托失败", "revokeHint": "设置为 0 可撤销委托", - "formatHint": "使用格式:6 位小数(如:1000000.000000)", + "formatHint": "SP(Steem Power)金额,例如 10.000", "invalidDelegatee": "请输入受让人用户名", "invalidAmount": "请输入有效金额", "amountMustBePositive": "金额必须大于 0" diff --git a/src/lib/wallet/power-down.ts b/src/lib/wallet/power-down.ts new file mode 100644 index 00000000..e84fe568 --- /dev/null +++ b/src/lib/wallet/power-down.ts @@ -0,0 +1,69 @@ +import { parseAssetAmount } from '@/lib/wallet/parse-asset-amount'; +import type { GlobalPropsData } from '@/lib/wallet/wallet-balance-types'; +import { steemPowerFromVests, vestsFromSteemPower } from '@/lib/wallet/vest-steem'; + +const MICRO_VEST_SCALE = 1_000_000; + +/** Chain `to_withdraw` / `withdrawn` fields are stored in micro-VESTS (legacy /1e6). */ +export function microVestsToVests(micro: number): number { + return micro / MICRO_VEST_SCALE; +} + +export interface PowerDownAccountFields { + vesting_shares: string; + delegated_vesting_shares: string; + to_withdraw: number; + withdrawn: number; +} + +/** Max VESTS that can be selected on the power down slider. */ +export function getPowerDownMaxVests(account: PowerDownAccountFields): number { + const vesting = parseAssetAmount(account.vesting_shares); + const delegated = parseAssetAmount(account.delegated_vesting_shares); + return Math.max(0, vesting - delegated); +} + +export function getPowerDownToWithdrawVests(account: PowerDownAccountFields): number { + return microVestsToVests(account.to_withdraw); +} + +export function getPowerDownWithdrawnVests(account: PowerDownAccountFields): number { + return microVestsToVests(account.withdrawn); +} + +/** Default slider position (matches wallet-legacy Powerdown constructor). */ +export function getDefaultPowerDownVests( + account: PowerDownAccountFields, + globalProps: GlobalPropsData +): number { + const toWithdraw = getPowerDownToWithdrawVests(account); + const withdrawn = getPowerDownWithdrawnVests(account); + if (toWithdraw - withdrawn > 0) { + return toWithdraw - withdrawn; + } + const max = getPowerDownMaxVests(account); + const reserve = vestsFromSteemPower(5.001, globalProps); + return Math.max(0, max - reserve); +} + +export function clampPowerDownVests(withdrawVests: number, maxVests: number): number { + return Math.min(Math.max(0, withdrawVests), maxVests); +} + +/** Weekly STEEM payout estimate (legacy: total / 4 payments per month). */ +export function formatPowerDownWeeklySteem( + withdrawVests: number, + globalProps: GlobalPropsData +): string { + const weekly = steemPowerFromVests(withdrawVests, globalProps) / 4; + return weekly.toFixed(weekly >= 10 ? 0 : 1); +} + +export function isPowerDownReserveWarning( + withdrawVests: number, + maxVests: number, + globalProps: GlobalPropsData +): boolean { + const reserve = vestsFromSteemPower(5, globalProps); + return withdrawVests > maxVests - reserve; +} diff --git a/src/lib/wallet/vest-steem.ts b/src/lib/wallet/vest-steem.ts index 5a60c7eb..34ef1dea 100644 --- a/src/lib/wallet/vest-steem.ts +++ b/src/lib/wallet/vest-steem.ts @@ -1,6 +1,8 @@ import { parseAssetAmount } from '@/lib/wallet/parse-asset-amount'; import type { GlobalPropsData } from '@/lib/wallet/wallet-balance-types'; +export const STEEM_POWER_TICKER = 'SP'; + /** Convert VEST amount to equivalent STEEM (Steem Power) using chain globals. */ export function steemPowerFromVests(vests: number, globalProps: GlobalPropsData): number { const totalVests = parseAssetAmount(globalProps.total_vesting_shares); @@ -9,6 +11,24 @@ export function steemPowerFromVests(vests: number, globalProps: GlobalPropsData) return totalVestSteem * (vests / totalVests); } +/** Convert STEEM (Steem Power) amount to equivalent VESTS using chain globals. */ +export function vestsFromSteemPower(steemPower: number, globalProps: GlobalPropsData): number { + const totalVests = parseAssetAmount(globalProps.total_vesting_shares); + const totalVestSteem = parseAssetAmount(globalProps.total_vesting_fund_steem); + if (totalVestSteem <= 0) return 0; + return totalVests * (steemPower / totalVestSteem); +} + +/** Format a VEST amount as a chain asset string for signing/broadcast. */ +export function formatVestsAsset(vests: number): string { + return `${vests.toFixed(6)} VESTS`; +} + +/** Convert a Steem Power user input to a VESTS asset string for chain ops. */ +export function steemPowerToVestsAsset(steemPower: number, globalProps: GlobalPropsData): string { + return formatVestsAsset(vestsFromSteemPower(steemPower, globalProps)); +} + /** Parse a VESTS asset string and return STEEM-power equivalent. */ export function steemPowerFromVestsString( vestAsset: string | undefined, diff --git a/src/lib/wallet/wallet-modal-search-params.ts b/src/lib/wallet/wallet-modal-search-params.ts index 66b8916a..bdcd6a27 100644 --- a/src/lib/wallet/wallet-modal-search-params.ts +++ b/src/lib/wallet/wallet-modal-search-params.ts @@ -15,7 +15,9 @@ export const WALLET_QUERY_KEYS = [ export type WalletModalAction = | 'transfer' + | 'powerUp' | 'powerDown' + | 'cancelPowerDown' | 'delegate' | 'advanced' | 'convert'; @@ -31,7 +33,9 @@ export function parseWalletModalAction(raw: string | null): WalletModalAction | if (!raw) return null; if ( raw === 'transfer' || + raw === 'powerUp' || raw === 'powerDown' || + raw === 'cancelPowerDown' || raw === 'delegate' || raw === 'advanced' || raw === 'convert' diff --git a/tests/unit/power-down.test.ts b/tests/unit/power-down.test.ts new file mode 100644 index 00000000..cd983793 --- /dev/null +++ b/tests/unit/power-down.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import type { GlobalPropsData } from '@/lib/wallet/wallet-balance-types'; +import { + clampPowerDownVests, + formatPowerDownWeeklySteem, + getDefaultPowerDownVests, + getPowerDownMaxVests, + getPowerDownToWithdrawVests, + getPowerDownWithdrawnVests, + isPowerDownReserveWarning, + microVestsToVests, +} from '@/lib/wallet/power-down'; + +const globalProps: GlobalPropsData = { + total_vesting_shares: '100.000000 VESTS', + total_vesting_fund_steem: '50.000 STEEM', +}; + +const baseAccount = { + vesting_shares: '100.000000 VESTS', + delegated_vesting_shares: '10.000000 VESTS', + to_withdraw: 0, + withdrawn: 0, +}; + +describe('power-down', () => { + it('converts micro-vests to vests', () => { + expect(microVestsToVests(5_000_000)).toBe(5); + }); + + it('computes max vests as vesting minus delegated', () => { + expect(getPowerDownMaxVests(baseAccount)).toBe(90); + }); + + it('reads to_withdraw and withdrawn from micro-vest fields', () => { + expect( + getPowerDownToWithdrawVests({ ...baseAccount, to_withdraw: 20_000_000 }) + ).toBe(20); + expect( + getPowerDownWithdrawnVests({ ...baseAccount, withdrawn: 5_000_000 }) + ).toBe(5); + }); + + it('defaults to remaining power down when already active', () => { + const account = { + ...baseAccount, + to_withdraw: 40_000_000, + withdrawn: 10_000_000, + }; + expect(getDefaultPowerDownVests(account, globalProps)).toBe(30); + }); + + it('defaults to available minus 5.001 STEEM reserve when not active', () => { + // max = 90 vests; 5.001 STEEM = 10.002 vests at 2:1 ratio + expect(getDefaultPowerDownVests(baseAccount, globalProps)).toBeCloseTo(79.998, 3); + }); + + it('clamps withdraw amount to max', () => { + expect(clampPowerDownVests(120, 90)).toBe(90); + expect(clampPowerDownVests(-1, 90)).toBe(0); + }); + + it('formats weekly steem payout', () => { + // 40 vests = 20 STEEM total -> 5 per week + expect(formatPowerDownWeeklySteem(40, globalProps)).toBe('5.0'); + }); + + it('warns when leaving less than 5 STEEM POWER reserve', () => { + const max = getPowerDownMaxVests(baseAccount); + const reserveVests = 10; // 5 STEEM + expect(isPowerDownReserveWarning(max - reserveVests + 0.001, max, globalProps)).toBe( + true + ); + expect(isPowerDownReserveWarning(max - reserveVests - 0.001, max, globalProps)).toBe( + false + ); + }); +}); diff --git a/tests/unit/vest-steem.test.ts b/tests/unit/vest-steem.test.ts index f3357b7d..ef5092cd 100644 --- a/tests/unit/vest-steem.test.ts +++ b/tests/unit/vest-steem.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest'; import { formatSteemPowerDisplay, + formatVestsAsset, steemPowerFromVests, steemPowerFromVestsString, + steemPowerToVestsAsset, + vestsFromSteemPower, } from '@/lib/wallet/vest-steem'; import type { GlobalPropsData } from '@/lib/wallet/wallet-balance-types'; @@ -23,4 +26,21 @@ describe('vest-steem', () => { it('formats steem power with thousands separators', () => { expect(formatSteemPowerDisplay(1234.5)).toBe('1,234.500'); }); + + it('converts steem power to vests using chain ratio', () => { + expect(vestsFromSteemPower(5, globalProps)).toBe(10); + }); + + it('formats vests asset strings for chain ops', () => { + expect(formatVestsAsset(10)).toBe('10.000000 VESTS'); + }); + + it('converts steem power input to vests asset strings', () => { + expect(steemPowerToVestsAsset(5, globalProps)).toBe('10.000000 VESTS'); + }); + + it('round-trips steem power and vests', () => { + const vests = vestsFromSteemPower(12.345, globalProps); + expect(steemPowerFromVests(vests, globalProps)).toBeCloseTo(12.345, 6); + }); });