Files
amethyst/AGENTS.md
T
2026-05-20 21:59:37 -07:00

24 KiB

Garnet Integration into Amethyst - Project Notes & Learnings

Quick Reference

Item Value
Garnet repo https://github.com/retrnull/garnet
Amethyst repo https://github.com/vitorpamplona/amethyst
Garnet base amethyst commit 175b79b29 (April 2024)
Garnet fork date ~July 10, 2024
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 Worked ✓

21 New Files Created

# 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

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

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)

// Garnet's NostrSigner callback style
signer.sign(eventBuilder, onReady: (Event) -> Unit)

New (coroutine, current)

// 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:

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:

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:

// 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:

// 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():

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:

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:

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

enum class TransactionPriority(val value: Int) {
    UNIMPORTANT(0),
    SLOW(1),
    MEDIUM(2),
    FAST(3),
    ESTIMATED(4),
}

Used everywhere Monero transactions are created:

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

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: Usercommons.model.User, Notecommons.model.Note
  • Compose Material3 imports: androidx.compose.material3.*

Priority D: Build and Verify

Run the full build after completing wiring:

cd ~/projects/amethyst && ./gradlew assembleFdroidDebug

Then check for any remaining lint/compiler errors specifically in monero files.


Build Commands

Verify baseline builds

cd ~/projects/amethyst && ./gradlew assembleFdroidDebug | tail -3

Get the diff between Garnet and the fork point

cd ~/projects/garnet
git diff 99614f07..bfaf92e5 --stat    # 103 files
git diff 99614f07..bfaf92e5 --name-status  # file list with A/M/D status

Extract native libs from garnet releases

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>/
cd ~/projects/amethyst && ./gradlew compileFdroidDebugKotlin 2>&1 | grep -E "^e:|FAILED|SUCCESS"

Count monero compilation errors

./gradlew compileFdroidDebugKotlin 2>&1 | grep "^e:" | grep -i monero | wc -l

Watch monero-specific errors in real time

./gradlew compileFdroidDebugKotlin 2>&1 | tee /tmp/build.log | grep "^e:" | grep -i monero

Common Error Patterns We Fixed

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.


Quick Commands for New Session

All paths use ~/ or $HOME — do NOT use absolute /home/ paths:

# Verify amethyst builds
cd ~/projects/amethyst && ./gradlew assembleFdroidDebug | tail -3

# Count remaining monero errors
./gradlew compileFdroidDebugKotlin 2>&1 | grep "^e:" -i monero | wc -l

# 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 "