Skip to content

Commit

Permalink
feat(crowdnode): increase withdrawal precision (#1220)
Browse files Browse the repository at this point in the history
* chore: apply ktlint

* chore: cleanup 7.5.0 handling code

* feat: use web API to request withdrawal

* fix: catch unknown host errors

* fix: apply ktlint

* fix: tests

* fix: ktlint check on tests
  • Loading branch information
Syn-McJ authored Nov 2, 2023
1 parent c024ad8 commit 9d2a473
Show file tree
Hide file tree
Showing 48 changed files with 428 additions and 349 deletions.
4 changes: 2 additions & 2 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ ij_kotlin_allow_trailing_comma_on_call_site = false
ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^
max_line_length = 120
ktlint_standard_no-wildcard-imports = disabled
ktlint_disabled_rules = no-wildcard-imports, spacing-between-declarations-with-annotations
ktlint_disabled_rules = no-wildcard-imports, spacing-between-declarations-with-annotations, package-name
ktlint_standard_package-name = disabled
ktlint_standard_spacing-between-declarations-with-annotations = disabled
ktlint_standard_colon-spacing = disabled
ktlint_standard_colon-spacing = disabled
3 changes: 1 addition & 2 deletions integrations/crowdnode/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@ plugins {
id 'androidx.navigation.safeargs.kotlin'
id 'dagger.hilt.android.plugin'
id 'kotlin-parcelize'
id 'org.jlleitschuh.gradle.ktlint'
}

android {
compileSdkVersion 33

defaultConfig {
compileSdk 33
minSdkVersion 23
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,17 @@ import org.bitcoinj.core.Address
import org.bitcoinj.core.Coin
import org.bitcoinj.core.Transaction
import org.dash.wallet.common.Configuration
import org.dash.wallet.common.util.Constants
import org.dash.wallet.common.WalletDataProvider
import org.dash.wallet.common.data.Resource
import org.dash.wallet.common.data.Status
import org.dash.wallet.common.data.ServiceName
import org.dash.wallet.common.services.AuthenticationManager
import org.dash.wallet.common.data.Status
import org.dash.wallet.common.data.TaxCategory
import org.dash.wallet.common.services.LeftoverBalanceException
import org.dash.wallet.common.services.NotificationService
import org.dash.wallet.common.services.TransactionMetadataProvider
import org.dash.wallet.common.services.analytics.AnalyticsService
import org.dash.wallet.common.data.TaxCategory
import org.dash.wallet.common.transactions.TransactionUtils
import org.dash.wallet.common.util.Constants
import org.dash.wallet.common.util.TickerFlow
import org.dash.wallet.integrations.crowdnode.R
import org.dash.wallet.integrations.crowdnode.model.*
Expand All @@ -50,11 +49,11 @@ import org.dash.wallet.integrations.crowdnode.transactions.CrowdNodeWelcomeToApi
import org.dash.wallet.integrations.crowdnode.utils.CrowdNodeConfig
import org.dash.wallet.integrations.crowdnode.utils.CrowdNodeConstants
import org.slf4j.LoggerFactory
import retrofit2.HttpException
import java.io.IOException
import java.net.URLEncoder
import java.net.UnknownHostException
import java.util.concurrent.Executors
import javax.inject.Inject
import kotlin.math.min
import kotlin.math.pow
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
Expand Down Expand Up @@ -94,10 +93,9 @@ class CrowdNodeApiAggregator @Inject constructor(
private val analyticsService: AnalyticsService,
private val config: CrowdNodeConfig,
private val globalConfig: Configuration,
private val securityFunctions: AuthenticationManager,
private val transactionMetadataProvider: TransactionMetadataProvider,
@ApplicationContext private val appContext: Context
): CrowdNodeApi {
) : CrowdNodeApi {
companion object {
private val log = LoggerFactory.getLogger(CrowdNodeApiAggregator::class.java)
private const val CONFIRMED_STATUS = "confirmed"
Expand Down Expand Up @@ -138,7 +136,7 @@ class CrowdNodeApiAggregator @Inject constructor(
.onEach { status ->
cancelTrackingJob()
val initialDelay = if (isOnlineStatusRestored) 0.seconds else 10.seconds
when(status) {
when (status) {
OnlineAccountStatus.Linking -> startTrackingLinked(linkingApiAddress!!)
OnlineAccountStatus.Validating -> startTrackingValidated(accountAddress!!, initialDelay)
OnlineAccountStatus.Confirming -> startTrackingConfirmed(accountAddress!!, initialDelay)
Expand Down Expand Up @@ -197,10 +195,12 @@ class CrowdNodeApiAggregator @Inject constructor(
log.info("CrowdNode persistent sign up")

val crowdNodeWorker = OneTimeWorkRequestBuilder<CrowdNodeWorker>()
.setInputData(workDataOf(
CrowdNodeWorker.API_REQUEST to CrowdNodeWorker.SIGNUP_CALL,
CrowdNodeWorker.ACCOUNT_ADDRESS to accountAddress.toBase58()
))
.setInputData(
workDataOf(
CrowdNodeWorker.API_REQUEST to CrowdNodeWorker.SIGNUP_CALL,
CrowdNodeWorker.ACCOUNT_ADDRESS to accountAddress.toBase58()
)
)
.build()

WorkManager.getInstance(appContext)
Expand Down Expand Up @@ -294,49 +294,45 @@ class CrowdNodeApiAggregator @Inject constructor(

return try {
apiError.value = null
val result = webApi.requestWithdrawal(accountAddress, amount)

val maxPermil = ApiCode.WithdrawAll.code
val requestPermil = min(amount.value * maxPermil / balance.value, maxPermil)
val requestValue = CrowdNodeConstants.API_OFFSET + Coin.valueOf(requestPermil)
val topUpTx = blockchainApi.topUpAddress(accountAddress, requestValue + Constants.ECONOMIC_FEE)
log.info("topUpTx id: ${topUpTx.txId}")
val withdrawTx = blockchainApi.requestWithdrawal(accountAddress, requestValue)
log.info("withdrawTx id: ${withdrawTx.txId}")

responseScope.launch {
try {
val txResponse = blockchainApi.waitForWithdrawalResponse(requestValue)
log.info("got withdrawal queue response: ${txResponse.txId}")
val txWithdrawal = blockchainApi.waitForWithdrawalReceived()
log.info("got withdrawal: ${txWithdrawal.txId}")
} catch (ex: Exception) {
handleError(ex, appContext.getString(R.string.crowdnode_withdraw_error))
}
if (result.messageStatus.lowercase() == MESSAGE_RECEIVED_STATUS) {
log.info("Withdrawal request sent successfully")
refreshBalance(retries = 3, afterWithdrawal = true)
true
} else {
log.info("Withdrawal request not received, status: ${result.messageStatus}. Result: ${result.result}")
apiError.value = MessageStatusException(result.result ?: "")
false
}

return true
} catch (ex: Exception) {
} catch (ex: HttpException) {
log.error("SendMessage error, code: ${ex.code()}, error: ${ex.response()?.errorBody()?.string()}")
handleError(ex, appContext.getString(R.string.crowdnode_withdraw_error))
false
} catch (ex: UnknownHostException) {
log.error("Withdrawal error: ${ex.message}")
handleError(ex, appContext.getString(R.string.crowdnode_withdraw_error))
false
}
}

override suspend fun getWithdrawalLimit(period: WithdrawalLimitPeriod): Coin {
return Coin.valueOf(when(period) {
WithdrawalLimitPeriod.PerTransaction -> {
config.get(CrowdNodeConfig.WITHDRAWAL_LIMIT_PER_TX) ?:
CrowdNodeConstants.WithdrawalLimits.DEFAULT_LIMIT_PER_TX.value
}
WithdrawalLimitPeriod.PerHour -> {
config.get(CrowdNodeConfig.WITHDRAWAL_LIMIT_PER_HOUR) ?:
CrowdNodeConstants.WithdrawalLimits.DEFAULT_LIMIT_PER_HOUR.value
}
WithdrawalLimitPeriod.PerDay -> {
config.get(CrowdNodeConfig.WITHDRAWAL_LIMIT_PER_DAY) ?:
CrowdNodeConstants.WithdrawalLimits.DEFAULT_LIMIT_PER_DAY.value
return Coin.valueOf(
when (period) {
WithdrawalLimitPeriod.PerTransaction -> {
config.get(CrowdNodeConfig.WITHDRAWAL_LIMIT_PER_TX)
?: CrowdNodeConstants.WithdrawalLimits.DEFAULT_LIMIT_PER_TX.value
}
WithdrawalLimitPeriod.PerHour -> {
config.get(CrowdNodeConfig.WITHDRAWAL_LIMIT_PER_HOUR)
?: CrowdNodeConstants.WithdrawalLimits.DEFAULT_LIMIT_PER_HOUR.value
}
WithdrawalLimitPeriod.PerDay -> {
config.get(CrowdNodeConfig.WITHDRAWAL_LIMIT_PER_DAY)
?: CrowdNodeConstants.WithdrawalLimits.DEFAULT_LIMIT_PER_DAY.value
}
}
})
)
}

override fun hasAnyDeposits(): Boolean {
Expand Down Expand Up @@ -373,14 +369,16 @@ class CrowdNodeApiAggregator @Inject constructor(
if (!afterWithdrawal) {
// balance changed, no need to retry anymore
break
} else if (lastBalance - (currentBalance.data?.value?: 0L) >= minimumWithdrawal.value) {
} else if (lastBalance - (currentBalance.data?.value ?: 0L) >= minimumWithdrawal.value) {
// balance changed, no need to retry anymore
break
}
}
}

balance.value = currentBalance
currentBalance.data?.let {
balance.value = currentBalance
}
}
}

Expand Down Expand Up @@ -413,9 +411,7 @@ class CrowdNodeApiAggregator @Inject constructor(
requireNotNull(address) { "Account address is null, make sure to sign up" }

try {
val signature = securityFunctions.signMessage(address, email)

if (sendSignedEmailMessage(address, email, signature)) {
if (sendSignedEmailMessage(address, email)) {
changeOnlineStatus(OnlineAccountStatus.Creating)
}
} catch (ex: Exception) {
Expand Down Expand Up @@ -477,31 +473,25 @@ class CrowdNodeApiAggregator @Inject constructor(
.launchIn(statusScope)
}

@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun sendSignedEmailMessage(
address: Address,
email: String,
signature: String
): Boolean {
private suspend fun sendSignedEmailMessage(address: Address, email: String): Boolean {
log.info("Sending signed email message")
val encodedSignature = URLEncoder.encode(signature, "utf-8")
val result = webApi.sendSignedMessage(address.toBase58(), email, encodedSignature)

if (result.isSuccessful && result.body()!!.messageStatus.lowercase() == MESSAGE_RECEIVED_STATUS) {
log.info("Signed email sent successfully")
config.set(CrowdNodeConfig.SIGNED_EMAIL_MESSAGE_ID, result.body()!!.id)
return true
}
val result = webApi.registerEmail(address, email)

if (result.isSuccessful) {
log.info("SendMessage not received, status: ${result.body()?.messageStatus ?: "null"}. Result: ${result.body()?.result}")
apiError.value = MessageStatusException(result.body()?.result ?: "")
return try {
if (result.messageStatus.lowercase() == MESSAGE_RECEIVED_STATUS) {
log.info("Signed email sent successfully")
config.set(CrowdNodeConfig.SIGNED_EMAIL_MESSAGE_ID, result.id)
true
} else {
log.info("SendMessage not received, status: ${result.messageStatus}. Result: ${result.result}")
apiError.value = MessageStatusException(result.result ?: "")
false
}
} catch (ex: HttpException) {
log.info("SendMessage error, code: ${ex.code()}, error: ${ex.response()?.errorBody()?.string()}")
apiError.value = MessageStatusException(ex.response()?.errorBody()?.string() ?: "")
return false
}

log.info("SendMessage error, code: ${result.code()}, error: ${result.errorBody()?.string()}")
apiError.value = MessageStatusException(result.errorBody()?.string() ?: "")
return false
}

override suspend fun reset() {
Expand Down Expand Up @@ -618,25 +608,22 @@ class CrowdNodeApiAggregator @Inject constructor(
when (status) {
OnlineAccountStatus.None -> {}
OnlineAccountStatus.Linking -> {
log.info("found linking online account in progress, account: ${address.toBase58()}, primary: $primaryAddressStr")
log.info(
"found linking online account in progress, " +
"account: ${address.toBase58()}, primary: $primaryAddressStr"
)
checkIfAddressIsInUse(address)
}
OnlineAccountStatus.Creating, OnlineAccountStatus.SigningUp -> {
if (status == OnlineAccountStatus.Creating && globalConfig.crowdNodePrimaryAddress.isNotEmpty()) {
// The bug from 7.5.0 -> 7.5.1 upgrade scenario.
// The actual state is Done, there is a linked account.
// TODO: remove when there is no 7.5.0 in the wild
log.info("found 7.5.0 -> 7.5.1 upgrade bug, resolving")
changeOnlineStatus(OnlineAccountStatus.Done, save = true)
log.info("found online account, status: ${OnlineAccountStatus.Done}, account: ${address.toBase58()}, primary: $primaryAddressStr")
} else {
// This should not happen - this method is reachable only for a linked account case
throw IllegalStateException("Invalid state found in tryRestoreLinkedOnlineAccount: $status")
}
// This should not happen - this method is reachable only for a linked account case
throw IllegalStateException("Invalid state found in tryRestoreLinkedOnlineAccount: $status")
}
else -> {
changeOnlineStatus(status, save = false)
log.info("found online account, status: ${status.name}, account: ${address.toBase58()}, primary: $primaryAddressStr")
log.info(
"found online account, status: ${status.name}, " +
"account: ${address.toBase58()}, primary: $primaryAddressStr"
)
}
}
}
Expand Down Expand Up @@ -673,8 +660,10 @@ class CrowdNodeApiAggregator @Inject constructor(
}

private fun restoreCreatedOnlineAccount(address: Address) {
val statusOrdinal = runBlocking { config.get(CrowdNodeConfig.ONLINE_ACCOUNT_STATUS)
?: OnlineAccountStatus.None.ordinal }
val statusOrdinal = runBlocking {
config.get(CrowdNodeConfig.ONLINE_ACCOUNT_STATUS)
?: OnlineAccountStatus.None.ordinal
}

when (val status = OnlineAccountStatus.values()[statusOrdinal]) {
OnlineAccountStatus.None -> statusScope.launch { checkIfEmailRegistered(address) }
Expand Down Expand Up @@ -862,4 +851,4 @@ class CrowdNodeApiAggregator @Inject constructor(
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,9 @@ open class CrowdNodeBlockchainApi @Inject constructor(
val selector = ByAddressCoinSelector(accountAddress)

return paymentService.sendCoins(
crowdNodeAddress, requestValue, selector,
crowdNodeAddress,
requestValue,
selector,
emptyWallet = false,
checkBalanceConditions = false
)
Expand All @@ -145,7 +147,6 @@ open class CrowdNodeBlockchainApi @Inject constructor(
errorResponse
).first()


if (deniedResponse.matches(tx) || errorResponse.matches(tx)) {
throw CrowdNodeException(CrowdNodeException.WITHDRAWAL_ERROR)
}
Expand All @@ -156,8 +157,8 @@ open class CrowdNodeBlockchainApi @Inject constructor(
suspend fun waitForSignUpResponse(): Transaction {
val acceptFilter = CrowdNodeAcceptTermsResponse(params)
val errorFilter = CrowdNodeErrorResponse(params, CrowdNodeSignUpTx.SIGNUP_REQUEST_CODE)
val tx = walletData.getTransactions(acceptFilter, errorFilter).firstOrNull() ?:
walletData.observeTransactions(true, acceptFilter, errorFilter).first()
val tx = walletData.getTransactions(acceptFilter, errorFilter).firstOrNull()
?: walletData.observeTransactions(true, acceptFilter, errorFilter).first()

if (errorFilter.matches(tx)) {
throw CrowdNodeException("SignUp request returned an error")
Expand All @@ -170,8 +171,8 @@ open class CrowdNodeBlockchainApi @Inject constructor(
val welcomeFilter = CrowdNodeWelcomeToApiResponse(params)
val errorFilter = CrowdNodeErrorResponse(params, CrowdNodeAcceptTermsTx.ACCEPT_TERMS_REQUEST_CODE)

val tx = walletData.getTransactions(welcomeFilter, errorFilter).firstOrNull() ?:
walletData.observeTransactions(true, welcomeFilter, errorFilter).first()
val tx = walletData.getTransactions(welcomeFilter, errorFilter).firstOrNull()
?: walletData.observeTransactions(true, welcomeFilter, errorFilter).first()

if (errorFilter.matches(tx)) {
throw CrowdNodeException("AcceptTerms request returned an error")
Expand Down Expand Up @@ -264,4 +265,4 @@ open class CrowdNodeBlockchainApi @Inject constructor(

return Coin.valueOf(withdrawals.sumOf { it.getValue(walletData.transactionBag).value })
}
}
}
Loading

0 comments on commit 9d2a473

Please sign in to comment.