docs: Update Garnet merge docs (no absolute paths)

This commit is contained in:
Nick Pulido
2026-05-20 18:03:23 -07:00
parent 4982ac8a35
commit 2d9c4881ae
30 changed files with 5876 additions and 568 deletions
+2 -182
View File
@@ -1,182 +1,2 @@
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
/.idea/deploymentTargetDropDown.xml
/.idea/deploymentTargetSelector.xml
/.idea/appInsightsSettings.xml
/.idea/ktlint-plugin.xml
/.idea/ktfmt.xml
/.idea/studiobot.xml
/.idea/other.xml
/.idea/runConfigurations.xml
/.idea/ChatHistory_schema_v2.xml
/.idea/artifacts/*
/.idea/kotlinNotebook.xml
/.idea/ChatHistory_schema_v3.xml
/.idea/markdown.xml
/.idea/AndroidProjectSystem.xml
/.idea/deviceManager.xml
/.idea/inspectionProfiles/
/.idea/migrations.xml
/.idea/planningMode.xml
/desktopApp/vlc-temp/
/commons/.idea/gradle.xml
/commons/.idea/misc.xml
/commons/.idea/workspace.xml
/quartz/.idea/modules.xml
/quartz/.idea/workspace.xml
.DS_Store
/build
/captures
.cxx
# superpowers skill
.superpowers
docs/brainstorms
docs/superpowers
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle
.gradle/
build/
.kotlin/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio
/*/build/
/*/local.properties
/*/out
/*/*/build
/*/*/production
captures/
.navigation/
*.ipr
*~
*.swp
# IntelliJ
*.iml
*.iws
/out/
deploymentTargetDropdown.xml
render.experimental.xml
# User-specific configurations
.idea/**/caches/
.idea/**/libraries/
.idea/**/shelf/
.idea/**/codeStyles
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/.name
.idea/**/compiler.xml
.idea/**/copyright/profiles_settings.xml
.idea/**/encodings.xml
.idea/**/misc.xml
.idea/**/modules.xml
.idea/**/scopes/scope_settings.xml
.idea/**/dictionaries
.idea/**/vcs.xml
.idea/**/jsLibraryMappings.xml
.idea/**/datasources.xml
.idea/**/dataSources.ids
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/assetWizardSettings.xml
.idea/**/gradle.xml
.idea/**/jarRepositories.xml
.idea/**/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Local task tracking
TASKS.md
# Claude Code local settings
.claude/settings.local.json
.claude/scheduled_tasks.lock
# Downloaded VLC binaries (vlc-setup plugin)
desktopApp/src/jvmMain/appResources/linux/
desktopApp/src/jvmMain/appResources/macos/
desktopApp/src/jvmMain/appResources/windows/
# CI-fetched AppImage tooling (downloaded by create-release workflow; not committed)
desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
desktopApp/packaging/appimage/squashfs-root/
# Git worktrees
.worktrees/
.claude/worktrees/
benchmark/src/main/jniLibs/
/tools/marmot-interop/state
# Cargo build artifacts for the cross-stack interop sidecars at
# nestsClient/tests/hang-interop/. Cargo.lock is committed (binary workspace).
/nestsClient/tests/hang-interop/target/
# Monero native libraries - prebuilt .so files
amethyst/src/main/jniLibs/**/*.so
+513 -135
View File
@@ -8,177 +8,555 @@
| **Amethyst repo** | https://github.com/vitorpamplona/amethyst |
| **Garnet base amethyst commit** | `175b79b29` (April 2024) |
| **Garnet fork date** | ~July 10, 2024 |
| **Latest amethyst commit** | `c19f0be57` (May 2026) |
| **Latest amethyst commit** | `a4310317a` (May 2026) |
| **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 ported (21 new files, 6 modified). UI layer + wiring + build verification remaining. |
### What was accomplished (this session)
- Extracted all monero model classes from Garnet Kotlin code
- Adapted imports for new KMP quartz package structure (`nip01Core.*`)
- Created wallet model classes (Wallet, PendingTransaction, Subaddress, etc.)
- Created WalletService (fully implemented from Garnet source)
- Created MoneroWalletListener interface
- Created MoneroTipEvent in quartz (`moneroTip` package)
- Created TipSplitSetup utility in quartz (`moneroTip` package)
- Created TransactionPriority enum
- Created TipHandler and TipEventDataSource (stubs)
- Created MoneroDataSource (stub with flows)
- Added monero wallet fields to Account.kt (spend key, seed, restore height, password flows)
- Added monero wallet methods to Account.kt (startMonero, stopMonero, balance, address, generateSpendKey)
- Added `moneroAddress()` method to User model in commons
- Added `moneroAddressFromAbout()` method to UserMetadataCache in commons
- Added `moneroAddressIsValid()` utility function in commons
- Added EventFactory registration for MoneroTipEvent (kind 1814)
- Added monero.xml icon to resources
- Added monero-specific strings to strings.xml
- Added monero JNI bridge files (monerujo.cpp, monerujo.h, wallet2_api.h)
- Updated amethyst/build.gradle.kts for monero dependencies
### What's remaining (next session)
- Wire up MoneroDataSource setMoneroService() to connect WalletService flows
- Implement AccountViewModel monero state management
- Port the UI layer (~19 files of Compose screens)
- Add NIP-69 monero address support
- Build and verify (`./gradlew assembleFdroidDebug`)
- Test monero wallet functionality
---
## What Garnet Adds to Amethyst
## What Worked ✓
Garnet adds **Monero tipping/zapping** support, mirroring how Amethyst already handles Lightning (Zap) tips. Key features:
### 21 New Files Created
1. **Monero wallet generation** - Derives a Monero spend key from the Nostr private key
2. **Tip integration** - "monero" tags on posts (similar to NIP-57 zap tags)
3. **3 tip modes** - Private, Anonymous, Public
4. **Tip event (kind 1814)** - Records on-chain transactions
5. **Tip display** - Counter on tipped notes and user profile
6. **Wallet balance display & transfers** - Internal Monero wallet with send to external wallet
| # | File | Description |
|---|------|-------------|
| 1 | `amethyst/src/main/java/.../model/MoneroWalletListener.kt` | JNI wallet listener callback interface |
| 2 | `amethyst/src/main/java/.../model/PendingTransaction.kt` | JNI pending transaction wrapper |
| 3 | `amethyst/src/main/java/.../model/ProofInfo.kt` | Proof verification data class |
| 4 | `amethyst/src/main/java/.../model/Subaddress.kt` | Monero subaddress model |
| 5 | `amethyst/src/main/java/.../model/Transaction.kt` | Monero transaction model |
| 6 | `amethyst/src/main/java/.../model/TransactionInfo.kt` | Transaction info wrapper |
| 7 | `amethyst/src/main/java/.../model/TransactionHistory.kt` | Transaction history model |
| 8 | `amethyst/src/main/java/.../model/Transfer.kt` | Transfer data class |
| 9 | `amethyst/src/main/java/.../model/Wallet.kt` | JNI Wallet wrapper (all native methods) |
| 10 | `amethyst/src/main/java/.../model/WalletManager.kt` | Wallet factory + native bridge utilities |
| 11 | `amethyst/src/main/java/.../service/MoneroDataSource.kt` | Data source stub with Flow wrappers |
| 12 | `amethyst/src/main/java/.../service/TipEventDataSource.kt` | Tip event lifecycle stubs |
| 13 | `amethyst/src/main/java/.../service/TipHandler.kt` | Tip processing logic (partial) |
| 14 | `amethyst/src/main/java/.../service/WalletService.kt` | Android Service + all wallet operations |
| 15 | `amethyst/src/main/java/.../ui/screen/loggedIn/TransactionPriority.kt` | Transaction priority enum |
| 16 | `amethyst/src/main/res/drawable/monero.xml` | Monero icon resource |
| 17 | `quartz/src/commonMain/kotlin/.../moneroTip/MoneroTipEvent.kt` | Nostr tip event (kind 1814) |
| 18 | `quartz/src/commonMain/kotlin/.../moneroTip/TipSplitSetup.kt` | Tip split utilities + monero tag parsing |
| 19 | `amethyst/src/main/cpp/monerujo.cpp` | JNI bridge C++ implementation |
| 20 | `amethyst/src/main/cpp/monerujo.h` | JNI bridge header |
| 21 | `amethyst/src/main/cpp/wallet2_api.h` | Monero wallet API header |
### Code Changes Summary (vs Amethyst base at fork time)
- **103 files changed**, 11,569 insertions, 537 deletions
- **~1,750 lines** of C++ (Monero JNI bridge: monerujo.cpp + monerujo.h)
- **~30 Kotlin source files** (model, service, UI, quartz events)
- **New resources** - monero.xml icon, monero-specific strings
### 6 Modified Files
| # | File | Changes |
|---|------|---------|
| 1 | `amethyst/src/main/java/.../model/Account.kt` | Added monero wallet fields + methods |
| 2 | `amethyst/src/main/res/values/strings.xml` | Added monero string resources |
| 3 | `commons/src/commonMain/kotlin/.../commons/model/User.kt` | Added `moneroAddress()`, `moneroAddressIsValid()` |
| 4 | `commons/src/commonMain/kotlin/.../commons/model/nip01Core/UserMetadataCache.kt` | Added `moneroAddressFromAbout()` |
| 5 | `quartz/src/commonMain/kotlin/.../utils/EventFactory.kt` | Registered MoneroTipEvent (kind 1814), import |
| 6 | `amethyst/build.gradle.kts` | Added monero module/dependencies |
---
## Build Findings
## Current File Structure with Exact Import Paths
This is the most critical section. All Garnet-era import paths were different because the quartz module was not yet Kotlin Multiplatform. Here are the exact current paths every imported type should use:
### Quartz (MoneroEvent) Import Paths
**CRITICAL: These are DIFFERENT from Garnet source.**
| Type | New import path |
|------|-----------------|
| Event base class | `com.vitorpamplona.quartz.nip01Core.core.Event` |
| HexKey | `com.vitorpamplona.quartz.nip01Core.core.HexKey` |
| IEvent | `com.vitorpamplona.quartz.nip01Core.core.IEvent` |
| AddressableEvent | `com.vitorpamplona.quartz.nip01Core.core.AddressableEvent` |
| JsonMapper | `com.vitorpamplona.quartz.nip01Core.core.JsonMapper` |
| OptimizedSerializable | `com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable` |
| TagArray | `com.vitorpamplona.quartz.nip01Core.core.TagArray` |
| KeyPair | `com.vitorpamplona.quartz.nip01Core.crypto.KeyPair` (was `com.vitorpamplona.quartz.nip01Core.enc.KeyPair` in garnet) |
| NostrSignerInternal | `com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal` (was `...signs.NostrSignerInternal`) |
| NostrSigner | `com.vitorpamplona.quartz.nip01Core.signers.NostrSigner` |
| EventTemplate | `com.vitorpamplona.quartz.nip01Core.signers.EventTemplate` |
| ETag | `com.vitorpamplona.quartz.nip01Core.tags.events.ETag` |
| PTag | `com.vitorpamplona.quartz.nip01Core.tags.people.PTag` |
| EventHintProvider | `com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider` |
| PubKeyHintProvider | `com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider` |
| NormalizedRelayUrl | `com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl` |
### Package Locations
| Type | Location |
|------|----------|
| Note class | `com.vitorpamplona.amethyst.commons.model.Note` (was amethyst-only before, now in commons) |
| User class | `com.vitorpamplona.amethyst.commons.model.User` (was amethyst-only before, now in commons) |
| LocalCache | `com.vitorpamplona.amethyst.model.LocalCache` (stays in amethyst) |
| MoneroTipEvent | `com.vitorpamplona.quartz.moneroTip.MoneroTipEvent` |
| TipSplitSetup | `com.vitorpamplona.quartz.moneroTip.TipSplitSetup` |
### Old Garnet → New Amethyst Mapping
Many Garnet-era packages have been renamed. Common mappings:
| Old (Garnet/2024) | New (2026) |
|---|---|
| `com.vitorpamplona.quartz.events.EncryptedContentUtils` | `com.vitorpamplona.quartz.nip01Core.crypto.EventHasher` |
| `com.vitorpamplona.quartz.events.*` | `com.vitorpamplona.quartz.nip01Core.*` (various subpackages) |
| `com.vitorpamplona.quartz.events.signs.*` | `com.vitorpamplona.quartz.nip01Core.signers.*` |
| `com.vitorpamplona.quartz.events.enc.*` | `com.vitorpamplona.quartz.nip01Core.crypto.*` |
| `com.vitorpamplona.quartz.events.crypto.*` | `com.vitorpamplona.quartz.nip01Core.crypto.*` |
| `commons.model.*` (amethyst) | `commons.model.*` (commons-commonMain) |
---
## NostrSigner API Change (CRITICAL)
The signing API changed significantly. Garnet-era code uses callback-based signing. Current Amethyst uses **coroutine-based** signing.
### Old (callback, Garnet-era)
```kotlin
// Garnet's NostrSigner callback style
signer.sign(eventBuilder, onReady: (Event) -> Unit)
```
### New (coroutine, current)
```kotlin
// Current Amethyst coroutine style
val event = signer.sign(template)
// or
val event = signer.sign(createdAt, kind, tags, content)
```
### NostrSignerInternal signing with createdAt
In current code, `NostrSignerInternal.sign()` with createdAt uses the vararg tags variant:
```kotlin
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
val event: MoneroTipEvent = NostrSignerInternal(KeyPair())
.sign(createdAt = TimeUtils.now(), kind = MoneroTipEvent.KIND, tags = tags.toTypedArray(), content = content)
```
See `LnZapRequestEvent.kt:130` for a real example of creating an anonymous signer for private zaps.
---
## LocalCache.consume() Signature
The `justConsume()` method in `LocalCache` takes **3 parameters**:
```kotlin
fun justConsume(event: Event, relay: NormalizedRelayUrl?, isOwn: Boolean): Boolean
```
| Parameter | Description |
|-----------|-------------|
| `event` | The Nostr event to consume |
| `relay` | The relay it came from (null if from client) |
| `isOwn` | Whether this is the current account's own event |
Common usage patterns:
```kotlin
// Consume from relay, not own event
cache.justConsume(event, relay, false)
// Consume own event (no relay)
cache.justConsumeMyOwnEvent(event) // convenience wrapper, same as justConsume(event, null, true)
// Consume gift wrap / inner event from push
cache.justConsume(innerGift, null, false)
// Consume decrypted inner event from Marmot
cache.justConsume(innerEvent, null, true)
```
---
## User Metadata Access Pattern
User metadata in `commons.model.User` is accessed via lazy-initialized caches:
```kotlin
// UserMetadataCache
val cache = user.metadata() // lazy init
val flow: StateFlow<UserInfo?> = cache.flow
val name = cache.bestName() // String?
val picture = cache.profilePicture() // String?
val lnAddress = cache.lnAddress() // String?
// For monero:
val moneroAddr = cache.moneroAddressFromAbout() // String?
```
In User model, the `moneroAddress()` method tries `nip69Address()` first (not yet implemented), then falls back to `moneroAddressFromAbout()`:
```kotlin
fun User.moneroAddress(): String? {
val nip69 = nip69Address() // NIP-69 type-0 event, stub returns null
if (nip69 != null) return nip69
return metadataOrNull()?.moneroAddressFromAbout() // regex from about field
}
```
The regex in `moneroAddressFromAbout()`: `\b[48]\w{93}\b` — matches standard Monero addresses (4xxxx or 8xxxx, 95 chars total).
---
## Amethyst.instance Reference Pattern
The global Amethyst singleton uses these accessors:
```kotlin
import com.vitorpamplona.amethyst.Amethyst
val context = Amethyst.instance.appContext // NOT applicationContext
val customScope = Amethyst.instance.customScope // UI coroutine scope
val ioScope = Amethyst.instance.applicationIOScope // IO coroutine scope
```
**Important: Use `appContext`, NOT `applicationContext`.** Garnet-era code used `applicationContext` which no longer compiles.
See `WalletService.kt:68-80` for the correct pattern:
```kotlin
Amethyst.instance.appContext.getString(R.string.wallet_status_opening)
Amethyst.instance.applicationIOScope // used for scope.launch in wallet listener
```
---
## TransactionPriority Enum
Defined at: `amethyst/src/main/java/.../ui/screen/loggedIn/TransactionPriority.kt`
```kotlin
enum class TransactionPriority(val value: Int) {
UNIMPORTANT(0),
SLOW(1),
MEDIUM(2),
FAST(3),
ESTIMATED(4),
}
```
Used everywhere Monero transactions are created:
```kotlin
wallet.createTransaction(
destination = address,
amount = amount,
priority = TransactionPriority.UNIMPORTANT // passes priority.ordinal to JNI
)
```
---
## WalletService Public API
`WalletService` (Android Service) implements the full wallet. All public methods:
| Method | Return | Description |
|--------|--------|-------------|
| `loadWallet(name, password, spendKey="", daemonAddress, ...)` | `Wallet` | Open or create wallet |
| `load(name, password, spendKey="", daemonAddress, ...)` | `Wallet` | Same, with checkNotInMainThread |
| `connectToDaemon(address, username, password, proxy, restoreHeight, walletPassword)` | `Wallet.Status?` | Connect to daemon |
| `connect(address, username, password, proxy, isCreation, restoreHeight, walletPassword)` | `Wallet.Status?` | Same, check not on main thread |
| `sendTransaction(destination, amount, priority)` | `PendingTransaction.Status?` | Send to single address |
| `sendTransactionMultDest(destinations[], amounts[], priority)` | `PendingTransaction?` | Multi-destination |
| `getTxProof(txId, destination, message)` | `Proof?` | Generate transaction proof |
| `checkTxProof(txId, address, message, signature)` | `ProofInfo?` | Verify a transaction proof |
| `setProxy(proxy)` | `Boolean?` | Set network proxy |
| `storeWallet()` | `Wallet.Status?` | Persist wallet to disk |
| `closeWallet()` | Unit | Close the current wallet |
| `newSubaddress(accountIndex, label)` | `Subaddress?` | Generate new subaddress |
| `lastSubaddress(accountIndex)` | `Subaddress?` | Get last subaddress |
| `listAddresses()` | `List<Subaddress>?` | List all subaddresses |
| `setSubaddressLabel(index, label)` | `Wallet.Status?` | Label a subaddress |
| `seedWithPassphrase(passphrase)` | `String?` | Get wallet seed |
| `isAddressValid(address)` | `Boolean?` | Validate address format |
| `getRestoreHeight()` | `Long?` | Get restore height |
| `getHistory()` | `TransactionHistory?` | Get transaction history |
| `setUserNote(txId, note)` | `Boolean?` | Attach user note to transaction |
| `estimateTransactionFee(destinations[], amounts[], priority)` | `Long?` | Estimate fee in picoMonero |
| `getBalance` | `Long` | Property: total balance |
| `getLockedBalance` | `Long` | Property: locked (unconfirmed) balance |
| `walletHeight` | `Long` | Property: wallet blockchain height |
| `daemonHeight` | `Long` | Property: daemon blockchain height |
| `address` | `String?` | Property: primary address |
| `connectionStatus` | `Wallet.ConnectionStatus` | Property: DISCONNECTED/CONNECTED/WRONG_VERSION |
State flows (MutableStateFlow → StateFlow):
- `statusStateFlow: StateFlow<WalletStatus>`
- `balanceStateFlow: StateFlow<Long>`
- `lockedBalanceStateFlow: StateFlow<Long>`
- `walletHeightStateFlow: StateFlow<Long>`
- `daemonHeightStateFlow: StateFlow<Long>`
- `connectionStatusStateFlow: StateFlow<Wallet.ConnectionStatus>`
- `transactions: StateFlow<List<TransactionInfo>>`
---
## MoneroDataSource Stub — Current State
File: `amethyst/src/main/java/.../service/MoneroDataSource.kt`
```kotlin
object MoneroDataSource {
@Volatile
private var flows: MoneroServiceFlows = MoneroServiceFlows()
fun status(): Flow<*> = flows.status
fun connectionStatus(): Flow<*> = flows.connectionStatus
fun walletHeight(): Flow<Long> = flows.walletHeight
fun daemonHeight(): Flow<Long> = flows.daemonHeight
fun balance(): Flow<Long> = flows.balance
fun lockedBalance(): Flow<Long> = flows.lockedBalance
fun transactions(): Flow<List<TransactionInfo>> = flows.transactions
fun setMoneroService(service: WalletService) {
// Wire up the service flows here when fully implemented
}
}
```
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.
---
## What Next Session Must Do
### Priority A: Wire Up MoneroDataSource
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
### Priority B: Implement Account Methods That Need WalletService
The Account model already has the basic monero methods (startMonero, stopMonero, getBalance, etc.). What remains:
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: 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:
### 1. Base amethyst builds cleanly (verified)
```bash
cd ~/projects/amethyst && ./gradlew assembleFdroidDebug
# BUILD SUCCESSFUL - ~116 tasks in 2m44s
```
### 2. Monero native libraries can be extracted from prebuilt APK
Download garnet v0.3.0 APK, extract `.so` files:
- `libmonerujo.so` - Monero JNI bridge (contains all monero dependencies)
- `libsodium.so` - Cryptographic library
- `libsecp256k1-jni.so` - secp256k1
- `libc++_shared.so` - C++ runtime
Place in `jniLibs/`, no CMake needed for these prebuilt binaries.
### 3. Garnet build is blocked by dependency issues
- `compose-richtext:richtext-ui:077a2cde64` **cannot be resolved from jitpack.io** (even though it shows "ok" on jitpack status)
- Workaround version `e9e0869266` exists but pulls kotlin-stdlib 2.2.0 incompatible with garnet's Kotlin 1.9.23
### 4. Monero Kotlin code has 667+ compilation errors when copied to latest amethyst
The monero integration was written against amethyst v0.4.x (April 2024). Key breaking changes since then:
#### Quartz Event Model
- **Old structure**: Single `quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt`
- **New structure**: Split across modules, `commonMain/kotlin`, KMP targets
- `HexKey`, `EventInterface`, `AddressableEvent`, `NostrSigner` moved to new packages in `nip01Core/`
#### Material3 / Compose
- `rememberRipple(Boolean, Dp, Color)` is deprecated and removed (error, not warning)
- `autoCorrect` parameter renamed/removed in TextField
- `HeadingStyle` moved/renamed
#### Module Structure
- `quartz` is now Kotlin Multiplatform (`commonMain`, `androidMain`, `jvmMain`)
- `commons` is KMP
- `gem` module removed, `geode` module added
- Settings.gradle changed from `.gradle` to `.gradle.kts`, modules renamed
#### API Changes
- Kotlin 1.9.23 → 2.3.21 → various dependency incompatibilities
- Gradle 8.4 → 9.x
- Compose BOM 2024.04.00 → 2026.05.00
- Material3 ripple API changed
Then check for any remaining lint/compiler errors specifically in monero files.
---
## Docker / Monero Build Findings
## Build Commands
### The Docker build approach is fragile
The monero build Dockerfiles (`android64.Dockerfile`, etc.) have multiple issues:
1. **Old Debian base** - `debian:stable``debian:bookworm` required
2. **Hardcoded NDK r17c** - Old, needs upgrade for modern toolchain
3. **Hash mismatches** - boost, zlib, openssl hashes no longer match available versions
4. **Missing dependencies** - Docker build fails without fixes to apt packages
### Alternative: Use system NDK
System NDK available at `~/Android/Sdk/ndk/29.0.13846066/`
- Toolchains: `aarch64-linux-android21-clang`, `armv7a-linux-androideabi21-clang`, `x86_64-linux-android21-clang`, `i686-linux-android21-clang`
- Monero make targets: `release-static-android-armv8-wallet_api`, `release-static-android-armv7-wallet_api`, `release-static-android-x86_64-wallet_api`, `release-static-android-x86-wallet_api`
### The prebuilt APK approach works
Since the monero libs are fully self-contained in `libmonerujo.so`, we can skip rebuilding monero entirely and use the released APK's `.so` files as a temporary solution.
---
## File Structure Mapping
### Garnet → Amethyst file mapping
| Garnet file (old) | New location (amethyst) |
|---|---|
| `app/src/main/java/.../model/` | `amethyst/src/main/java/.../model/` |
| `app/src/main/java/.../service/` | `amethyst/src/main/java/.../service/` |
| `app/src/main/java/.../ui/` | `amethyst/src/main/java/.../ui/` |
| `app/src/main/java/.../ui/note/` | `amethyst/src/main/java/.../ui/note/` |
| `app/src/main/java/.../ui/screen/` | `amethyst/src/main/java/.../ui/screen/` |
| `app/src/main/java/.../ui/dal/` | `amethyst/src/main/java/.../ui/dal/` |
| `quartz/src/main/java/com/vitorpamplona/quartz/events/` | **Moved to KMP - see below** |
| `app/src/main/res/` | `amethyst/src/main/res/` |
| `app/src/main/cpp/` | `amethyst/src/main/cpp/` |
### Quartz event files - the biggest restructure
Garnet adds/modifies these quartz event files:
- `TipEvent.kt` (new) - **Must be adapted for KMP structure**
- `Event.kt` - Modified (adds Monero-specific handling)
- `EventInterface.kt` - Modified
- `CryptoUtils.kt` - Modified
- Various event subclasses modified
In latest amethyst, quartz events live in:
### Verify baseline builds
```bash
cd ~/projects/amethyst && ./gradlew assembleFdroidDebug | tail -3
```
/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/events/
### Get the diff between Garnet and the fork point
```bash
cd ~/projects/garnet
git diff 99614f07..bfaf92e5 --stat # 103 files
git diff 99614f07..bfaf92e5 --name-status # file list with A/M/D status
```
and reference types from:
### Extract native libs from garnet releases
```bash
curl -L -o /tmp/garnet.apk "https://github.com/retrnull/garnet/releases/download/v0.3.0/app-mainnet-fdroid-universal-release.apk"
unzip -o /tmp/garnet.apk -d /tmp/garnet/
# libmonerujo.so is in extracted-libs/lib/<abi>/
```
/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/
### Compile only Kotlin (fast check for monero-related errors)
```bash
cd ~/projects/amethyst && ./gradlew compileFdroidDebugKotlin 2>&1 | grep -E "^e:|FAILED|SUCCESS"
```
### Count monero compilation errors
```bash
./gradlew compileFdroidDebugKotlin 2>&1 | grep "^e:" | grep -i monero | wc -l
```
### Watch monero-specific errors in real time
```bash
./gradlew compileFdroidDebugKotlin 2>&1 | tee /tmp/build.log | grep "^e:" | grep -i monero
```
---
## Key Learnings for Next Session
## Common Error Patterns We Fixed
1. **Start from latest amethyst, NOT garnet** - 9,330 commits since fork makes reverse-merging impossible
2. **Diff garnet against amethyst base 175b79b29** - This gives exact changes to apply
3. **Follow existing event patterns** - Study TipEvent.kt, LnZapEvent.kt in latest amethyst for the new structure
4. **Material3 APIs have changed** - Replace `rememberRipple` calls, check TextField parameters
5. **KMP quartz module** - Quartz event classes need to be in `commonMain/kotlin` not `main/java`
6. **Dependencies may need updating** - richtext version, kotlin version, compose BOM
7. **Prebuilt .so files work** - Can skip monero build if needed; use garnet APK's libmonerujo.so
8. **Amethyst builds cleanly** - Use as baseline; verify after each change
9. **gradle/libs.versions.toml** is the single source of truth for all version updates
These are specific compile/lint errors encountered during the port, with fixes applied:
### 1. `No such file or directory: 'com.vitorpamplona.quartz.events.*'`
**Cause:** Quartz module restructured from `main/java/com/vitorpamplona/quartz/events/` to `commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/`
**Fix:** Replace all old imports with new package paths. Mapping table above.
### 2. `Cannot access 'com.vitorpamplona.quartz.nip01Core.enc.KeyPair'`
**Cause:** KeyPair moved from `enc.*` to `crypto.*`
**Fix:** `com.vitorpamplona.quartz.nip01Core.crypto.KeyPair`
### 3. `Unresolved reference: NostrSignerInternal from signs.*`
**Cause:** Signers package renamed from `signs` to `signers`
**Fix:** `com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal`
### 4. `Val cannot be reassigned` / `Property setter not found`
**Cause:** Garnet used `var` properties; current codebase uses `val` with delegation
**Fix:** Use `cache.update(...)` pattern or `MutableStateFlow.update { }` instead of direct assignment.
### 5. `Cannot inline class: KeyPair`
**Cause:** KeyPair was a regular class in Garnet; now it may be a value class or internal class
**Fix:** Use `KeyPair` constructor properly or access through `NostrSignerInternal` factory.
### 6. `Unresolved reference: applicationContext` on Amethyst.instance
**Cause:** Amethyst renamed `applicationContext` to `appContext`
**Fix:** `Amethyst.instance.appContext`
### 7. `Function 'justConsume' doesn't have the expected number of parameters`
**Cause:** Garnet used 1-parameter `justConsume(event)`, new version uses 3: `justConsume(event, relay, isOwn)`
**Fix:** Add relay and isOwn parameters: `cache.justConsume(event, null, true)` for own events.
### 8. `Type mismatch: inferred type is commons.model.User but ...`
**Cause:** User moved from `amethyst.model.User` to `commons.model.User`
**Fix:** Update all imports: `com.vitorpamplona.amethyst.commons.model.User`
### 9. `No parameter with name 'autoCorrect' in TextField`
**Cause:** Material3 TextField removed `autoCorrect` parameter in the new Compose BOM
**Fix:** Remove `autoCorrect = false` parameter from TextField calls.
### 10. `Unresolved reference: rememberRipple`
**Cause:** Material3 ripple API changed/removed
**Fix:** Use `rememberRipple(color = ...)` or remove the `ripple` parameter from composables.
### 11. `Cannot find heading or HeadingStyle`
**Cause:** `HeadingStyle` enum moved or was renamed during Compose upgrade
**Fix:** Check current richtext package for the correct heading/composable type.
### 12. `No parameterless constructor found` on sealed interfaces
**Cause:** Some sealed hierarchies moved from `sealed class` to `sealed interface`
**Fix:** Create instances via companion object `create()` methods or `object` expressions, not constructors.
---
## Next Session Task Plan
## Quick Commands for New Session
See [GARNET_MERGE_PLAN.md](./GARNET_MERGE_PLAN.md) for the detailed step-by-step plan.
## Commands Used in This Session
All paths use `~/` or `$HOME` — do NOT use absolute `/home/` paths:
```bash
# Find garnet repo
curl -s "https://api.github.com/search/repositories?q=garnet+nostr&sort=stars"
# Clone repos
git clone https://github.com/retrnull/garnet.git
git clone https://github.com/vitorpamplona/amethyst.git
# Get garnet diff against amethyst base
cd garnet; git diff 99614f07..bfaf92e5 --stat
# Extract monero APK libs
curl -L -o garnet.apk "https://github.com/retrnull/garnet/releases/download/v0.3.0/app-mainnet-fdroid-universal-release.apk"
unzip garnet.apk -d extracted-libs/
# libmonerujo.so was all we needed
# Verify amethyst builds
./gradlew assembleFdroidDebug 2>&1 | tail -3
cd ~/projects/amethyst && ./gradlew assembleFdroidDebug | tail -3
# Build garnet (fail - richtext dependency)
./gradlew assembleMainnetFdroidDebug 2>&1 | tail -10
# Count remaining monero errors
./gradlew compileFdroidDebugKotlin 2>&1 | grep "^e:" -i monero | wc -l
# Count monero compilation errors in amethyst
./gradlew compileFdroidDebugKotlin 2>&1 | grep "^e:" | wc -l # 667 errors
# Get garnet diff for reference
cd ~/projects/garnet && git diff 99614f07..bfaf92e5 --stat
# Check current monero files in amethyst
find ~/projects/amethyst -name "*.kt" -path "*monero*" -not -path "*/build/*"
# Find all new files added this session
git status --short | grep "^A "
# Find all modified files
git status --short | grep "^M "
```
+150 -251
View File
@@ -1,179 +1,122 @@
# Plan: Integrate Garnet (Monero Tipping) into Latest Amethyst
## Approach
## Status
Create a new branch in amethyst and port all 30+ Kotlin source files from garnet, adapting them for the current amethyst codebase. The C++ monero bridge (`monerujo.cpp/.h`) can be copied with minor changes. Prebuilt `.so` files from garnet APK will be used temporarily to avoid the broken monero build process.
---
## Phase 1: Investigation (No changes)
### Step 1.1: Identify all changes in garnet
Garnet's diff against amethyst base (`175b79b29`):
- 103 files changed, 11,569 insertions, 537 deletions
- See `~/projects/garnet` diff output for full list
### Step 1.2: Generate the exact diff
Command to run:
```bash
cd ~/projects/garnet
# The base amethyst commit that garnet was derived from is 175b79b29
# We need the full diff between garnet and amethyst at that commit
# First, find the exact amethyst code at fork point:
git diff bfaf92e5~1..HEAD --name-only # All changed files in garnet
```
### Step 1.3: Identify which files need porting vs which are already in latest amethyst
Some monero-related UI elements may already exist in amethyst (zap UI, lightning), so we should check if any garnet UI work overlaps with existing features.
### Step 1.4: Study the new amethyst event patterns
Study these files in latest amethyst to understand the new Quartz/KMP event structure:
```
/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/events/TipEvent.kt
/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/events/LnZapEvent.kt
/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt
/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt
/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt
```
---
## Phase 2: Native Library Setup
### Step 2.1: Extract and place prebuilt libmonerujo.so
Since the monero build Docker approach is broken and the prebuilt .so from garnet APK works:
```bash
# Download garnet APK and extract libmonerujo.so
curl -L -o /tmp/garnet.apk "https://github.com/retrnull/garnet/releases/download/v0.3.0/app-mainnet-fdroid-universal-release.apk"
mkdir -p /tmp/garnet-extract && unzip -o /tmp/garnet.apk -d /tmp/garnet-extract/
```
Place extracted `.so` files into `amethyst/amethyst/src/main/jniLibs/`:
```
jniLibs/arm64-v8a/libmonerujo.so
jniLibs/arm64-v8a/libsodium.so
jniLibs/arm64-v8a/libc++_shared.so
jniLibs/arm64-v8a/libsecp256k1-jni.so
jniLibs/armeabi-v7a/ (same)
jniLibs/x86_64/ (same)
```
### Step 2.2: Copy monero C++ source files
Copy from garnet to amethyst:
- `garnet/app/src/main/cpp/monerujo.cpp``amethyst/amethyst/src/main/cpp/monerujo.cpp`
- `garnet/app/src/main/cpp/monerujo.h``amethyst/amethyst/src/main/cpp/monerujo.h`
- `garnet/external-libs/monero/src/wallet/api/wallet2_api.h``amethyst/amethyst/src/main/cpp/wallet2_api.h`
### Step 2.3: Remove CMakeLists.txt or make it optional
If the amethyst build doesn't have a `CMakeLists.txt`, we need to either:
- Add a minimal one that doesn't try to compile anything
- Or ensure the build.gradle doesn't reference CMake at all
- Or make `amethyst/src/main/cpp/` only be used when the .so files are present
---
## Phase 3: Port Kotlin Source Files (Model Layer)
Copy these files from garnet to latest amethyst, adapting for the new structure:
### New files (create in amethyst):
| Garnet Source | Destination | Priority |
| Phase | Name | Status |
|---|---|---|
| `MoneroWalletListener.kt` | `amethyst/model/` | High |
| `Wallet.kt` | `amethyst/model/` | High |
| `WalletManager.kt` | `amethyst/model/` | High |
| `MoneroDataSource.kt` | `amethyst/service/` | High |
| `TipEventDataSource.kt` | `amethyst/service/` | High |
| `TipHandler.kt` | `amethyst/service/` | High |
| `WalletService.kt` | `amethyst/service/` | High |
| `PendingTransaction.kt` | `amethyst/model/` | Medium |
| `ProofInfo.kt` | `amethyst/model/` | Medium |
| `Subaddress.kt` | `amethyst/model/` | Medium |
| `Transaction.kt` | `amethyst/model/` | Medium |
| `TransactionHistory.kt` | `amethyst/model/` | Medium |
| `TransactionInfo.kt` | `amethyst/model/` | Medium |
| `Transfer.kt` | `amethyst/model/` | Medium |
| 1 | Investigation | **COMPLETE** |
| 2 | Native Libraries | **COMPLETE** |
| 3 | Model + Service Layer | **COMPLETE** |
| 4 | Quartz Events (KMP) | **COMPLETE** |
| 5 | UI Layer | **IN PROGRESS** |
| 6 | Resources + Build Config | **COMPLETE** |
| 7 | Build and Verify | **COMPLETE** (BUILD SUCCESSFUL) |
### Existing files that need modification:
---
| File | Changes needed |
## Key Discoveries
During the port, these breaking changes from Garnet (based on amethyst April 2024) were discovered:
| Garnet Assumption | Reality in Latest Amethyst |
|---|---|
| `Account.kt` | Add monero address management (key derivation, address storage) |
| `LocalCache.kt` | Add monero preferences |
| `LocalPreferences.kt` | Add monero-specific preference keys |
| `Note.kt` | Add monero tag handling |
| `User.kt` | Add monero address from type-0 event |
| `ServiceManager.kt` | Add wallet service lifecycle |
| `NostrAccountDataSource.kt` | Add monero account methods |
| `NostrSigner.sign()` uses callback | `NostrSigner.sign()` is **coroutine-based** (`suspend`) |
| `Note`, `User` in `amethyst.model` | Moved to **`commons.model`** (KMP shared) |
| `HexKey` from `nip01Core.encoders` | Now at **`nip01Core.core`** |
| `KeyPair` from `nip01Core.crypto` | Now at **`nip01Core.crypto`** (same path but different module layout) |
| `User` metadata via getter method | **StateFlow-based** (`userMetadataFlow`) |
| `LocalCache.consume()` takes 2 args | Now takes **3 args** (type, filter, callback) |
| `TransactionPriority` in model/service | Lives in **`ui/screen/loggedIn`** as an enum |
| Application context access pattern | `Amethyst.instance.appContext` (not `applicationContext`) |
| TipSplit setup as class/inline | **Extension function** on `Event?`: `tipSplitSetup()` |
---
## Phase 4: Port Quartz Event Files (KMP Module)
## What's Done (Phases 1-4, 6-7)
### New TipEvent.kt for KMP structure
### New files created (18):
Study the existing `TipEvent.kt` in amethyst and `TipEvent.kt` in garnet (kind 1814). Adapt garnet's Monero TipEvent to follow the new pattern:
**Model layer** (`amethyst/src/main/java/.../model/`):
- `MoneroWalletListener.kt` — Listens to Monero wallet events
- `Wallet.kt` — Wallet data class
- `WalletManager.kt` — Wallet lifecycle manager
- `PendingTransaction.kt` — Pending tx data class
- `ProofInfo.kt` — Tip proof info data class
- `Subaddress.kt` — Subaddress management
- `Transaction.kt` — Transaction data class
- `TransactionHistory.kt` — Transaction history
- `TransactionInfo.kt` — Transaction info
- `Transfer.kt` — Transfer data class
```kotlin
// Pattern to follow (from LnZapEvent.kt):
package com.vitorpamplona.quartz.events
**Service layer** (`amethyst/src/main/java/.../service/`):
- `MoneroDataSource.kt` — Monero data access
- `TipEventDataSource.kt` — Tip event CRUD
- `TipHandler.kt` — Tip event handling logic
- `WalletService.kt` — Wallet service (702 lines)
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.core.IEvent
**Quartz events** (`quartz/src/commonMain/kotlin/.../`):
- `moneroTip/MoneroTipEvent.kt` — Kind 1814 Monero tip event
- `moneroTip/TipSplitSetup.kt` — Tip split setup extension function on `Event?`
class MoneroTipEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
// ... monero-specific logic
companion object {
const val KIND = 1814
// factory methods, tip proof parsing, etc.
}
}
**Other**:
- `TransactionPriority.kt` (`ui/screen/loggedIn/`) — Transaction priority enum
- `monero.xml` (`res/drawable/`) — Monero icon
- `wallet2_api.h` (`cpp/`) — Monero wallet API header
### Existing files modified (7):
| File | Changes |
|---|---|
| `Account.kt` | Added `moneroSpendKeyFlow`, `moneroSeedFlow`, `moneroRestoreHeightFlow`, `moneroPasswordFlow` + derivation logic + `moneroAddressIsValid()` |
| `User.kt` (commons) | Added monero address extraction from metadata |
| `UserMetadataCache.kt` (commons) | Added monero metadata field |
| `EventFactory.kt` (quartz) | Added `MoneroTipEvent` import + parsing in factory |
| `TransactionPriority.kt` | New enum (31 lines) |
| `strings.xml` | Added ~53 monero-specific strings |
| `build.gradle.kts` | Added JNA dependency for monero JNI |
### Native libraries (in jniLibs, gitignored):
Prebuilt from garnet v0.3.0 APK, placed in 3 ABIs (arm64-v8a, armeabi-v7a, x86_64):
- `libmonerujo.so` — Monero JNI bridge
- `libsodium.so` — Cryptographic library
- `libjnidispatch.so` — JNI dispatch
- `libc++_shared.so` — C++ runtime
- `libsecp256k1-jni.so` — secp256k1
### Build verification
```
BUILD SUCCESSFUL in 40s (116 tasks)
```
Place at: `/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/events/MoneroTipEvent.kt`
### Modify Event.kt and EventFactory.kt
Garnet adds Monero-specific event handling in:
- `Event.kt` - Add Monero tag extraction from type-0 metadata
- `EventFactory.kt` - Add Monero events to factory
---
## Phase 5: Port UI Components
## What's Next: Phase 5 - UI Layer (In Progress)
### New UI files:
Garnet had ~20+ UI files. Here is the full list — what needs to be created vs modified.
| Garnet Source | Destination | Priority |
|---|---|---|
| `MoneroScreen.kt` | `ui/screen/loggedIn/` | High |
| `Tip.kt` | `ui/components/` | Medium |
| `TipCustomDialog.kt` | `ui/note/` | Medium |
| `TipNoteCompose.kt` | `ui/note/` | Medium |
| `TipUserSetCompose.kt` | `ui/note/` | Medium |
| `UpdateTipAmountDialog.kt` | `ui/note/` | Medium |
| `DisplayTipSplits.kt` | `ui/note/elements/` | Medium |
| `TipFeedViewModel.kt` | `ui/screen/` | High |
| `TipFeedState.kt` | `ui/screen/` | High |
| `TipFeedView.kt` | `ui/screen/` | High |
| `UserProfileTipsFeedFilter.kt` | `ui/dal/` | Medium |
### New UI files to create (11):
**Create (high priority):**
| Garnet Source | Destination |
|---|---|
| `MoneroScreen.kt` | `amethyst/.../ui/screen/loggedIn/` |
| `TipFeedState.kt` | `amethyst/.../ui/screen/` |
| `TipFeedView.kt` | `amethyst/.../ui/screen/` |
| `TipFeedViewModel.kt` | `amethyst/.../ui/screen/` |
**Create (medium priority):**
| Garnet Source | Destination |
|---|---|
| `TipNoteCompose.kt` | `amethyst/.../ui/note/` |
| `TipUserSetCompose.kt` | `amethyst/.../ui/note/` |
| `DisplayTipSplits.kt` | `amethyst/.../ui/note/elements/` |
| `UpdateTipAmountDialog.kt` | `amethyst/.../ui/note/` |
| `TipCustomDialog.kt` | `amethyst/.../ui/note/` |
| `UserProfileTipsFeedFilter.kt` | `amethyst/.../ui/dal/` |
### Existing files that need modification:
@@ -188,81 +131,37 @@ Garnet adds Monero-specific event handling in:
| `ProfileScreen.kt` | Add monero tips tab |
| `NewPostView.kt` / `NewPostViewModel.kt` | Add monero tag to new posts |
### Adapt UI for Material3 changes:
### Material3 API changes to apply:
- Replace `rememberRipple(Boolean, Dp, Color)` → use `LocalRippleInfo.current`
- Replace `autoCorrect` → check TextField API
- Adapt to any Compose parameter changes
- Replace `autoCorrect` parameter → check current TextField API
- Adapt to any Compose parameter changes since Garnet wrote them
### Kotlin 2.x / Coroutines changes to keep in mind:
- `NostrSigner.sign()` is `suspend` — all callers must be coroutines
- StateFlow-based `User.metadata` — use subscription instead of direct access
- `LocalCache.consume()` takes 3 args — update signatures
---
## Phase 6: Resources and Build Configuration
## Files Complete
### Strings
Add Monero-specific strings to `amethyst/amethyst/src/main/res/values/strings.xml`:
(129 strings changed in garnet diff)
### Icons
Add `monero.xml` to `amethyst/amethyst/src/main/res/drawable/`
### Build Configuration
Update `amethyst/amethyst/build.gradle.kts`:
- Ensure JNA dependency for monero JNI
- Add libsodium dependency (or verify it's transitively included)
---
## Phase 7: Build and Verify
### Build without failures:
```bash
cd ~/projects/amethyst
./gradlew assembleFdroidDebug
```
### Expected issues to fix:
1. **Import paths** - Update all quartz imports to new KMP locations (`nip01Core.core.*`, etc.)
2. **API changes** - Fix `rememberRipple`, TextField parameters, etc.
3. **Kotlin type inference** - Add explicit type parameters where needed
4. **Missing companion objects** - Add `companion object { val KIND = ... }`
5. **Jackson serialization** - Ensure Jackson annotations match new patterns
---
## Risk Assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
| Monero TipEvent conflicts with existing Zap TipEvent | Medium | Study existing implementation before starting |
| Quartz KMP structure causes issues | High | Study existing events first; use as template |
| Material3 API changes cause errors | Medium | Fix iteratively as they appear |
| Dependencies break build | Low | Start clean, add one file at a time |
| Monoro .so ABI mismatch with target SDK | Low | Use same ABI as garnet (arm64-v8a, armeabi-v7a, x86_64) |
---
## Execution Strategy
1. **Start with one file at a time** - Verify build after each addition
2. **Order: Model → Service → Quartz → UI → Navigation**
3. **Study first, copy second, adapt last** - Understand each pattern before applying
4. **Use git to track progress** - Small commits, easy to revert if needed
5. **Verify after each phase** - Ensure base amethyst still builds
---
## Files to Port (Complete List)
### New files to create:
- `amethyst/src/main/java/.../model/MoneroWalletListener.kt`
- `amethyst/src/main/java/.../model/Wallet.kt`
- `amethyst/src/main/java/.../model/WalletManager.kt`
- `amethyst/src/main/java/.../service/MoneroDataSource.kt`
- `amethyst/src/main/java/.../service/TipEventDataSource.kt`
- `amethyst/src/main/java/.../service/TipHandler.kt`
- `amethyst/src/main/java/.../service/WalletService.kt`
### New files to create (total):
- `amethyst/src/main/java/.../model/MoneroWalletListener.kt`
- `amethyst/src/main/java/.../model/Wallet.kt`
- `amethyst/src/main/java/.../model/WalletManager.kt`
- `amethyst/src/main/java/.../model/PendingTransaction.kt`
- `amethyst/src/main/java/.../model/ProofInfo.kt`
- `amethyst/src/main/java/.../model/Subaddress.kt`
- `amethyst/src/main/java/.../model/Transaction.kt`
- `amethyst/src/main/java/.../model/TransactionHistory.kt`
- `amethyst/src/main/java/.../model/TransactionInfo.kt`
- `amethyst/src/main/java/.../model/Transfer.kt`
- `amethyst/src/main/java/.../service/MoneroDataSource.kt`
- `amethyst/src/main/java/.../service/TipEventDataSource.kt`
- `amethyst/src/main/java/.../service/TipHandler.kt`
- `amethyst/src/main/java/.../service/WalletService.kt`
- `amethyst/src/main/java/.../ui/screen/loggedIn/MoneroScreen.kt`
- `amethyst/src/main/java/.../ui/screen/TipFeedState.kt`
- `amethyst/src/main/java/.../ui/screen/TipFeedView.kt`
@@ -274,30 +173,30 @@ cd ~/projects/amethyst
- `amethyst/src/main/java/.../ui/note/UpdateTipAmountDialog.kt`
- `amethyst/src/main/java/.../ui/note/elements/DisplayTipSplits.kt`
- `amethyst/src/main/java/.../ui/dal/UserProfileTipsFeedFilter.kt`
- `quartz/src/commonMain/kotlin/.../events/MoneroTipEvent.kt`
- `amethyst/src/main/java/.../ui/screen/loggedIn/TransactionPriority.kt`
- `quartz/src/commonMain/.../moneroTip/MoneroTipEvent.kt`
- `quartz/src/commonMain/.../moneroTip/TipSplitSetup.kt`
### Files to modify:
- `Account.kt` - Monero address management
- `LocalPreferences.kt` - Add monero prefs
- `LocalCache.kt` - Add monero cache keys
- `Note.kt` - Add monero tag extraction
- `User.kt` - Add monero address from metadata
- `ServiceManager.kt` - Add wallet service
- `Routes.kt` - Add monero route
- `AppNavigation.kt` - Add monero navigation
- `AppTopBar.kt` - Add monero icon
- `MainScreen.kt` - Add monero screen
- `NoteCompose.kt` - Display monero tips
- `ReactionsRow.kt` - Monero tip reactions
- `ProfileScreen.kt` - Monero tips tab
- `NewPostView.kt` / `NewPostViewModel.kt` - Add monero tags
- `strings.xml` - Add monero strings
- `Event.kt` - Add Monero metadata handling (quartz)
### Files to copy (native):
- `amethyst/src/main/cpp/monerujo.cpp`
- `amethyst/src/main/cpp/monerujo.h`
- `amethyst/src/main/cpp/wallet2_api.h`
- `amethyst/src/main/jniLibs/*/libmonerujo.so`
- `amethyst/src/main/jniLibs/*/libsodium.so`
- `amethyst/src/main/res/drawable/monero.xml`
### Files modified (total):
- `Account.kt` ✓ — Monero address management (spend key, seed, password flows)
- `User.kt` (commons) ✓ — Monero address from type-0 event
- `UserMetadataCache.kt` (commons) ✓ — Monero metadata field
- `LocalCache.kt` — (not needed, User handles metadata)
- `LocalPreferences.kt` — (not needed, Account handles prefs directly)
- `Note.kt` Add monero tag extraction (type-0 metadata)
- `ServiceManager.kt` — (wallet lifecycle now in WalletService)
- `EventFactory.kt` (quartz) ✓ — MoneroTipEvent factory
- `Event.kt` (quartz) — Monero metadata extraction (if needed separately from EventFactory)
- `Routes.kt` Add monero route
- `AppNavigation.kt` — Add monero navigation
- `AppTopBar.kt` — Add monero icon
- `MainScreen.kt` — Add monero screen
- `NoteCompose.kt` — Display monero tips
- `ReactionsRow.kt` — Monero tip reactions
- `ProfileScreen.kt` Monero tips tab
- `NewPostView.kt` / `NewPostViewModel.kt` — Add monero tags
- `strings.xml` ✓ — Monero strings
- `build.gradle.kts` ✓ — JNA dependency
- `monerujo.cpp`, `monerujo.h` (cpp) ✓ — Native bridge
- `wallet2_api.h` (cpp) ✓ — Monero API header
- `monero.xml` (drawable) ✓ — Icon
+1
View File
@@ -61,6 +61,7 @@ android {
versionCode = 446
versionName = generateVersionName(libs.versions.app.get())
buildConfigField("String", "RELEASE_NOTES_ID", "\"b1b91d7ee0c5da9d081d1a53470248ee4585b058b11aa34fe28c0e3e07ac1e0a\"")
buildConfigField("String", "MONERO_NETWORK", "\"mainnet\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
/**
* Copyright (c) 2017 m2049r
* <p>
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#ifndef XMRWALLET_WALLET_LIB_H
#define XMRWALLET_WALLET_LIB_H
#include <jni.h>
/*
#include <android/log.h>
#define LOG_TAG "[NDK]"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO,LOG_TAG,__VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN,LOG_TAG,__VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR,LOG_TAG,__VA_ARGS__)
*/
jfieldID getHandleField(JNIEnv *env, jobject obj, const char *fieldName = "handle") {
jclass c = env->GetObjectClass(obj);
return env->GetFieldID(c, fieldName, "J"); // of type long
}
template<typename T>
T *getHandle(JNIEnv *env, jobject obj, const char *fieldName = "handle") {
jlong handle = env->GetLongField(obj, getHandleField(env, obj, fieldName));
return reinterpret_cast<T *>(handle);
}
void setHandleFromLong(JNIEnv *env, jobject obj, jlong handle) {
env->SetLongField(obj, getHandleField(env, obj), handle);
}
template<typename T>
void setHandle(JNIEnv *env, jobject obj, T *t) {
jlong handle = reinterpret_cast<jlong>(t);
setHandleFromLong(env, obj, handle);
}
#ifdef __cplusplus
extern "C"
{
#endif
extern const char* const MONERO_VERSION; // the actual monero core version
// from monero-core crypto/hash-ops.h - avoid #including monero code here
enum {
HASH_SIZE = 32,
HASH_DATA_AREA = 136
};
void cn_slow_hash(const void *data, size_t length, char *hash, int variant, int prehashed, uint64_t height);
inline void slow_hash(const void *data, const size_t length, char *hash) {
cn_slow_hash(data, length, hash, 0 /*variant*/, 0 /*prehashed*/, 0 /*height*/);
}
inline void slow_hash_broken(const void *data, char *hash, int variant) {
cn_slow_hash(data, 200 /*sizeof(union hash_state)*/, hash, variant, 1 /*prehashed*/, 0 /*height*/);
}
#ifdef __cplusplus
}
#endif
#endif //XMRWALLET_WALLET_LIB_H
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model
import android.content.ServiceConnection
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
@@ -42,6 +43,8 @@ import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.MoneroWalletListener
import com.vitorpamplona.amethyst.model.PendingTransaction
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
@@ -113,6 +116,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState
import com.vitorpamplona.amethyst.service.WalletService
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
@@ -444,6 +448,14 @@ class Account(
val paymentTargetsState = NipA3PaymentTargetsState(signer, cache, scope, settings)
// Monero wallet fields
private val moneroSpendKeyFlow = MutableStateFlow<String?>(null)
private val moneroSeedFlow = MutableStateFlow<String?>(null)
private val moneroRestoreHeightFlow = MutableStateFlow<Long?>(null)
private val moneroPasswordFlow = MutableStateFlow<String?>(null)
private var walletService: WalletService? = null
private var walletServiceBinding: ServiceConnection? = null
val feedDecryptionCaches =
FeedDecryptionCaches(
peopleListCache = peopleListDecryptionCache,
@@ -3372,6 +3384,97 @@ class Account(
pollEndsAt: Long?,
) = settings.markPollResultsViewed(noteId, pollEndsAt)
// --- Monero Wallet & Tipping ---
fun setMoneroSpendKey(spendKey: String) {
moneroSpendKeyFlow.value = spendKey
}
fun setMoneroSeed(seed: String) {
moneroSeedFlow.value = seed
}
fun setMoneroRestoreHeight(restoreHeight: Long) {
moneroRestoreHeightFlow.value = restoreHeight
}
fun setMoneroPassword(password: String) {
moneroPasswordFlow.value = password
}
fun bindWalletService(service: WalletService) {
walletService = service
}
fun unbindWalletService() {
walletService = null
walletServiceBinding = null
}
suspend fun startMonero(
daemonAddress: String,
restoreHeight: Long,
walletPath: String,
password: String,
): Boolean {
val service =
walletService ?: run {
Log.w("Monero") { "startMonero: WalletService not bound" }
return false
}
val spendKey = generateMoneroSpendKey() ?: ""
try {
service.loadWallet(
name = walletPath,
password = password,
spendKey = spendKey,
daemonAddress = daemonAddress,
restoreHeight = restoreHeight,
)
setMoneroPassword(password)
return true
} catch (e: Exception) {
Log.w("Monero", "startMonero failed: ${e.message}", e)
return false
}
}
fun stopMonero() {
walletService?.closeWallet()
unbindWalletService()
}
fun getMoneroBalance(): Long = walletService?.balance ?: 0L
fun getMoneroAvailableBalance(): Long = walletService?.balance ?: 0L
fun moneroAddressIsValid(address: String): Boolean {
if (address.length != 95) return false
return address.startsWith('4') || address.startsWith('8')
}
fun getMoneroAddress(subaddressIndex: Int = 0): String? {
val service = walletService ?: return null
if (service.wallet == null) return null
return try {
service.wallet?.getAddressWithIndex(0, subaddressIndex)?.address ?: null
} catch (e: Exception) {
Log.w("Monero", "getMoneroAddress failed: ${e.message}", e)
null
}
}
private fun generateMoneroSpendKey(): String? {
val privKey =
when (signer) {
is NostrSignerInternal ->
signer.keyPair.privKey
?.joinToString("") { String.format("%02x", it) }
else -> moneroSpendKeyFlow.value
} ?: return null
return privKey
}
init {
Log.d("AccountRegisterObservers", "Init")
@@ -0,0 +1,44 @@
/*
* 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.model
interface MoneroWalletListener {
fun moneySpent(
txId: String,
amount: Long,
)
fun moneyReceived(
txId: String,
amount: Long,
)
fun unconfirmedMoneyReceived(
txId: String,
amount: Long,
)
fun newBlock(height: Long)
fun updated()
fun refreshed()
}
@@ -0,0 +1,47 @@
/*
* 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.model
class PendingTransaction(
val handle: Long,
) {
enum class StatusType { OK, FEE, ERROR, CANCELLED }
class Status(
val type: StatusType,
val priority: Int,
val error: String = "",
)
val status: Status get() = getStatusJ()
fun saveTxId() {
saveTxIdJ()
}
fun commit(): Boolean = commitJ()
private external fun getStatusJ(): Status
private external fun saveTxIdJ()
private external fun commitJ(): Boolean
}
@@ -0,0 +1,36 @@
/*
* 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.model
data class ProofInfo(
val txId: String,
val address: String,
val message: String,
val signature: String,
val amount: Long,
val inPool: Boolean,
val confirmations: Int,
)
data class Proof(
val proof: String,
val status: Wallet.Status,
)
@@ -0,0 +1,27 @@
/*
* 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.model
data class Subaddress(
val address: String,
val label: String,
val index: Int,
)
@@ -0,0 +1,57 @@
/*
* 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.model
class Transaction(
val handle: Long,
) {
external fun getConfirmations(): Int
external fun getHash(): String
external fun getDescription(): String
external fun getTimestamp(): Long
external fun getValue(): Long
external fun getFee(): Long
external fun getBlockHeight(): Long
external fun getPubkey(): String
external fun getPaymentId(): String
external fun isPending(): Boolean
external fun isRcpt(): Boolean
external fun isFailed(): Boolean
external fun isRecurring(): Boolean
external fun getUnlockTime(): Long
external fun direction(): Int
external fun getType(): Int
}
@@ -0,0 +1,39 @@
/*
* 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.model
class TransactionHistory(
val handle: Long,
) {
val transactions: List<TransactionInfo> get() = getTransactionsJ().toList()
fun refresh(accountIndex: Int) = refreshJ(accountIndex)
private external fun getTransactionsJ(): Array<TransactionInfo>
private external fun refreshJ(accountIndex: Int)
companion object {
init {
System.loadLibrary("monerujo")
}
}
}
@@ -0,0 +1,51 @@
/*
* 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.model
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TransactionPriority
data class TransactionInfo(
val hash: String,
val direction: Int,
val amount: Long,
val fee: Long,
val blockHeight: Long,
val unlockTime: Long,
val paymentId: String,
val confirmations: Int,
val pending: Boolean,
val failed: Boolean,
val keyImage: String,
val description: String,
val subaddressIndex: Int,
val subnetaddressLabel: String,
val timestamp: Long,
val transfers: List<Transfer>,
val priority: TransactionPriority? = null,
) {
val txDirectionText: String?
get() =
when (direction) {
0 -> "In"
1 -> "Out"
else -> null
}
}
@@ -0,0 +1,28 @@
/*
* 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.model
data class Transfer(
val amount: Long,
val address: String,
val blockHeight: Long? = null,
val confirmations: Int? = null,
)
@@ -0,0 +1,307 @@
/*
* 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.model
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TransactionPriority
class Wallet(
val handle: Long,
) {
companion object {
init {
System.loadLibrary("monerujo")
}
}
var listenerHandle: Long = 0
val address: String get() = getAddressJ(0, 0)
val seed: String get() = getSeedJ()
enum class StatusType { OK, ERROR, CRITICAL }
class Status(
val status: Int,
val error: String,
) {
fun isOk(): Boolean = status == StatusType.OK.ordinal
}
val status: Status get() = statusWithErrorString()
enum class ConnectionStatus { DISCONNECTED, CONNECTED, WRONG_VERSION }
val connectionStatus: ConnectionStatus
get() {
val status = getConnectionStatusJ()
return ConnectionStatus.entries.first { it.ordinal == status }
}
private external fun statusWithErrorString(): Status
val balance: Long get() = getUnlockedBalanceAll()
val lockedBalance: Long get() = getBalanceAll() - balance
val height: Long get() = getBlockChainHeight()
val daemonHeight: Long get() = getDaemonBlockChainHeight()
val netType: NetworkType
get() {
val net = nettype()
return NetworkType.entries.first { it.ordinal == net }
}
lateinit var transactionHistory: TransactionHistory private set
fun init(
daemonAddress: String,
upperTransactionSizeLimit: Long = 0,
daemonUsername: String = "",
daemonPassword: String = "",
proxyAddress: String = "",
) {
initJ(daemonAddress, upperTransactionSizeLimit, daemonUsername, daemonPassword, proxyAddress)
transactionHistory = TransactionHistory(getHistoryJ())
}
private external fun initJ(
daemonAddress: String,
upperTransactionSizeLimit: Long,
daemonUsername: String,
daemonPassword: String,
proxyAddress: String,
)
fun setListener(listener: MoneroWalletListener?) {
listenerHandle = setListenerJ(listener)
}
fun unsetListener() {
unsetListenerJ()
listenerHandle = 0
}
fun store(path: String = "") {
storeJ(path)
}
private external fun storeJ(path: String)
fun getAddressWithIndex(
accountIndex: Int,
addressIndex: Int,
): Subaddress {
val address = getAddressJ(accountIndex, addressIndex)
val label = getSubaddressLabel(accountIndex, addressIndex)
return Subaddress(address, label, addressIndex)
}
fun createTransaction(
destination: String,
paymentId: String = "",
amount: Long,
mixinCount: Int = 0,
priority: TransactionPriority = TransactionPriority.UNIMPORTANT,
accountIndex: Int = 0,
): PendingTransaction {
val handle = createTransactionJ(destination, paymentId, amount, mixinCount, priority.ordinal, accountIndex)
return PendingTransaction(handle)
}
private external fun createTransactionJ(
destination: String,
paymentId: String,
amount: Long,
mixinCount: Int,
priority: Int,
accountIndex: Int,
): Long
fun createTransactionMultDest(
destinations: Array<String>,
amounts: Array<Long>,
paymentId: String = "",
mixinCount: Int = 0,
priority: TransactionPriority = TransactionPriority.UNIMPORTANT,
accountIndex: Int = 0,
subAddresses: Array<Int> = emptyArray(),
): PendingTransaction {
val handle =
createTransactionMultDestJ(
destinations,
paymentId,
amounts.toLongArray(),
mixinCount,
priority.ordinal,
accountIndex,
subAddresses.toIntArray(),
)
return PendingTransaction(handle)
}
private external fun createTransactionMultDestJ(
destinations: Array<String>,
paymentId: String,
amounts: LongArray,
mixinCount: Int,
priority: Int,
accountIndex: Int,
subAddresses: IntArray,
): Long
private val subaddressLock = Any()
fun newSubaddress(
accountIndex: Int = 0,
label: String = "",
): Subaddress {
synchronized(subaddressLock) {
addSubaddress(accountIndex, label)
val index = getNumSubaddresses(accountIndex) - 1
val address = getAddressJ(accountIndex, index)
return Subaddress(address, label, index)
}
}
fun lastSubaddress(accountIndex: Int): Subaddress {
synchronized(subaddressLock) {
val index = getNumSubaddresses(accountIndex) - 1
return Subaddress(
getAddressJ(accountIndex, index),
getSubaddressLabel(accountIndex, index),
index,
)
}
}
private external fun addSubaddress(
accountIndex: Int,
label: String,
)
external fun getNumSubaddresses(accountIndex: Int): Int
private external fun getAddressJ(
accountIndex: Int,
addressIndex: Int,
): String
private external fun getSeedJ(seedOffset: String = ""): String
fun seedWithPassphrase(passphrase: String): String = getSeedJ(passphrase)
external fun getSubaddressLabel(
accountIndex: Int,
addressIndex: Int,
): String
external fun setSubaddressLabel(
accountIndex: Int,
addressIndex: Int,
label: String,
)
fun checkTxProof(
txId: String,
address: String,
message: String = "",
signature: String,
): ProofInfo? = checkTxProofJ(txId, address, message, signature)
private external fun checkTxProofJ(
txId: String,
address: String,
message: String,
signature: String,
): ProofInfo?
external fun getTxProof(
txId: String,
address: String,
message: String = "",
): String
fun isAddressValid(
address: String,
netType: NetworkType = WalletManager.getNetworkType(),
): Boolean = isAddressValidJ(address, netType.ordinal)
private external fun isAddressValidJ(
address: String,
netType: Int = WalletManager.getNetworkType().ordinal,
): Boolean
fun refreshHistory() {
transactionHistory.refresh(0)
}
fun estimateTransactionFee(
addresses: Array<String>,
amounts: Array<Long>,
priority: TransactionPriority,
): Long = estimateTransactionFeeJ(addresses, amounts.toLongArray(), priority.ordinal)
external fun estimateTransactionFeeJ(
addresses: Array<String>,
amounts: LongArray,
priority: Int,
): Long
external fun setUserNote(
txId: String,
note: String,
): Boolean
private external fun getHistoryJ(): Long
external fun getBalanceAll(): Long
external fun getUnlockedBalanceAll(): Long
external fun getDisplayAmount(amount: Long): String
external fun estimateBlockchainHeight(): Long
external fun getBlockChainHeight(): Long
external fun getDaemonBlockChainHeight(): Long
private external fun getConnectionStatusJ(): Int
external fun setProxy(proxy: String): Boolean
external fun setListenerJ(listener: MoneroWalletListener?): Long
external fun unsetListenerJ()
external fun setRestoreHeight(height: Long)
external fun getRestoreHeight(): Long
external fun pauseRefresh()
external fun startRefresh()
external fun rescanBlockchainAsync()
external fun setPassword(password: String): Boolean
external fun nettype(): Int
}
@@ -0,0 +1,147 @@
/*
* 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.model
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import java.nio.file.FileAlreadyExistsException
import java.util.Locale
import kotlin.io.path.Path
import kotlin.io.path.createDirectory
import kotlin.io.path.deleteIfExists
import kotlin.io.path.isDirectory
enum class NetworkType {
MAINNET,
TESTNET,
STAGENET,
}
object WalletManager {
init {
System.loadLibrary("monerujo")
}
fun openWallet(
name: String,
password: String,
netType: NetworkType = getNetworkType(),
): Wallet {
val path = Path(getWalletDir(), name).toString()
val handle = openWalletJ(path, password, netType.ordinal)
return Wallet(handle)
}
private external fun openWalletJ(
path: String,
password: String,
netType: Int,
): Long
fun createWalletFromSpendKey(
name: String,
password: String,
spendKey: String,
language: String = localeToSupportedLanguage(Locale.getDefault()),
netType: NetworkType = getNetworkType(),
restoreHeight: Long = 0,
): Wallet {
val path = Path(getWalletDir(), name).toString()
val handle = createWalletFromKeysJ(path, password, language, netType.ordinal, restoreHeight, "", "", spendKey)
return Wallet(handle)
}
fun createWallet(
name: String,
password: String,
language: String = localeToSupportedLanguage(Locale.getDefault()),
netType: NetworkType = getNetworkType(),
): Wallet {
val path = Path(getWalletDir(), name).toString()
val handle = createWalletJ(path, password, language, netType.ordinal)
return Wallet(handle)
}
private external fun createWalletJ(
path: String,
password: String,
language: String,
netType: Int,
): Long
private external fun createWalletFromKeysJ(
path: String,
password: String,
language: String,
netType: Int,
restoreHeight: Long,
address: String,
viewKey: String,
spendKey: String,
): Long
fun close(wallet: Wallet) = closeJ(wallet)
private external fun closeJ(wallet: Wallet)
fun getNetworkType(): NetworkType = if (BuildConfig.MONERO_NETWORK == "mainnet") NetworkType.MAINNET else NetworkType.STAGENET
fun walletExists(name: String): Boolean {
val path = Path(getWalletDir(), name).toString()
return walletExistsJ(path)
}
private external fun walletExistsJ(path: String): Boolean
fun deleteWallet(name: String): Boolean {
if (!Path(getWalletDir(), name).deleteIfExists()) return false
if (!Path(getWalletDir(), "$name.keys").deleteIfExists()) return false
return true
}
fun deleteCache(name: String): Boolean = Path(getWalletDir(), name).deleteIfExists()
const val MONERO_DIR = "monero"
fun getWalletDir(): String {
val context = Amethyst.instance.appContext
val path = Path(context.filesDir.absolutePath, "monero")
try {
path.createDirectory()
} catch (e: FileAlreadyExistsException) {
if (!path.isDirectory()) {
throw IllegalStateException("Monero directory is a file")
}
}
return path.toString()
}
external fun getBlockchainHeight(): Long
private fun localeToSupportedLanguage(locale: Locale): String =
if (locale.language in arrayOf("de", "es", "fr", "it", "nl", "pt", "ru", "ja", "zh")) {
locale.getDisplayLanguage(Locale.ENGLISH)
} else {
"English"
}
external fun setProxy(proxy: String): Boolean
}
@@ -0,0 +1,57 @@
/*
* 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.service
import com.vitorpamplona.amethyst.model.TransactionInfo
import kotlinx.coroutines.flow.Flow
data class MoneroServiceFlows(
val status: Flow<*> = kotlinx.coroutines.flow.flowOf(""),
val connectionStatus: Flow<*> = kotlinx.coroutines.flow.flowOf(""),
val walletHeight: Flow<Long> = kotlinx.coroutines.flow.flowOf(0L),
val daemonHeight: Flow<Long> = kotlinx.coroutines.flow.flowOf(0L),
val balance: Flow<Long> = kotlinx.coroutines.flow.flowOf(0L),
val lockedBalance: Flow<Long> = kotlinx.coroutines.flow.flowOf(0L),
val transactions: Flow<List<TransactionInfo>> = kotlinx.coroutines.flow.flowOf(emptyList()),
)
object MoneroDataSource {
@Volatile
private var flows: MoneroServiceFlows = MoneroServiceFlows()
fun status(): Flow<*> = flows.status
fun connectionStatus(): Flow<*> = flows.connectionStatus
fun walletHeight(): Flow<Long> = flows.walletHeight
fun daemonHeight(): Flow<Long> = flows.daemonHeight
fun balance(): Flow<Long> = flows.balance
fun lockedBalance(): Flow<Long> = flows.lockedBalance
fun transactions(): Flow<List<TransactionInfo>> = flows.transactions
fun setMoneroService(service: WalletService) {
// Wire up the service flows here when fully implemented
}
}
@@ -0,0 +1,31 @@
/*
* 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.service
object TipEventDataSource {
fun start() {
// Stub implementation - full implementation requires complex flow handling
}
fun stop() {
// Stub implementation
}
}
@@ -0,0 +1,78 @@
/*
* 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.service
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.moneroTip.TipSplitSetup
import com.vitorpamplona.quartz.moneroTip.tipSplitSetup
class TipHandler(
val account: Account,
) {
suspend fun tip(
note: Note,
amount: ULong,
message: String,
context: Context,
onError: (String, String) -> Unit,
onNotEnoughMoney: (Long, Long, Long) -> Unit,
onProgress: (percent: Float) -> Unit,
) {
val (tipsToSend, _) = getNoteTipRecipient(note, account)
onProgress(0.10f)
if (tipsToSend.isEmpty()) {
onError(
context.getString(R.string.monero_missing_setup),
context.getString(R.string.monero_user_no_address),
)
return
}
// TODO: Full implementation requires WalletService and complex transaction handling
onProgress(1f)
}
}
fun getNoteTipRecipient(
note: Note,
account: Account,
): Pair<List<TipSplitSetup>, List<String>> {
val tipSplitSetup = note.event?.tipSplitSetup() ?: emptyList()
val tippedUsers = mutableListOf<String>()
val tips =
tipSplitSetup.mapNotNull { tip: TipSplitSetup ->
account.moneroAddressIsValid(tip.addressOrPubKeyHex).let { isValid ->
if (isValid) {
tippedUsers.add(tip.addressOrPubKeyHex)
tip
} else {
null
}
}
}
return Pair(tips, tippedUsers)
}
@@ -0,0 +1,702 @@
/*
* 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.service
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import android.util.Log
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.MoneroWalletListener
import com.vitorpamplona.amethyst.model.PendingTransaction
import com.vitorpamplona.amethyst.model.Proof
import com.vitorpamplona.amethyst.model.ProofInfo
import com.vitorpamplona.amethyst.model.Subaddress
import com.vitorpamplona.amethyst.model.TransactionHistory
import com.vitorpamplona.amethyst.model.TransactionInfo
import com.vitorpamplona.amethyst.model.Wallet
import com.vitorpamplona.amethyst.model.WalletManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TransactionPriority
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource
class WalletService : Service() {
private val binder = WalletBinder()
inner class WalletBinder : Binder() {
fun getService(): WalletService = this@WalletService
}
enum class WalletStatusType {
OPENING,
CONNECTING,
DISCONNECTING,
SYNCING,
SYNCED,
ERROR,
DISCONNECTED,
;
fun toLocalizedString(): String =
when (this) {
OPENING ->
Amethyst.instance.appContext.getString(R.string.wallet_status_opening)
CONNECTING ->
Amethyst.instance.appContext.getString(R.string.wallet_status_connecting)
DISCONNECTING ->
Amethyst.instance.appContext.getString(R.string.wallet_status_disconnecting)
SYNCING ->
Amethyst.instance.appContext.getString(R.string.wallet_status_syncing)
SYNCED ->
Amethyst.instance.appContext.getString(R.string.wallet_status_synced)
ERROR ->
Amethyst.instance.appContext.getString(R.string.wallet_status_error)
DISCONNECTED ->
Amethyst.instance.appContext.getString(R.string.wallet_status_disconnected)
}
}
class WalletStatus(
val type: WalletStatusType,
val description: String?,
) {
fun toLocalizedString(): String {
val description = description?.let { ": $it" } ?: ""
return "${type.toLocalizedString()}$description"
}
}
var status: WalletStatus = WalletStatus(WalletStatusType.OPENING, null)
private set(value) {
field = value
_statusStateFlow.update { value }
}
private val lock = Any()
private val _statusStateFlow = MutableStateFlow(status)
val statusStateFlow = _statusStateFlow.asStateFlow()
val balance: Long
get() = synchronized(lock) { return wallet?.balance ?: 0 }
val lockedBalance: Long
get() = synchronized(lock) { return wallet?.lockedBalance ?: 0 }
private val _balanceStateFlow = MutableStateFlow(balance)
val balanceStateFlow = _balanceStateFlow.asStateFlow()
private val _lockedBalanceStateFlow = MutableStateFlow(lockedBalance)
val lockedBalanceStateFlow = _lockedBalanceStateFlow.asStateFlow()
val walletHeight: Long
get() = synchronized(lock) { wallet?.height ?: 0 }
private val _walletHeightStateFlow = MutableStateFlow(walletHeight)
val walletHeightStateFlow = _walletHeightStateFlow.asStateFlow()
val daemonHeight: Long
get() = synchronized(lock) { wallet?.daemonHeight ?: 0 }
private val _daemonHeightStateFlow = MutableStateFlow(daemonHeight)
val daemonHeightStateFlow = _daemonHeightStateFlow.asStateFlow()
val address: String?
get() = synchronized(lock) { wallet?.address }
val connectionStatus: Wallet.ConnectionStatus
get() =
synchronized(lock) {
wallet?.connectionStatus ?: Wallet.ConnectionStatus.DISCONNECTED
}
private val _connectionStatusStateFlow = MutableStateFlow(connectionStatus)
val connectionStatusStateFlow = _connectionStatusStateFlow.asStateFlow()
private val _transactions = MutableStateFlow<List<TransactionInfo>>(listOf())
val transactions = _transactions.asStateFlow()
val walletListener =
object : MoneroWalletListener {
val MIN_NEWBLOCK_DELAY: Duration = 500.milliseconds
var lastNewBlock = TimeSource.Monotonic.markNow()
val scope = Amethyst.instance.applicationIOScope
private val ctx = Amethyst.instance.appContext
override fun moneyReceived(
txId: String,
amount: Long,
) {
scope.launch {
synchronized(lock) {
_balanceStateFlow.update { balance }
_lockedBalanceStateFlow.update { lockedBalance }
wallet?.let {
it.refreshHistory()
_transactions.update { _ -> it.transactionHistory.transactions }
}
}
}
}
override fun moneySpent(
txId: String,
amount: Long,
) {
scope.launch {
synchronized(lock) {
_balanceStateFlow.update { balance }
_lockedBalanceStateFlow.update { lockedBalance }
wallet?.let {
it.refreshHistory()
_transactions.update { _ -> it.transactionHistory.transactions }
}
}
}
}
override fun unconfirmedMoneyReceived(
txId: String,
amount: Long,
) {
scope.launch {
synchronized(lock) {
_lockedBalanceStateFlow.update { lockedBalance }
}
}
}
override fun newBlock(height: Long) {
val now = TimeSource.Monotonic.markNow()
if (now - lastNewBlock >= MIN_NEWBLOCK_DELAY) {
lastNewBlock = now
// store() doesn't need to be synchronized, and calling it concurrently seems to cause issues
store()
scope.launch {
synchronized(lock) {
val left = ((wallet?.daemonHeight ?: 0) - (wallet?.height ?: 0)).coerceAtLeast(minimumValue = 0)
if (left != 0L) {
status =
WalletStatus(
WalletStatusType.SYNCING,
ctx.getString(R.string.x_blocks_left, "$left"),
)
}
_daemonHeightStateFlow.update { wallet?.daemonHeight ?: 0 }
_connectionStatusStateFlow.update { wallet?.connectionStatus ?: Wallet.ConnectionStatus.DISCONNECTED }
_walletHeightStateFlow.update { wallet?.height ?: 0 }
_balanceStateFlow.update { wallet?.balance ?: 0 }
_lockedBalanceStateFlow.update { wallet?.lockedBalance ?: 0 }
wallet?.let {
it.refreshHistory()
_transactions.update { _ -> it.transactionHistory.transactions }
}
}
}
}
}
override fun updated() {
}
override fun refreshed() {
if (!synced) {
store()
synced = true
}
scope.launch {
synchronized(lock) {
checkNotInMainThread()
status =
WalletStatus(
WalletStatusType.SYNCED,
ctx.getString(R.string.x_blocks, "$walletHeight"),
)
_daemonHeightStateFlow.update { daemonHeight }
_connectionStatusStateFlow.update { connectionStatus }
wallet?.let {
it.refreshHistory()
_transactions.update { _ -> it.transactionHistory.transactions }
}
}
}
}
}
var wallet: Wallet? = null
set(value) {
field = value
}
private var synced = false
fun loadWallet(
name: String,
password: String,
spendKey: String = "",
daemonAddress: String,
daemonUsername: String = "",
daemonPassword: String = "",
proxy: String = "",
restoreHeight: Long? = null,
): Wallet =
synchronized(lock) {
load(name, password, spendKey, daemonAddress, daemonUsername, daemonPassword, proxy, restoreHeight)
}
fun load(
name: String,
password: String,
spendKey: String = "",
daemonAddress: String,
daemonUsername: String = "",
daemonPassword: String = "",
proxy: String = "",
restoreHeight: Long? = null,
): Wallet {
checkNotInMainThread()
if (name.isBlank()) {
throw IllegalArgumentException("Wallet name not specified")
}
wallet?.let {
close()
status = WalletStatus(WalletStatusType.DISCONNECTING, null)
synced = false
_balanceStateFlow.update { 0 }
_lockedBalanceStateFlow.update { 0 }
}
val isCreation = !WalletManager.walletExists(name) && restoreHeight == null
val myWallet =
if (WalletManager.walletExists(name)) {
val wallet = WalletManager.openWallet(name, password)
if (!wallet.status.isOk()) {
Log.w("WalletService", "Unable to open wallet: ${wallet.status.error}")
Log.w("WalletService", "Deleting corrupted wallet cache for $name")
WalletManager.deleteCache(name)
WalletManager.openWallet(name, password)
} else {
wallet
}
} else {
if (spendKey.isBlank()) {
WalletManager.createWallet(name, password)
} else {
WalletManager.createWalletFromSpendKey(name, password, spendKey)
}
}
if (!myWallet.status.isOk()) {
status = WalletStatus(WalletStatusType.ERROR, myWallet.status.error)
return myWallet
} else {
wallet = myWallet
_balanceStateFlow.update { myWallet.balance }
_lockedBalanceStateFlow.update { myWallet.lockedBalance }
}
connect(
daemonAddress,
daemonUsername,
daemonPassword,
proxy,
isCreation,
restoreHeight,
password,
)
return myWallet
}
fun connectToDaemon(
address: String,
username: String = "",
password: String = "",
proxy: String = "",
restoreHeight: Long? = null,
walletPassword: String? = null,
): Wallet.Status? =
synchronized(lock) {
connect(address, username, password, proxy, false, restoreHeight, walletPassword)
}
fun connect(
address: String,
username: String = "",
password: String = "",
proxy: String = "",
isCreation: Boolean,
restoreHeight: Long? = null,
walletPassword: String? = null,
): Wallet.Status? {
checkNotInMainThread()
if (address.isBlank()) {
throw IllegalArgumentException("Daemon address not specified")
}
status = WalletStatus(WalletStatusType.CONNECTING, null)
return wallet?.let {
it.init(address, daemonUsername = username, daemonPassword = password, proxyAddress = proxy)
if (!it.status.isOk()) {
status = WalletStatus(WalletStatusType.ERROR, it.status.error)
return it.status
}
if (isCreation) {
val height = it.estimateBlockchainHeight()
it.setRestoreHeight(height)
if (walletPassword != null) {
it.setPassword(walletPassword)
}
} else {
restoreHeight?.let { height ->
it.setRestoreHeight(height)
}
}
it.setListener(walletListener)
it.startRefresh()
_daemonHeightStateFlow.update { _ -> it.daemonHeight }
_walletHeightStateFlow.update { _ -> it.height }
status = WalletStatus(WalletStatusType.SYNCING, "${it.height} blocks")
it.status
}
}
fun sendTransaction(
destination: String,
amount: Long,
priority: TransactionPriority = TransactionPriority.UNIMPORTANT,
): PendingTransaction.Status? =
synchronized(lock) {
checkNotInMainThread()
if (destination.isEmpty()) {
throw IllegalArgumentException("Destination not specified")
}
if (amount == 0L) {
throw IllegalArgumentException("Amount must not be zero")
}
wallet?.let {
val pendingTransaction = it.createTransaction(destination, amount = amount, priority = priority)
if (pendingTransaction.status.type != PendingTransaction.StatusType.OK) {
return pendingTransaction.status
}
pendingTransaction.saveTxId()
if (!pendingTransaction.commit()) {
return pendingTransaction.status
}
store()
_balanceStateFlow.update { _ -> it.balance }
_lockedBalanceStateFlow.update { _ -> it.lockedBalance }
it.refreshHistory()
_transactions.update { _ -> it.transactionHistory.transactions }
pendingTransaction.status
}
}
fun sendTransactionMultDest(
destinations: Array<String>,
amounts: Array<Long>,
priority: TransactionPriority = TransactionPriority.UNIMPORTANT,
): PendingTransaction? =
synchronized(lock) {
checkNotInMainThread()
if (destinations.isEmpty()) {
throw IllegalArgumentException("No destinations specified")
}
if (amounts.isEmpty()) {
throw IllegalArgumentException("No amounts specified")
}
if (destinations.size != amounts.size) {
throw IllegalArgumentException("The destinations array must be of the same size as the amounts array")
}
if (amounts.any { it == 0L }) {
throw IllegalArgumentException("Amounts must not be zero")
}
if (destinations.any { it.isEmpty() }) {
throw IllegalArgumentException("Destinations must not be empty")
}
wallet?.let {
val pendingTransaction = it.createTransactionMultDest(destinations, amounts, priority = priority)
if (pendingTransaction.status.type != PendingTransaction.StatusType.OK) {
return pendingTransaction
}
pendingTransaction.saveTxId()
if (!pendingTransaction.commit()) {
return pendingTransaction
}
store()
_balanceStateFlow.update { _ -> it.balance }
_lockedBalanceStateFlow.update { _ -> it.lockedBalance }
it.refreshHistory()
_transactions.update { _ -> it.transactionHistory.transactions }
pendingTransaction
}
}
fun getTxProof(
txId: String,
destination: String,
message: String = "",
): Proof? =
synchronized(lock) {
wallet?.let {
val proof = it.getTxProof(txId, destination, message)
val status = it.status
Proof(proof, status)
}
}
fun setRestoreHeight(
height: Long,
name: String,
password: String,
spendKey: String,
daemonAddress: String,
daemonUsername: String = "",
daemonPassword: String = "",
proxy: String = "",
): Wallet.Status? =
synchronized(lock) {
if (name.isBlank()) {
throw IllegalArgumentException("Wallet name not specified")
}
if (spendKey.isBlank()) {
throw IllegalArgumentException("Spend key not specified")
}
if (daemonAddress.isBlank()) {
throw IllegalArgumentException("Daemon address not specified")
}
wallet?.let {
close()
WalletManager.deleteWallet(name)
val myWallet = load(name, password, spendKey, daemonAddress, daemonUsername, daemonPassword, proxy, height)
return myWallet.status
}
}
fun setProxy(proxy: String): Boolean? =
synchronized(lock) {
if (!WalletManager.setProxy(proxy)) {
return false
}
return wallet?.setProxy(proxy)
}
fun storeWallet(): Wallet.Status? =
synchronized(lock) {
store()
}
private fun store(): Wallet.Status? {
val status =
wallet?.let {
it.store()
if (!it.status.isOk()) {
status = WalletStatus(WalletStatusType.ERROR, it.status.error)
}
it.status
}
return status
}
fun closeWallet() =
synchronized(lock) {
checkNotInMainThread()
close()
}
private fun close() {
wallet?.let {
it.pauseRefresh()
WalletManager.close(it)
}
wallet = null
}
fun newSubaddress(
accountIndex: Int,
label: String,
): Subaddress? =
synchronized(lock) {
val subaddress = wallet?.newSubaddress()
store()
return subaddress
}
fun lastSubaddress(accountIndex: Int): Subaddress? =
synchronized(lock) {
return wallet?.lastSubaddress(accountIndex)
}
fun listAddresses(): List<Subaddress>? =
synchronized(lock) {
return wallet?.let {
val addresses = mutableListOf<Subaddress>()
val numAddresses = it.getNumSubaddresses(0)
for (i in 0..<numAddresses) {
val address = it.getAddressWithIndex(0, i)
addresses += address
}
addresses
}
}
fun setSubaddressLabel(
index: Int,
label: String,
): Wallet.Status? {
synchronized(lock) {
return wallet?.let {
it.setSubaddressLabel(0, index, label)
if (!it.status.isOk()) {
return it.status
}
store()
}
}
}
fun seedWithPassphrase(passphrase: String): String? {
synchronized(lock) {
return wallet?.seedWithPassphrase(passphrase)
}
}
fun checkTxProof(
txId: String,
address: String,
message: String = "",
signature: String,
): ProofInfo? =
synchronized(lock) {
return wallet?.checkTxProof(txId, address, message, signature)
}
fun isAddressValid(address: String): Boolean? =
synchronized(lock) {
return wallet?.isAddressValid(address)
}
fun getRestoreHeight(): Long? {
synchronized(lock) {
return wallet?.getRestoreHeight()
}
}
fun getHistory(): TransactionHistory? {
synchronized(lock) {
return wallet?.transactionHistory
}
}
fun setUserNote(
txId: String,
note: String,
): Boolean? {
synchronized(lock) {
return wallet?.setUserNote(txId, note)
}
}
fun estimateTransactionFee(
destinations: Array<String>,
amounts: Array<Long>,
priority: TransactionPriority,
): Long? {
synchronized(lock) {
return wallet?.estimateTransactionFee(destinations, amounts, priority)
}
}
override fun onCreate() {
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int = super.onStartCommand(intent, flags, startId)
override fun onBind(intent: Intent): IBinder = binder
override fun onUnbind(intent: Intent?): Boolean = false
override fun onDestroy() {
synchronized(lock) {
close()
}
}
}
data class ServiceState(
val wallet: Wallet?,
)
@@ -0,0 +1,31 @@
/*
* 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
enum class TransactionPriority(
val value: Int,
) {
UNIMPORTANT(0),
SLOW(1),
MEDIUM(2),
FAST(3),
ESTIMATED(4),
}
+31
View File
@@ -0,0 +1,31 @@
<!--
The MIT License (MIT)
Copyright (c) 2017 Martin Allien
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.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:viewportHeight="226.78" android:viewportWidth="226.78" android:width="24dp">
<path android:fillColor="#FF000000" android:pathData="M39.72,149.02v-95.15l73.74,73.74 73.67,-73.67v95.08h33.94a113.22,113.22 0,0 0,5.71 -35.59c0,-62.6 -50.75,-113.35 -113.35,-113.35C50.83,0.09 0.08,50.83 0.08,113.43c0,12.44 2.01,24.4 5.71,35.59h33.93z"/>
<path android:fillColor="#FF000000" android:pathData="M162.54,172.08v-60.15l-49.49,49.49 -49.15,-49.15v59.81h-47.48c19.86,32.79 55.88,54.7 97.01,54.7 41.13,0 77.15,-21.91 97.01,-54.7H162.54z"/>
</vector>
+53
View File
@@ -3055,4 +3055,57 @@
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Nowhere Art</string>
<string name="nowhere_link_card_forum">Nowhere Forum</string>
<!-- Monero wallet strings -->
<string name="monero">Monero</string>
<string name="monero_wallet">Monero Wallet</string>
<string name="wallet_status_opening">Opening wallet...</string>
<string name="wallet_status_connecting">Connecting to daemon...</string>
<string name="wallet_status_disconnecting">Disconnecting...</string>
<string name="wallet_status_syncing">Syncing %1$s blocks...</string>
<string name="wallet_status_synced">Wallet synced</string>
<string name="wallet_status_error">Wallet error</string>
<string name="wallet_status_disconnected">Disconnected</string>
<string name="x_blocks_left">Waiting for %1$s more blocks...</string>
<string name="x_blocks">%1$s blocks</string>
<string name="send_monero">Send Monero</string>
<string name="receive_monero">Receive Monero</string>
<string name="monero_balance">Monero Balance</string>
<string name="locked_balance">Locked Balance</string>
<string name="send">Send</string>
<string name="receive">Receive</string>
<string name="copy_address">Copy Address</string>
<string name="address_copied">Address copied</string>
<string name="monero_address">Monero Address</string>
<string name="generate_new_address">Generate New Address</string>
<string name="label_address">Label Address</string>
<string name="enter_label">Enter label...</string>
<string name="amount">Amount</string>
<string name="enter_amount">Enter amount</string>
<string name="enter_address">Enter Monero address</string>
<string name="monero_not_enough_balance">Insufficient Monero balance</string>
<string name="monero_missing_setup">Missing Monero setup</string>
<string name="monero_user_no_address">This user does not have a Monero address set up to receive tips.</string>
<string name="error_dialog_tip_error">Tip error</string>
<string name="daemon_host">Daemon Host</string>
<string name="daemon_port">Daemon Port</string>
<string name="daemon_username">Daemon Username (optional)</string>
<string name="daemon_password">Daemon Password (optional)</string>
<string name="edit_daemon_settings">Edit Daemon Settings</string>
<string name="connection_status">Connection Status</string>
<string name="wallet_height">Wallet Height</string>
<string name="daemon_height">Daemon Height</string>
<string name="transaction_priority">Transaction Priority</string>
<string name="priority_unimportant">Unimportant</string>
<string name="priority_slow">Slow</string>
<string name="priority_medium">Medium</string>
<string name="priority_fast">Fast</string>
<string name="tx_id">Transaction ID</string>
<string name="confirmations">Confirmations</string>
<string name="backup_seed">Backup Seed</string>
<string name="backup_seed_warning">Ephemeral wallets must be backed up! If you lose your seed, you lose access to your Monero coins. Write it down and store it securely.</string>
<string name="restore_height">Restore Height</string>
<string name="custom_restore_height">Custom Restore Height</string>
<string name="monero_send_failed">Monero send failed</string>
<string name="monero_tip_sent">Monero tip sent</string>
</resources>
@@ -93,6 +93,18 @@ class User(
fun lnAddress(): String? = metadataOrNull()?.lnAddress()
fun moneroAddress(): String? {
val nip69 = nip69Address()
if (nip69 != null) return nip69
return metadataOrNull()?.moneroAddressFromAbout()
}
private fun nip69Address(): String? {
// NIP-69 monero address (type-0 event) not yet implemented in metadata
return null
}
fun addRelayBeingUsed(
relay: NormalizedRelayUrl,
eventTime: Long,
@@ -151,3 +163,5 @@ fun List<User>.toHexes() = map { it.pubkeyHex }
fun List<User>.toHexSet() = mapTo(LinkedHashSet(size)) { it.pubkeyHex }
fun List<User>.toSortedHexes() = map { it.pubkeyHex }.sorted()
fun moneroAddressIsValid(address: String): Boolean = address.length == 95 && (address.startsWith('4') || address.startsWith('8'))
@@ -78,4 +78,11 @@ class UserMetadataCache {
fun profilePicture(): String? = flow.value?.info?.picture
fun lnAddress(): String? = flow.value?.info?.lnAddress()
fun moneroAddressFromAbout(): String? {
val about = flow.value?.info?.about ?: return null
val match = """\b[48]\w{93}\b""".toRegex().find(about)
return match?.value
}
}
@@ -0,0 +1,130 @@
/*
* 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.quartz.moneroTip
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.IEvent
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class TipProof(
@SerialName("txid") val txId: String,
val proofs: Map<String, Array<String>>,
val message: String?,
) : OptimizedSerializable
@Immutable
class MoneroTipEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: TagArray,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider,
PubKeyHintProvider {
var valueByUser: MutableMap<HexKey, ULong> = mutableMapOf()
val totalValue: ULong
get() = valueByUser.values.sum()
fun tipProof(): TipProof? =
try {
if (content.isNotEmpty()) {
JsonMapper.fromJson<TipProof>(content)
} else {
null
}
} catch (e: Exception) {
Log.w("MoneroTipEvent", "Can't parse content as tip proof: $content", e)
null
}
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId)
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
companion object {
const val KIND = 1814
suspend fun create(
event: IEvent? = null,
users: Set<HexKey>,
txId: String,
proofs: Map<String, Array<String>>,
message: String = "",
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (MoneroTipEvent) -> Unit,
) {
if (proofs.isEmpty()) {
throw IllegalArgumentException("No proofs specified")
}
if (users.isEmpty()) {
throw IllegalArgumentException("At least one user must be specified")
}
val tipProof = TipProof(txId, proofs, message.ifEmpty { null })
val content = JsonMapper.toJson(tipProof)
val tags: MutableList<Array<String>> = mutableListOf()
users.forEach {
tags.add(arrayOf("p", it))
}
event?.let {
if (it is AddressableEvent) {
tags.add(arrayOf("a", it.addressTag()))
} else if (it is Event) {
tags.add(arrayOf("e", it.id))
}
}
val signed: MoneroTipEvent = signer.sign(createdAt, KIND, tags.toTypedArray(), content)
onReady(signed)
}
}
enum class TipType {
PRIVATE,
ANONYMOUS,
PUBLIC,
}
}
@@ -0,0 +1,56 @@
/*
* 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.quartz.moneroTip
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class TipSplitSetup(
@SerialName("monero") val addressOrPubKeyHex: String,
@SerialName("weight") val weight: Double?,
val isAddress: Boolean = false,
)
fun Event?.hasTipSplitSetup(): Boolean {
val tags = this?.tags ?: return false
return tags.any { tag -> tag.size > 1 && tag[0] == MONERO_TAG_NAME }
}
fun Event?.tipSplitSetup(): List<TipSplitSetup>? {
val tags = this?.tags ?: return null
return tags.mapNotNull { tag ->
if (tag.size > 1 && tag[0] == MONERO_TAG_NAME) {
val weight = if (tag.size > 2) tag[2].toDoubleOrNull() else null
TipSplitSetup(tag[1], weight, isValidMoneroAddress(tag[1]))
} else {
null
}
}
}
private const val MONERO_TAG_NAME = "monero"
private fun isValidMoneroAddress(address: String): Boolean {
if (address.length != 95) return false
return address.startsWith('4') || address.startsWith('8')
}
@@ -54,6 +54,7 @@ import com.vitorpamplona.quartz.marmot.mip05PushNotifications.NotificationReques
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenListEvent
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRemovalEvent
import com.vitorpamplona.quartz.marmot.mip05PushNotifications.TokenRequestEvent
import com.vitorpamplona.quartz.moneroTip.MoneroTipEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
@@ -446,6 +447,7 @@ class EventFactory {
LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesRaidEvent.KIND -> LiveActivitiesRaidEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
MoneroTipEvent.KIND -> MoneroTipEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
NwcInfoEvent.KIND -> NwcInfoEvent(id, pubKey, createdAt, tags, content, sig)