mirror of
https://github.com/mollyim/monero-wallet-sdk.git
synced 2025-07-24 23:35:27 -04:00
lib: add more unit and E2E tests
This commit is contained in:
parent
40ca4b4237
commit
0d7f98b622
28 changed files with 592 additions and 155 deletions
|
@ -39,7 +39,8 @@ internal fun Project.configureJacoco(
|
|||
androidComponentsExtension.onVariants { variant ->
|
||||
val myObjFactory = project.objects
|
||||
val buildDir = layout.buildDirectory.get().asFile
|
||||
val allJars: ListProperty<RegularFile> = myObjFactory.listProperty(RegularFile::class.java)
|
||||
val allJars: ListProperty<RegularFile> =
|
||||
myObjFactory.listProperty(RegularFile::class.java)
|
||||
val allDirectories: ListProperty<Directory> =
|
||||
myObjFactory.listProperty(Directory::class.java)
|
||||
val reportTask =
|
||||
|
@ -47,7 +48,6 @@ internal fun Project.configureJacoco(
|
|||
"create${variant.name.capitalize()}CombinedCoverageReport",
|
||||
JacocoReport::class,
|
||||
) {
|
||||
|
||||
classDirectories.setFrom(
|
||||
allJars,
|
||||
allDirectories.map { dirs ->
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
package im.molly.monero
|
||||
|
||||
import com.google.common.truth.FailureMetadata
|
||||
import com.google.common.truth.Subject
|
||||
import com.google.common.truth.Truth.assertAbout
|
||||
|
||||
class LedgerChainSubject private constructor(
|
||||
metadata: FailureMetadata,
|
||||
private val actual: List<Ledger>,
|
||||
) : Subject(metadata, actual) {
|
||||
|
||||
companion object {
|
||||
fun assertThat(ledgerChain: List<Ledger>): LedgerChainSubject {
|
||||
return assertAbout(factory).that(ledgerChain)
|
||||
}
|
||||
|
||||
private val factory = Factory { metadata, actual: List<Ledger> ->
|
||||
LedgerChainSubject(metadata, actual)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasValidWalletHistory() {
|
||||
isNotEmpty()
|
||||
hasStableOrGrowingAccountSets()
|
||||
hasStableOrGrowingTransactionSets()
|
||||
hasStableOrGrowingKeyImageSets()
|
||||
allPublicAddressesMatch()
|
||||
}
|
||||
|
||||
fun isNotEmpty() {
|
||||
check("ledgers").that(actual).isNotEmpty()
|
||||
}
|
||||
|
||||
fun hasStableOrGrowingAccountSets() {
|
||||
actual.ledgerTransitionsByHeight { step, prev, next ->
|
||||
val prevAccByIndex = prev.indexedAccounts.associateBy { it.accountIndex }
|
||||
val nextAccByIndex = next.indexedAccounts.associateBy { it.accountIndex }
|
||||
|
||||
prevAccByIndex.forEach { (accIndex, prevAccount) ->
|
||||
val nextAccount = nextAccByIndex[accIndex]
|
||||
val subjectPath = "Ledger[$step → ${step + 1}].indexedAccounts[$accIndex]"
|
||||
|
||||
check(subjectPath).that(nextAccount).isNotNull()
|
||||
|
||||
if (nextAccount != null) {
|
||||
val missingAddresses =
|
||||
prevAccount.addresses.toSet() - nextAccount.addresses.toSet()
|
||||
check("missing addresses in $subjectPath").that(missingAddresses).isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasStableOrGrowingTransactionSets() {
|
||||
actual.ledgerTransitionsByHeight { step, prev, next ->
|
||||
val missingTxIds = prev.transactionById.keys - next.transactionById.keys
|
||||
val subjectPath = "Ledger[$step → ${step + 1}].transactionById"
|
||||
|
||||
check("$subjectPath: missing transaction set").that(missingTxIds).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
fun hasStableOrGrowingKeyImageSets() {
|
||||
actual.ledgerTransitionsByHeight { step, prev, next ->
|
||||
val missingKI = prev.keyImages - next.keyImages
|
||||
val subjectPath = "Ledger[$step → ${step + 1}].keyImages"
|
||||
|
||||
check("$subjectPath: missing key images: $missingKI").that(missingKI).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
fun allPublicAddressesMatch() {
|
||||
val addresses = actual.map { it.publicAddress }.distinct()
|
||||
check("publicAddresses").that(addresses).hasSize(1)
|
||||
}
|
||||
|
||||
private fun List<Ledger>.ledgerTransitionsByHeight(
|
||||
action: (step: Int, prev: Ledger, next: Ledger) -> Unit
|
||||
) {
|
||||
this.sortedBy { it.checkedAt.height }
|
||||
.zipWithNext()
|
||||
.forEachIndexed { step, (prev, next) -> action(step, prev, next) }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package im.molly.monero
|
||||
|
||||
import com.google.common.truth.FailureMetadata
|
||||
import com.google.common.truth.Subject
|
||||
import com.google.common.truth.Truth.assertAbout
|
||||
import java.math.BigDecimal
|
||||
|
||||
class LedgerSubject private constructor(
|
||||
metadata: FailureMetadata,
|
||||
private val actual: Ledger,
|
||||
) : Subject(metadata, actual) {
|
||||
|
||||
companion object {
|
||||
fun assertThat(ledgerChain: Ledger): LedgerSubject {
|
||||
return assertAbout(factory).that(ledgerChain)
|
||||
}
|
||||
|
||||
private val factory = Factory { metadata, actual: Ledger ->
|
||||
LedgerSubject(metadata, actual)
|
||||
}
|
||||
}
|
||||
|
||||
fun isConsistent() {
|
||||
balanceIsNonNegative()
|
||||
}
|
||||
|
||||
fun balanceIsNonNegative() {
|
||||
actual.indexedAccounts.forEach { account ->
|
||||
val accountIndex = account.accountIndex
|
||||
val balance = actual.getBalanceForAccount(accountIndex)
|
||||
|
||||
val pending = balance.pendingAmount.xmr
|
||||
val confirmed = balance.confirmedAmount.xmr
|
||||
|
||||
check("indexedAccounts[$accountIndex].pendingAmount.xmr").that(pending)
|
||||
.isAtLeast(BigDecimal.ZERO)
|
||||
check("indexedAccounts[$accountIndex].confirmedAmount.xmr").that(confirmed)
|
||||
.isAtLeast(BigDecimal.ZERO)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
package im.molly.monero.e2etest
|
||||
|
||||
import androidx.test.filters.LargeTest
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.molly.monero.InMemoryWalletDataStore
|
||||
import im.molly.monero.Mainnet
|
||||
import im.molly.monero.MoneroWalletSubject
|
||||
import im.molly.monero.RestorePoint
|
||||
import im.molly.monero.exceptions.NoSuchAccountException
|
||||
import im.molly.monero.mnemonics.MoneroMnemonic
|
||||
import im.molly.monero.mnemonics.toSecretKey
|
||||
import im.molly.monero.service.BaseWalletService
|
||||
import im.molly.monero.service.InProcessWalletService
|
||||
import im.molly.monero.service.SandboxedWalletService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
|
||||
abstract class WalletPersistenceTest(
|
||||
serviceClass: Class<out BaseWalletService>,
|
||||
) : WalletTestBase(serviceClass) {
|
||||
|
||||
@Test
|
||||
fun restoredWalletHasExpectedAddress() = runTest {
|
||||
val key =
|
||||
MoneroMnemonic.recoverEntropy(
|
||||
"velvet lymph giddy number token physics poetry unquoted nibs useful sabotage limits benches lifestyle eden nitrogen anvil fewest avoid batch vials washing fences goat unquoted"
|
||||
)?.toSecretKey()
|
||||
val wallet = walletProvider.restoreWallet(
|
||||
network = Mainnet,
|
||||
secretSpendKey = key ?: error("recoverEntropy failed"),
|
||||
restorePoint = RestorePoint.creationTime(Instant.now()),
|
||||
)
|
||||
assertThat(wallet.publicAddress.address)
|
||||
.isEqualTo("42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveToMultipleStores() = runTest {
|
||||
val defaultStore = InMemoryWalletDataStore()
|
||||
val wallet = wallet(defaultStore)
|
||||
wallet.save()
|
||||
|
||||
val newStore = InMemoryWalletDataStore()
|
||||
wallet.save(newStore)
|
||||
|
||||
assertThat(defaultStore.toByteArray()).isEqualTo(newStore.toByteArray())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getAllAccountsReturnsAll() = runTest {
|
||||
val wallet = wallet().apply {
|
||||
repeat(2) { createAccount() }
|
||||
createSubAddressForAccount(1)
|
||||
}
|
||||
|
||||
val allAccounts = wallet.getAllAccounts()
|
||||
assertThat(allAccounts).hasSize(3)
|
||||
assertThat(allAccounts).containsExactlyElementsIn(
|
||||
(0..2).map { wallet.getAccount(it) }
|
||||
)
|
||||
|
||||
val addresses = allAccounts.map { it.addresses }
|
||||
assertThat(addresses[0]).hasSize(1)
|
||||
assertThat(addresses[1]).hasSize(2)
|
||||
assertThat(addresses[2]).hasSize(1)
|
||||
|
||||
allAccounts.forEach { acc ->
|
||||
val primary = acc.addresses[0]
|
||||
assertThat(primary.isPrimaryAddress).isTrue()
|
||||
assertThat(primary.subAddressIndex).isEqualTo(0)
|
||||
if (acc.accountIndex == 1) {
|
||||
val sub = acc.addresses[1]
|
||||
assertThat(sub.isPrimaryAddress).isFalse()
|
||||
assertThat(sub.subAddressIndex).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
withReopenedWallet(wallet) { original, reopened ->
|
||||
MoneroWalletSubject.assertThat(reopened).matchesStateOf(original)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = NoSuchAccountException::class)
|
||||
fun getAccountThrowsForMissingAccount() = runTest {
|
||||
wallet().getAccount(accountIndex = 42)
|
||||
}
|
||||
|
||||
@Test(expected = NoSuchAccountException::class)
|
||||
fun createSubAddressForAccountThrowsForMissingAccount() = runTest {
|
||||
wallet().createSubAddressForAccount(accountIndex = 42)
|
||||
}
|
||||
|
||||
@Test(expected = NoSuchAccountException::class)
|
||||
fun findUnusedSubAddressThrowsForMissingAccount() = runTest {
|
||||
wallet().findUnusedSubAddress(accountIndex = 42)
|
||||
}
|
||||
}
|
||||
|
||||
@LargeTest
|
||||
class WalletPersistenceInProcessTest : WalletPersistenceTest(InProcessWalletService::class.java)
|
||||
|
||||
@LargeTest
|
||||
class WalletPersistenceSandboxedTest : WalletPersistenceTest(SandboxedWalletService::class.java)
|
|
@ -0,0 +1,66 @@
|
|||
package im.molly.monero.e2etest
|
||||
|
||||
import androidx.test.filters.LargeTest
|
||||
import im.molly.monero.LedgerChainSubject
|
||||
import im.molly.monero.Mainnet
|
||||
import im.molly.monero.MoneroWalletSubject
|
||||
import im.molly.monero.RemoteNode
|
||||
import im.molly.monero.RestorePoint
|
||||
import im.molly.monero.SecretKey
|
||||
import im.molly.monero.service.BaseWalletService
|
||||
import im.molly.monero.service.InProcessWalletService
|
||||
import im.molly.monero.service.SandboxedWalletService
|
||||
import im.molly.monero.singleNodeClient
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.takeWhile
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
abstract class WalletRefreshTest(
|
||||
serviceClass: Class<out BaseWalletService>,
|
||||
) : WalletTestBase(serviceClass) {
|
||||
|
||||
@Test
|
||||
fun restoredWalletEmitsExpectedLedgerOnRefresh(): Unit = runBlocking {
|
||||
val key =
|
||||
SecretKey("148d78d2aba7dbca5cd8f6abcfb0b3c009ffbdbea1ff373d50ed94d78286640e".hexToByteArray())
|
||||
val node = RemoteNode("http://node.monerodevs.org:18089", Mainnet)
|
||||
val restorePoint = RestorePoint.blockHeight(2861767)
|
||||
|
||||
val wallet = walletProvider.restoreWallet(
|
||||
network = Mainnet,
|
||||
client = node.singleNodeClient(),
|
||||
secretSpendKey = key,
|
||||
restorePoint = restorePoint,
|
||||
)
|
||||
|
||||
val refreshJob = launch {
|
||||
wallet.awaitRefresh()
|
||||
}
|
||||
|
||||
val ledgers = withTimeout(5.minutes) {
|
||||
wallet.ledger()
|
||||
.takeWhile { it.checkedAt.height < 2862121 }
|
||||
.toList()
|
||||
}
|
||||
|
||||
refreshJob.cancelAndJoin()
|
||||
|
||||
LedgerChainSubject.assertThat(ledgers).hasValidWalletHistory()
|
||||
|
||||
withReopenedWallet(wallet) { original, reopened ->
|
||||
MoneroWalletSubject.assertThat(reopened).matchesStateOf(original)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@LargeTest
|
||||
class WalletRefreshInProcessTest : WalletRefreshTest(InProcessWalletService::class.java)
|
||||
|
||||
@LargeTest
|
||||
class WalletRefreshSandboxedTest : WalletRefreshTest(SandboxedWalletService::class.java)
|
|
@ -0,0 +1,41 @@
|
|||
package im.molly.monero.e2etest
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.ServiceTestRule
|
||||
import im.molly.monero.WalletProvider
|
||||
import im.molly.monero.internal.IWalletService
|
||||
import im.molly.monero.internal.WalletServiceClient
|
||||
import im.molly.monero.service.BaseWalletService
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runners.model.Statement
|
||||
|
||||
class WalletServiceRule(private val serviceClass: Class<out BaseWalletService>) : TestRule {
|
||||
|
||||
val walletProvider: WalletProvider
|
||||
get() = _walletProvider ?: error("WalletService not bound yet")
|
||||
|
||||
private var _walletProvider: WalletProvider? = null
|
||||
|
||||
private val context: Context by lazy { InstrumentationRegistry.getInstrumentation().context }
|
||||
|
||||
private val delegate = ServiceTestRule()
|
||||
|
||||
override fun apply(base: Statement, description: Description): Statement {
|
||||
return delegate.apply(object : Statement() {
|
||||
override fun evaluate() {
|
||||
val binder = delegate.bindService(Intent(context, serviceClass))
|
||||
val walletService = IWalletService.Stub.asInterface(binder)
|
||||
_walletProvider = WalletServiceClient.withBoundService(context, walletService)
|
||||
|
||||
try {
|
||||
walletProvider.use { base.evaluate() }
|
||||
} finally {
|
||||
delegate.unbindService()
|
||||
}
|
||||
}
|
||||
}, description)
|
||||
}
|
||||
}
|
|
@ -1,114 +0,0 @@
|
|||
package im.molly.monero.e2etest
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.test.filters.LargeTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.ServiceTestRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.molly.monero.InMemoryWalletDataStore
|
||||
import im.molly.monero.MoneroNetwork
|
||||
import im.molly.monero.MoneroWallet
|
||||
import im.molly.monero.MoneroWalletSubject
|
||||
import im.molly.monero.RestorePoint
|
||||
import im.molly.monero.SecretKey
|
||||
import im.molly.monero.WalletProvider
|
||||
import im.molly.monero.internal.IWalletService
|
||||
import im.molly.monero.internal.WalletServiceClient
|
||||
import im.molly.monero.mnemonics.MoneroMnemonic
|
||||
import im.molly.monero.mnemonics.toSecretKey
|
||||
import im.molly.monero.service.BaseWalletService
|
||||
import im.molly.monero.service.InProcessWalletService
|
||||
import im.molly.monero.service.SandboxedWalletService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
|
||||
@LargeTest
|
||||
abstract class MoneroWalletTest(private val serviceClass: Class<out BaseWalletService>) {
|
||||
|
||||
@get:Rule
|
||||
val walletServiceRule = ServiceTestRule()
|
||||
|
||||
private lateinit var walletProvider: WalletProvider
|
||||
|
||||
private val context: Context by lazy { InstrumentationRegistry.getInstrumentation().context }
|
||||
|
||||
private fun bindService(): IWalletService {
|
||||
val binder = walletServiceRule.bindService(Intent(context, serviceClass))
|
||||
return IWalletService.Stub.asInterface(binder)
|
||||
}
|
||||
|
||||
private fun unbindService() {
|
||||
walletServiceRule.unbindService()
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val walletService = bindService()
|
||||
walletProvider = WalletServiceClient.withBoundService(context, walletService)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
walletProvider.disconnect()
|
||||
unbindService()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoredWalletHasExpectedAddress() = runTest {
|
||||
val key =
|
||||
MoneroMnemonic.recoverEntropy(
|
||||
"velvet lymph giddy number token physics poetry unquoted nibs useful sabotage limits benches lifestyle eden nitrogen anvil fewest avoid batch vials washing fences goat unquoted"
|
||||
)?.toSecretKey()
|
||||
val wallet = walletProvider.restoreWallet(
|
||||
network = MoneroNetwork.Mainnet,
|
||||
secretSpendKey = key ?: error("recoverEntropy failed"),
|
||||
restorePoint = RestorePoint.creationTime(Instant.now()),
|
||||
)
|
||||
assertThat(wallet.publicAddress.address)
|
||||
.isEqualTo("42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveToMultipleStores() = runTest {
|
||||
val defaultStore = InMemoryWalletDataStore()
|
||||
val wallet = walletProvider.createNewWallet(MoneroNetwork.Mainnet, defaultStore)
|
||||
wallet.save()
|
||||
|
||||
val newStore = InMemoryWalletDataStore()
|
||||
wallet.save(newStore)
|
||||
|
||||
assertThat(defaultStore.toByteArray()).isEqualTo(newStore.toByteArray())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createAccountAndSave() = runTest {
|
||||
val wallet = walletProvider.createNewWallet(MoneroNetwork.Mainnet)
|
||||
val newAccount = wallet.createAccount()
|
||||
|
||||
withReopenedWallet(wallet) { original, reopened ->
|
||||
MoneroWalletSubject.assertThat(reopened).matchesStateOf(original)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun withReopenedWallet(
|
||||
wallet: MoneroWallet,
|
||||
action: suspend (original: MoneroWallet, reopened: MoneroWallet) -> Unit,
|
||||
) {
|
||||
walletProvider.openWallet(
|
||||
network = wallet.network,
|
||||
dataStore = InMemoryWalletDataStore().apply {
|
||||
wallet.save(targetStore = this)
|
||||
},
|
||||
).use { reopened ->
|
||||
action(wallet, reopened)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MoneroWalletInProcessTest : MoneroWalletTest(InProcessWalletService::class.java)
|
||||
class MoneroWalletSandboxedTest : MoneroWalletTest(SandboxedWalletService::class.java)
|
|
@ -0,0 +1,70 @@
|
|||
package im.molly.monero.e2etest
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.ServiceTestRule
|
||||
import im.molly.monero.InMemoryWalletDataStore
|
||||
import im.molly.monero.Mainnet
|
||||
import im.molly.monero.MoneroWallet
|
||||
import im.molly.monero.WalletDataStore
|
||||
import im.molly.monero.WalletProvider
|
||||
import im.molly.monero.internal.IWalletService
|
||||
import im.molly.monero.internal.WalletServiceClient
|
||||
import im.molly.monero.service.BaseWalletService
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
|
||||
abstract class WalletTestBase(private val serviceClass: Class<out BaseWalletService>) {
|
||||
|
||||
@get:Rule
|
||||
val walletServiceRule = ServiceTestRule()
|
||||
|
||||
protected lateinit var walletProvider: WalletProvider
|
||||
private set
|
||||
|
||||
protected val context: Context by lazy {
|
||||
InstrumentationRegistry.getInstrumentation().context
|
||||
}
|
||||
|
||||
private fun bindService(): IWalletService {
|
||||
val binder = walletServiceRule.bindService(Intent(context, serviceClass))
|
||||
return IWalletService.Stub.asInterface(binder)
|
||||
}
|
||||
|
||||
private fun unbindService() {
|
||||
walletServiceRule.unbindService()
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUpBase() {
|
||||
val walletService = bindService()
|
||||
walletProvider = WalletServiceClient.withBoundService(context, walletService)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDownBase() {
|
||||
runCatching {
|
||||
walletProvider.disconnect()
|
||||
}
|
||||
unbindService()
|
||||
}
|
||||
|
||||
protected suspend fun wallet(defaultStore: WalletDataStore? = null) =
|
||||
walletProvider.createNewWallet(Mainnet, defaultStore)
|
||||
|
||||
protected suspend fun withReopenedWallet(
|
||||
wallet: MoneroWallet,
|
||||
action: suspend (original: MoneroWallet, reopened: MoneroWallet) -> Unit,
|
||||
) {
|
||||
walletProvider.openWallet(
|
||||
network = wallet.network,
|
||||
dataStore = InMemoryWalletDataStore().apply {
|
||||
wallet.save(targetStore = this)
|
||||
},
|
||||
).use { reopened ->
|
||||
action(wallet, reopened)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ package im.molly.monero.service
|
|||
import android.content.Context
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.molly.monero.isIsolatedProcess
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
|
@ -13,14 +14,14 @@ class WalletServiceSandboxingTest {
|
|||
@Test
|
||||
fun inProcessWalletServiceIsNotIsolated() = runTest {
|
||||
InProcessWalletService.connect(context).use { walletProvider ->
|
||||
assertThat(walletProvider.isServiceIsolated()).isFalse()
|
||||
assertThat(walletProvider.isServiceSandboxed()).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sandboxedWalletServiceIsIsolated() = runTest {
|
||||
SandboxedWalletService.connect(context).use { walletProvider ->
|
||||
assertThat(walletProvider.isServiceIsolated()).isTrue()
|
||||
assertThat(walletProvider.isServiceSandboxed()).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,5 +7,6 @@ oneway interface IWalletCallbacks {
|
|||
void onCommitResult(boolean success);
|
||||
void onSubAddressReady(String subAddress);
|
||||
void onSubAddressListReceived(in String[] subAddresses);
|
||||
void onAccountNotFound(int accountIndex);
|
||||
void onFeesReceived(in long[] fees);
|
||||
}
|
||||
|
|
|
@ -11,4 +11,5 @@ interface IWalletService {
|
|||
oneway void restoreWallet(in WalletConfig config, in IHttpRpcClient rpcClient, in IWalletServiceCallbacks callback, in SecretKey spendSecretKey, long restorePoint);
|
||||
oneway void openWallet(in WalletConfig config, in IHttpRpcClient rpcClient, in IWalletServiceCallbacks callback, in ParcelFileDescriptor inputFd);
|
||||
void setListener(in IWalletServiceListener listener);
|
||||
boolean isServiceIsolated();
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
#endif
|
||||
|
||||
// Low-level debug macros. Log messages are not scrubbed.
|
||||
#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__))
|
||||
#define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__))
|
||||
#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__))
|
||||
#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
|
||||
|
|
|
@ -84,6 +84,8 @@ void Wallet::restoreAccount(const std::vector<char>& secret_scalar, uint64_t res
|
|||
m_last_block_timestamp = account.get_createtime();
|
||||
}
|
||||
m_last_block_height = (m_restore_height == 0) ? 1 : m_restore_height;
|
||||
LOGD("Restoring account: restore_point=%" PRIu64 ", computed restore_height=%" PRIu64,
|
||||
restore_point, m_restore_height);
|
||||
m_wallet.rescan_blockchain(true, false, false);
|
||||
m_account_ready = true;
|
||||
}
|
||||
|
|
|
@ -114,6 +114,15 @@ sealed interface UnlockTime : Comparable<BlockchainTime>, Parcelable {
|
|||
|
||||
@Parcelize
|
||||
data class Block(override val blockchainTime: BlockchainTime) : UnlockTime {
|
||||
|
||||
constructor(height: Int, network: MoneroNetwork) : this(
|
||||
BlockchainTime(
|
||||
height = height,
|
||||
timestamp = network.estimateTimestamp(height),
|
||||
network = network,
|
||||
)
|
||||
)
|
||||
|
||||
override operator fun compareTo(other: BlockchainTime): Int {
|
||||
return blockchainTime.height.compareTo(other.height)
|
||||
}
|
||||
|
@ -121,8 +130,21 @@ sealed interface UnlockTime : Comparable<BlockchainTime>, Parcelable {
|
|||
|
||||
@Parcelize
|
||||
data class Timestamp(override val blockchainTime: BlockchainTime) : UnlockTime {
|
||||
|
||||
constructor(timestamp: Instant, network: MoneroNetwork) : this(
|
||||
BlockchainTime(
|
||||
height = network.estimateHeight(timestamp),
|
||||
timestamp = timestamp,
|
||||
network = network,
|
||||
)
|
||||
)
|
||||
|
||||
override operator fun compareTo(other: BlockchainTime): Int {
|
||||
return blockchainTime.timestamp.compareTo(other.timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun MoneroNetwork.unlockAtBlock(height: Int) = UnlockTime.Block(height, this)
|
||||
|
||||
fun MoneroNetwork.unlockAtTime(timestamp: Instant) = UnlockTime.Timestamp(timestamp, this)
|
||||
|
|
|
@ -4,11 +4,11 @@ import android.app.ActivityManager
|
|||
import android.app.Application
|
||||
import android.content.Context.ACTIVITY_SERVICE
|
||||
import android.os.Build
|
||||
import android.os.Process.isIsolated
|
||||
import android.os.Process
|
||||
|
||||
fun Application.isIsolatedProcess(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= 28) {
|
||||
isIsolated()
|
||||
Process.isIsolated()
|
||||
} else {
|
||||
try {
|
||||
val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager
|
||||
|
|
|
@ -6,7 +6,7 @@ import im.molly.monero.internal.Logger
|
|||
/**
|
||||
* Adapter to output logs to the host application.
|
||||
*
|
||||
* Priority values matches Android framework log priority levels.
|
||||
* Priority values matches Android framework [Log] priority levels.
|
||||
*/
|
||||
interface LogAdapter {
|
||||
fun isLoggable(priority: Int, tag: String): Boolean = true
|
||||
|
@ -18,7 +18,7 @@ interface LogAdapter {
|
|||
*/
|
||||
class DebugLogAdapter : LogAdapter {
|
||||
override fun isLoggable(priority: Int, tag: String): Boolean {
|
||||
return priority == Log.ASSERT || BuildConfig.DEBUG
|
||||
return BuildConfig.DEBUG || (priority == Log.ASSERT)
|
||||
}
|
||||
|
||||
override fun print(priority: Int, tag: String, msg: String?, tr: Throwable?) {
|
||||
|
|
|
@ -77,6 +77,10 @@ class MoneroWallet internal constructor(
|
|||
override fun onSubAddressReady(subAddress: String) {
|
||||
continuation.resume(AccountAddress.parseWithIndexes(subAddress)) {}
|
||||
}
|
||||
|
||||
override fun onAccountNotFound(accountIndex: Int) {
|
||||
continuation.resumeWithException(NoSuchAccountException(accountIndex))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -102,6 +106,10 @@ class MoneroWallet internal constructor(
|
|||
val accounts = parseAndAggregateAddresses(subAddresses.asIterable())
|
||||
continuation.resume(accounts.single()) {}
|
||||
}
|
||||
|
||||
override fun onAccountNotFound(accountIndex: Int) {
|
||||
continuation.resumeWithException(NoSuchAccountException(accountIndex))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -282,6 +290,8 @@ private abstract class BaseWalletCallbacks : IWalletCallbacks.Stub() {
|
|||
|
||||
override fun onSubAddressListReceived(subAddresses: Array<String>) = Unit
|
||||
|
||||
override fun onAccountNotFound(accountIndex: Int) = Unit
|
||||
|
||||
override fun onFeesReceived(fees: LongArray?) = Unit
|
||||
}
|
||||
|
||||
|
|
|
@ -1,16 +1,36 @@
|
|||
package im.molly.monero
|
||||
|
||||
class TimeLocked<T>(val value: T, val unlockTime: UnlockTime?) {
|
||||
data class TimeLocked<T>(val value: T, val unlockTime: UnlockTime? = null) {
|
||||
fun isLocked(currentTime: BlockchainTime): Boolean {
|
||||
return unlockTime != null && unlockTime > currentTime
|
||||
val unlock = unlockTime ?: return false
|
||||
requireSameNetworkAs(currentTime)
|
||||
return unlock > currentTime
|
||||
}
|
||||
|
||||
fun isUnlocked(currentTime: BlockchainTime) = !isLocked(currentTime)
|
||||
|
||||
fun timeUntilUnlock(currentTime: BlockchainTime): BlockchainTimeSpan {
|
||||
if (unlockTime == null || isUnlocked(currentTime)) {
|
||||
return BlockchainTimeSpan.ZERO
|
||||
if (unlockTime == null) return BlockchainTimeSpan.ZERO
|
||||
|
||||
requireSameNetworkAs(currentTime)
|
||||
|
||||
return if (unlockTime > currentTime) {
|
||||
unlockTime.blockchainTime - currentTime
|
||||
} else {
|
||||
BlockchainTimeSpan.ZERO
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireSameNetworkAs(other: BlockchainTime) {
|
||||
val expected = unlockTime?.blockchainTime?.network
|
||||
require(expected == other.network) {
|
||||
"BlockchainTime network mismatch: expected $expected, got ${other.network}"
|
||||
}
|
||||
return unlockTime.blockchainTime - currentTime
|
||||
}
|
||||
}
|
||||
|
||||
fun MoneroAmount.lockedUntil(unlockTime: UnlockTime): TimeLocked<MoneroAmount> =
|
||||
TimeLocked(this, unlockTime)
|
||||
|
||||
fun MoneroAmount.unlocked(): TimeLocked<MoneroAmount> =
|
||||
TimeLocked(this)
|
||||
|
|
|
@ -35,4 +35,3 @@ sealed interface TxState {
|
|||
|
||||
data object OffChain : TxState
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ interface WalletProvider : Closeable {
|
|||
client: MoneroNodeClient? = null,
|
||||
): MoneroWallet
|
||||
|
||||
fun isServiceIsolated(): Boolean
|
||||
fun isServiceSandboxed(): Boolean
|
||||
|
||||
fun disconnect()
|
||||
|
||||
|
|
|
@ -260,7 +260,7 @@ internal class NativeWallet private constructor(
|
|||
override fun addDetachedSubAddress(
|
||||
accountIndex: Int,
|
||||
subAddressIndex: Int,
|
||||
callback: IWalletCallbacks?,
|
||||
callback: IWalletCallbacks,
|
||||
) {
|
||||
scope.launch(ioDispatcher) {
|
||||
val subAddress = nativeAddDetachedSubAddress(handle, accountIndex, subAddressIndex)
|
||||
|
@ -268,20 +268,20 @@ internal class NativeWallet private constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun createAccount(callback: IWalletCallbacks?) {
|
||||
override fun createAccount(callback: IWalletCallbacks) {
|
||||
scope.launch(ioDispatcher) {
|
||||
val primaryAddress = nativeCreateSubAddressAccount(handle)
|
||||
notifyAddressCreation(primaryAddress, callback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun createSubAddressForAccount(accountIndex: Int, callback: IWalletCallbacks?) {
|
||||
override fun createSubAddressForAccount(accountIndex: Int, callback: IWalletCallbacks) {
|
||||
scope.launch(ioDispatcher) {
|
||||
val subAddress = nativeCreateSubAddress(handle, accountIndex)
|
||||
if (subAddress != null) {
|
||||
notifyAddressCreation(subAddress, callback)
|
||||
} else {
|
||||
TODO()
|
||||
callback.onAccountNotFound(accountIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -310,7 +310,7 @@ internal class NativeWallet private constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun notifyAddressCreation(subAddress: String, callback: IWalletCallbacks?) {
|
||||
private fun notifyAddressCreation(subAddress: String, callback: IWalletCallbacks) {
|
||||
balanceListenersLock.withLock {
|
||||
if (balanceListeners.isNotEmpty()) {
|
||||
val subAddresses = getSubAddresses()
|
||||
|
@ -319,7 +319,7 @@ internal class NativeWallet private constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
callback?.onSubAddressReady(subAddress)
|
||||
callback.onSubAddressReady(subAddress)
|
||||
}
|
||||
|
||||
override fun getAddressesForAccount(accountIndex: Int, callback: IWalletCallbacks) {
|
||||
|
@ -328,7 +328,7 @@ internal class NativeWallet private constructor(
|
|||
if (accountSubAddresses.isNotEmpty()) {
|
||||
callback.onSubAddressListReceived(accountSubAddresses)
|
||||
} else {
|
||||
TODO()
|
||||
callback.onAccountNotFound(accountIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
package im.molly.monero.internal
|
||||
|
||||
import android.app.Service
|
||||
import android.os.ParcelFileDescriptor
|
||||
import im.molly.monero.LogAdapter
|
||||
import im.molly.monero.SecretKey
|
||||
import im.molly.monero.isIsolatedProcess
|
||||
import im.molly.monero.randomSecretKey
|
||||
import im.molly.monero.setLoggingAdapter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
internal class NativeWalletService(
|
||||
private val service: Service,
|
||||
private val serviceScope: CoroutineScope,
|
||||
) : IWalletService.Stub(), LogAdapter {
|
||||
|
||||
|
@ -15,11 +18,10 @@ internal class NativeWalletService(
|
|||
|
||||
init {
|
||||
NativeLoader.loadWalletLibrary(logger = logger)
|
||||
}
|
||||
|
||||
fun configureLoggingAdapter() {
|
||||
if (isServiceIsolated) {
|
||||
setLoggingAdapter(this)
|
||||
}
|
||||
}
|
||||
|
||||
private var listener: IWalletServiceListener? = null
|
||||
|
||||
|
@ -27,9 +29,7 @@ internal class NativeWalletService(
|
|||
listener = l
|
||||
}
|
||||
|
||||
override fun print(priority: Int, tag: String, msg: String?, tr: Throwable?) {
|
||||
listener?.onLogMessage(priority, tag, msg, tr?.toString())
|
||||
}
|
||||
override fun isServiceIsolated(): Boolean = service.application.isIsolatedProcess()
|
||||
|
||||
override fun createWallet(
|
||||
config: WalletConfig,
|
||||
|
@ -87,4 +87,8 @@ internal class NativeWalletService(
|
|||
coroutineContext = serviceScope.coroutineContext,
|
||||
)
|
||||
}
|
||||
|
||||
override fun print(priority: Int, tag: String, msg: String?, tr: Throwable?) {
|
||||
listener?.onLogMessage(priority, tag, msg, tr?.toString())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -143,7 +143,8 @@ internal class WalletServiceClient(
|
|||
}
|
||||
}
|
||||
|
||||
override fun isServiceIsolated(): Boolean = service.isRemote()
|
||||
override fun isServiceSandboxed(): Boolean =
|
||||
service.isRemote() && service.isServiceIsolated
|
||||
|
||||
override fun disconnect() {
|
||||
context.unbindService(serviceConnection ?: return)
|
||||
|
@ -151,7 +152,7 @@ internal class WalletServiceClient(
|
|||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private class WalletResultCallback(
|
||||
private val continuation: CancellableContinuation<IWallet>
|
||||
private val continuation: CancellableContinuation<IWallet>,
|
||||
) : IWalletServiceCallbacks.Stub() {
|
||||
override fun onWalletResult(wallet: IWallet?) {
|
||||
if (wallet != null) {
|
||||
|
|
|
@ -7,13 +7,9 @@ import androidx.lifecycle.lifecycleScope
|
|||
import im.molly.monero.internal.IWalletService
|
||||
import im.molly.monero.internal.NativeWalletService
|
||||
|
||||
abstract class BaseWalletService(private val isolated: Boolean) : LifecycleService() {
|
||||
abstract class BaseWalletService : LifecycleService() {
|
||||
private val service: IWalletService by lazy {
|
||||
NativeWalletService(lifecycleScope).apply {
|
||||
if (isolated) {
|
||||
configureLoggingAdapter()
|
||||
}
|
||||
}
|
||||
NativeWalletService(this, lifecycleScope)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
|
|
|
@ -7,7 +7,7 @@ import im.molly.monero.internal.WalletServiceClient
|
|||
/**
|
||||
* Provides wallet services using an in-process bound service.
|
||||
*/
|
||||
class InProcessWalletService : BaseWalletService(isolated = false) {
|
||||
class InProcessWalletService : BaseWalletService() {
|
||||
companion object {
|
||||
/**
|
||||
* Connects to the in-process wallet service and returns a connected [WalletProvider].
|
||||
|
|
|
@ -7,7 +7,7 @@ import im.molly.monero.internal.WalletServiceClient
|
|||
/**
|
||||
* Provides wallet services using a sandboxed process.
|
||||
*/
|
||||
class SandboxedWalletService : BaseWalletService(isolated = true) {
|
||||
class SandboxedWalletService : BaseWalletService() {
|
||||
companion object {
|
||||
/**
|
||||
* Connects to the sandboxed wallet service and returns a connected [WalletProvider].
|
||||
|
|
63
lib/android/src/test/kotlin/im/molly/monero/BalanceTest.kt
Normal file
63
lib/android/src/test/kotlin/im/molly/monero/BalanceTest.kt
Normal file
|
@ -0,0 +1,63 @@
|
|||
package im.molly.monero
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
|
||||
class BalanceTest {
|
||||
|
||||
@Test
|
||||
fun `confirmed and total amounts are sum of time-locked values`() {
|
||||
val balance = Balance(
|
||||
lockableAmounts = listOf(1.xmr, 2.xmr, 3.xmr).map { it.unlocked() },
|
||||
)
|
||||
|
||||
assertThat(balance.confirmedAmount).isEqualTo(6.xmr)
|
||||
assertThat(balance.totalAmount).isEqualTo(6.xmr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `total amount includes pending`() {
|
||||
val balance = Balance(
|
||||
lockableAmounts = listOf(2.xmr.unlocked()),
|
||||
pendingAmount = 4.xmr,
|
||||
)
|
||||
|
||||
assertThat(balance.totalAmount).isEqualTo(6.xmr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty balance returns zero amounts`() {
|
||||
assertThat(Balance.EMPTY.totalAmount).isEqualTo(0.xmr)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `excludes locked amounts from unlocked sum`() {
|
||||
val current = BlockchainTime(100, Instant.now(), Mainnet)
|
||||
|
||||
val allUnlocked = Balance(
|
||||
listOf(
|
||||
TimeLocked(2.xmr, Mainnet.unlockAtBlock(50)),
|
||||
TimeLocked(3.xmr, Mainnet.unlockAtBlock(50))
|
||||
)
|
||||
)
|
||||
assertThat(allUnlocked.unlockedAmountAt(current)).isEqualTo(5.xmr)
|
||||
|
||||
val allLocked = Balance(
|
||||
listOf(
|
||||
TimeLocked(2.xmr, Mainnet.unlockAtBlock(150)),
|
||||
TimeLocked(3.xmr, Mainnet.unlockAtBlock(200))
|
||||
)
|
||||
)
|
||||
assertThat(allLocked.unlockedAmountAt(current)).isEqualTo(0.xmr)
|
||||
|
||||
val partial = Balance(
|
||||
listOf(
|
||||
TimeLocked(1.xmr, Mainnet.unlockAtBlock(50)),
|
||||
TimeLocked(2.xmr, Mainnet.unlockAtBlock(100)),
|
||||
TimeLocked(5.xmr, Mainnet.unlockAtBlock(150)),
|
||||
)
|
||||
)
|
||||
assertThat(partial.unlockedAmountAt(current)).isEqualTo(3.xmr)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package im.molly.monero
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class TimeLockedTest {
|
||||
|
||||
private val mainnetUnlockTime = UnlockTime.Block(100, Mainnet)
|
||||
private val locked = 1.xmr.lockedUntil(mainnetUnlockTime)
|
||||
|
||||
@Test
|
||||
fun `isLocked throws on network mismatch`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
locked.isLocked(Stagenet.genesisTime)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timeUntilUnlock throws on network mismatch`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
locked.timeUntilUnlock(Stagenet.genesisTime)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue