Skip to content

Commit

Permalink
feat(dashpay): add timeskew support to CoinJoinService and warning di…
Browse files Browse the repository at this point in the history
…alogs (#1252)

* feat: add handling for timeskew
  • Loading branch information
HashEngineering authored Feb 21, 2024
1 parent bf6da4e commit d09b5a6
Show file tree
Hide file tree
Showing 10 changed files with 222 additions and 48 deletions.
2 changes: 2 additions & 0 deletions wallet/res/values/strings-extra.xml
Original file line number Diff line number Diff line change
Expand Up @@ -697,4 +697,6 @@
<string name="masternode_key_not_used">Not used</string>
<string name="masternode_key_revoked">Revoked</string>
<string name="masternode_key_private_public_base64">Private / Public Keys (base64)</string>
<string name="timeskew_ahead">ahead</string>
<string name="timeskew_behind">behind</string>
</resources>
2 changes: 2 additions & 0 deletions wallet/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
<string name="wallet_low_storage_dialog_button_apps">Manage apps</string>
<string name="wallet_timeskew_dialog_title">Check date &amp; time settings</string>
<string name="wallet_timeskew_dialog_msg">Your device time is off by %d minutes. You probably cannot send or receive Dash due to this problem.\n\nYou should check and if necessary correct your date, time and timezone settings.</string>
<string name="wallet_coinjoin_timeskew_dialog_msg">Your device time is %s by %d seconds. You cannot use CoinJoin due to this difference.\n\nThe time settings on your device needs to be changed to “Set time automatically” to use CoinJoin.</string>
<string name="settings_coinjoin_timeskew_dialog_msg">Your device time is off by more than 5 seconds. You cannot use CoinJoin due to this difference.\n\nThe time settings on your device needs to be changed to “Set time automatically” before using CoinJoin.</string>
<string name="send_coins_activity_title">Send Dash</string>
<string name="send_coins_fragment_request_payment_request_progress">Fetching signature from %s…</string>
<string name="send_coins_fragment_request_payment_request_failed_title">Fetching signature failed</string>
Expand Down
82 changes: 68 additions & 14 deletions wallet/src/de/schildbach/wallet/service/CoinJoinService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@

package de.schildbach.wallet.service

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.google.common.base.Stopwatch
import dagger.hilt.android.qualifiers.ApplicationContext
import de.schildbach.wallet.data.CoinJoinConfig
import de.schildbach.wallet.ui.dashpay.PlatformRepo
import de.schildbach.wallet.util.getTimeSkew
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
Expand Down Expand Up @@ -50,7 +56,6 @@ import org.bitcoinj.coinjoin.utils.CoinJoinManager
import org.bitcoinj.coinjoin.utils.CoinJoinTransactionType
import org.bitcoinj.core.AbstractBlockChain
import org.bitcoinj.core.Coin
import org.bitcoinj.core.Context
import org.bitcoinj.core.ECKey
import org.bitcoinj.core.MasternodeAddress
import org.bitcoinj.core.Transaction
Expand Down Expand Up @@ -84,6 +89,7 @@ interface CoinJoinService {
suspend fun getMixingState(): MixingStatus
fun observeMixingState(): Flow<MixingStatus>
fun observeMixingProgress(): Flow<Double>
fun updateTimeSkew(timeSkew: Long)
}

enum class MixingStatus {
Expand All @@ -94,8 +100,12 @@ enum class MixingStatus {
ERROR // An error stopped the mixing process
}

const val MAX_ALLOWED_AHEAD_TIMESKEW = 5000L // 5 seconds
const val MAX_ALLOWED_BEHIND_TIMESKEW = 20000L // 20 seconds

@Singleton
class CoinJoinMixingService @Inject constructor(
@ApplicationContext private val context: Context,
val walletDataProvider: WalletDataProvider,
private val blockchainStateProvider: BlockchainStateProvider,
private val config: CoinJoinConfig,
Expand All @@ -111,7 +121,15 @@ class CoinJoinMixingService @Inject constructor(
const val DEFAULT_DENOMINATIONS_HARDCAP = 300

// these are not for production
val FAST_MIXING_DENOMINATIONS_REMOVE = listOf<Denomination>() // Denomination.THOUSANDTH)
val FAST_MIXING_DENOMINATIONS_REMOVE = listOf<Denomination>() // Denomination.THOUSANDTH

fun isInsideTimeSkewBounds(timeSkew: Long): Boolean {
return if (timeSkew > 0) {
timeSkew < MAX_ALLOWED_AHEAD_TIMESKEW
} else {
(-timeSkew) < MAX_ALLOWED_BEHIND_TIMESKEW
}
}
}

private val coinJoinManager: CoinJoinManager?
Expand Down Expand Up @@ -140,12 +158,25 @@ class CoinJoinMixingService @Inject constructor(
private var networkStatus: NetworkStatus = NetworkStatus.UNKNOWN
private var isSynced = false
private var hasAnonymizableBalance: Boolean = false
private var timeSkew: Long = 0L

// https://stackoverflow.com/questions/55421710/how-to-suspend-kotlin-coroutine-until-notified
private val updateMutex = Mutex(locked = false)
private val updateMixingStateMutex = Mutex(locked = false)
private var exception: Throwable? = null

private val timeChangeReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == Intent.ACTION_TIME_CHANGED) {
// Time has changed, handle the change here
log.info("Time or Time Zone changed")
coroutineScope.launch {
updateTimeSkewInternal(getTimeSkew())
}
}
}
}

override fun observeMixingProgress(): Flow<Double> = _progressFlow

init {
Expand All @@ -168,7 +199,7 @@ class CoinJoinMixingService @Inject constructor(
.onEach { blockChainState ->
val isSynced = blockChainState.isSynced()
if (isSynced != this.isSynced) {
updateState(config.getMode(), hasAnonymizableBalance, networkStatus, isSynced, blockChain)
updateState(config.getMode(), timeSkew, hasAnonymizableBalance, networkStatus, isSynced, blockChain)
}
// this will trigger mixing as new blocks are mined and received tx's are confirmed
if (isSynced) {
Expand Down Expand Up @@ -196,9 +227,18 @@ class CoinJoinMixingService @Inject constructor(
.launchIn(coroutineScope)
}

/** updates timeSkew in #[coroutineScope] */
override fun updateTimeSkew(timeSkew: Long) {
coroutineScope.launch {
updateTimeSkewInternal(timeSkew)
}
}

suspend fun updateTimeSkewInternal(timeSkew: Long) {
updateState(config.getMode(), timeSkew, hasAnonymizableBalance, networkStatus, isSynced, blockChain)
}

private suspend fun updateBalance(balance: Coin) {
// leave this ui scope
Context.propagate(walletDataProvider.wallet!!.context)
CoinJoinClientOptions.setAmount(balance)
log.info("coinjoin: total balance: ${balance.toFriendlyString()}")
val walletEx = walletDataProvider.wallet as WalletEx
Expand All @@ -223,9 +263,14 @@ class CoinJoinMixingService @Inject constructor(
else -> false
}

log.info("coinjoin: mixing can occur: $hasBalanceLeftToMix = balance: (${anonBalance.isGreaterThan(CoinJoin.getSmallestDenomination())} && canDenominate: $canDenominate) || partially-mixed: $hasPartiallyMixedCoins")
log.info(
"coinjoin: mixing can occur: $hasBalanceLeftToMix = balance: (${anonBalance.isGreaterThan(
CoinJoin.getSmallestDenomination()
)} && canDenominate: $canDenominate) || partially-mixed: $hasPartiallyMixedCoins"
)
updateState(
config.getMode(),
getTimeSkew(),
hasBalanceLeftToMix,
networkStatus,
isSynced,
Expand All @@ -235,26 +280,29 @@ class CoinJoinMixingService @Inject constructor(

private suspend fun updateState(
mode: CoinJoinMode,
timeSkew: Long,
hasAnonymizableBalance: Boolean,
networkStatus: NetworkStatus,
isSynced: Boolean,
blockChain: AbstractBlockChain?
) {
updateMutex.lock()
log.info(
"coinjoin-old-state: ${this.mode}, ${this.hasAnonymizableBalance}, ${this.networkStatus}, synced: ${this.isSynced} ${blockChain != null}"
"coinjoin-old-state: ${this.mode}, ${this.timeSkew}ms, ${this.hasAnonymizableBalance}, ${this.networkStatus}, synced: ${this.isSynced} ${blockChain != null}"
)
try {
setBlockchain(blockChain)
log.info(
"coinjoin-new-state: $mode, $hasAnonymizableBalance, $networkStatus, synced: $isSynced, ${blockChain != null}"
"coinjoin-new-state: $mode, $timeSkew ms, $hasAnonymizableBalance, $networkStatus, synced: $isSynced, ${blockChain != null}"
)
log.info("coinjoin-Current timeskew: ${getTimeSkew()}")
this.networkStatus = networkStatus
this.hasAnonymizableBalance = hasAnonymizableBalance
this.isSynced = isSynced
this.mode = mode
this.timeSkew = timeSkew

if (mode == CoinJoinMode.NONE) {
if (mode == CoinJoinMode.NONE || !isInsideTimeSkewBounds(timeSkew)) {
updateMixingState(MixingStatus.NOT_STARTED)
} else {
configureMixing()
Expand Down Expand Up @@ -302,11 +350,11 @@ class CoinJoinMixingService @Inject constructor(
}

private suspend fun updateBlockChain(blockChain: AbstractBlockChain?) {
updateState(mode, hasAnonymizableBalance, networkStatus, isSynced, blockChain)
updateState(mode, timeSkew, hasAnonymizableBalance, networkStatus, isSynced, blockChain)
}

private suspend fun updateNetworkStatus(networkStatus: NetworkStatus) {
updateState(mode, hasAnonymizableBalance, networkStatus, isSynced, blockChain)
updateState(mode, timeSkew, hasAnonymizableBalance, networkStatus, isSynced, blockChain)
}

private suspend fun updateMode(mode: CoinJoinMode) {
Expand All @@ -315,7 +363,8 @@ class CoinJoinMixingService @Inject constructor(
configureMixing()
updateBalance(walletDataProvider.wallet!!.getBalance(Wallet.BalanceType.AVAILABLE))
}
updateState(mode, hasAnonymizableBalance, networkStatus, isSynced, blockChain)
val currentTimeSkew = getTimeSkew()
updateState(mode, currentTimeSkew, hasAnonymizableBalance, networkStatus, isSynced, blockChain)
}

private var mixingProgressTracker: MixingProgressTracker = object : MixingProgressTracker() {
Expand Down Expand Up @@ -475,15 +524,19 @@ class CoinJoinMixingService @Inject constructor(
}

private suspend fun startMixing(): Boolean {
Context.propagate(walletDataProvider.wallet!!.context)
val filter = IntentFilter().apply {
addAction(Intent.ACTION_TIME_CHANGED)
}
context.registerReceiver(timeChangeReceiver, filter)
clientManager.setBlockChain(blockChain)
return if (!clientManager.startMixing()) {
log.info("Mixing has been started already.")
false
} else {
// run this on a different thread?
val asyncStart = coroutineScope.async(Dispatchers.IO) {
Context.propagate(walletDataProvider.wallet!!.context)
// though coroutineScope is on a Context propogated thread, we still need this
org.bitcoinj.core.Context.propagate(walletDataProvider.wallet!!.context)
coinJoinManager?.initMasternodeGroup(blockChain)
clientManager.doAutomaticDenominating()
}
Expand Down Expand Up @@ -511,6 +564,7 @@ class CoinJoinMixingService @Inject constructor(
sessionCompleteListeners.forEach { coinJoinManager?.removeSessionCompleteListener(it) }
clientManager.stopMixing()
coinJoinManager?.stop()
context.unregisterReceiver(timeChangeReceiver)
}

private fun setBlockchain(blockChain: AbstractBlockChain?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@

package de.schildbach.wallet.ui.coinjoin

import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
Expand Down Expand Up @@ -81,7 +83,7 @@ class CoinJoinLevelFragment : Fragment(R.layout.fragment_coinjoin_level) {
}
}

private fun showConnectionWaringDialog(mode: CoinJoinMode) {
private fun showConnectionWarningDialog(mode: CoinJoinMode) {
AdaptiveDialog.create(
R.drawable.ic_warning,
getString(
Expand Down Expand Up @@ -123,9 +125,27 @@ class CoinJoinLevelFragment : Fragment(R.layout.fragment_coinjoin_level) {
}
}
} else if (viewModel.isWifiConnected()) {
setMode(mode)
val settingsIntent = Intent(Settings.ACTION_DATE_SETTINGS)
val hasSettings = requireActivity().packageManager.resolveActivity(settingsIntent, 0) != null
lifecycleScope.launch {
if (viewModel.isTimeSkewedForCoinJoin()) {
AdaptiveDialog.create(
R.drawable.ic_coinjoin,
getString(R.string.coinjoin),
getString(R.string.settings_coinjoin_timeskew_dialog_msg),
getString(R.string.cancel),
if (hasSettings) getString(R.string.button_settings) else null
).show(requireActivity()) { openSettings ->
if (openSettings == true && hasSettings) {
startActivity(settingsIntent)
}
}
} else {
setMode(mode)
}
}
} else {
showConnectionWaringDialog(mode)
showConnectionWarningDialog(mode)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.schildbach.wallet.data.CoinJoinConfig
import de.schildbach.wallet.service.CoinJoinMode
import de.schildbach.wallet.service.CoinJoinService
import de.schildbach.wallet.service.MAX_ALLOWED_AHEAD_TIMESKEW
import de.schildbach.wallet.service.MAX_ALLOWED_BEHIND_TIMESKEW
import de.schildbach.wallet.util.getTimeSkew
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
Expand All @@ -35,6 +39,7 @@ import javax.inject.Inject
open class CoinJoinLevelViewModel @Inject constructor(
private val analytics: AnalyticsService,
private val coinJoinConfig: CoinJoinConfig,
private val coinJoinService: CoinJoinService,
private var networkState: NetworkStateInt
) : ViewModel() {

Expand Down Expand Up @@ -64,4 +69,18 @@ open class CoinJoinLevelViewModel @Inject constructor(
fun logEvent(event: String) {
analytics.logEvent(event, mapOf())
}

suspend fun isTimeSkewedForCoinJoin(): Boolean {
return try {
val timeSkew = getTimeSkew()
coinJoinService.updateTimeSkew(timeSkew)
if (timeSkew > 0) {
timeSkew > MAX_ALLOWED_AHEAD_TIMESKEW
} else {
-timeSkew > MAX_ALLOWED_BEHIND_TIMESKEW
}
} catch (e: Exception) {
false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ class CreateIdentityService : LifecycleService() {
}

private val walletApplication by lazy { application as WalletApplication }
@Inject lateinit var platformRepo: PlatformRepo// by lazy { PlatformRepo.getInstance() }
@Inject lateinit var platformRepo: PlatformRepo
@Inject lateinit var platformSyncService: PlatformSyncService
@Inject lateinit var userAlertDao: UserAlertDao
@Inject lateinit var blockchainIdentityDataDao: BlockchainIdentityConfig
Expand Down
18 changes: 8 additions & 10 deletions wallet/src/de/schildbach/wallet/ui/main/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package de.schildbach.wallet.ui.main

import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
Expand Down Expand Up @@ -50,6 +51,7 @@ import de.schildbach.wallet.ui.dashpay.*
import de.schildbach.wallet.ui.invite.AcceptInviteActivity
import de.schildbach.wallet.ui.invite.InviteHandler
import de.schildbach.wallet.ui.invite.InviteSendContactRequestDialog
import de.schildbach.wallet.ui.main.WalletActivityExt.checkLowStorageAlert
import de.schildbach.wallet.ui.main.WalletActivityExt.checkTimeSkew
import de.schildbach.wallet.ui.main.WalletActivityExt.handleFirebaseAction
import de.schildbach.wallet.ui.main.WalletActivityExt.requestDisableBatteryOptimisation
Expand Down Expand Up @@ -109,7 +111,7 @@ class MainActivity : AbstractBindServiceActivity(), ActivityCompat.OnRequestPerm

val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
requestDisableBatteryOptimisation()
};
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand All @@ -129,9 +131,9 @@ class MainActivity : AbstractBindServiceActivity(), ActivityCompat.OnRequestPerm

handleIntent(intent)

//Prevent showing dialog twice or more when activity is recreated (e.g: rotating device, etc)
// Prevent showing dialog twice or more when activity is recreated (e.g: rotating device, etc)
if (savedInstanceState == null) {
//Add BIP44 support and PIN if missing
// Add BIP44 support and PIN if missing
upgradeWalletKeyChains(Constants.BIP44_PATH, false)
}

Expand All @@ -143,6 +145,9 @@ class MainActivity : AbstractBindServiceActivity(), ActivityCompat.OnRequestPerm
currencies.component1()!!, currencies.component2()!!
)
}
val timeChangedFilter = IntentFilter().apply {
addAction(Intent.ACTION_TIME_CHANGED)
}
}

override fun onStart() {
Expand Down Expand Up @@ -532,13 +537,6 @@ class MainActivity : AbstractBindServiceActivity(), ActivityCompat.OnRequestPerm
}
}

private fun checkLowStorageAlert() {
val stickyIntent = registerReceiver(null, IntentFilter(Intent.ACTION_DEVICE_STORAGE_LOW))
if (stickyIntent != null) {
showLowStorageAlertDialog()
}
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
Expand Down
Loading

0 comments on commit d09b5a6

Please sign in to comment.