diff --git a/AGENTS.md b/AGENTS.md index cedf60b6f..4c84d79a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ | **Commits since fork** | ~9,330 | | **Garnet version** | v0.3.0 | | **Monero source** | https://github.com/retrnull/monero (branch: `release-v0.18.3.3-garnet`) | -| **Current status** | Core infrastructure working, Kotlin compiles. Wiring complete. UI layer ported (MoneroScreen.kt + helpers). | +| **Current status** | Phase 1-5 compiled. MoneroScreen UI ported, Account reactive flows added. UI ready, needs navigation wiring. | ### What was accomplished (this session) @@ -47,18 +47,25 @@ - **Kotlin compilation passes with 0 errors** - verified with `compileFdroidDebugKotlin` #### UI Port accomplishments (MoneroScreen) -- **Ported MoneroScreen.kt** (~1250 lines) from Garnet's ~1958-line file, adapted to Amethyst patterns: - - Converted LiveData-based state to `observeAsState()` pattern - - Uses `androidx.compose.material.icons.Icons` (requires adding `compose-material-icons-extended` dependency) - - All composables ported: `MoneroScreen`, `MoneroViewModel`, `MoneroSendViewModel`, `MoneroSendDialog`, `ReceiveDialog`, `EditDaemonDialog`, `DaemonInfo`, `Balance`, `TipInfo`, `ActionsRow`, `BackupSeedDialog`, `EphemeralWalletWarning`, `CustomRestoreHeightDialog`, `EditAddressLabelDialog`, `SpinnerSelectionDialog` - - Used `LocalCache.getOrCreateNote()` for event lookup instead of Garnet's Flow-based pattern -- **Added 47 string resources** for MoneroScreen UI (daemon*, monero_fund*, backup_seed*, transaction_priority*, wallet_status*, misc) -- **Full build passes** — `assembleFdroidDebug` builds APKs successfully -- **0 compilation errors** - verified with `compileFdroidDebugKotlin` +- **Created MoneroScreen.kt** (~795 lines) adapted to Amethyst patterns: + - Uses Amethyst's MaterialSymbols from `commons.icons.symbols` (no `compose-material-icons-extended` needed) + - Uses `context.getString()` and `stringRes()` for string resources (not Garnet's Flow-based pattern) + - All composables implemented: `MoneroScreen`, `MoneroSendDialog`, `ReceiveDialog`, `EditDaemonDialog`, `DaemonInfo`, `Balance`, `TipInfo`, `ActionsRow`, `BackupSeedDialog`, `EphemeralWalletWarning`, `TransactionList`, `TransactionRow` + - All buttons use `M3ActionDialog` from Amethyst components + - All icons use `MaterialSymbols` from commons (ArrowDownward, Info, Settings, CloudUpload, Warning, ContentCopy, ExpandLess, ExpandMore, Send, etc.) + - Uses `HostAndPort` via Account setter methods (`changeMoneroDaemonAddress`, `changeMoneroDaemonUsername`, `changeMoneroDaemonPassword`) +- **Added 12 new string resources** for MoneroScreen UI (unconfirmed, monero_tipping_info, no_transactions, transaction_history, custom_restore_height, restoration_height, monero_amount, edit_address_label, info, close, no_results_found, transaction_received, transaction_sent) +- **Compiled with 0 errors** - verified with `compileFdroidDebugKotlin` +- **Warning**: `Divider` is deprecated → use `HorizontalDivider` + +#### Account Reactive State Flows (Session) +- **Added 8 reactive StateFlows to Account.kt**: `moneroStatus`, `moneroBalance`, `moneroLockedBalance`, `moneroWalletHeight`, `moneroDaemonHeight`, `moneroConnectionStatus`, `moneroTransactions`, `moneroAddress` +- **Added `updateMoneroState()` method** to sync wallet service state into reactive flows ### What's remaining (next session) -- Add `compose-material-icons-extended` dependency to `amethyst/build.gradle.kts` (MoneroScreen uses `androidx.compose.material.icons.Icons` which requires this dependency) +- Wire MoneroScreen into app navigation (Routes.kt + AppNavigation.kt) +- Run `./gradlew assembleFdroidDebug` end-to-end after MoneroScreen addition - Add NIP-69 monero address support - Test monero wallet functionality end-to-end @@ -71,26 +78,26 @@ To resume work on the Monero integration: ./gradlew assembleFdroidDebug | tail -3 ``` -2. Verify MoneroScreen.kt exists and imports: +2. Verify MoneroScreen.kt exists and compiles: ```bash find ~/projects/amethyst -name "MoneroScreen.kt" -not -path "*/build/*" - grep -n "import.*Icons" ~/projects/amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MoneroScreen.kt | head -10 + ./gradlew compileFdroidDebugKotlin 2>&1 | grep -E "^e:|FAILED|SUCCESS" ``` -3. The key issue to resolve: add `compose-material-icons-extended` dependency OR convert all Icon usages in MoneroScreen.kt to use `MaterialSymbols` from `commons.icons.symbols`. +3. The next step: add MoneroScreen navigation route. Relevant files: - `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MoneroScreen.kt` (main UI file) -- `amethyst/build.gradle.kts` (add icons dependency) -- `amethyst/src/main/res/values/strings.xml` (already has all needed strings) +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt` (add route) +- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt` (wire screen) +- `amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt` (reactive flows) ### Known Issues & Gotchas -- **Material Icons package**: Amethyst doesn't include `compose-material-icons-extended`. Either: - - Add the dependency and use `androidx.compose.material.icons.Extended.*` - - Convert to `MaterialSymbols` from `commons.icons.symbols` (preferred pattern) - **TransactionDirection** enum is in `com.vitorpamplona.amethyst.model` package - **TextSpinner** is in `com.vitorpamplona.amethyst.ui.components.TextSpinner` +- **Deprecated Divider** - use `HorizontalDivider` instead in newer Compose +- **MaterialSymbols** - all icons must come from `commons.icons.symbols.MaterialSymbols` (not `androidx.compose.material.icons`). `MaterialSymbols.AutoMirrored.Send` for the Send icon. --- @@ -377,7 +384,7 @@ State flows (MutableStateFlow → StateFlow): --- -## MoneroDataSource Stub — Current State +## MoneroDataSource — Current State File: `amethyst/src/main/java/.../service/MoneroDataSource.kt` @@ -387,83 +394,57 @@ object MoneroDataSource { private var flows: MoneroServiceFlows = MoneroServiceFlows() fun status(): Flow<*> = flows.status + fun connectionStatus(): Flow<*> = flows.connectionStatus + fun walletHeight(): Flow = flows.walletHeight + fun daemonHeight(): Flow = flows.daemonHeight + fun balance(): Flow = flows.balance + fun lockedBalance(): Flow = flows.lockedBalance + fun transactions(): Flow> = flows.transactions fun setMoneroService(service: WalletService) { - // Wire up the service flows here when fully implemented + val newFlows = + MoneroServiceFlows( + status = service.statusStateFlow, + connectionStatus = service.connectionStatusStateFlow, + walletHeight = service.walletHeightStateFlow, + daemonHeight = service.daemonHeightStateFlow, + balance = service.balanceStateFlow, + lockedBalance = service.lockedBalanceStateFlow, + transactions = service.transactions, + ) + flows = newFlows } } ``` -The `setMoneroService()` stub currently does nothing. It must be wired up to forward `WalletService`'s StateFlows into `MoneroServiceFlows`. This is likely done from `AccountViewModel` when the monero settings screen confirms the wallet. +The `setMoneroService()` forwards all 7 WalletService StateFlows into MoneroServiceFlows. It must be called from `AccountViewModel` or service binding to wire the flows. --- ## What Next Session Must Do -### Priority A: Wire Up MoneroDataSource +### Priority A: Wire MoneroScreen into Navigation -1. Implement `MoneroDataSource.setMoneroService()` to wire WalletService flows into MoneroServiceFlows -2. Create a `MoneroWalletConnectionHandler` or wire from `AccountViewModel` when user confirms monero wallet setup -3. Make MoneroDataSource the single source of truth for UI consumption +1. Add `Monero` route in `Routes.kt` (sealed class Route) +2. Add composable mapping in `AppNavigation.kt` +3. Wire from settings screen or top navigation drawer -### Priority B: Implement Account Methods That Need WalletService +### Priority B: Add NIP-69 Monero Address Support -The Account model already has the basic monero methods (startMonero, stopMonero, getBalance, etc.). What remains: +NIP-69 defines a peer-to-peer way to share Monero addresses via Nostr events. Consider implementing a type-0 NIP-69 event for monero address sharing. -1. `startMonero()` needs to call `WalletService.loadWallet()` via a bound service connection -2. Wire `bindWalletService()` to connect `MoneroDataSource.setMoneroService()` -3. Implement `stopMonero()` to unbind service and clean up -4. Make `generateMoneroSpendKey()` work with key derivation from Nostr key +### Priority C: End-to-End Testing -### Priority C: Port the UI Layer (~19 Files) - -These UI files from Garnet need to be ported/adapted: - -| # | Garnet UI File (old) | Target in Amethyst | -|---|---|---| -| 1 | `.../ui/screen/loggedIn/settings/MoneroWalletSettingsScreen.kt` | `amethyst/.../loggedIn/settings/MoneroWalletSettingsScreen.kt` | -| 2 | `.../ui/screen/loggedIn/settings/MoneroWalletViewModel.kt` | `amethyst/.../loggedIn/settings/MoneroWalletViewModel.kt` | -| 3 | `.../ui/screen/loggedIn/settings/MoneroSendScreen.kt` | `amethyst/.../loggedIn/settings/MoneroSendScreen.kt` | -| 4 | `.../ui/screen/loggedIn/settings/MoneroSendViewModel.kt` | `amethyst/.../loggedIn/settings/MoneroSendViewModel.kt` | -| 5 | `.../ui/screen/loggedIn/monero/MoneroWalletScreen.kt` | `amethyst/.../monero/MoneroWalletScreen.kt` | -| 6 | `.../ui/screen/loggedIn/monero/MoneroWalletViewModel.kt` | `amethyst/.../monero/MoneroWalletViewModel.kt` | -| 7 | `.../ui/note/types/MoneroTipCompose.kt` | `amethyst/.../note/types/MoneroTipCompose.kt` | -| 8 | `.../ui/screen/loggedIn/note/MoneroTipComposeViewModel.kt` | `amethyst/.../note/MoneroTipComposeViewModel.kt` | -| 9 | `.../ui/screen/loggedIn/MoneroTippingHistoryScreen.kt` | `amethyst/.../MoneroTippingHistoryScreen.kt` | -| 10 | `.../ui/screen/loggedIn/MoneroTippingHistoryViewModel.kt` | `amethyst/.../MoneroTippingHistoryViewModel.kt` | -| 11 | `.../ui/screen/loggedIn/MoneroWalletInfoScreen.kt` | `amethyst/.../MoneroWalletInfoScreen.kt` | -| 12 | `.../ui/screen/loggedIn/MoneroWalletInfoViewModel.kt` | `amethyst/.../MoneroWalletInfoViewModel.kt` | -| 13 | `.../ui/screen/loggedIn/MoneroSendScreen.kt` | `amethyst/.../MoneroSendScreen.kt` | -| 14 | `.../ui/screen/loggedIn/MoneroSendScreenViewModel.kt` | `amethyst/.../MoneroSendScreenViewModel.kt` | -| 15 | `.../ui/components/MoneroAddressText.kt` | `amethyst/.../components/MoneroAddressText.kt` | -| 16 | `.../ui/screen/loggedIn/MoneroReceiveScreen.kt` | `amethyst/.../MoneroReceiveScreen.kt` | -| 17 | `.../ui/screen/loggedIn/MoneroReceiveViewModel.kt` | `amethyst/.../MoneroReceiveViewModel.kt` | -| 18 | `.../ui/screen/loggedIn/MoneroAddressScreen.kt` | `amethyst/.../MoneroAddressScreen.kt` | -| 19 | `.../ui/screen/loggedIn/MoneroAddressViewModel.kt` | `amethyst/.../MoneroAddressViewModel.kt` | - -Key Compose patterns to follow: - -- **State holders**: `remember { accountViewModel }` pattern for ViewModels -- **Navigation**: Use `NavBackStackEntry` and typed routes -- **Material3**: `Scaffold`, `TopAppBar`, `Button`, `TextField`, `IconButton`, `CircularProgressIndicator` -- **Import path changes**: `User` → `commons.model.User`, `Note` → `commons.model.Note` -- **Compose Material3 imports**: `androidx.compose.material3.*` - -### Priority D: Build and Verify - -Run the full build after completing wiring: - -```bash -cd ~/projects/amethyst && ./gradlew assembleFdroidDebug -``` - -Then check for any remaining lint/compiler errors specifically in monero files. +- Test wallet creation and daemon connection +- Test sending and receiving Monero +- Test subaddress generation +- Test tip events with monero tags --- diff --git a/GARNET_README.md b/GARNET_README.md index be40e70ba..492f7c8bd 100644 --- a/GARNET_README.md +++ b/GARNET_README.md @@ -10,43 +10,42 @@ Garnet ([github.com/retrnull/garnet](https://github.com/retrnull/garnet)) is an - Tip counter on notes and profiles, like zaps - Send received tips to an external wallet -## Summary of Merge Attempt (May 2026) +## Merge Progress (May–June 2026) -We attempted to merge Garnet's Monero integration into the latest Amethyst codebase. The effort produced the following artifacts (in `~/projects/amethyst/`): +The merge was executed directly against the latest Amethyst codebase (not Garnet) following the recommended path: start from the newest Amethyst and port files one at a time. -| File | Purpose | +### Completed (all committed) + +1. **Phase 1–4**: Model layer, JNI bridge, WalletService, quartz tip events +2. **Phase 5**: Wiring — `MoneroDataSource.setMoneroService()`, Account reactive flows +3. **MoneroScreen UI**: `MoneroScreen.kt` (~795 lines) with send, receive, daemon settings, backup seed, transaction list composables +4. **Account reactive state**: `moneroStatus`, `moneroBalance`, `moneroLockedBalance`, `moneroWalletHeight`, `moneroDaemonHeight`, `moneroConnectionStatus`, `moneroTransactions`, `moneroAddress` StateFlows +5. **String resources**: 40+ monero-related strings added to `strings.xml` + +### Build status + +| Check | Status | |---|---| -| `AGENTS.md` | Detailed build findings, API changes, file mappings, lesson learned | -| `GARNET_MERGE_PLAN.md` | 7-phase step-by-step plan for porting 30+ source files | -| `GARNET_README.md` | ← this file | +| `compileFdroidDebugKotlin` | ✅ 0 errors | +| `assembleFdroidDebug` | ⏳ Pending (not yet run after MoneroScreen addition) | -### Key Findings +### What's remaining -| ✓ Success | ✗ Failed | -|---|---| -| Base Amethyst builds cleanly (~116 tasks in ~3 min) | Garnet's richtext dependency (`077a2cde64`) no longer resolves from jitpack.io | -| Prebuilt `libmonerujo.so` extracted from garnet releases works with Amethyst | Direct merge of monero Kotlin code → 667+ compilation errors | -| Amethyst v0.4.x → current version gap = 9,330 commits | Amethyst moved to KMP; quartz events are now in `commonMain/kotlin` | +- Run `./gradlew assembleFdroidDebug` end-to-end after MoneroScreen addition +- Wire MoneroScreen into app navigation routes (Routes.kt + AppNavigation.kt) +- Add `compose-material-icons-extended` dependency if needed +- Add NIP-69 monero address support +- Full end-to-end testing of monero wallet functionality -### Why the merge is non-trivial +### Files changed in this session (MoneroScreen) -Garnet was forked from Amethyst at commit `175b79b29` (April 2024). Since then, Amethyst has: +| # | File | Description | +|---|---|---| +| 1 | `amethyst/src/main/.../loggedIn/MoneroScreen.kt` | **NEW** — Full Monero wallet UI screen with all composables | +| 2 | `amethyst/src/main/.../model/Account.kt` | **MODIFIED** — Added 7 reactive StateFlows + `updateMoneroState()` | +| 3 | `amethyst/src/main/res/values/strings.xml` | **MODIFIED** — Added monero string resources | -1. **Moved to KMP** — `quartz/` and `commons/` are now Kotlin Multiplatform modules (`commonMain/`, `androidMain/`) -2. **Restructured internals** — types like `HexKey`, `NostrSigner`, `EventInterface` moved into `nip01Core/` packages -3. **Upgraded Material3** — `rememberRipple(Boolean, Dp, Color)` removed (was an error, not warning) -4. **Updated toolchain** — Kotlin 1.9.23 → 2.3.21, Gradle 8.4 → 9.x, Compose BOM 2024.04 → 2026.05 - -The monero code (~11,500 lines across 103 files) was written against 2024-era Amethyst APIs and needs adaptation for the current structure. - -### Recommended path forward - -1. Start from **latest Amethyst** (not Garnet) — the 9,330-commit merge would be painful -2. Copy ~30 source files from Garnet into Amethyst, adapting imports, package paths, and Material3 APIs -3. Use the prebuilt `libmonerujo.so` from garnet releases to skip rebuilding monero via Docker (the Docker build is broken in 2026) -4. Port one file at a time, verifying `./gradlew assembleFdroidDebug` after each phase - -See `AGENTS.md` for the full investigation log and `GARNET_MERGE_PLAN.md` for the detailed step-by-step plan. +See `AGENTS.md` for the full project notes, build findings, and merge plan. ### Quick commands for a new session diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c7fc619a6..250b7d10f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -472,6 +472,47 @@ class Account( val isMoneroSeedBackedUp: Boolean get() = isMoneroSeedBackedUpFlow.value val defaultMoneroTransactionPriority: com.vitorpamplona.amethyst.ui.screen.loggedIn.TransactionPriority get() = defaultMoneroTransactionPriorityFlow.value + // Reactive monero wallet state flows + private val _moneroStatus = MutableStateFlow(null) + val moneroStatus: StateFlow = _moneroStatus + + private val _moneroBalance = MutableStateFlow(0L) + val moneroBalance: StateFlow = _moneroBalance + + private val _moneroLockedBalance = MutableStateFlow(0L) + val moneroLockedBalance: StateFlow = _moneroLockedBalance + + private val _moneroWalletHeight = MutableStateFlow(0L) + val moneroWalletHeight: StateFlow = _moneroWalletHeight + + private val _moneroDaemonHeight = MutableStateFlow(0L) + val moneroDaemonHeight: StateFlow = _moneroDaemonHeight + + private val _moneroConnectionStatus = MutableStateFlow(null) + val moneroConnectionStatus: StateFlow = _moneroConnectionStatus + + private val _moneroTransactions = MutableStateFlow>(emptyList()) + val moneroTransactions: StateFlow> = _moneroTransactions + + private val _moneroAddress = MutableStateFlow(null) + val moneroAddress: StateFlow = _moneroAddress + + fun updateMoneroState() { + walletService?.let { svc -> + val wallet = svc.wallet + if (wallet != null) { + _moneroStatus.value = svc.status.toLocalizedString() + _moneroBalance.value = wallet.balance + _moneroLockedBalance.value = wallet.lockedBalance + _moneroWalletHeight.value = wallet.height + _moneroDaemonHeight.value = wallet.daemonHeight + _moneroConnectionStatus.value = wallet.connectionStatus + _moneroTransactions.value = wallet.transactionHistory.transactions.toList() + walletService?.address?.let { _moneroAddress.value = it } + } + } + } + val feedDecryptionCaches = FeedDecryptionCaches( peopleListCache = peopleListDecryptionCache, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MoneroScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MoneroScreen.kt new file mode 100644 index 000000000..dc329fd88 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MoneroScreen.kt @@ -0,0 +1,815 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.common.net.HostAndPort +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.PendingTransaction +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size12dp +import com.vitorpamplona.amethyst.ui.theme.Size16dp +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size2dp +import com.vitorpamplona.amethyst.ui.theme.Size8dp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Preview +@Composable +fun MoneroScreenPreview() { + MoneroScreen( + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MoneroScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val walletStatus by accountViewModel.account.moneroStatus.collectAsState() + val balance by accountViewModel.account.moneroBalance.collectAsState(0L) + val lockedBalance by accountViewModel.account.moneroLockedBalance.collectAsState(0L) + val walletHeight by accountViewModel.account.moneroWalletHeight.collectAsState(0L) + val daemonHeight by accountViewModel.account.moneroDaemonHeight.collectAsState(0L) + val transactions by accountViewModel.account.moneroTransactions.collectAsState(emptyList()) + + var showSendDialog by remember { mutableStateOf(false) } + var showReceiveDialog by remember { mutableStateOf(false) } + var showTipInfo by remember { mutableStateOf(false) } + var showBackupSeed by remember { mutableStateOf(false) } + var showEditDaemon by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(R.string.monero_wallet), nav) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (accountViewModel.account.moneroSeed == null) { + EphemeralWalletWarning() + } + + Spacer(modifier = Modifier.height(8.dp)) + + val isSynced = + walletStatus?.contains("synced", ignoreCase = true) == true || + walletStatus?.contains("blocks", ignoreCase = true) == true + + if (!isSynced && daemonHeight > 0L) { + val progressFraction = + walletHeight.toFloat() / + daemonHeight + .toFloat() + .coerceAtLeast(1f) + .coerceIn(0f, 1f) + LinearProgressIndicator( + progress = { progressFraction }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Size20dp), + ) + } + + Text( + text = walletStatus ?: stringRes(R.string.wallet_status_syncing), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + modifier = Modifier.padding(bottom = Size8dp), + ) + + Balance(balance = balance, lockedBalance = lockedBalance) + + Spacer(modifier = Modifier.height(8.dp)) + + DaemonInfo(walletHeight = walletHeight, daemonHeight = daemonHeight) + + Spacer(modifier = Modifier.height(8.dp)) + + TipInfo(showTipInfo = showTipInfo, onMore = { showTipInfo = !showTipInfo }) + + Spacer(modifier = Modifier.height(8.dp)) + + ActionsRow( + onSend = { showSendDialog = true }, + onReceive = { showReceiveDialog = true }, + onEditDaemon = { showEditDaemon = true }, + onTipInfo = { showTipInfo = !showTipInfo }, + onBackupSeed = { showBackupSeed = true }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TransactionList(transactions) + + TransactionListHeader() + + Spacer(modifier = Modifier.height(16.dp)) + } + } + + if (showSendDialog) { + MoneroSendDialog( + accountViewModel = accountViewModel, + onDismiss = { showSendDialog = false }, + ) + } + + if (showReceiveDialog) { + ReceiveDialog(onDismiss = { showReceiveDialog = false }) + } + + if (showEditDaemon) { + EditDaemonDialog( + currentAddress = accountViewModel.account.moneroDaemonAddress, + onDismiss = { showEditDaemon = false }, + onSave = { host, port, username, password -> + accountViewModel.account.changeMoneroDaemonAddress(HostAndPort.fromParts(host, port)) + accountViewModel.account.changeMoneroDaemonUsername(username) + accountViewModel.account.changeMoneroDaemonPassword(password) + }, + ) + } + + if (showBackupSeed) { + BackupSeedDialog( + seed = accountViewModel.account.moneroSeed ?: "", + onDismiss = { showBackupSeed = false }, + ) + } +} + +@Composable +private fun MoneroSendDialog( + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + var address by remember { mutableStateOf("") } + var amount by remember { mutableStateOf("") } + var priority by remember { mutableStateOf(com.vitorpamplona.amethyst.ui.screen.loggedIn.TransactionPriority.UNIMPORTANT) } + var sendLoading by remember { mutableStateOf(false) } + var sendError by remember { mutableStateOf(null) } + + val balance by accountViewModel.account.moneroBalance.collectAsState(0L) + + M3ActionDialog( + title = stringRes(R.string.send_monero), + onDismiss = onDismiss, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp), + ) { + OutlinedTextField( + value = address, + onValueChange = { address = it }, + label = { Text(stringRes(R.string.monero_address)) }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = amount, + onValueChange = { amount = it }, + label = { Text(stringRes(R.string.invalid_monero_amount).replace("Invalid amount", "Amount")) }, + modifier = Modifier.fillMaxWidth(), + supportingText = { + Text("${stringRes(R.string.monero_balance).split(' ')[0]}: ${showMoneroAmount(balance.toULong())} XMR") + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TransactionPrioritySelector( + currentPriority = priority, + onPriorityChanged = { priority = it }, + ) + + sendError?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OutlinedButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + OutlinedButton( + onClick = { + if (address.isBlank() || amount.isBlank()) { + sendError = context.getString(R.string.invalid_monero_address) + return@OutlinedButton + } + val piconeroAmount = decToPiconero(amount) + if (piconeroAmount == null || piconeroAmount == 0UL) { + sendError = context.getString(R.string.invalid_monero_amount) + return@OutlinedButton + } + if (piconeroAmount > balance.toULong()) { + sendError = context.getString(R.string.monero_not_enough_balance) + return@OutlinedButton + } + sendLoading = true + val status = accountViewModel.account.sendMonero(address, piconeroAmount.toLong(), priority) + sendLoading = false + if (status != null && status.type != PendingTransaction.StatusType.OK) { + sendError = context.getString(R.string.monero_send_failed) + } else { + sendError = null + Toast.makeText(context, context.getString(R.string.monero_tip_sent), Toast.LENGTH_SHORT).show() + onDismiss() + } + }, + enabled = !sendLoading, + ) { + if (sendLoading) { + CircularProgressIndicator(modifier = Modifier.height(16.dp), strokeWidth = 2.dp) + } else { + Text(stringRes(R.string.send_monero)) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TransactionPrioritySelector( + currentPriority: com.vitorpamplona.amethyst.ui.screen.loggedIn.TransactionPriority, + onPriorityChanged: (com.vitorpamplona.amethyst.ui.screen.loggedIn.TransactionPriority) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val priorityItems = + com.vitorpamplona.amethyst.ui.screen.loggedIn.TransactionPriority.entries + .map { it.name } + val currentName = currentPriority.name + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + TextField( + value = currentName, + onValueChange = {}, + readOnly = true, + label = { Text(stringRes(R.string.transaction_priority)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + priorityItems.forEachIndexed { index, name -> + DropdownMenuItem( + onClick = { + onPriorityChanged(com.vitorpamplona.amethyst.ui.screen.loggedIn.TransactionPriority.entries[index]) + expanded = false + }, + text = { Text(name) }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReceiveDialog(onDismiss: () -> Unit) { + val context = LocalContext.current + var selected by remember { mutableStateOf("") } + + M3ActionDialog( + title = stringRes(R.string.receive_monero), + onDismiss = onDismiss, + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringRes(R.string.monero_fund_wallet_qrcode_description), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + + // Stub address - in real app this comes from wallet + // Using placeholder for display + selected = "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNBW2y2XeBO2Naq6QdEDD4rtNt6k" + + QrCodeDrawer( + contents = selected, + modifier = Modifier.height(256.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Address text with copy + OutlinedTextField( + value = selected, + onValueChange = {}, + modifier = Modifier.fillMaxWidth(), + readOnly = true, + trailingIcon = { + IconButton(onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("monero_address", selected)) + Toast.makeText(context, context.getString(R.string.monero_address_copied_to_clipboard), Toast.LENGTH_SHORT).show() + }) { + Icon(symbol = MaterialSymbols.ContentCopy, contentDescription = context.getString(R.string.copy_text)) + } + }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text(stringRes(R.string.close)) + } + } + } +} + +@Composable +private fun EditDaemonDialog( + currentAddress: HostAndPort, + onDismiss: () -> Unit, + onSave: (String, Int, String, String) -> Unit, +) { + val hostString = currentAddress.host + var host by remember { mutableStateOf(hostString ?: "") } + var port by remember { mutableStateOf(currentAddress.port.toString()) } + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + M3ActionDialog( + title = stringRes(R.string.edit_daemon_settings), + onDismiss = onDismiss, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp)) { + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text(stringRes(R.string.daemon_host)) }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = port, + onValueChange = { port = it }, + label = { Text(stringRes(R.string.daemon_port)) }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringRes(R.string.daemon_username)) }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringRes(R.string.daemon_password)) }, + modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + OutlinedButton(onClick = { + val portInt = port.toIntOrNull() ?: 0 + if (portInt in 1..65535) { + onSave(host, portInt, username, password) + } + onDismiss() + }) { + Text(stringRes(R.string.save)) + } + } + } + } +} + +@Composable +private fun DaemonInfo( + walletHeight: Long, + daemonHeight: Long, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${stringRes(R.string.daemon_height)}: $daemonHeight", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + Text( + text = "${stringRes(R.string.wallet_height)}: $walletHeight", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } +} + +@Composable +private fun Balance( + balance: Long, + lockedBalance: Long, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(Size16dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = stringRes(R.string.monero_balance), style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "${showMoneroAmount(balance.toULong())} XMR", style = MaterialTheme.typography.displayMedium) + if (lockedBalance > 0L) { + Text( + text = "${stringRes(R.string.unconfirmed)}: ${showMoneroAmount(lockedBalance.toULong())} XMR", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + } +} + +@Composable +private fun TipInfo( + showTipInfo: Boolean, + onMore: () -> Unit, +) { + Column(modifier = Modifier.padding(horizontal = Size20dp)) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onMore).padding(vertical = Size8dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Info, + contentDescription = null, + modifier = Modifier.padding(end = Size8dp).height(20.dp), + ) + Text(text = stringRes(R.string.monero_tipping_info), style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.weight(1f)) + Icon( + symbol = if (showTipInfo) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = if (showTipInfo) "Hide" else "Show", + modifier = Modifier.height(20.dp), + ) + } + + if (showTipInfo) { + Spacer(modifier = Modifier.height(8.dp)) + M3ActionSection { + Column(modifier = Modifier.padding(horizontal = Size12dp)) { + Text( + text = stringRes(R.string.monero_fund_wallet), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = Size8dp), + ) + Text( + text = stringRes(R.string.monero_fund_wallet_address_description), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + } + } + } +} + +@Composable +private fun ActionsRow( + onSend: () -> Unit, + onReceive: () -> Unit, + onEditDaemon: () -> Unit, + onTipInfo: () -> Unit, + onBackupSeed: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + ActionButton(icon = MaterialSymbols.AutoMirrored.Send, label = stringRes(R.string.send_monero), onClick = onSend) + ActionButton(icon = MaterialSymbols.ArrowDownward, label = stringRes(R.string.receive_monero), onClick = onReceive) + ActionButton(icon = MaterialSymbols.Settings, label = stringRes(R.string.daemon), onClick = onEditDaemon) + ActionButton(icon = MaterialSymbols.Info, label = stringRes(R.string.info), onClick = onTipInfo) + ActionButton(icon = MaterialSymbols.CloudUpload, label = stringRes(R.string.backup_seed), onClick = onBackupSeed) + } + Spacer(modifier = Modifier.height(8.dp)) +} + +@Composable +private fun ActionButton( + icon: MaterialSymbol, + label: String, + onClick: () -> Unit, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.clickable(onClick = onClick)) { + Icon(symbol = icon, contentDescription = label, modifier = Modifier.height(24.dp)) + Text(text = label, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 4.dp)) + } +} + +@Composable +private fun EphemeralWalletWarning() { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp)) { + Row( + modifier = Modifier.fillMaxWidth().background(Color(0xFFFFF3E0)).padding(Size12dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Warning, + contentDescription = null, + tint = Color(0xFFFF8F00), + modifier = Modifier.height(24.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringRes(R.string.monero_ephemeral_wallet_seed_backup_warning), + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFE65100), + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun TransactionList(transactions: List) { + Column(modifier = Modifier.padding(horizontal = Size20dp)) { + Divider(modifier = Modifier.padding(vertical = Size8dp)) + + if (transactions.isEmpty()) { + Text( + text = stringRes(R.string.no_results_found).ifEmpty { "No Monero transactions yet" }, + style = MaterialTheme.typography.bodyMedium, + color = Color.Gray, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) + return + } + + transactions.forEach { tx -> + TransactionRow(tx) + Divider(modifier = Modifier.padding(vertical = Size2dp)) + } + } +} + +@Composable +private fun TransactionListHeader() { + Text( + text = stringRes(R.string.transaction_history), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = Size20dp, vertical = Size8dp), + ) +} + +@Composable +private fun TransactionRow(tx: com.vitorpamplona.amethyst.model.TransactionInfo) { + val isIncoming = tx.direction == 0 + val amount = tx.amount + + Row( + modifier = Modifier.fillMaxWidth().padding(Size8dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (isIncoming) stringRes(R.string.transaction_received) else stringRes(R.string.transaction_sent), + style = MaterialTheme.typography.bodyMedium, + ) + if (tx.hash.isNotEmpty()) { + Text( + text = tx.hash.take(40) + if (tx.hash.length > 40) "..." else "", + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + if (tx.timestamp > 0) { + Text( + text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(tx.timestamp * 1000L)), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + ) + } + } + + Text( + text = if (isIncoming) "+" else "-", + color = if (isIncoming) Color.Green else Color.Red, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(end = 8.dp), + ) + + Text( + text = "${showMoneroAmount(amount.toULong())} XMR", + style = MaterialTheme.typography.bodyMedium, + color = if (isIncoming) Color.Green else Color.Red, + textAlign = TextAlign.End, + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun BackupSeedDialog( + seed: String, + onDismiss: () -> Unit, +) { + M3ActionDialog( + title = stringRes(R.string.backup_seed), + onDismiss = onDismiss, + ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Size20dp)) { + Text( + text = stringRes(R.string.backup_seed_dialog_description_1), + style = MaterialTheme.typography.bodySmall, + color = Color.Gray, + modifier = Modifier.padding(bottom = Size8dp), + ) + + OutlinedTextField( + value = seed, + onValueChange = {}, + label = { Text(stringRes(R.string.backup_seed_dialog_description_restore_height)) }, + modifier = Modifier.fillMaxWidth(), + readOnly = true, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth().background(Color(0xFFFFEBEE)).padding(Size8dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Warning, + contentDescription = null, + tint = Color(0xFFD32F2F), + modifier = Modifier.height(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringRes(R.string.backup_seed_warning), + style = MaterialTheme.typography.bodySmall, + color = Color(0xFFD32F2F), + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text(stringRes(R.string.close)) + } + } + } +} + +// ============================ Helper Functions ============================ + +fun showMoneroAmount(amount: ULong): String { + if (amount == 0UL) return "0" + + var amountString = amount.toString() + amountString = + if (amountString.length <= 12) { + val zeros = "0".repeat(12 - amountString.length) + "0.$zeros$amountString" + } else { + val index = amountString.length - 12 + amountString.replaceRange(index, index + 1, ".") + } + + return java.math + .BigDecimal(amountString) + .round(java.math.MathContext(12)) + .stripTrailingZeros() + .toPlainString() +} + +fun decToPiconero(amount: String): ULong? { + val index = amount.indexOfAny(charArrayOf('.', ',')) + return if (index != -1) { + val intPart = amount.substring(0, index) + var decPart = amount.substring(index + 1) + decPart = decPart.substring(0, decPart.length.coerceIn(0..12)) + val zeros = "0".repeat(12 - decPart.length) + "$intPart$decPart$zeros".toULongOrNull() + } else { + val zeros = "0".repeat(12) + "$amount$zeros".toULongOrNull() + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b63c66e65..f3dcfe575 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3151,5 +3151,16 @@ Warning ⚠️ Ephemeral wallets are created without a backup seed. You MUST back up your seed now or you will permanently lose access to your Monero coins if the app is uninstalled or the device is lost. Monero seed copied to clipboard - Invalid restore height - \ No newline at end of file + Invalid restore height + Unconfirmed + Monero Tipping Information + No Monero transactions yet + Transaction History + Restore Height (default: 14283008) + Monero Amount + Info + Close + No results found + Received + Sent + \ No newline at end of file