Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ import org.dash.wallet.common.data.SecuritySystemStatus
interface AuthenticationManager {
fun authenticate(activity: FragmentActivity, pinOnly: Boolean = false, callback: (String?) -> Unit)
suspend fun authenticate(activity: FragmentActivity, pinOnly: Boolean = false): String?
/**
* Sign [message] with the private key of [address], returning the
* base64 signature.
*
* Throws [MessageSigningException] on every failure — implementations
* must NOT return an empty string when the wallet cannot sign (see that
* type's doc for why). [message] must contain no unpaired UTF-16
* surrogate.
*/
suspend fun signMessage(address: String, message: String): String
fun getHealth(): SecuritySystemStatus
fun observeHealth(): Flow<SecuritySystemStatus>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright 2026 Dash Core Group.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package org.dash.wallet.common.services

/**
* The failure contract of [AuthenticationManager.signMessage].
*
* ## Why this type exists in `:common`
*
* The signing implementation lives in the `:wallet` module and delegates to
* the Dash Platform Kotlin SDK, whose typed errors
* (`org.dashfoundation.dashsdk.errors.DashSdkError`) are NOT on the
* classpath of the feature modules that call [AuthenticationManager]
* (`:integrations:crowdnode` depends on `:common` only). A caller therefore
* has no way to `catch` the SDK type. Since the interface being implemented
* is declared here, its error contract has to be expressible here too — so
* the wallet-side implementation maps every signing failure onto this type
* and keeps the SDK error as [cause] for logging/analytics.
*
* ## Behavior change vs. the previous dashj implementation
*
* The dashj implementation returned an EMPTY STRING when the wallet did not
* own the requested address. That silently produced a valid-looking request
* carrying no signature, which the CrowdNode server then rejected with an
* opaque error — the real cause (wrong/foreign address) never reached the
* user or the logs. Signing failures are now thrown, never swallowed; there
* is no dashj fallback (the codebase's fail-closed cutover philosophy, cf.
* `cutoverSendRoute` in `SendCoinsTaskRunner`).
*
* @property reason machine-readable classification, for callers that want
* to distinguish "this address isn't ours" from a generic failure.
*/
class MessageSigningException(
val reason: Reason,
message: String,
cause: Throwable? = null
) : Exception(message, cause) {

enum class Reason {
/**
* The wallet cannot produce a signature for the requested address:
* it does not own the corresponding private key, or the key is not
* derivable/available. Maps from the SDK's
* `DashSdkError.PlatformWallet.SigningKeyUnavailable`.
*
* This is the case the old dashj code answered with `""`.
*/
SIGNING_KEY_UNAVAILABLE,

/**
* The address (or message) was rejected as malformed before any key
* lookup happened. Maps from the SDK's platform-wallet
* `ErrorInvalidParameter` (native code 2, surfaced as
* `DashSdkError.PlatformWallet.Generic` with `nativeCode == 2`).
*/
INVALID_ADDRESS,

/**
* Anything else: the SDK was not startable, no wallet was bound, or
* the signing call failed for an unclassified reason. Always carries
* a [cause].
*/
UNAVAILABLE
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import org.dash.wallet.common.payments.parsers.AddressNetwork
import org.dash.wallet.common.payments.parsers.AddressUtils
import org.dash.wallet.common.services.BlockchainStateProvider
import org.dash.wallet.common.services.LeftoverBalanceException
import org.dash.wallet.common.services.MessageSigningException
import org.dash.wallet.common.services.NotificationService
import org.dash.wallet.common.services.TransactionMetadataProvider
import org.dash.wallet.common.services.analytics.AnalyticsConstants
Expand Down Expand Up @@ -346,6 +347,17 @@ class CrowdNodeApiAggregator @Inject constructor(
} catch (ex: UnknownHostException) {
log.error("Withdrawal error: ${ex.message}")
handleError(ex, appContext.getString(R.string.crowdnode_withdraw_error))
} catch (ex: MessageSigningException) {
// The withdrawal request is signed before it is sent
// (CrowdNodeWebApi.requestWithdrawal). Signing now THROWS rather
// than yielding an empty signature, and neither the caller
// (TransferFragment.handleWithdraw, which only catches
// WithdrawalLimitsException) nor its lifecycleScope.launch would
// catch it — so without this arm a signing failure would crash
// the app instead of showing the withdrawal-error screen.
// Nothing was sent to CrowdNode, so failing here is safe.
log.error("Withdrawal signing error (${ex.reason}): ${ex.message}")
handleError(ex, appContext.getString(R.string.crowdnode_withdraw_error))
}

analyticsService.logEvent(AnalyticsConstants.CrowdNode.PORTAL_WITHDRAW_ERROR, mapOf())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import org.dash.wallet.common.transactions.TxInfo
import org.dash.wallet.common.transactions.filters.CoinsReceivedTxFilter
import org.dash.wallet.common.transactions.filters.TxWithinTimePeriod
import org.dash.wallet.integrations.crowdnode.model.CrowdNodeException
import org.dash.wallet.integrations.crowdnode.model.CrowdNodeServiceUnavailableException
import org.dash.wallet.integrations.crowdnode.transactions.*
import org.dash.wallet.integrations.crowdnode.utils.CrowdNodeConstants
import org.slf4j.LoggerFactory
Expand All @@ -36,6 +37,49 @@ import java.util.*
import javax.inject.Inject
import kotlin.time.Duration

/**
* On-chain side of the CrowdNode integration.
*
* ## The senders are fenced off, not removed
*
* CrowdNode has disabled account creation and deposits service-side, and the
* remaining users are all served by the API path ([CrowdNodeWebApi]), so the
* six senders here - [topUpAddress], [makeSignUpRequest], [acceptTerms],
* [deposit], [requestWithdrawal] and [resendConfirmationTx] - each open with
* a guard on
* [org.dash.wallet.integrations.crowdnode.utils.CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED]
* that throws [CrowdNodeServiceUnavailableException].
*
* The guard is the FIRST statement in every one, so nothing is attempted
* before the refusal: no output locking, no top-up self-send, no partially
* executed flow. That is also what retires the partial-deposit stranding
* hazard. Throwing rather than returning quietly is deliberate - an
* operation that reports success while moving no funds is the failure mode
* being refused.
*
* Everything after each guard is the ORIGINAL dashj implementation, kept
* deliberately rather than deleted. It is the working template for a future
* port to the platform SDK, which is blocked on an `add_inputs_from_outpoints`
* JNI binding - the SDK has no equivalent of the [SpendSelection.ByAddress] /
* [SpendSelection.ExactOutput] input pinning these flows depend on.
*
* Two things follow from that, and neither is obvious from the flag's name:
* flipping [org.dash.wallet.integrations.crowdnode.utils.CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED]
* back to `true` will NOT make these sends work, because the fail-closed
* cutover in `SendCoinsTaskRunner` (`cutoverSendRoute`) rejects a custom
* coin selection outright once the SDK cutover is committed; and re-enabling
* for real therefore means doing that SDK port, not just moving the boolean.
*
* ## The observers are untouched
*
* Everything that reads chain state still works and is still needed for
* balances, history, withdrawals and wallet restore: the `waitFor*` family,
* [getDeposits], [getDepositConfirmations], [getApiAddressConfirmationTx],
* [getFullSignUpTxSet] and [getWithdrawalsForTheLast].
*
* The user-facing gate on the same flag is what users normally meet; these
* throws are the backstop for any path that gate misses.
*/
open class CrowdNodeBlockchainApi @Inject constructor(
private val paymentService: SendPaymentService,
private val walletData: WalletDataProvider
Expand All @@ -47,6 +91,9 @@ open class CrowdNodeBlockchainApi @Inject constructor(
private val networkId = walletData.networkId

suspend fun topUpAddress(accountAddress: String, amount: Dash, emptyWallet: Boolean = false): TxInfo {
if (!CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED) {
throw CrowdNodeServiceUnavailableException("topUpAddress")
}
// lock funds in outputs to accountAddress to prevent other send operations from using these funds
val topUpTx = paymentService.sendCoinsSelected(
accountAddress,
Expand All @@ -59,6 +106,9 @@ open class CrowdNodeBlockchainApi @Inject constructor(
}

suspend fun makeSignUpRequest(accountAddress: String): TxInfo {
if (!CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED) {
throw CrowdNodeServiceUnavailableException("makeSignUpRequest")
}
val requestValue = CrowdNodeSignUpTx.SIGNUP_REQUEST_CODE
val crowdNodeAddress = CrowdNodeConstants.getCrowdNodeAddress(networkId)
val signUpTx = paymentService.sendCoinsSelected(
Expand All @@ -84,6 +134,9 @@ open class CrowdNodeBlockchainApi @Inject constructor(
}

suspend fun acceptTerms(accountAddress: String): TxInfo {
if (!CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED) {
throw CrowdNodeServiceUnavailableException("acceptTerms")
}
val requestValue = CrowdNodeAcceptTermsTx.ACCEPT_TERMS_REQUEST_CODE
val crowdNodeAddress = CrowdNodeConstants.getCrowdNodeAddress(networkId)
val acceptTx = paymentService.sendCoinsSelected(
Expand Down Expand Up @@ -115,6 +168,9 @@ open class CrowdNodeBlockchainApi @Inject constructor(
emptyWallet: Boolean,
checkBalanceConditions: Boolean
): TxInfo {
if (!CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED) {
throw CrowdNodeServiceUnavailableException("deposit")
}
val crowdNodeAddress = CrowdNodeConstants.getCrowdNodeAddress(networkId)

return paymentService.sendCoinsSelected(
Expand Down Expand Up @@ -144,6 +200,9 @@ open class CrowdNodeBlockchainApi @Inject constructor(

// not currently used
suspend fun requestWithdrawal(accountAddress: String, requestValue: Dash): TxInfo {
if (!CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED) {
throw CrowdNodeServiceUnavailableException("requestWithdrawal")
}
val crowdNodeAddress = CrowdNodeConstants.getCrowdNodeAddress(networkId)

return paymentService.sendCoinsSelected(
Expand Down Expand Up @@ -242,6 +301,9 @@ open class CrowdNodeBlockchainApi @Inject constructor(
}

suspend fun resendConfirmationTx(confirmationTx: TxInfo, accountAddress: String) {
if (!CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED) {
throw CrowdNodeServiceUnavailableException("resendConfirmationTx")
}
// lock the outputs
walletData.lockOutputsPayingTo(confirmationTx.txId, accountAddress)
val confirmationOutput = confirmationTx.outputs.first {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import org.dash.wallet.common.transactions.TxInfo
import org.dash.wallet.common.transactions.filters.CoinsToAddressTxFilter
import org.dash.wallet.integrations.crowdnode.R
import org.dash.wallet.integrations.crowdnode.model.CrowdNodeException
import org.dash.wallet.integrations.crowdnode.model.CrowdNodeServiceUnavailableException
import org.dash.wallet.integrations.crowdnode.model.OnlineAccountStatus
import org.dash.wallet.integrations.crowdnode.utils.CrowdNodeConfig
import org.dash.wallet.integrations.crowdnode.utils.CrowdNodeConstants
Expand Down Expand Up @@ -81,6 +82,12 @@ class CrowdNodeAPIConfirmationHandler(

try {
blockchainApi.resendConfirmationTx(tx, primaryAddress)
} catch (ex: CrowdNodeServiceUnavailableException) {
// The resend sender is retired. Distinguished from the arm
// below on purpose: this is not a wrong-address situation,
// and reporting it as one would send the user chasing a
// problem with their address that does not exist.
log.info("Confirmation resend is retired; leaving the status unchanged")
} catch (ex: CrowdNodeException) {
handleWrongAddressError()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,35 @@ open class CrowdNodeException(message: String) : Exception(message) {
const val CONFIRMATION_ERROR = "confirmation_error"
const val WITHDRAWAL_ERROR = "withdrawal_error"
const val MISSING_PRIMARY = "primary_not_specified"
const val SERVICE_UNAVAILABLE = "service_unavailable"
}
}

class MessageStatusException(details: String) : CrowdNodeException(details)

/**
* A retired on-chain operation was invoked. CrowdNode has disabled account
* creation and deposits service-side, and the remaining users are all on the
* API path, so the dashj senders in
* [org.dash.wallet.integrations.crowdnode.api.CrowdNodeBlockchainApi] no
* longer build or broadcast anything — they raise this instead.
*
* Deliberately an exception rather than a silent no-op: an operation that
* reports success while moving no funds is the failure mode being refused
* here. It is raised BEFORE any side effect (no output locking, no partial
* flow), so nothing is left half-done for the caller to reconcile.
*
* Extends [CrowdNodeException] on purpose — the existing handlers
* (`CrowdNodeApi.signUp`/`deposit`'s `catch (Exception)` and
* `CrowdNodeConfirmationTxHandler`'s `catch (CrowdNodeException)`) then turn
* it into an error state rather than an uncaught crash.
*
* The UI gate ([org.dash.wallet.integrations.crowdnode.utils.CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED])
* is what users normally meet; this is the backstop for any path the gate
* misses.
*
* @property operation the retired call, for logs and analytics.
*/
class CrowdNodeServiceUnavailableException(
val operation: String
) : CrowdNodeException("$SERVICE_UNAVAILABLE: $operation is no longer available")
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import org.dash.wallet.integrations.crowdnode.databinding.FragmentResultBinding
import org.dash.wallet.integrations.crowdnode.model.CrowdNodeException
import org.dash.wallet.integrations.crowdnode.model.MessageStatusException
import org.dash.wallet.integrations.crowdnode.model.SignUpStatus
import org.dash.wallet.integrations.crowdnode.utils.CrowdNodeConstants
import javax.inject.Inject

@AndroidEntryPoint
Expand Down Expand Up @@ -95,7 +96,20 @@ class ResultFragment : Fragment(R.layout.fragment_result) {
setErrorMessage(it)
}

if (viewModel.crowdNodeError?.isInsufficientMoney == true ||
// Retrying a signup would re-enter the retired on-chain senders, so
// when the capability is off there is nothing to retry — offering the
// button would just reproduce the same failure
// (CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED).
// The MessageStatusException exclusion matters: that is an API-path
// failure (a rejected signed message), which is still live and worth
// retrying via retryOnlineSignUp. Only a non-API signup error would
// re-enter the fenced-off senders and re-throw immediately.
val signUpRetryRetired = !CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED &&
viewModel.signUpStatus == SignUpStatus.Error &&
viewModel.crowdNodeError !is MessageStatusException

if (signUpRetryRetired ||
viewModel.crowdNodeError?.isInsufficientMoney == true ||
viewModel.crowdNodeError?.message?.startsWith(INSUFFICIENT_MONEY_PREFIX) == true ||
viewModel.crowdNodeError?.message == CrowdNodeException.CONFIRMATION_ERROR
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ class EntryPointFragment : Fragment(R.layout.fragment_entry_point) {
// CrowdNode functionality is limited: linking an existing account isn't supported
binding.existingAccountBtn.isVisible = false

// Account creation is fenced off (CrowdNodeConstants.
// SIGNUP_AND_DEPOSITS_ENABLED). This is THE entry point to the signup
// flow, so hiding the button here is what stops a user reaching the
// fenced-off on-chain senders; CrowdNodeBlockchainApi's throws are
// the backstop. Say why, rather than leaving an empty screen.
//
// The explanation replaces the screen's own title/hint, which sit
// OUTSIDE the button card — requiredDashTxt is a child of
// newAccountBtn and would be hidden along with it.
if (!CrowdNodeConstants.SIGNUP_AND_DEPOSITS_ENABLED) {
binding.newAccountBtn.isVisible = false
binding.getStartedTitle.text = getString(R.string.crowdnode_signup_deposits_disabled)
binding.getStartedHint.text = getString(R.string.crowdnode_signup_deposits_disabled_message)
}

binding.backupPassphraseHint.setOnClickListener {
val dialog = AdaptiveDialog.create(
null,
Expand Down
Loading
Loading