@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.
Files changed (205) hide show
  1. package/.github/workflows/artifacts.yml +33 -0
  2. package/.github/workflows/ci.yml +68 -0
  3. package/.github/workflows/publish-sdk.yml +35 -0
  4. package/.nvmrc +1 -0
  5. package/.prettierignore +5 -0
  6. package/.prettierrc +8 -0
  7. package/LICENSE +22 -0
  8. package/README.md +117 -0
  9. package/extension/background/index.ts +1500 -0
  10. package/extension/content/index.ts +59 -0
  11. package/extension/icons/rose.svg +27 -0
  12. package/extension/icons/rose128.png +0 -0
  13. package/extension/icons/rose16.png +0 -0
  14. package/extension/icons/rose256.png +0 -0
  15. package/extension/icons/rose32.png +0 -0
  16. package/extension/icons/rose48.png +0 -0
  17. package/extension/icons/rose512.png +0 -0
  18. package/extension/inpage/index.ts +86 -0
  19. package/extension/manifest.json +48 -0
  20. package/extension/popup/Popup.tsx +94 -0
  21. package/extension/popup/Router.tsx +121 -0
  22. package/extension/popup/assets/arrow-down-icon.svg +3 -0
  23. package/extension/popup/assets/arrow-left-icon.svg +3 -0
  24. package/extension/popup/assets/arrow-right-icon.svg +3 -0
  25. package/extension/popup/assets/arrow-up-icon.svg +3 -0
  26. package/extension/popup/assets/arrow-up-right-icon.svg +3 -0
  27. package/extension/popup/assets/checkmark-icon.svg +3 -0
  28. package/extension/popup/assets/checkmark-pencil-icon.svg +3 -0
  29. package/extension/popup/assets/checkmark-success-icon.svg +3 -0
  30. package/extension/popup/assets/clock-icon.svg +3 -0
  31. package/extension/popup/assets/close-x-icon.svg +3 -0
  32. package/extension/popup/assets/copy-icon.svg +6 -0
  33. package/extension/popup/assets/explorer-icon.svg +3 -0
  34. package/extension/popup/assets/eye-off-icon.svg +3 -0
  35. package/extension/popup/assets/eye-open-icon.svg +4 -0
  36. package/extension/popup/assets/feedback-icon.svg +3 -0
  37. package/extension/popup/assets/green-status-dot.svg +3 -0
  38. package/extension/popup/assets/info-icon.svg +3 -0
  39. package/extension/popup/assets/iris-logo-40.svg +27 -0
  40. package/extension/popup/assets/iris-logo-96.svg +27 -0
  41. package/extension/popup/assets/iris-logo-blue.svg +27 -0
  42. package/extension/popup/assets/iris-logo-no-eye.svg +27 -0
  43. package/extension/popup/assets/iris-logo-orange.svg +27 -0
  44. package/extension/popup/assets/iris-logo.svg +27 -0
  45. package/extension/popup/assets/key-icon.svg +3 -0
  46. package/extension/popup/assets/lock-icon-yellow.svg +3 -0
  47. package/extension/popup/assets/lock-icon.svg +3 -0
  48. package/extension/popup/assets/pencil-edit-icon.svg +3 -0
  49. package/extension/popup/assets/permissions-icon.svg +3 -0
  50. package/extension/popup/assets/receipt-icon.svg +5 -0
  51. package/extension/popup/assets/refresh-icon.svg +3 -0
  52. package/extension/popup/assets/settings-gear-icon.svg +8 -0
  53. package/extension/popup/assets/settings-icon.svg +3 -0
  54. package/extension/popup/assets/theme-icon.svg +3 -0
  55. package/extension/popup/assets/trash-bin-icon.svg +3 -0
  56. package/extension/popup/assets/trend-down-arrow.svg +5 -0
  57. package/extension/popup/assets/trend-up-arrow.svg +5 -0
  58. package/extension/popup/assets/user-account-icon.svg +3 -0
  59. package/extension/popup/assets/vector-bottom-left.svg +9 -0
  60. package/extension/popup/assets/vector-left.svg +9 -0
  61. package/extension/popup/assets/vector-right.svg +9 -0
  62. package/extension/popup/assets/vector-top-right-rotated.svg +8 -0
  63. package/extension/popup/assets/vector-top-right.svg +9 -0
  64. package/extension/popup/assets/wallet-dropdown-arrow.svg +5 -0
  65. package/extension/popup/assets/wallet-icon-style-1.svg +6 -0
  66. package/extension/popup/assets/wallet-icon-style-10.svg +8 -0
  67. package/extension/popup/assets/wallet-icon-style-11.svg +8 -0
  68. package/extension/popup/assets/wallet-icon-style-12.svg +8 -0
  69. package/extension/popup/assets/wallet-icon-style-13.svg +8 -0
  70. package/extension/popup/assets/wallet-icon-style-14.svg +8 -0
  71. package/extension/popup/assets/wallet-icon-style-15.svg +8 -0
  72. package/extension/popup/assets/wallet-icon-style-2.svg +8 -0
  73. package/extension/popup/assets/wallet-icon-style-3.svg +8 -0
  74. package/extension/popup/assets/wallet-icon-style-4.svg +8 -0
  75. package/extension/popup/assets/wallet-icon-style-5.svg +8 -0
  76. package/extension/popup/assets/wallet-icon-style-6.svg +8 -0
  77. package/extension/popup/assets/wallet-icon-style-7.svg +8 -0
  78. package/extension/popup/assets/wallet-icon-style-8.svg +8 -0
  79. package/extension/popup/assets/wallet-icon-style-9.svg +8 -0
  80. package/extension/popup/components/AccountIcon.tsx +78 -0
  81. package/extension/popup/components/AccountSelector.tsx +246 -0
  82. package/extension/popup/components/Alert.tsx +48 -0
  83. package/extension/popup/components/ConfirmModal.tsx +81 -0
  84. package/extension/popup/components/PasswordInput.tsx +49 -0
  85. package/extension/popup/components/ScreenContainer.tsx +17 -0
  86. package/extension/popup/components/SiteIcon.tsx +60 -0
  87. package/extension/popup/components/ThemeToggle.tsx +44 -0
  88. package/extension/popup/components/icons/ArrowDownLeftIcon.tsx +20 -0
  89. package/extension/popup/components/icons/ArrowUpRightIcon.tsx +20 -0
  90. package/extension/popup/components/icons/CheckIcon.tsx +20 -0
  91. package/extension/popup/components/icons/ChevronDownIcon.tsx +15 -0
  92. package/extension/popup/components/icons/ChevronLeftIcon.tsx +15 -0
  93. package/extension/popup/components/icons/ChevronRightIcon.tsx +15 -0
  94. package/extension/popup/components/icons/ChevronUpIcon.tsx +15 -0
  95. package/extension/popup/components/icons/CloseIcon.tsx +26 -0
  96. package/extension/popup/components/icons/CopyIcon.tsx +20 -0
  97. package/extension/popup/components/icons/EditIcon.tsx +20 -0
  98. package/extension/popup/components/icons/EyeIcon.tsx +13 -0
  99. package/extension/popup/components/icons/EyeOffIcon.tsx +13 -0
  100. package/extension/popup/components/icons/InfoIcon.tsx +20 -0
  101. package/extension/popup/components/icons/LockIcon.tsx +20 -0
  102. package/extension/popup/components/icons/PlusIcon.tsx +15 -0
  103. package/extension/popup/components/icons/ReceiveArrowIcon.tsx +14 -0
  104. package/extension/popup/components/icons/ReceiveCircleIcon.tsx +20 -0
  105. package/extension/popup/components/icons/SendPaperPlaneIcon.tsx +18 -0
  106. package/extension/popup/components/icons/SentArrowIcon.tsx +21 -0
  107. package/extension/popup/components/icons/SettingsIcon.tsx +26 -0
  108. package/extension/popup/components/icons/ShieldIcon.tsx +20 -0
  109. package/extension/popup/components/icons/UploadIcon.tsx +20 -0
  110. package/extension/popup/components/icons/WalletIcon.tsx +20 -0
  111. package/extension/popup/contexts/ThemeContext.tsx +105 -0
  112. package/extension/popup/hooks/useApprovalDetection.ts +128 -0
  113. package/extension/popup/hooks/useAutoFocus.ts +36 -0
  114. package/extension/popup/hooks/useAutoRejectOnClose.ts +25 -0
  115. package/extension/popup/hooks/useClickOutside.ts +33 -0
  116. package/extension/popup/hooks/useCopyToClipboard.ts +33 -0
  117. package/extension/popup/hooks/useFavicon.ts +64 -0
  118. package/extension/popup/hooks/useNumericInput.ts +93 -0
  119. package/extension/popup/index.html +13 -0
  120. package/extension/popup/index.tsx +24 -0
  121. package/extension/popup/screens/AboutScreen.tsx +118 -0
  122. package/extension/popup/screens/HomeScreen.tailwind.css +85 -0
  123. package/extension/popup/screens/HomeScreen.tsx +902 -0
  124. package/extension/popup/screens/KeySettingsPasswordScreen.tsx +164 -0
  125. package/extension/popup/screens/LockTimeScreen.tsx +155 -0
  126. package/extension/popup/screens/ReceiveScreen.tsx +149 -0
  127. package/extension/popup/screens/RecoveryPhraseScreen.tsx +183 -0
  128. package/extension/popup/screens/SendReviewScreen.tsx +308 -0
  129. package/extension/popup/screens/SendScreen.tsx +825 -0
  130. package/extension/popup/screens/SendSubmittedScreen.tsx +193 -0
  131. package/extension/popup/screens/SettingsScreen.tsx +116 -0
  132. package/extension/popup/screens/ThemeSettingsScreen.tsx +107 -0
  133. package/extension/popup/screens/TransactionDetailsScreen.tsx +346 -0
  134. package/extension/popup/screens/ViewSecretPhraseScreen.tsx +212 -0
  135. package/extension/popup/screens/WalletPermissionsScreen.tsx +123 -0
  136. package/extension/popup/screens/WalletSettingsScreen.tsx +381 -0
  137. package/extension/popup/screens/WalletStylingScreen.tsx +306 -0
  138. package/extension/popup/screens/approvals/ConnectApprovalScreen.tsx +136 -0
  139. package/extension/popup/screens/approvals/SignMessageScreen.tsx +140 -0
  140. package/extension/popup/screens/approvals/SignRawTxScreen.tsx +320 -0
  141. package/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +167 -0
  142. package/extension/popup/screens/onboarding/BackupScreen.tsx +254 -0
  143. package/extension/popup/screens/onboarding/CreateScreen.tsx +273 -0
  144. package/extension/popup/screens/onboarding/ImportScreen.tsx +676 -0
  145. package/extension/popup/screens/onboarding/ImportScreenV0.tsx +678 -0
  146. package/extension/popup/screens/onboarding/ImportSuccessScreen.tsx +236 -0
  147. package/extension/popup/screens/onboarding/ResumeBackupScreen.tsx +166 -0
  148. package/extension/popup/screens/onboarding/StartScreen.tsx +142 -0
  149. package/extension/popup/screens/onboarding/SuccessScreen.tsx +193 -0
  150. package/extension/popup/screens/onboarding/VerifyScreen.tsx +220 -0
  151. package/extension/popup/screens/system/LockedScreen.tsx +288 -0
  152. package/extension/popup/screens/transactions/ReceiveScreen.tsx +84 -0
  153. package/extension/popup/screens/transactions/SentScreen.tsx +138 -0
  154. package/extension/popup/store.ts +482 -0
  155. package/extension/popup/styles.css +246 -0
  156. package/extension/popup/utils/format.ts +58 -0
  157. package/extension/popup/utils/formatWalletError.ts +36 -0
  158. package/extension/popup/utils/memo.ts +299 -0
  159. package/extension/popup/utils/messaging.ts +16 -0
  160. package/extension/shared/address-encoding.ts +69 -0
  161. package/extension/shared/balance-query.ts +123 -0
  162. package/extension/shared/constants.ts +386 -0
  163. package/extension/shared/currency.ts +128 -0
  164. package/extension/shared/first-name-derivation.ts +128 -0
  165. package/extension/shared/keyfile.ts +58 -0
  166. package/extension/shared/onboarding.ts +78 -0
  167. package/extension/shared/price-api.ts +79 -0
  168. package/extension/shared/rpc-client-browser.ts +315 -0
  169. package/extension/shared/transaction-builder.ts +443 -0
  170. package/extension/shared/types.ts +450 -0
  171. package/extension/shared/utxo-diff.ts +212 -0
  172. package/extension/shared/utxo-store.ts +548 -0
  173. package/extension/shared/utxo-sync.ts +343 -0
  174. package/extension/shared/validators.ts +26 -0
  175. package/extension/shared/vault.ts +1580 -0
  176. package/extension/shared/wallet-crypto.ts +77 -0
  177. package/extension/shared/wasm-utils.ts +76 -0
  178. package/extension/shared/webcrypto.ts +67 -0
  179. package/extension/types/wasm.d.ts +13 -0
  180. package/package.json +39 -0
  181. package/postcss.config.js +6 -0
  182. package/rose-extension-dist.zip +0 -0
  183. package/sdk/README.md +88 -0
  184. package/sdk/examples/app.ts +166 -0
  185. package/sdk/examples/index.html +51 -0
  186. package/sdk/examples/tsconfig.json +15 -0
  187. package/sdk/examples/tx-builder.html +532 -0
  188. package/sdk/examples/tx-builder.ts +1766 -0
  189. package/sdk/package-lock.json +424 -0
  190. package/sdk/package.json +68 -0
  191. package/sdk/src/constants.ts +28 -0
  192. package/sdk/src/errors.ts +74 -0
  193. package/sdk/src/hooks/index.ts +1 -0
  194. package/sdk/src/hooks/use-rose.ts +94 -0
  195. package/sdk/src/index.ts +12 -0
  196. package/sdk/src/provider.ts +396 -0
  197. package/sdk/src/transaction.ts +163 -0
  198. package/sdk/src/types/rose-wasm.d.ts +14 -0
  199. package/sdk/src/types.ts +97 -0
  200. package/sdk/src/wasm.ts +13 -0
  201. package/sdk/tsconfig.json +20 -0
  202. package/sdk/vite.config.examples.ts +32 -0
  203. package/tailwind.config.ts +38 -0
  204. package/tsconfig.json +20 -0
  205. 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
+ }