@nockchain/rose 0.1.4-nightly.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/artifacts.yml +33 -0
- package/.github/workflows/ci.yml +68 -0
- package/.github/workflows/publish-sdk.yml +35 -0
- package/.nvmrc +1 -0
- package/.prettierignore +5 -0
- package/.prettierrc +8 -0
- package/LICENSE +22 -0
- package/README.md +117 -0
- package/extension/background/index.ts +1500 -0
- package/extension/content/index.ts +59 -0
- package/extension/icons/rose.svg +27 -0
- package/extension/icons/rose128.png +0 -0
- package/extension/icons/rose16.png +0 -0
- package/extension/icons/rose256.png +0 -0
- package/extension/icons/rose32.png +0 -0
- package/extension/icons/rose48.png +0 -0
- package/extension/icons/rose512.png +0 -0
- package/extension/inpage/index.ts +86 -0
- package/extension/manifest.json +48 -0
- package/extension/popup/Popup.tsx +94 -0
- package/extension/popup/Router.tsx +121 -0
- package/extension/popup/assets/arrow-down-icon.svg +3 -0
- package/extension/popup/assets/arrow-left-icon.svg +3 -0
- package/extension/popup/assets/arrow-right-icon.svg +3 -0
- package/extension/popup/assets/arrow-up-icon.svg +3 -0
- package/extension/popup/assets/arrow-up-right-icon.svg +3 -0
- package/extension/popup/assets/checkmark-icon.svg +3 -0
- package/extension/popup/assets/checkmark-pencil-icon.svg +3 -0
- package/extension/popup/assets/checkmark-success-icon.svg +3 -0
- package/extension/popup/assets/clock-icon.svg +3 -0
- package/extension/popup/assets/close-x-icon.svg +3 -0
- package/extension/popup/assets/copy-icon.svg +6 -0
- package/extension/popup/assets/explorer-icon.svg +3 -0
- package/extension/popup/assets/eye-off-icon.svg +3 -0
- package/extension/popup/assets/eye-open-icon.svg +4 -0
- package/extension/popup/assets/feedback-icon.svg +3 -0
- package/extension/popup/assets/green-status-dot.svg +3 -0
- package/extension/popup/assets/info-icon.svg +3 -0
- package/extension/popup/assets/iris-logo-40.svg +27 -0
- package/extension/popup/assets/iris-logo-96.svg +27 -0
- package/extension/popup/assets/iris-logo-blue.svg +27 -0
- package/extension/popup/assets/iris-logo-no-eye.svg +27 -0
- package/extension/popup/assets/iris-logo-orange.svg +27 -0
- package/extension/popup/assets/iris-logo.svg +27 -0
- package/extension/popup/assets/key-icon.svg +3 -0
- package/extension/popup/assets/lock-icon-yellow.svg +3 -0
- package/extension/popup/assets/lock-icon.svg +3 -0
- package/extension/popup/assets/pencil-edit-icon.svg +3 -0
- package/extension/popup/assets/permissions-icon.svg +3 -0
- package/extension/popup/assets/receipt-icon.svg +5 -0
- package/extension/popup/assets/refresh-icon.svg +3 -0
- package/extension/popup/assets/settings-gear-icon.svg +8 -0
- package/extension/popup/assets/settings-icon.svg +3 -0
- package/extension/popup/assets/theme-icon.svg +3 -0
- package/extension/popup/assets/trash-bin-icon.svg +3 -0
- package/extension/popup/assets/trend-down-arrow.svg +5 -0
- package/extension/popup/assets/trend-up-arrow.svg +5 -0
- package/extension/popup/assets/user-account-icon.svg +3 -0
- package/extension/popup/assets/vector-bottom-left.svg +9 -0
- package/extension/popup/assets/vector-left.svg +9 -0
- package/extension/popup/assets/vector-right.svg +9 -0
- package/extension/popup/assets/vector-top-right-rotated.svg +8 -0
- package/extension/popup/assets/vector-top-right.svg +9 -0
- package/extension/popup/assets/wallet-dropdown-arrow.svg +5 -0
- package/extension/popup/assets/wallet-icon-style-1.svg +6 -0
- package/extension/popup/assets/wallet-icon-style-10.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-11.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-12.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-13.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-14.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-15.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-2.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-3.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-4.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-5.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-6.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-7.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-8.svg +8 -0
- package/extension/popup/assets/wallet-icon-style-9.svg +8 -0
- package/extension/popup/components/AccountIcon.tsx +78 -0
- package/extension/popup/components/AccountSelector.tsx +246 -0
- package/extension/popup/components/Alert.tsx +48 -0
- package/extension/popup/components/ConfirmModal.tsx +81 -0
- package/extension/popup/components/PasswordInput.tsx +49 -0
- package/extension/popup/components/ScreenContainer.tsx +17 -0
- package/extension/popup/components/SiteIcon.tsx +60 -0
- package/extension/popup/components/ThemeToggle.tsx +44 -0
- package/extension/popup/components/icons/ArrowDownLeftIcon.tsx +20 -0
- package/extension/popup/components/icons/ArrowUpRightIcon.tsx +20 -0
- package/extension/popup/components/icons/CheckIcon.tsx +20 -0
- package/extension/popup/components/icons/ChevronDownIcon.tsx +15 -0
- package/extension/popup/components/icons/ChevronLeftIcon.tsx +15 -0
- package/extension/popup/components/icons/ChevronRightIcon.tsx +15 -0
- package/extension/popup/components/icons/ChevronUpIcon.tsx +15 -0
- package/extension/popup/components/icons/CloseIcon.tsx +26 -0
- package/extension/popup/components/icons/CopyIcon.tsx +20 -0
- package/extension/popup/components/icons/EditIcon.tsx +20 -0
- package/extension/popup/components/icons/EyeIcon.tsx +13 -0
- package/extension/popup/components/icons/EyeOffIcon.tsx +13 -0
- package/extension/popup/components/icons/InfoIcon.tsx +20 -0
- package/extension/popup/components/icons/LockIcon.tsx +20 -0
- package/extension/popup/components/icons/PlusIcon.tsx +15 -0
- package/extension/popup/components/icons/ReceiveArrowIcon.tsx +14 -0
- package/extension/popup/components/icons/ReceiveCircleIcon.tsx +20 -0
- package/extension/popup/components/icons/SendPaperPlaneIcon.tsx +18 -0
- package/extension/popup/components/icons/SentArrowIcon.tsx +21 -0
- package/extension/popup/components/icons/SettingsIcon.tsx +26 -0
- package/extension/popup/components/icons/ShieldIcon.tsx +20 -0
- package/extension/popup/components/icons/UploadIcon.tsx +20 -0
- package/extension/popup/components/icons/WalletIcon.tsx +20 -0
- package/extension/popup/contexts/ThemeContext.tsx +105 -0
- package/extension/popup/hooks/useApprovalDetection.ts +128 -0
- package/extension/popup/hooks/useAutoFocus.ts +36 -0
- package/extension/popup/hooks/useAutoRejectOnClose.ts +25 -0
- package/extension/popup/hooks/useClickOutside.ts +33 -0
- package/extension/popup/hooks/useCopyToClipboard.ts +33 -0
- package/extension/popup/hooks/useFavicon.ts +64 -0
- package/extension/popup/hooks/useNumericInput.ts +93 -0
- package/extension/popup/index.html +13 -0
- package/extension/popup/index.tsx +24 -0
- package/extension/popup/screens/AboutScreen.tsx +118 -0
- package/extension/popup/screens/HomeScreen.tailwind.css +85 -0
- package/extension/popup/screens/HomeScreen.tsx +902 -0
- package/extension/popup/screens/KeySettingsPasswordScreen.tsx +164 -0
- package/extension/popup/screens/LockTimeScreen.tsx +155 -0
- package/extension/popup/screens/ReceiveScreen.tsx +149 -0
- package/extension/popup/screens/RecoveryPhraseScreen.tsx +183 -0
- package/extension/popup/screens/SendReviewScreen.tsx +308 -0
- package/extension/popup/screens/SendScreen.tsx +825 -0
- package/extension/popup/screens/SendSubmittedScreen.tsx +193 -0
- package/extension/popup/screens/SettingsScreen.tsx +116 -0
- package/extension/popup/screens/ThemeSettingsScreen.tsx +107 -0
- package/extension/popup/screens/TransactionDetailsScreen.tsx +346 -0
- package/extension/popup/screens/ViewSecretPhraseScreen.tsx +212 -0
- package/extension/popup/screens/WalletPermissionsScreen.tsx +123 -0
- package/extension/popup/screens/WalletSettingsScreen.tsx +381 -0
- package/extension/popup/screens/WalletStylingScreen.tsx +306 -0
- package/extension/popup/screens/approvals/ConnectApprovalScreen.tsx +136 -0
- package/extension/popup/screens/approvals/SignMessageScreen.tsx +140 -0
- package/extension/popup/screens/approvals/SignRawTxScreen.tsx +320 -0
- package/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +167 -0
- package/extension/popup/screens/onboarding/BackupScreen.tsx +254 -0
- package/extension/popup/screens/onboarding/CreateScreen.tsx +273 -0
- package/extension/popup/screens/onboarding/ImportScreen.tsx +676 -0
- package/extension/popup/screens/onboarding/ImportScreenV0.tsx +678 -0
- package/extension/popup/screens/onboarding/ImportSuccessScreen.tsx +236 -0
- package/extension/popup/screens/onboarding/ResumeBackupScreen.tsx +166 -0
- package/extension/popup/screens/onboarding/StartScreen.tsx +142 -0
- package/extension/popup/screens/onboarding/SuccessScreen.tsx +193 -0
- package/extension/popup/screens/onboarding/VerifyScreen.tsx +220 -0
- package/extension/popup/screens/system/LockedScreen.tsx +288 -0
- package/extension/popup/screens/transactions/ReceiveScreen.tsx +84 -0
- package/extension/popup/screens/transactions/SentScreen.tsx +138 -0
- package/extension/popup/store.ts +482 -0
- package/extension/popup/styles.css +246 -0
- package/extension/popup/utils/format.ts +58 -0
- package/extension/popup/utils/formatWalletError.ts +36 -0
- package/extension/popup/utils/memo.ts +299 -0
- package/extension/popup/utils/messaging.ts +16 -0
- package/extension/shared/address-encoding.ts +69 -0
- package/extension/shared/balance-query.ts +123 -0
- package/extension/shared/constants.ts +386 -0
- package/extension/shared/currency.ts +128 -0
- package/extension/shared/first-name-derivation.ts +128 -0
- package/extension/shared/keyfile.ts +58 -0
- package/extension/shared/onboarding.ts +78 -0
- package/extension/shared/price-api.ts +79 -0
- package/extension/shared/rpc-client-browser.ts +315 -0
- package/extension/shared/transaction-builder.ts +443 -0
- package/extension/shared/types.ts +450 -0
- package/extension/shared/utxo-diff.ts +212 -0
- package/extension/shared/utxo-store.ts +548 -0
- package/extension/shared/utxo-sync.ts +343 -0
- package/extension/shared/validators.ts +26 -0
- package/extension/shared/vault.ts +1580 -0
- package/extension/shared/wallet-crypto.ts +77 -0
- package/extension/shared/wasm-utils.ts +76 -0
- package/extension/shared/webcrypto.ts +67 -0
- package/extension/types/wasm.d.ts +13 -0
- package/package.json +39 -0
- package/postcss.config.js +6 -0
- package/rose-extension-dist.zip +0 -0
- package/sdk/README.md +88 -0
- package/sdk/examples/app.ts +166 -0
- package/sdk/examples/index.html +51 -0
- package/sdk/examples/tsconfig.json +15 -0
- package/sdk/examples/tx-builder.html +532 -0
- package/sdk/examples/tx-builder.ts +1766 -0
- package/sdk/package-lock.json +424 -0
- package/sdk/package.json +68 -0
- package/sdk/src/constants.ts +28 -0
- package/sdk/src/errors.ts +74 -0
- package/sdk/src/hooks/index.ts +1 -0
- package/sdk/src/hooks/use-rose.ts +94 -0
- package/sdk/src/index.ts +12 -0
- package/sdk/src/provider.ts +396 -0
- package/sdk/src/transaction.ts +163 -0
- package/sdk/src/types/rose-wasm.d.ts +14 -0
- package/sdk/src/types.ts +97 -0
- package/sdk/src/wasm.ts +13 -0
- package/sdk/tsconfig.json +20 -0
- package/sdk/vite.config.examples.ts +32 -0
- package/tailwind.config.ts +38 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +60 -0
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useStore } from '../store';
|
|
3
|
+
import { useTheme } from '../contexts/ThemeContext';
|
|
4
|
+
import { truncateAddress } from '../utils/format';
|
|
5
|
+
import { send } from '../utils/messaging';
|
|
6
|
+
import { formatWalletError } from '../utils/formatWalletError';
|
|
7
|
+
import { INTERNAL_METHODS, NOCK_TO_NICKS } from '../../shared/constants';
|
|
8
|
+
import { formatNock, isDustAmount, MIN_SENDABLE_NOCK } from '../../shared/currency';
|
|
9
|
+
import type { Account } from '../../shared/types';
|
|
10
|
+
import { AccountIcon } from '../components/AccountIcon';
|
|
11
|
+
import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon';
|
|
12
|
+
import { ChevronDownIcon } from '../components/icons/ChevronDownIcon';
|
|
13
|
+
import { base58 } from '@scure/base';
|
|
14
|
+
import PencilEditIcon from '../assets/pencil-edit-icon.svg';
|
|
15
|
+
import CheckmarkIcon from '../assets/checkmark-pencil-icon.svg';
|
|
16
|
+
import InfoIcon from '../assets/info-icon.svg';
|
|
17
|
+
|
|
18
|
+
function formatInt(n: number) {
|
|
19
|
+
return n.toLocaleString('en-US', { maximumFractionDigits: 0 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function SendScreen() {
|
|
23
|
+
const { theme } = useTheme();
|
|
24
|
+
const { navigate, wallet, syncWallet, setLastTransaction, fetchBalance } = useStore();
|
|
25
|
+
|
|
26
|
+
const [walletDropdownOpen, setWalletDropdownOpen] = useState(false);
|
|
27
|
+
const [receiverAddress, setReceiverAddress] = useState('');
|
|
28
|
+
const [amount, setAmount] = useState('');
|
|
29
|
+
// No default fee - will be calculated once user enters amount
|
|
30
|
+
const [fee, setFee] = useState('');
|
|
31
|
+
const [isEditingFee, setIsEditingFee] = useState(false);
|
|
32
|
+
const [editedFee, setEditedFee] = useState('');
|
|
33
|
+
const [showFeeTooltip, setShowFeeTooltip] = useState(false);
|
|
34
|
+
const [showSpendableTooltip, setShowSpendableTooltip] = useState(false);
|
|
35
|
+
const [error, setError] = useState('');
|
|
36
|
+
const [errorType, setErrorType] = useState<'fee_too_low' | 'general' | null>(null);
|
|
37
|
+
const [isFeeManuallyEdited, setIsFeeManuallyEdited] = useState(false);
|
|
38
|
+
const [isCalculatingFee, setIsCalculatingFee] = useState(false);
|
|
39
|
+
const [minimumFee, setMinimumFee] = useState<number | null>(null); // Minimum fee from WASM calculation
|
|
40
|
+
const [isSendingMax, setIsSendingMax] = useState(false); // Track if user is sending entire balance
|
|
41
|
+
const [isLoadingBalance, setIsLoadingBalance] = useState(false); // Track balance refresh after account switch
|
|
42
|
+
|
|
43
|
+
// Get real accounts from vault (filter out hidden accounts)
|
|
44
|
+
const accounts = (wallet.accounts || []).filter(acc => !acc.hidden);
|
|
45
|
+
const currentAccount = wallet.currentAccount || accounts[0];
|
|
46
|
+
// Use spendable balance (only UTXOs that are not in_flight - can be spent NOW)
|
|
47
|
+
const currentBalance = wallet.spendableBalance;
|
|
48
|
+
|
|
49
|
+
// Refresh balance when screen mounts to ensure spendable balance is accurate
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
fetchBalance();
|
|
52
|
+
}, [fetchBalance]);
|
|
53
|
+
|
|
54
|
+
// Account switching handler
|
|
55
|
+
async function handleSwitchAccount(index: number) {
|
|
56
|
+
setIsLoadingBalance(true);
|
|
57
|
+
setWalletDropdownOpen(false);
|
|
58
|
+
|
|
59
|
+
// Clear amount and fee fields when switching accounts
|
|
60
|
+
setAmount('');
|
|
61
|
+
setFee('');
|
|
62
|
+
setEditedFee('');
|
|
63
|
+
setMinimumFee(null);
|
|
64
|
+
setIsFeeManuallyEdited(false);
|
|
65
|
+
setIsSendingMax(false);
|
|
66
|
+
setError('');
|
|
67
|
+
setErrorType(null);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const result = await send<{ ok?: boolean; account?: Account; error?: string }>(
|
|
71
|
+
INTERNAL_METHODS.SWITCH_ACCOUNT,
|
|
72
|
+
[index]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (result?.ok && result.account) {
|
|
76
|
+
const updatedWallet = {
|
|
77
|
+
...wallet,
|
|
78
|
+
currentAccount: result.account,
|
|
79
|
+
address: result.account.address,
|
|
80
|
+
balance: wallet.accountBalances?.[result.account.address] ?? 0,
|
|
81
|
+
spendableBalance: wallet.accountSpendableBalances?.[result.account.address] ?? 0,
|
|
82
|
+
};
|
|
83
|
+
syncWallet(updatedWallet);
|
|
84
|
+
// Refresh balance to ensure spendable balance is accurate for new account
|
|
85
|
+
await fetchBalance();
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
setIsLoadingBalance(false);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handleMaxAmount() {
|
|
93
|
+
setIsSendingMax(true);
|
|
94
|
+
setIsCalculatingFee(true);
|
|
95
|
+
setError('');
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// Use recipient address if valid, otherwise use a dummy address for estimation
|
|
99
|
+
let addressToUse = receiverAddress.trim();
|
|
100
|
+
|
|
101
|
+
if (addressToUse) {
|
|
102
|
+
try {
|
|
103
|
+
const bytes = base58.decode(addressToUse);
|
|
104
|
+
if (bytes.length !== 40) {
|
|
105
|
+
addressToUse = ''; // Invalid, will use dummy
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
addressToUse = ''; // Invalid base58, will use dummy
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// If no valid address, use dummy (fee doesn't depend on recipient)
|
|
113
|
+
if (!addressToUse) {
|
|
114
|
+
const dummyBytes = new Uint8Array(40).fill(0);
|
|
115
|
+
addressToUse = base58.encode(dummyBytes);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result = await send<{
|
|
119
|
+
maxAmount?: number;
|
|
120
|
+
fee?: number;
|
|
121
|
+
totalAvailable?: number;
|
|
122
|
+
utxoCount?: number;
|
|
123
|
+
error?: string;
|
|
124
|
+
}>(INTERNAL_METHODS.ESTIMATE_MAX_SEND, [addressToUse]);
|
|
125
|
+
|
|
126
|
+
if (result?.error) {
|
|
127
|
+
console.error('[SendScreen] Max estimation error:', result.error);
|
|
128
|
+
setError(formatWalletError(result.error));
|
|
129
|
+
setErrorType('general');
|
|
130
|
+
setIsSendingMax(false);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (result?.maxAmount !== undefined && result?.fee !== undefined) {
|
|
135
|
+
// Set amount to max (already accounts for fee)
|
|
136
|
+
// IMPORTANT: Floor to 5 decimal places to avoid rounding up beyond available balance
|
|
137
|
+
// Standard rounding could make 395.9621276... become 395.96213 which exceeds available
|
|
138
|
+
const maxAmountNock = Math.floor((result.maxAmount / NOCK_TO_NICKS) * 100000) / 100000;
|
|
139
|
+
const feeNock = result.fee / NOCK_TO_NICKS;
|
|
140
|
+
|
|
141
|
+
setAmount(formatNock(maxAmountNock));
|
|
142
|
+
setFee(feeNock.toString());
|
|
143
|
+
setEditedFee(feeNock.toString());
|
|
144
|
+
setMinimumFee(feeNock);
|
|
145
|
+
setIsFeeManuallyEdited(true); // Lock fee for max send
|
|
146
|
+
setError('');
|
|
147
|
+
setErrorType(null);
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error('[SendScreen] Max estimation failed:', err);
|
|
151
|
+
// Fallback: just set balance (will fail validation if fee not covered)
|
|
152
|
+
setAmount(formatNock(currentBalance));
|
|
153
|
+
setIsSendingMax(false);
|
|
154
|
+
} finally {
|
|
155
|
+
setIsCalculatingFee(false);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function handleEditFee() {
|
|
160
|
+
setIsEditingFee(true);
|
|
161
|
+
setEditedFee(fee);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function handleSaveFee() {
|
|
165
|
+
const feeNum = parseFloat(editedFee);
|
|
166
|
+
if (!isNaN(feeNum) && feeNum >= 0) {
|
|
167
|
+
// Validate against minimum fee if we have one
|
|
168
|
+
if (minimumFee !== null && feeNum < minimumFee) {
|
|
169
|
+
setError('Fee too low.');
|
|
170
|
+
setErrorType('fee_too_low');
|
|
171
|
+
// Still allow saving the fee, but show warning
|
|
172
|
+
} else {
|
|
173
|
+
setError('');
|
|
174
|
+
setErrorType(null);
|
|
175
|
+
}
|
|
176
|
+
setFee(editedFee);
|
|
177
|
+
setIsFeeManuallyEdited(true); // Mark as manually edited - stops auto-updates
|
|
178
|
+
}
|
|
179
|
+
setIsEditingFee(false);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function handleFeeInputChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
183
|
+
const value = e.target.value;
|
|
184
|
+
// Only allow numbers and single decimal point
|
|
185
|
+
if (value === '' || /^\d*\.?\d*$/.test(value)) {
|
|
186
|
+
setEditedFee(value);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function handleFeeInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
191
|
+
if (e.key === 'Enter') {
|
|
192
|
+
handleSaveFee();
|
|
193
|
+
}
|
|
194
|
+
if (e.key === 'Escape') {
|
|
195
|
+
setIsEditingFee(false);
|
|
196
|
+
setEditedFee(fee);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function handleFeeInputBlur() {
|
|
201
|
+
// Auto-save fee when input loses focus
|
|
202
|
+
handleSaveFee();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function handleCancel() {
|
|
206
|
+
navigate('home');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function handleContinue() {
|
|
210
|
+
setError('');
|
|
211
|
+
|
|
212
|
+
// Validation
|
|
213
|
+
if (!receiverAddress.trim()) {
|
|
214
|
+
setError('Please enter a receiver address');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Validate V1 PKH address by decoding and checking for exactly 40 bytes
|
|
219
|
+
try {
|
|
220
|
+
const bytes = base58.decode(receiverAddress.trim());
|
|
221
|
+
if (bytes.length !== 40) {
|
|
222
|
+
setError('Invalid Nockchain address (V1 PKH: 40 bytes expected)');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
setError('Invalid Nockchain address (invalid base58 encoding)');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const amountNum = parseFloat(amount.replace(/,/g, ''));
|
|
231
|
+
if (!amount || isNaN(amountNum) || amountNum <= 0) {
|
|
232
|
+
setError('Please enter a valid amount');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (isDustAmount(amountNum)) {
|
|
237
|
+
const minFormatted = MIN_SENDABLE_NOCK.toFixed(16).replace(/\.?0+$/, '');
|
|
238
|
+
setError(`Amount too small. Minimum: ${minFormatted} NOCK`);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const feeNum = parseFloat(fee);
|
|
243
|
+
if (!fee || isNaN(feeNum) || feeNum < 0) {
|
|
244
|
+
setError('Please enter a valid fee');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Check if user has sufficient spendable balance for amount + fee
|
|
249
|
+
const totalNeeded = amountNum + feeNum;
|
|
250
|
+
if (totalNeeded > currentBalance) {
|
|
251
|
+
setError(`Insufficient spendable balance`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Store transaction details for review screen
|
|
256
|
+
setLastTransaction({
|
|
257
|
+
txid: '', // Will be generated when actually sent
|
|
258
|
+
amount: amountNum,
|
|
259
|
+
fee: feeNum,
|
|
260
|
+
to: receiverAddress.trim(),
|
|
261
|
+
from: currentAccount?.address,
|
|
262
|
+
sendMax: isSendingMax, // Flag for sweep transaction (all UTXOs to recipient)
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
navigate('send-review');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// --- Dropdown sizing/positioning (prevents going off screen) ----------------
|
|
269
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
270
|
+
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
271
|
+
const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({});
|
|
272
|
+
const [renderAbove, setRenderAbove] = useState(false);
|
|
273
|
+
|
|
274
|
+
useLayoutEffect(() => {
|
|
275
|
+
if (!walletDropdownOpen || !triggerRef.current) return;
|
|
276
|
+
|
|
277
|
+
const update = () => {
|
|
278
|
+
const r = triggerRef.current!.getBoundingClientRect();
|
|
279
|
+
const width = r.width;
|
|
280
|
+
const left = r.left;
|
|
281
|
+
const gap = 4;
|
|
282
|
+
|
|
283
|
+
// Provisional height (measure if already mounted)
|
|
284
|
+
let menuHeight = 240;
|
|
285
|
+
if (menuRef.current) {
|
|
286
|
+
const mh = menuRef.current.getBoundingClientRect().height;
|
|
287
|
+
if (mh) menuHeight = Math.min(mh, 240);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const spaceBelow = window.innerHeight - r.bottom - gap;
|
|
291
|
+
const shouldFlip = spaceBelow < menuHeight && r.top > menuHeight;
|
|
292
|
+
|
|
293
|
+
const top = shouldFlip ? Math.max(8, r.top - menuHeight - gap) : r.bottom + gap;
|
|
294
|
+
|
|
295
|
+
setRenderAbove(!!shouldFlip);
|
|
296
|
+
setMenuStyle({
|
|
297
|
+
position: 'fixed',
|
|
298
|
+
left,
|
|
299
|
+
top,
|
|
300
|
+
width,
|
|
301
|
+
zIndex: 50,
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
update();
|
|
306
|
+
// Reposition on scroll/resize while open
|
|
307
|
+
window.addEventListener('resize', update);
|
|
308
|
+
window.addEventListener('scroll', update, true);
|
|
309
|
+
// Wait one frame to measure actual menu height
|
|
310
|
+
const raf = requestAnimationFrame(update);
|
|
311
|
+
|
|
312
|
+
return () => {
|
|
313
|
+
window.removeEventListener('resize', update);
|
|
314
|
+
window.removeEventListener('scroll', update, true);
|
|
315
|
+
cancelAnimationFrame(raf);
|
|
316
|
+
};
|
|
317
|
+
}, [walletDropdownOpen]);
|
|
318
|
+
|
|
319
|
+
// Close on outside click / escape
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
if (!walletDropdownOpen) return;
|
|
322
|
+
const onDown = (e: MouseEvent) => {
|
|
323
|
+
if (
|
|
324
|
+
triggerRef.current?.contains(e.target as Node) ||
|
|
325
|
+
menuRef.current?.contains(e.target as Node)
|
|
326
|
+
) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
setWalletDropdownOpen(false);
|
|
330
|
+
};
|
|
331
|
+
const onKey = (e: KeyboardEvent) => {
|
|
332
|
+
if (e.key === 'Escape') setWalletDropdownOpen(false);
|
|
333
|
+
};
|
|
334
|
+
document.addEventListener('mousedown', onDown);
|
|
335
|
+
document.addEventListener('keydown', onKey);
|
|
336
|
+
return () => {
|
|
337
|
+
document.removeEventListener('mousedown', onDown);
|
|
338
|
+
document.removeEventListener('keydown', onKey);
|
|
339
|
+
};
|
|
340
|
+
}, [walletDropdownOpen]);
|
|
341
|
+
|
|
342
|
+
// Dynamic fee estimation - debounced
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
// Skip if user manually edited fee - they have full control
|
|
345
|
+
if (isFeeManuallyEdited) return;
|
|
346
|
+
|
|
347
|
+
// Skip if amount is not entered
|
|
348
|
+
if (!amount) return;
|
|
349
|
+
|
|
350
|
+
const amountNum = parseFloat(amount.replace(/,/g, ''));
|
|
351
|
+
if (isNaN(amountNum) || amountNum <= 0) return;
|
|
352
|
+
|
|
353
|
+
// Use recipient address if provided and valid, otherwise use a dummy address
|
|
354
|
+
// (fee doesn't depend on recipient address, only on amount/UTXOs needed)
|
|
355
|
+
let addressToUse = receiverAddress.trim();
|
|
356
|
+
|
|
357
|
+
// Validate address if provided, otherwise use dummy
|
|
358
|
+
if (addressToUse) {
|
|
359
|
+
try {
|
|
360
|
+
const bytes = base58.decode(addressToUse);
|
|
361
|
+
if (bytes.length !== 40) {
|
|
362
|
+
addressToUse = ''; // Invalid, will use dummy
|
|
363
|
+
}
|
|
364
|
+
} catch {
|
|
365
|
+
addressToUse = ''; // Invalid base58, will use dummy
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// If no valid address provided, use a dummy address for estimation
|
|
370
|
+
// The actual recipient doesn't affect fee - only amount/UTXOs matter
|
|
371
|
+
if (!addressToUse) {
|
|
372
|
+
// Dummy V1 PKH address (8 byte version + 32 byte PKH digest)
|
|
373
|
+
const dummyBytes = new Uint8Array(40).fill(0);
|
|
374
|
+
addressToUse = base58.encode(dummyBytes);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Show loading state immediately
|
|
378
|
+
setIsCalculatingFee(true);
|
|
379
|
+
|
|
380
|
+
// Debounce: wait 500ms before estimating to avoid excessive WASM operations
|
|
381
|
+
const timeoutId = setTimeout(async () => {
|
|
382
|
+
try {
|
|
383
|
+
const amountNicks = Math.floor(amountNum * NOCK_TO_NICKS);
|
|
384
|
+
const result = await send<{ fee?: number; error?: string }>(
|
|
385
|
+
INTERNAL_METHODS.ESTIMATE_TRANSACTION_FEE,
|
|
386
|
+
[addressToUse, amountNicks]
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
if (result?.fee) {
|
|
390
|
+
const feeNock = result.fee / NOCK_TO_NICKS;
|
|
391
|
+
setFee(feeNock.toString());
|
|
392
|
+
setEditedFee(feeNock.toString());
|
|
393
|
+
setMinimumFee(feeNock); // Store as minimum required fee
|
|
394
|
+
setError('');
|
|
395
|
+
setErrorType(null);
|
|
396
|
+
} else if (result?.error) {
|
|
397
|
+
console.error('[SendScreen] Fee estimation error from vault:', result.error);
|
|
398
|
+
setError(formatWalletError(result.error));
|
|
399
|
+
setErrorType('general');
|
|
400
|
+
} else {
|
|
401
|
+
console.warn('[SendScreen] Fee estimation returned no fee or error:', result);
|
|
402
|
+
}
|
|
403
|
+
} catch (error) {
|
|
404
|
+
console.error('[SendScreen] Fee estimation failed:', error);
|
|
405
|
+
// Keep current fee on error - don't disrupt user experience
|
|
406
|
+
} finally {
|
|
407
|
+
setIsCalculatingFee(false);
|
|
408
|
+
}
|
|
409
|
+
}, 500);
|
|
410
|
+
|
|
411
|
+
return () => {
|
|
412
|
+
clearTimeout(timeoutId);
|
|
413
|
+
setIsCalculatingFee(false);
|
|
414
|
+
};
|
|
415
|
+
}, [receiverAddress, amount, isFeeManuallyEdited]);
|
|
416
|
+
|
|
417
|
+
// -----------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<div
|
|
421
|
+
className="w-[357px] h-[600px] flex flex-col"
|
|
422
|
+
style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text-primary)' }}
|
|
423
|
+
>
|
|
424
|
+
{/* Header */}
|
|
425
|
+
<header
|
|
426
|
+
className="flex items-center justify-between h-16 px-4"
|
|
427
|
+
style={{ borderBottom: '1px solid var(--color-divider)' }}
|
|
428
|
+
>
|
|
429
|
+
<button
|
|
430
|
+
className="p-2 transition"
|
|
431
|
+
style={{ color: 'var(--color-text-primary)', opacity: 0.8 }}
|
|
432
|
+
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
|
|
433
|
+
onMouseLeave={e => (e.currentTarget.style.opacity = '0.8')}
|
|
434
|
+
onClick={handleCancel}
|
|
435
|
+
aria-label="Back"
|
|
436
|
+
>
|
|
437
|
+
<ChevronLeftIcon className="w-5 h-5" />
|
|
438
|
+
</button>
|
|
439
|
+
<h1 className="text-[16px] font-medium tracking-[0.01em]">Send NOCK</h1>
|
|
440
|
+
<div className="w-7" /> {/* spacer to balance the back button */}
|
|
441
|
+
</header>
|
|
442
|
+
|
|
443
|
+
{/* Wallet Selector */}
|
|
444
|
+
<div className="px-4 pt-2">
|
|
445
|
+
<div className="relative">
|
|
446
|
+
<button
|
|
447
|
+
ref={triggerRef}
|
|
448
|
+
type="button"
|
|
449
|
+
className="w-full rounded-lg p-2 pr-4 flex items-center gap-2 focus:outline-none focus:ring-2"
|
|
450
|
+
style={{
|
|
451
|
+
border: '1px solid var(--color-surface-700)',
|
|
452
|
+
backgroundColor: 'var(--color-bg)',
|
|
453
|
+
cursor: accounts.length <= 1 ? 'default' : 'pointer',
|
|
454
|
+
}}
|
|
455
|
+
onClick={() => accounts.length > 1 && setWalletDropdownOpen(o => !o)}
|
|
456
|
+
aria-haspopup="listbox"
|
|
457
|
+
aria-expanded={walletDropdownOpen}
|
|
458
|
+
disabled={accounts.length <= 1}
|
|
459
|
+
>
|
|
460
|
+
<div
|
|
461
|
+
className="flex-shrink-0 w-10 h-10 rounded-lg grid place-items-center"
|
|
462
|
+
style={{ backgroundColor: 'var(--color-surface-800)' }}
|
|
463
|
+
>
|
|
464
|
+
<AccountIcon
|
|
465
|
+
styleId={currentAccount?.iconStyleId}
|
|
466
|
+
color={currentAccount?.iconColor}
|
|
467
|
+
className="w-6 h-6"
|
|
468
|
+
/>
|
|
469
|
+
</div>
|
|
470
|
+
<div className="flex-1 text-left">
|
|
471
|
+
<div className="text-[14px] leading-[18px] font-medium tracking-[0.01em]">
|
|
472
|
+
{currentAccount?.name || 'Wallet'}
|
|
473
|
+
</div>
|
|
474
|
+
<div
|
|
475
|
+
className="text-[13px] leading-[18px] tracking-[0.02em]"
|
|
476
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
477
|
+
>
|
|
478
|
+
{truncateAddress(currentAccount?.address)}
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
{accounts.length > 1 && (
|
|
482
|
+
<ChevronDownIcon
|
|
483
|
+
className={`w-4 h-4 transition-transform ${walletDropdownOpen ? 'rotate-180' : ''}`}
|
|
484
|
+
/>
|
|
485
|
+
)}
|
|
486
|
+
</button>
|
|
487
|
+
|
|
488
|
+
{walletDropdownOpen && (
|
|
489
|
+
<div
|
|
490
|
+
ref={menuRef}
|
|
491
|
+
style={{
|
|
492
|
+
...menuStyle,
|
|
493
|
+
backgroundColor: 'var(--color-bg)',
|
|
494
|
+
border: '1px solid var(--color-surface-700)',
|
|
495
|
+
}}
|
|
496
|
+
role="listbox"
|
|
497
|
+
className="rounded-xl shadow-[0_4px_12px_rgba(0,0,0,0.1)] p-1 max-h-[240px] overflow-y-auto"
|
|
498
|
+
>
|
|
499
|
+
{accounts.map(account => {
|
|
500
|
+
const isSelected = currentAccount?.index === account.index;
|
|
501
|
+
return (
|
|
502
|
+
<button
|
|
503
|
+
key={account.index}
|
|
504
|
+
role="option"
|
|
505
|
+
aria-selected={isSelected}
|
|
506
|
+
className="w-full flex items-center gap-2 p-2 rounded-lg transition border"
|
|
507
|
+
style={{
|
|
508
|
+
backgroundColor: 'var(--color-bg)',
|
|
509
|
+
borderColor: isSelected ? 'var(--color-text-primary)' : 'transparent',
|
|
510
|
+
}}
|
|
511
|
+
onMouseEnter={e => {
|
|
512
|
+
if (!isSelected) {
|
|
513
|
+
e.currentTarget.style.backgroundColor = 'var(--color-surface-900)';
|
|
514
|
+
}
|
|
515
|
+
}}
|
|
516
|
+
onMouseLeave={e => {
|
|
517
|
+
if (!isSelected) {
|
|
518
|
+
e.currentTarget.style.backgroundColor = 'var(--color-bg)';
|
|
519
|
+
}
|
|
520
|
+
}}
|
|
521
|
+
onClick={() => handleSwitchAccount(account.index)}
|
|
522
|
+
>
|
|
523
|
+
<div
|
|
524
|
+
className="flex-shrink-0 w-10 h-10 rounded-lg grid place-items-center"
|
|
525
|
+
style={{ backgroundColor: 'var(--color-bg)' }}
|
|
526
|
+
>
|
|
527
|
+
<AccountIcon
|
|
528
|
+
styleId={account.iconStyleId}
|
|
529
|
+
color={account.iconColor}
|
|
530
|
+
className="w-6 h-6"
|
|
531
|
+
/>
|
|
532
|
+
</div>
|
|
533
|
+
<div className="flex-1 text-left">
|
|
534
|
+
<div className="text-[14px] leading-[18px] font-medium tracking-[0.01em]">
|
|
535
|
+
{account.name}
|
|
536
|
+
</div>
|
|
537
|
+
<div
|
|
538
|
+
className="text-[13px] leading-[18px] tracking-[0.02em]"
|
|
539
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
540
|
+
>
|
|
541
|
+
{truncateAddress(account.address)}
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
<div className="ml-auto text-[14px] leading-[18px] font-medium tracking-[0.01em] whitespace-nowrap">
|
|
545
|
+
{formatInt(wallet.accountSpendableBalances?.[account.address] ?? 0)} NOCK
|
|
546
|
+
</div>
|
|
547
|
+
</button>
|
|
548
|
+
);
|
|
549
|
+
})}
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
|
|
555
|
+
{/* Amount */}
|
|
556
|
+
<div className="flex flex-col items-center gap-3 px-4 pt-16 mb-12">
|
|
557
|
+
<input
|
|
558
|
+
type="text"
|
|
559
|
+
inputMode="decimal"
|
|
560
|
+
className="w-full bg-transparent border-0 text-center outline-none font-serif text-[48px] leading-[48px] font-semibold tracking-[-0.036em]"
|
|
561
|
+
style={{
|
|
562
|
+
color: 'var(--color-text-primary)',
|
|
563
|
+
}}
|
|
564
|
+
placeholder="100.00"
|
|
565
|
+
value={amount}
|
|
566
|
+
onChange={e => {
|
|
567
|
+
const value = e.target.value;
|
|
568
|
+
if (value === '' || /^[\d,]*\.?\d{0,5}$/.test(value)) {
|
|
569
|
+
setAmount(value);
|
|
570
|
+
setIsSendingMax(false); // User manually edited, no longer sending max
|
|
571
|
+
}
|
|
572
|
+
}}
|
|
573
|
+
/>
|
|
574
|
+
<div className="w-full h-px" style={{ backgroundColor: 'var(--color-surface-700)' }} />
|
|
575
|
+
<div className="flex items-center gap-2">
|
|
576
|
+
<div
|
|
577
|
+
className="text-[12px] leading-4 font-medium tracking-[0.02em] flex items-center gap-1"
|
|
578
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
579
|
+
>
|
|
580
|
+
Spendable Balance:{' '}
|
|
581
|
+
{isLoadingBalance ? (
|
|
582
|
+
<span
|
|
583
|
+
className="inline-block w-16 h-3 rounded animate-pulse ml-1"
|
|
584
|
+
style={{ backgroundColor: 'var(--color-surface-700)' }}
|
|
585
|
+
/>
|
|
586
|
+
) : (
|
|
587
|
+
`${formatInt(currentBalance)} NOCK`
|
|
588
|
+
)}
|
|
589
|
+
<div
|
|
590
|
+
className="relative inline-block"
|
|
591
|
+
onMouseEnter={() => setShowSpendableTooltip(true)}
|
|
592
|
+
onMouseLeave={() => setShowSpendableTooltip(false)}
|
|
593
|
+
>
|
|
594
|
+
<img
|
|
595
|
+
src={InfoIcon}
|
|
596
|
+
alt="Spendable balance information"
|
|
597
|
+
className="w-3.5 h-3.5 cursor-help"
|
|
598
|
+
/>
|
|
599
|
+
|
|
600
|
+
{showSpendableTooltip && (
|
|
601
|
+
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 w-56 z-50">
|
|
602
|
+
<div
|
|
603
|
+
className="rounded-lg px-3 py-2.5 text-[12px] leading-4 font-medium tracking-[0.02em] shadow-lg"
|
|
604
|
+
style={{
|
|
605
|
+
backgroundColor: 'var(--color-surface-800)',
|
|
606
|
+
color: 'var(--color-text-muted)',
|
|
607
|
+
border: '1px solid var(--color-surface-700)',
|
|
608
|
+
}}
|
|
609
|
+
>
|
|
610
|
+
Excludes UTXOs locked in pending transactions.
|
|
611
|
+
<div
|
|
612
|
+
className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0"
|
|
613
|
+
style={{
|
|
614
|
+
borderLeft: '6px solid transparent',
|
|
615
|
+
borderRight: '6px solid transparent',
|
|
616
|
+
borderTop: '6px solid var(--color-surface-800)',
|
|
617
|
+
}}
|
|
618
|
+
/>
|
|
619
|
+
</div>
|
|
620
|
+
</div>
|
|
621
|
+
)}
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
<button
|
|
625
|
+
onClick={handleMaxAmount}
|
|
626
|
+
className="rounded-full text-[12px] leading-4 font-medium px-[7px] py-[3px] transition"
|
|
627
|
+
style={{ backgroundColor: 'var(--color-surface-800)' }}
|
|
628
|
+
onMouseEnter={e => (e.currentTarget.style.backgroundColor = 'var(--color-surface-700)')}
|
|
629
|
+
onMouseLeave={e => (e.currentTarget.style.backgroundColor = 'var(--color-surface-800)')}
|
|
630
|
+
>
|
|
631
|
+
Max
|
|
632
|
+
</button>
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
|
|
636
|
+
{/* Form */}
|
|
637
|
+
<div className="flex-1 overflow-y-auto flex flex-col gap-2 px-4">
|
|
638
|
+
{/* Receiver */}
|
|
639
|
+
<div className="flex flex-col gap-1.5">
|
|
640
|
+
<label className="text-[13px] leading-[18px] font-medium tracking-[0.02em]">
|
|
641
|
+
Receiver address
|
|
642
|
+
</label>
|
|
643
|
+
<input
|
|
644
|
+
type="text"
|
|
645
|
+
placeholder="Enter Nockchain address"
|
|
646
|
+
value={receiverAddress}
|
|
647
|
+
onChange={e => setReceiverAddress(e.target.value)}
|
|
648
|
+
className="w-full rounded-lg px-4 py-[21px] text-[16px] leading-[22px] font-medium tracking-[0.01em] outline-none"
|
|
649
|
+
style={{
|
|
650
|
+
border: '1px solid var(--color-surface-700)',
|
|
651
|
+
backgroundColor: 'var(--color-bg)',
|
|
652
|
+
color: 'var(--color-text-primary)',
|
|
653
|
+
}}
|
|
654
|
+
onFocus={e => (e.currentTarget.style.borderColor = 'var(--color-primary)')}
|
|
655
|
+
onBlur={e => (e.currentTarget.style.borderColor = 'var(--color-surface-700)')}
|
|
656
|
+
/>
|
|
657
|
+
</div>
|
|
658
|
+
|
|
659
|
+
{/* Fee */}
|
|
660
|
+
<div className="flex flex-col gap-1.5 mb-4">
|
|
661
|
+
<div className="flex items-center justify-between">
|
|
662
|
+
<div className="flex items-center gap-1.5 text-[14px] leading-[18px] font-medium">
|
|
663
|
+
Fee
|
|
664
|
+
<div
|
|
665
|
+
className="relative inline-block"
|
|
666
|
+
onMouseEnter={() => setShowFeeTooltip(true)}
|
|
667
|
+
onMouseLeave={() => setShowFeeTooltip(false)}
|
|
668
|
+
>
|
|
669
|
+
<img src={InfoIcon} alt="Fee information" className="w-4 h-4 cursor-help" />
|
|
670
|
+
|
|
671
|
+
{showFeeTooltip && (
|
|
672
|
+
<div className="absolute left-0 bottom-full mb-2 w-64 z-50">
|
|
673
|
+
<div
|
|
674
|
+
className="rounded-lg px-3 py-2.5 text-[12px] leading-4 font-medium tracking-[0.02em] shadow-lg"
|
|
675
|
+
style={{
|
|
676
|
+
backgroundColor: 'var(--color-surface-800)',
|
|
677
|
+
color: 'var(--color-text-muted)',
|
|
678
|
+
border: '1px solid var(--color-surface-700)',
|
|
679
|
+
}}
|
|
680
|
+
>
|
|
681
|
+
Network transaction fee. Adjustable if needed.
|
|
682
|
+
<div
|
|
683
|
+
className="absolute left-4 top-full w-0 h-0"
|
|
684
|
+
style={{
|
|
685
|
+
borderLeft: '6px solid transparent',
|
|
686
|
+
borderRight: '6px solid transparent',
|
|
687
|
+
borderTop: '6px solid var(--color-surface-800)',
|
|
688
|
+
}}
|
|
689
|
+
/>
|
|
690
|
+
</div>
|
|
691
|
+
</div>
|
|
692
|
+
)}
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
{isEditingFee ? (
|
|
696
|
+
<div
|
|
697
|
+
className="rounded-lg pl-1 pr-1 py-1 inline-flex items-center gap-2 "
|
|
698
|
+
style={{ border: '1px solid var(--color-surface-700)' }}
|
|
699
|
+
>
|
|
700
|
+
<input
|
|
701
|
+
type="text"
|
|
702
|
+
inputMode="decimal"
|
|
703
|
+
value={editedFee}
|
|
704
|
+
onChange={handleFeeInputChange}
|
|
705
|
+
onKeyDown={handleFeeInputKeyDown}
|
|
706
|
+
onBlur={handleFeeInputBlur}
|
|
707
|
+
autoFocus
|
|
708
|
+
className="w-8 h-3 bg-transparent outline-none text-[14px] leading-[18px] font-medium text-right"
|
|
709
|
+
style={{ color: 'var(--color-text-primary)' }}
|
|
710
|
+
placeholder="1"
|
|
711
|
+
/>
|
|
712
|
+
<span
|
|
713
|
+
className="text-[14px] leading-[18px] font-medium"
|
|
714
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
715
|
+
>
|
|
716
|
+
NOCK
|
|
717
|
+
</span>
|
|
718
|
+
<button
|
|
719
|
+
type="button"
|
|
720
|
+
onClick={handleSaveFee}
|
|
721
|
+
className="p-0.5 rounded transition-opacity hover:opacity-80 focus:outline-none"
|
|
722
|
+
aria-label="Save fee"
|
|
723
|
+
>
|
|
724
|
+
<img src={CheckmarkIcon} alt="" className="w-5 h-5" />
|
|
725
|
+
</button>
|
|
726
|
+
</div>
|
|
727
|
+
) : (
|
|
728
|
+
<button
|
|
729
|
+
type="button"
|
|
730
|
+
onClick={handleEditFee}
|
|
731
|
+
className="rounded-lg pl-2.5 pr-2 py-1.5 flex items-center justify-between transition-colors focus:outline-none"
|
|
732
|
+
style={{
|
|
733
|
+
backgroundColor: 'var(--color-surface-800)',
|
|
734
|
+
minWidth: '120px',
|
|
735
|
+
minHeight: '34px',
|
|
736
|
+
}}
|
|
737
|
+
onMouseEnter={e =>
|
|
738
|
+
(e.currentTarget.style.backgroundColor = 'var(--color-surface-700)')
|
|
739
|
+
}
|
|
740
|
+
onMouseLeave={e =>
|
|
741
|
+
(e.currentTarget.style.backgroundColor = 'var(--color-surface-800)')
|
|
742
|
+
}
|
|
743
|
+
>
|
|
744
|
+
<div
|
|
745
|
+
className="text-[14px] leading-[18px] font-medium flex items-center gap-1.5"
|
|
746
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
747
|
+
>
|
|
748
|
+
{isCalculatingFee ? (
|
|
749
|
+
<div
|
|
750
|
+
className="w-3.5 h-3.5 border-2 rounded-full animate-spin"
|
|
751
|
+
style={{
|
|
752
|
+
borderColor: 'var(--color-text-muted)',
|
|
753
|
+
borderTopColor: 'transparent',
|
|
754
|
+
}}
|
|
755
|
+
/>
|
|
756
|
+
) : fee ? (
|
|
757
|
+
`${fee} NOCK`
|
|
758
|
+
) : (
|
|
759
|
+
'-'
|
|
760
|
+
)}
|
|
761
|
+
</div>
|
|
762
|
+
<img src={PencilEditIcon} alt="Edit" className="w-4 h-4 flex-shrink-0" />
|
|
763
|
+
</button>
|
|
764
|
+
)}
|
|
765
|
+
</div>
|
|
766
|
+
|
|
767
|
+
{/* Error display - below fee section */}
|
|
768
|
+
{error && (
|
|
769
|
+
<div
|
|
770
|
+
className="px-3 py-2 text-[13px] leading-[18px] font-medium rounded-lg flex items-center justify-between mt-2"
|
|
771
|
+
style={{
|
|
772
|
+
backgroundColor: 'var(--color-red-light)',
|
|
773
|
+
color: 'var(--color-red)',
|
|
774
|
+
}}
|
|
775
|
+
>
|
|
776
|
+
<span>{error}</span>
|
|
777
|
+
{errorType === 'fee_too_low' && minimumFee !== null && (
|
|
778
|
+
<button
|
|
779
|
+
type="button"
|
|
780
|
+
onClick={() => {
|
|
781
|
+
const feeStr = minimumFee.toString();
|
|
782
|
+
setFee(feeStr);
|
|
783
|
+
setEditedFee(feeStr);
|
|
784
|
+
setError('');
|
|
785
|
+
setErrorType(null);
|
|
786
|
+
setIsFeeManuallyEdited(false); // Allow auto-updates again
|
|
787
|
+
}}
|
|
788
|
+
className="underline hover:opacity-70 transition-opacity"
|
|
789
|
+
style={{ color: 'var(--color-red)' }}
|
|
790
|
+
>
|
|
791
|
+
Reset
|
|
792
|
+
</button>
|
|
793
|
+
)}
|
|
794
|
+
</div>
|
|
795
|
+
)}
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
|
|
799
|
+
{/* Actions */}
|
|
800
|
+
<div
|
|
801
|
+
className="flex gap-3 p-3 mt-auto"
|
|
802
|
+
style={{ borderTop: '1px solid var(--color-divider)' }}
|
|
803
|
+
>
|
|
804
|
+
<button
|
|
805
|
+
className="flex-1 rounded-lg px-5 py-3.5 text-[14px] leading-[18px] font-medium transition"
|
|
806
|
+
style={{ backgroundColor: 'var(--color-surface-800)' }}
|
|
807
|
+
onMouseEnter={e => (e.currentTarget.style.backgroundColor = 'var(--color-surface-700)')}
|
|
808
|
+
onMouseLeave={e => (e.currentTarget.style.backgroundColor = 'var(--color-surface-800)')}
|
|
809
|
+
onClick={handleCancel}
|
|
810
|
+
>
|
|
811
|
+
Cancel
|
|
812
|
+
</button>
|
|
813
|
+
<button
|
|
814
|
+
className="flex-1 rounded-lg px-5 py-3.5 text-[14px] leading-[18px] font-medium transition"
|
|
815
|
+
style={{ backgroundColor: 'var(--color-primary)', color: '#000' }}
|
|
816
|
+
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
|
|
817
|
+
onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
|
|
818
|
+
onClick={handleContinue}
|
|
819
|
+
>
|
|
820
|
+
Continue
|
|
821
|
+
</button>
|
|
822
|
+
</div>
|
|
823
|
+
</div>
|
|
824
|
+
);
|
|
825
|
+
}
|