diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaBlockchainApi.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaBlockchainApi.kt index d1ef9ec28e..f4abeadccb 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaBlockchainApi.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/api/MayaBlockchainApi.kt @@ -18,13 +18,16 @@ package org.dash.wallet.integrations.maya.api import org.dash.wallet.common.data.ResponseResource +import org.dash.wallet.common.money.Dash import org.dash.wallet.integrations.maya.model.SwapTradeUIModel /** * Builds, signs and broadcasts the Maya swap transaction for a quoted trade. * * Implemented in the wallet module (de.schildbach.wallet.payments.MayaBlockchainApiImpl), - * which owns the dashj transaction machinery; this module stays dashj-free. + * which builds the deposit on the Kotlin SDK's deferred build/broadcast surface and + * verifies the MAYACHAIN deposit shape before broadcasting; this module stays free + * of wallet-engine types. */ interface MayaBlockchainApi { /** @@ -44,4 +47,20 @@ interface MayaBlockchainApi { suspend fun buildAndSendSwapTx( swapTradeUIModel: SwapTradeUIModel ): ResponseResource + + /** + * The largest amount a swap deposit can pay a vault right now: what a + * DRAIN of the funding account delivers, measured through the real SDK + * builder and reported by the engine — never estimated, and never reduced + * by a headroom or reserve constant. + * + * Quote a MAX sell at exactly this figure. The deposit that follows runs + * the identical drain, so quote and payment agree by construction rather + * than by a margin chosen to be safe. A quote above the real deliverable + * would make the deposit pay less than quoted, and NEAR Intents refuses + * under-delivery (~1h wait, then a refund minus 0.001 DASH). + * + * [Dash.ZERO] when no drain is fundable at all. + */ + suspend fun maxSwapDepositAmount(): Dash } diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt index 50f4cdfe03..864a900f4e 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/di/MayaModule.kt @@ -107,8 +107,8 @@ abstract class MayaModule { abstract fun bindMayaApi(mayaApi: MayaApiAggregator): MayaApi // Note: MayaBlockchainApi is implemented and bound in the wallet module - // (de.schildbach.wallet.payments.MayaBlockchainApiImpl), which owns the dashj - // transaction machinery that swap-transaction construction requires. + // (de.schildbach.wallet.payments.MayaBlockchainApiImpl), which builds the + // swap deposit on the Kotlin SDK's deferred build/broadcast surface. @Binds @Singleton diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/MayaErrorResponse.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/MayaErrorResponse.kt index 55edec3fc2..80fe120188 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/MayaErrorResponse.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/model/MayaErrorResponse.kt @@ -38,8 +38,6 @@ enum class MayaErrorType { } class MayaException(val errorType: MayaErrorType, message: String?) : Exception(message) -class IncorrectSwapOutputCount(val outputCount: Int) : - Exception("Maya transaction has $outputCount outputs. Only 3 are allowed") fun getMayaErrorType(error: String): MayaErrorType { val endOfErrorType = error.indexOf(':') diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt index d4bd075f05..cc958dd9d8 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/swapkit/SwapKitApiAggregator.kt @@ -639,25 +639,34 @@ class SwapKitApiAggregator @Inject constructor( // guarded again in buildAndSendDepositTx before broadcasting. The receive address is // only a size stand-in for the estimate; the real deposit address is also P2PKH. val effectiveAmount = if (swapRequest.maximum) { - val balance = walletDataProvider.getWalletBalance() - val sweep = try { - sendPaymentService.estimateNetworkFee( - walletDataProvider.currentReceiveAddressString(), - balance, - emptyWallet = true - ) + // MEASURED through the SDK builder (MayaBlockchainApi.maxSwapDepositAmount), + // not estimated by dashj: post-cutover the dashj engine is held, and + // incoming SDK transactions never reach its wallet, so its coin set is + // frozen at cutover time — a sweep estimate from it either throws + // (funds received since the cutover are invisible to it) or prices the + // wrong transaction shape. The SDK figure is biased HIGH so the deposit + // can never come in under the quote. + val maxDeposit = try { + blockchainApi.maxSwapDepositAmount() } catch (e: Exception) { - log.error("swapkit max sell: sweep fee estimation failed", e) + log.error("swapkit max sell: deposit fee measurement failed", e) return ResponseResource.Failure(e, false, 0, e.message) } + if (maxDeposit.duffs <= 0L) { + return ResponseResource.Failure( + MayaException("balance too low to cover a swap deposit and its fee"), + false, + 0, + null + ) + } log.info( - "swapkit max sell: quoting sweep output {} (balance {}, fee {})", - sweep.amountToSend.toFriendlyString(), - balance.toFriendlyString(), - sweep.fee + "swapkit max sell: quoting the measured max deposit {} (balance {})", + maxDeposit.toFriendlyString(), + walletDataProvider.getWalletBalance().toFriendlyString() ) swapRequest.amount.copy().apply { - dash = sweep.amountToSend.toBigDecimal() + dash = maxDeposit.toBigDecimal() anchoredType = swapRequest.amount.anchoredType } } else { @@ -986,15 +995,11 @@ class SwapKitApiAggregator @Inject constructor( // broadcasting a doomed deposit. A balance that grew simply over-delivers, which // NEAR accepts. if (swapTradeUIModel.maximum) { - val sweep = sendPaymentService.estimateNetworkFee( - swapTradeUIModel.vaultAddress, - walletDataProvider.getWalletBalance(), - emptyWallet = true - ) - if (sweep.amountToSend.isLessThan(amount)) { + val maxDeposit = blockchainApi.maxSwapDepositAmount() + if (maxDeposit.isLessThan(amount)) { log.warn( - "swapkit max sell aborted: sweep would deliver {} < quoted {}", - sweep.amountToSend.toFriendlyString(), + "swapkit max sell aborted: {} now depositable < quoted {}", + maxDeposit.toFriendlyString(), amount.toFriendlyString() ) return ResponseResource.Failure( diff --git a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewViewModel.kt index 82c1ea59cc..e878630d69 100644 --- a/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewViewModel.kt +++ b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewViewModel.kt @@ -21,7 +21,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import org.dash.wallet.common.WalletDataProvider @@ -32,7 +31,6 @@ import org.dash.wallet.common.data.TaxCategory import org.dash.wallet.common.data.TxId import org.dash.wallet.common.data.entity.SwapOrder import org.dash.wallet.common.money.TxIds -import org.dash.wallet.common.observeTransactionLocked import org.dash.wallet.common.services.InsufficientFundsException import org.dash.wallet.common.services.NetworkStateInt import org.dash.wallet.common.services.TransactionMetadataProvider @@ -128,12 +126,24 @@ class MayaConversionPreviewViewModel @Inject constructor( // This verifies that the transaction was successfully broadcast and seen by peers. // Dash IS locks typically arrive within 1-2 seconds; we allow up to 10 seconds // before proceeding anyway (the tx was sent; lock may arrive later). + // waitUntilLocked (not a change-only observation) because the lock usually + // lands BEFORE this code runs — the SDK route has often seen the IS-lock by + // the time the send call returns, and a change-only stream would miss it and + // always burn the full timeout. val txId = result.value.txid if (txId != TxIds.ZERO_HASH_HEX) { - val locked = withTimeoutOrNull(IS_LOCK_TIMEOUT_MS) { - walletDataProvider.observeTransactionLocked(txId).first() + val locked = try { + withTimeoutOrNull(IS_LOCK_TIMEOUT_MS) { + walletDataProvider.waitUntilLocked(txId) + // Reached only if the lock arrived in time; + // a timeout yields null instead. + true + } ?: false + } catch (e: Exception) { + log.warn("could not watch maya swap tx {} for a lock", txId, e) + false } - if (locked != null) { + if (locked) { log.info("maya swap tx {} IS-locked or confirmed", txId) } else { log.warn("maya swap tx {} not IS-locked within {}ms timeout", txId, IS_LOCK_TIMEOUT_MS) diff --git a/wallet/src/de/schildbach/wallet/data/WalletDataAdapter.kt b/wallet/src/de/schildbach/wallet/data/WalletDataAdapter.kt index 241608cde8..54a38c2f5a 100644 --- a/wallet/src/de/schildbach/wallet/data/WalletDataAdapter.kt +++ b/wallet/src/de/schildbach/wallet/data/WalletDataAdapter.kt @@ -202,22 +202,26 @@ class WalletDataAdapter @Inject constructor( } override suspend fun waitUntilLocked(txId: String) { - // Held-wallet path first (self-authored txs; the only path pre-cutover) — - // dashj confidence semantics, byte-identical to before. - val tx = walletData.getTransaction(Sha256Hash.wrap(txId)) - if (tx != null) { - tx.waitToMatchFilters(LockedTransaction()) - return - } - // Post-cutover: an SDK-fed tx has no held-wallet confidence to wait on — wait - // for the seam feed instead (its TxInfo isLocked flips on islock/block context - // events). The current-state replay closes the check-then-subscribe race. + // Post-cutover seam path FIRST: a bridged self-authored tx (an SDK send) also + // exists in the held dashj wallet, but its confidence is FROZEN there — no + // peergroup ever delivers it an IS-lock — so the held-wallet wait would sit + // out any timeout even though the engine saw the lock within seconds (the + // 2026-08-05 Maya mainnet field test: engine IS-lock in 1.6s, UI waited the + // full 10s). The seam's TxInfo carries the live engine lock state, and the + // current-state replay means an already-locked tx returns immediately. if (txSeamService.sdkTxInfosOrNull()?.get(txId.lowercase()) != null) { txSeamService .observeSdkTransactionsWithCurrentState(arrayOf(NeutralLockedTransaction(txId))) .first() return } + // Pre-cutover (and anything the SDK store never learned): dashj confidence + // semantics, byte-identical to before. + val tx = walletData.getTransaction(Sha256Hash.wrap(txId)) + if (tx != null) { + tx.waitToMatchFilters(LockedTransaction()) + return + } // Fail closed rather than pretending the tx is locked (see lockOutputsPayingTo): // the tx exists nowhere. throw IllegalStateException("transaction $txId not found in wallet") diff --git a/wallet/src/de/schildbach/wallet/payments/FakeDashSpendService.kt b/wallet/src/de/schildbach/wallet/payments/FakeDashSpendService.kt index 5f8b105d79..a765aefc73 100644 --- a/wallet/src/de/schildbach/wallet/payments/FakeDashSpendService.kt +++ b/wallet/src/de/schildbach/wallet/payments/FakeDashSpendService.kt @@ -119,15 +119,4 @@ class FakeDashSpendService @Inject constructor( ) } - override suspend fun completeTransaction(sendRequest: SendRequest) { - return realService.completeTransaction(sendRequest) - } - - override suspend fun signTransaction(sendRequest: SendRequest) { - return realService.signTransaction(sendRequest) - } - - override suspend fun sendTransaction(sendRequest: SendRequest): Transaction { - return realService.sendTransaction(sendRequest) - } } diff --git a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt index 87ea3502d1..03def54c02 100644 --- a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt +++ b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt @@ -17,59 +17,217 @@ package de.schildbach.wallet.payments +import de.schildbach.wallet.Constants +import de.schildbach.wallet.service.platform.sdk.BridgedTxResult +import de.schildbach.wallet.service.platform.sdk.ReservationLockMirror +import de.schildbach.wallet.service.platform.sdk.SdkBridgedTransactionFactory +import de.schildbach.wallet.service.platform.sdk.SdkL1SendService +import de.schildbach.wallet.service.platform.sdk.SdkWriteResult +import de.schildbach.wallet.service.platform.sdk.toSdkNetwork import kotlinx.coroutines.CancellationException -import org.bitcoinj.core.Address -import org.bitcoinj.core.Coin -import org.bitcoinj.core.InsufficientMoneyException -import org.bitcoinj.core.Transaction -import org.bitcoinj.core.TransactionOutput -import org.bitcoinj.script.ScriptBuilder -import org.bitcoinj.script.ScriptPattern -import org.bitcoinj.wallet.SendRequest -import de.schildbach.wallet.data.WalletData import org.dash.wallet.common.data.ResponseResource +import org.dash.wallet.common.money.Dash import org.dash.wallet.common.services.InsufficientFundsException -import de.schildbach.wallet.payments.WalletSendPaymentService -import org.dash.wallet.common.services.SendPaymentService -import de.schildbach.wallet.util.toDashjCoin -import org.dash.wallet.common.util.toCoin import org.dash.wallet.integrations.maya.api.MayaBlockchainApi import org.dash.wallet.integrations.maya.api.MayaException import org.dash.wallet.integrations.maya.api.MayaWebApi -import org.dash.wallet.integrations.maya.model.IncorrectSwapOutputCount import org.dash.wallet.integrations.maya.model.SwapQuoteRequest import org.dash.wallet.integrations.maya.model.SwapTradeUIModel +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.keywallet.DecodedTransaction +import org.dashfoundation.dashsdk.keywallet.TransactionDecoder import org.slf4j.Logger import org.slf4j.LoggerFactory import java.math.RoundingMode import javax.inject.Inject /** - * Wallet-module implementation of the Maya integration's [MayaBlockchainApi]: constructs the - * swap transaction (Asgard vault output + OP_RETURN memo + controlled change output ordering) - * with dashj and broadcasts it. Lives here so integrations/maya stays dashj-free. + * The exact scriptPubKey a MAYACHAIN memo output must carry: `OP_RETURN` + * (0x6a) followed by the minimal push of [memo] — a direct-length push up + * to 75 bytes, `OP_PUSHDATA1` (0x4c) beyond (the 80-byte relay ceiling + * keeps anything larger out). Pure, so the verifier can compare the SDK's + * output byte-for-byte instead of pattern-matching. + */ +internal fun expectedOpReturnScript(memo: ByteArray): ByteArray { + require(memo.isNotEmpty()) { "memo must not be empty" } + require(memo.size <= SdkL1SendService.MAX_MAYA_MEMO_BYTES) { "memo exceeds the OP_RETURN limit" } + return if (memo.size <= 75) { + byteArrayOf(0x6a, memo.size.toByte()) + memo + } else { + byteArrayOf(0x6a, 0x4c, memo.size.toByte()) + memo + } +} + +/** + * Pre-broadcast verification of the MAYACHAIN UTXO deposit shape + * (https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/sending-transactions, + * "UTXO Chains") against the SDK-decoded signed transaction: + * + * - `VOUT0` pays [vaultAddressBase58] exactly [vaultDuffs]; + * - `VOUT1` is a zero-value output whose script is byte-for-byte the + * OP_RETURN push of [memo] ([expectedOpReturnScript]); + * - at most one further output, and when present it is P2PKH change paying + * the FIRST input's own address (MAYAChain identifies the depositor by + * VIN0 and sends refunds there — change anywhere else strands a refund). + * + * Returns null when the shape holds, otherwise a human-readable reason. + * Nothing has been broadcast when this runs, so a non-null result is always + * recoverable: release the reservation and surface the error. Pure over the + * decoded transaction — host-testable without a wallet or native library. + */ +internal fun verifyMayaDepositShape( + tx: DecodedTransaction, + vaultAddressBase58: String, + vaultDuffs: Long, + memo: ByteArray +): String? { + if (tx.inputs.isEmpty()) { + return "transaction has no inputs" + } + if (tx.outputs.size !in 2..3) { + return "expected 2 or 3 outputs (vault, memo[, change]), found ${tx.outputs.size}" + } + + val vaultOutput = tx.outputs[0] + if (vaultOutput.address == null) { + return "VOUT0 is not a plain address output" + } + if (vaultOutput.address != vaultAddressBase58) { + return "VOUT0 pays ${vaultOutput.address}, expected the Asgard vault $vaultAddressBase58" + } + if (vaultOutput.valueDuffs != vaultDuffs) { + return "VOUT0 carries ${vaultOutput.valueDuffs} duffs, expected $vaultDuffs" + } + + val memoOutput = tx.outputs[1] + if (memoOutput.valueDuffs != 0L) { + return "VOUT1 OP_RETURN must be zero-value, carries ${memoOutput.valueDuffs} duffs" + } + if (!memoOutput.scriptPubkey.contentEquals(expectedOpReturnScript(memo))) { + return "VOUT1 is not the OP_RETURN of the swap memo" + } + + if (tx.outputs.size == 3) { + val change = tx.outputs[2] + // P2PKH shape: OP_DUP OP_HASH160 <20-byte hash> OP_EQUALVERIFY OP_CHECKSIG. + val script = change.scriptPubkey + val isP2pkh = script.size == 25 && + script[0] == 0x76.toByte() && script[1] == 0xa9.toByte() && script[2] == 0x14.toByte() && + script[23] == 0x88.toByte() && script[24] == 0xac.toByte() + if (!isP2pkh || change.address == null) { + return "VOUT2 change is not P2PKH" + } + // change-to-VIN0: the decoder recovers VIN0's address from a + // P2PKH-shaped scriptSig (` `). Only checkable when + // that recovery succeeded; otherwise the engine's own + // change_to_first_input contract is the guarantee. + val vin0Address = tx.inputs[0].address + if (vin0Address != null && change.address != vin0Address) { + return "VOUT2 change does not pay VIN0's address" + } + } + return null +} + + +/** + * Wallet-module implementation of the Maya integration's [MayaBlockchainApi]: + * builds the swap deposit on the Kotlin SDK's deferred build/broadcast + * surface ([SdkL1SendService.buildDeferredMayaDeposit] — vault VOUT0, + * OP_RETURN memo VOUT1, change back to VIN0's address VOUT2, no BIP-69 + * reordering), verifies the shape from the signed bytes BEFORE anything + * reaches the network ([verifyMayaDepositShape], over the SDK's own + * [TransactionDecoder]), then broadcasts. Lives here so integrations/maya + * stays free of wallet-engine types. + * + * DASHJ-FREE: building, signing, decoding, verifying and broadcasting all + * run on the SDK. The only dashj left on this flow is inside the + * transition-only [ReservationLockMirror] (which dies with Phase 2) and + * the shared display bridge. + * + * Failure semantics (funds-critical): + * - build/verify failure → reservation released, recoverable error, no + * funds moved; + * - broadcast refused provably pre-network → released, recoverable error; + * - broadcast outcome AMBIGUOUS → the reservation is KEPT (releasing would + * let a rebuilt retry select different inputs and pay the vault twice if + * the first deposit did reach the network — the BIP70 field-test lesson) + * and the error tells the user not to retry. + * + * MAX sells never under-deliver: the quote is [maxSwapDepositAmount], which is + * `spendable − fee reserve`, and the deposit then pays exactly that as an + * ordinary fixed-amount send — quote and payment are equal by construction, so + * there is no gap for NEAR Intents to refuse. The reserve's unused remainder + * returns as change, which also keeps a wallet-owned output in the transaction + * so it confirms and settles normally (a changeless drain did not — see + * `mayaMaxFeeReserveDuffs`). A balance drop between quote and build is caught + * by the pre-build re-measurement. */ class MayaBlockchainApiImpl @Inject constructor( - private val sendPaymentService: WalletSendPaymentService, + private val sdkL1SendService: SdkL1SendService, private val mayaWebApi: MayaWebApi, - private val walletProviderData: WalletData + private val reservationLockMirror: ReservationLockMirror, + private val bridgedTransactionFactory: SdkBridgedTransactionFactory ) : MayaBlockchainApi { companion object { private val log: Logger = LoggerFactory.getLogger(MayaBlockchainApiImpl::class.java) - // Maximum bytes the DASH OP_RETURN can hold (enforced by - // ScriptBuilder.createOpReturnScript). A Maya swap memo longer than this would - // otherwise crash with an IllegalArgumentException inside the builder. - private const val MAX_OP_RETURN_BYTES = 80 + + /** Duffs per DASH as a decimal shift (1 DASH = 1e8 duffs). */ + private const val DUFFS_DECIMAL_SHIFT = 8 } + override suspend fun maxSwapDepositAmount(): Dash = + Dash(sdkL1SendService.maxMayaDepositDuffs()) + override suspend fun commitSwapTransaction( tradeId: String, swapTradeUIModel: SwapTradeUIModel ): ResponseResource { log.info("commitSwapTransaction($tradeId, $swapTradeUIModel") + // A MAX sell arrives quoted at the FULL spendable balance (the UI fills + // the amount from the balance so `maximum` can be detected by equality). + // The mining fee has to come from somewhere, so re-quote at the measured + // maximum deposit before asking Maya for a price — quoting the full + // balance would price a deposit that cannot be built, and paying the + // vault less than the quote is exactly the under-delivery we refuse. + val quoteAmount = if (swapTradeUIModel.maximum) { + // Contained: the measurement refuses (throws) when the wallet holds + // app-locked outputs a max deposit would sweep, and can fail on + // gate/bind errors — surface those as a recoverable failure rather + // than letting them escape into the caller's scope. + val maxDeposit = try { + maxSwapDepositAmount() + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + log.error("maya max sell: deposit fee measurement failed", t) + return ResponseResource.Failure( + (t as? Exception) ?: MayaException(t.message ?: "could not size the swap deposit"), + false, + 0, + t.message + ) + } + if (maxDeposit.duffs <= 0L) { + return ResponseResource.Failure( + MayaException("balance too low to cover a swap deposit and its fee"), + false, + 0, + null + ) + } + log.info( + "maya max sell: re-quoting at the measured max deposit {} (was {})", + maxDeposit.toFriendlyString(), + swapTradeUIModel.amount.dash + ) + swapTradeUIModel.amount.copy().apply { dash = maxDeposit.toBigDecimal() } + } else { + swapTradeUIModel.amount + } val resultSwapTrade = mayaWebApi.getSwapInfo( SwapQuoteRequest( - amount = swapTradeUIModel.amount, + amount = quoteAmount, source_maya_asset = "DASH.DASH", target_maya_asset = swapTradeUIModel.outputAsset, fiatCurrency = swapTradeUIModel.amount.fiatCode, @@ -87,171 +245,229 @@ class MayaBlockchainApiImpl @Inject constructor( override suspend fun buildAndSendSwapTx( swapTradeUIModel: SwapTradeUIModel ): ResponseResource { - val params = walletProviderData.networkParameters try { - val sendRequest: SendRequest + // memo documentation: + // https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/transaction-memos#swap + // SWAP:ASSET:DESTADDR[:AFFILIATE:FEE] val memo = swapTradeUIModel.memo ?: "=:${swapTradeUIModel.outputAsset}:${swapTradeUIModel.destinationAddress}" - - // Guard the OP_RETURN size before building the script. ScriptBuilder - // .createOpReturnScript throws an IllegalArgumentException (with a null - // message) for payloads over MAX_OP_RETURN_BYTES; fail cleanly instead so the - // UI can surface a real error. Long token identifiers (e.g. an asset contract - // address plus the destination address) are what push a memo past the limit. val memoBytes = memo.toByteArray() - if (memoBytes.size > MAX_OP_RETURN_BYTES) { - log.error("maya swap memo too long: {} bytes (max {}): {}", memoBytes.size, MAX_OP_RETURN_BYTES, memo) + // Guard the OP_RETURN size up front (the SDK build re-checks + // pre-reservation): long token identifiers (an asset contract + // address plus the destination address) are what push a memo + // past the limit — fail with a real error the UI can surface. + if (memoBytes.size > SdkL1SendService.MAX_MAYA_MEMO_BYTES) { + log.error( + "maya swap memo too long: {} bytes (max {}): {}", + memoBytes.size, SdkL1SendService.MAX_MAYA_MEMO_BYTES, memo + ) return ResponseResource.Failure( - MayaException("swap memo too long for OP_RETURN: ${memoBytes.size} > $MAX_OP_RETURN_BYTES bytes"), + MayaException( + "swap memo too long for OP_RETURN: ${memoBytes.size} > " + + "${SdkL1SendService.MAX_MAYA_MEMO_BYTES} bytes" + ), false, 0, null ) } - val tx = Transaction(params) + log.info("memo: {}", memo) - // set outputs according to: - // https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/sending-transactions#utxo-chains - // Send the transaction with Asgard vault as VOUT0 - if (!swapTradeUIModel.maximum) { - val dashAmountWithFees = if (!swapTradeUIModel.maximum) { - (swapTradeUIModel.amount.dash + swapTradeUIModel.feeAmount.dash) - } else { - swapTradeUIModel.amount.dash - }.setScale(8, RoundingMode.HALF_UP).toCoin().toDashjCoin() - tx.addOutput( - dashAmountWithFees, - Address.fromBase58(params, swapTradeUIModel.vaultAddress) - ) - // Include the memo as an OP_RETURN in VOUT1 - // memo documentation: https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/transaction-memos#swap - // SWAP:ASSET:DESTADDR[:AFFILIATE:FEE] - log.info("memo: {}", memo) - tx.addOutput( - TransactionOutput( - params, - tx, - Coin.ZERO, - ScriptBuilder.createOpReturnScript(memo.toByteArray()).program - ) - ) - sendRequest = SendRequest.forTx(tx) + // Vault amount per https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/sending-transactions: + // the swap fee rides on top of the sell amount for a normal + // sell; a MAX sell was quoted against the whole spendable + // balance, so the fee comes out of the quoted amount itself. + // BigDecimal DASH → duffs by decimal shift; longValueExact is + // safe because the scale is pinned to 8 first. + val quotedDuffs = if (!swapTradeUIModel.maximum) { + swapTradeUIModel.amount.dash + swapTradeUIModel.feeAmount.dash } else { - sendRequest = SendRequest.emptyWallet(Address.fromBase58(params, swapTradeUIModel.vaultAddress)) - } + swapTradeUIModel.amount.dash + }.setScale(DUFFS_DECIMAL_SHIFT, RoundingMode.HALF_UP) + .movePointRight(DUFFS_DECIMAL_SHIFT) + .longValueExact() - // Override randomised VOUT ordering; MAYAChain requires specific output ordering. - sendRequest.sortByBIP69 = false // we don't want the output order changed - sendRequest.shuffleOutputs = false // we don't want the output order changed + val vaultDuffs = quotedDuffs + + // A MAX sell was quoted at the drain-measured maximum deposit. + // Re-measure with the REAL memo before + // building: if the spendable balance dropped since the quote, the + // deposit can no longer pay the quoted amount, and paying the vault + // LESS than quoted is never acceptable — NEAR Intents refuses + // under-delivery (~1h wait, then a refund minus 0.001 DASH) and Maya + // would execute a swap for an amount the user never agreed to. Abort + // with a recoverable error and let the user re-quote instead. + // + // This can only fire on a real balance drop: the quote reserved for a + // worst-case 80-byte memo, so re-measuring with the actual (shorter + // or equal) memo can only raise the ceiling, never lower it. + if (swapTradeUIModel.maximum) { + val maxDeposit = sdkL1SendService.maxMayaDepositDuffs(memoBytes.size) + if (vaultDuffs > maxDeposit) { + log.warn( + "maya max sell aborted: quoted {} duffs exceeds the {} duffs now depositable", + vaultDuffs, maxDeposit + ) + return ResponseResource.Failure( + MayaException( + "wallet balance changed; the deposit would fall below the quoted " + + "amount — please request a new quote" + ), + false, + 0, + null + ) + } + } - // this will complete the transaction by adding inputs and an output for change - sendPaymentService.completeTransaction(sendRequest) + // Build + sign with the funding inputs RESERVED, no broadcast. + // A MAX sell is an ORDINARY fixed-amount send of + // `spendable − reserve` (maxMayaDepositDuffs), not a drain — so it + // names its amount like any other deposit and leaves change. The + // flag only marks sweep scale, for the app-locked-output guard. + val payment = sdkL1SendService.buildDeferredMayaDeposit( + swapTradeUIModel.vaultAddress, + vaultDuffs, + memoBytes, + isMaxDeposit = swapTradeUIModel.maximum + ) - // verify that there are only 3 outputs in the transaction - if (!swapTradeUIModel.maximum && sendRequest.tx.outputs.size != 3) { + // Assert the deposit shape from the signed bytes BEFORE any + // broadcast decision — a mis-shaped deposit to a Maya vault + // strands funds. Decoded with the SDK's own consensus decoder; + // a decode failure counts as a failed shape check (released, + // recoverable), never as a broadcastable pass. + // The app chose the amount for every deposit now, max included, so + // the quote IS the expectation and the shape check stays an exact + // comparison against it. + val depositDuffs = vaultDuffs + val shapeError = try { + verifyMayaDepositShape( + TransactionDecoder.decode(payment.rawTxBytes, toSdkNetwork(Constants.NETWORK_PARAMETERS)), + swapTradeUIModel.vaultAddress, + depositDuffs, + memoBytes + ) + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + "signed bytes failed to decode: ${t.message}" + } + if (shapeError != null) { + log.error("maya swap deposit {} failed shape verification: {}", payment.txidHex, shapeError) + sdkL1SendService.releaseDeferredPayment(payment) return ResponseResource.Failure( - IncorrectSwapOutputCount(sendRequest.tx.outputs.size), + MayaException("swap deposit failed pre-broadcast verification: $shapeError"), false, 0, null ) } - if (swapTradeUIModel.maximum) { - // Include the memo as an OP_RETURN in VOUT1 - // memo documentation: https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/transaction-memos#swap - // SWAP:ASSET:DESTADDR[:AFFILIATE:FEE] - sendRequest.tx.addOutput( - TransactionOutput( - params, - tx, - Coin.ZERO, - ScriptBuilder.createOpReturnScript(memo.toByteArray()).program - ) - ) - // account for the size and possibly larger signatures when re-signed - val size = sendRequest.tx.bitcoinSerialize().size + sendRequest.tx.inputs.size - sendRequest.tx.outputs[0].value = swapTradeUIModel.amount.dash.toCoin().toDashjCoin() - - Coin.valueOf(size * Transaction.REFERENCE_DEFAULT_MIN_TX_FEE.value / 1000) - } else { - // Pass all change back to the VIN0 address in VOUT2 - val connectedOutput = sendRequest.tx.getInput(0).connectedOutput - ?: return ResponseResource.Failure( - MayaException("transaction input not connected"), + // Reservation mirror — TRANSITION-ONLY, same rationale and + // lifetime as the BIP70 mirror: dashj-side spenders (manual + // sends, the background CoinJoin mixer) have their own coin + // selection and no view of the SDK reservation. Best-effort; a + // lock failure must not fail the swap. + runCatching { reservationLockMirror.setLocks(payment, locked = true) } + .onFailure { log.warn("failed to mirror the maya reservation into wallet locks", it) } + + log.info("maya swap deposit {}: broadcasting ({} duffs to the vault)", payment.txidHex, depositDuffs) + return when (val result = sdkL1SendService.broadcastDeferredPayment(payment)) { + is SdkWriteResult.Broadcast -> { + // The mirrored reservation locks are deliberately NOT + // cleared here. It looks like a leak — the deposit + // succeeded, so why keep holding its inputs? — but + // post-cutover the held dashj wallet never learns that + // these outpoints were spent. If the display bridge below + // returns NotBridged, that stale lock is the only thing + // stopping the mixer from selecting a coin that is already + // gone. Clearing them would trade a harmless stale lock for + // a double-selected input, so leave them. + // + // Synchronous display bridge (same mechanism as every SDK + // send) so the confirmation screen's InstantSend watch and + // the tx list see the deposit immediately. Non-fatal: the + // funds ARE sent; without the bridge the tx appears on the + // next display-sync tick. + when (val bridged = bridgedTransactionFactory.bridge(result.value)) { + is BridgedTxResult.Bridged -> Unit + is BridgedTxResult.NotBridged -> log.warn( + "maya swap deposit {} broadcast but the display bridge failed ({})", + result.value, bridged.reason + ) + } + swapTradeUIModel.txid = result.value + ResponseResource.Success(swapTradeUIModel) + } + is SdkWriteResult.NotBroadcast -> { + // Provably never reached the network: free the inputs so a + // retry can rebuild cleanly. + runCatching { reservationLockMirror.setLocks(payment, locked = false) } + .onFailure { log.warn("failed to clear the mirrored maya reservation locks", it) } + sdkL1SendService.releaseDeferredPayment(payment) + ResponseResource.Failure( + MayaException("swap deposit was not broadcast (${result.reason}); no funds moved"), false, 0, null ) - val scriptPubKey = connectedOutput.scriptPubKey - - // to replace output[2], we must clear all outputs and them back - // this is because Transaction.getOutputs returns an immutable list - val outputs = sendRequest.tx.outputs.map { it } - sendRequest.tx.clearOutputs() - for (i in outputs.indices) { - if (i != 2) { - sendRequest.tx.addOutput(outputs[i]) - } else { - sendRequest.tx.addOutput(outputs[i].value, scriptPubKey) - } } - } - - // remove all signatures since we changed the last output. - for (input in sendRequest.tx.inputs) { - input.clearScriptBytes() - } - - log.info("maya swap transaction: {}", sendRequest.tx) - - sendPaymentService.signTransaction(sendRequest) - log.info("maya swap transaction resigned: {}", sendRequest.tx) - - // check that vout3 is using vin0 - if (!swapTradeUIModel.maximum && ScriptPattern.isP2PKH(sendRequest.tx.outputs[2].scriptPubKey)) { - val input0 = sendRequest.tx.inputs[0] - if (sendRequest.tx.outputs[2].scriptPubKey != input0.connectedOutput?.scriptPubKey) { - return ResponseResource.Failure(MayaException("vout3 script != vin0"), false, 0, null) + is SdkWriteResult.Ambiguous -> { + // The deposit MAY be on the network. Keep the reservation + // AND the mirrored locks: releasing would let a rebuilt + // retry select different inputs and pay the vault twice. + log.error( + "maya swap deposit {} outcome unconfirmed — inputs stay reserved; NOT retryable", + payment.txidHex, result.cause + ) + ResponseResource.Failure( + MayaException( + "swap deposit outcome is unconfirmed — it may already be on the " + + "network; check the transaction list before retrying" + ), + false, + 0, + null + ) } } - // check the fee - val fee = sendRequest.tx.fee / sendRequest.tx.bitcoinSerialize().size * 1000 - if (fee < Transaction.DEFAULT_TX_FEE) { - return ResponseResource.Failure(MayaException("swap transaction fee too small"), false, 0, null) - } - - // Replace sendRequest.tx with a fresh Transaction before committing. - // wallet.completeTx() caches a TransactionConfidence (keyed to the txid at - // that moment) in Transaction.confidence. After we modify outputs and re-sign, - // the txid changes but the cached field is not updated — it still points to the - // stale confidence. Creating a new Transaction and moving the same input/output - // objects into it leaves confidence == null, so wallet.commitTx() will create - // the correct confidence for the final txid, keeping the TxConfidenceTable and - // any confidence listeners in sync. All transient state (connectedOutput, - // input.value, signatures) is preserved because we reuse the same objects. - val freshTx = Transaction(params) - sendRequest.tx.outputs.forEach { freshTx.addOutput(it) } - sendRequest.tx.inputs.forEach { freshTx.addInput(it) } - sendRequest.tx = freshTx - - // send the transaction - log.info("maya swap transaction: {}", sendRequest.tx.toStringHex()) - val sentTransaction = sendPaymentService.sendTransaction(sendRequest) - swapTradeUIModel.txid = sentTransaction.txId.toString() - return ResponseResource.Success(swapTradeUIModel) - } catch (e: InsufficientMoneyException) { - // rethrown as the neutral exception so the maya module can detect it without dashj - val neutral = InsufficientFundsException(e.message, e) - return ResponseResource.Failure(neutral, false, 0, e.message) } catch (e: CancellationException) { - // Never convert cancellation into Failure: if the coroutine is cancelled after - // sendTransaction() has broadcast the swap tx, a Failure would tell the caller the - // swap failed and invite a retry — a double swap. Propagate so the caller's scope - // handles it as a cancellation, not a result. + // Never convert cancellation into Failure: if the coroutine is + // cancelled after the broadcast, a Failure would tell the caller + // the swap failed and invite a retry — a double swap. Propagate + // so the caller's scope handles it as a cancellation. throw e - } catch (e: Exception) { - log.error("failed to build/send maya swap transaction", e) - return ResponseResource.Failure(e, false, 0, e.message) + } catch (t: Throwable) { + if (isInsufficientFunds(t)) { + // Neutral exception so the maya module can detect it without + // wallet-engine types. + return ResponseResource.Failure(InsufficientFundsException(t.message, t), false, 0, t.message) + } + log.error("failed to build/send maya swap deposit", t) + return ResponseResource.Failure( + (t as? Exception) ?: MayaException(t.message ?: "maya swap deposit failed"), + false, + 0, + t.message + ) } } + + /** + * The engine's pre-broadcast funding shortfall. Only ever consulted for + * throws from the BUILD step, which never broadcasts. + * + * Matched on the TYPE — [DashSdkError.PlatformWallet.CoreInsufficientFunds], + * FFI code 22 — not on message text. The string form was written when the + * shortfall reached us only as key-wallet's `Insufficient funds` Display + * wrapped in a build failure; the SDK types it now, and a matcher keyed on + * wording silently stops recognising a shortfall the moment that wording + * changes, turning "not enough funds" into an opaque swap failure. The + * cause chain is still walked: the typed error can arrive wrapped. + */ + private fun isInsufficientFunds(t: Throwable): Boolean = + generateSequence(t) { it.cause?.takeIf { cause -> cause !== it } } + .take(5) + .any { it is DashSdkError.PlatformWallet.CoreInsufficientFunds } } diff --git a/wallet/src/de/schildbach/wallet/payments/SendCoinsTaskRunner.kt b/wallet/src/de/schildbach/wallet/payments/SendCoinsTaskRunner.kt index 87f1dea276..5f70f55cd7 100644 --- a/wallet/src/de/schildbach/wallet/payments/SendCoinsTaskRunner.kt +++ b/wallet/src/de/schildbach/wallet/payments/SendCoinsTaskRunner.kt @@ -696,31 +696,6 @@ class SendCoinsTaskRunner @Inject constructor( } } - override suspend fun completeTransaction(sendRequest: SendRequest) { - val wallet = walletData.wallet ?: throw RuntimeException(WALLET_EXCEPTION_MESSAGE) - val securityGuard = SecurityGuard.getInstance() - val password = securityGuard.retrievePassword() - val encryptionKey = securityFunctions.deriveKey(wallet, password) - sendRequest.aesKey = encryptionKey - sendRequest.coinSelector = ZeroConfCoinSelector.get() // default coin selector - wallet.completeTx(sendRequest) - sendRequest.aesKey = null - } - - override suspend fun signTransaction(sendRequest: SendRequest) { - val wallet = walletData.wallet ?: throw RuntimeException(WALLET_EXCEPTION_MESSAGE) - val securityGuard = SecurityGuard.getInstance() - val password = securityGuard.retrievePassword() - val encryptionKey = securityFunctions.deriveKey(wallet, password) - sendRequest.aesKey = encryptionKey - wallet.signTransaction(sendRequest) - sendRequest.aesKey = null - } - - override suspend fun sendTransaction(sendRequest: SendRequest): Transaction { - return sendCoins(sendRequest, txCompleted = true, checkBalanceConditions = false) - } - /** * Fetches a BIP70/BIP270 payment request from the given URL. * @param basePaymentIntent The base payment intent containing the payment request URL diff --git a/wallet/src/de/schildbach/wallet/payments/WalletSendPaymentService.kt b/wallet/src/de/schildbach/wallet/payments/WalletSendPaymentService.kt index 15853673bf..b6799de463 100644 --- a/wallet/src/de/schildbach/wallet/payments/WalletSendPaymentService.kt +++ b/wallet/src/de/schildbach/wallet/payments/WalletSendPaymentService.kt @@ -59,9 +59,4 @@ interface WalletSendPaymentService : SendPaymentService { /** The dashj-typed twin of the neutral `payWithDashUrl` (returns the live transaction). */ suspend fun payWithDashUrlTx(dashUri: String, serviceName: String?): Transaction - - /** support manual tx creation */ - suspend fun completeTransaction(sendRequest: SendRequest) - suspend fun signTransaction(sendRequest: SendRequest) - suspend fun sendTransaction(sendRequest: SendRequest): Transaction } diff --git a/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt b/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt index 3cd158b195..8235ed559b 100644 --- a/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt +++ b/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt @@ -78,6 +78,7 @@ import org.bitcoinj.wallet.WalletEx import de.schildbach.wallet.data.WalletData import de.schildbach.wallet_test.R import org.dash.wallet.common.data.PresentableTxMetadata +import org.dash.wallet.common.data.entity.SwapOrder import org.dash.wallet.common.data.ServiceName import org.dash.wallet.common.ui.components.merchantNameBitmap import org.dash.wallet.common.services.BlockchainStateProvider @@ -297,6 +298,12 @@ class TxDisplayCacheService @Inject constructor( val oldMetadata = this.metadata this.metadata = newMetadata + // Swap rows first, and UNCONDITIONALLY: their decoration comes from the + // swap-orders table by txid and needs no dashj wrapper, so it must not be + // gated on either the changedIds diff below or on the wrapper being + // resolvable (see reconcileSwapRows). + reconcileSwapRows(newMetadata) + val changedIds = buildSet { newMetadata.forEach { (id, meta) -> if (meta != oldMetadata[id]) add(id.toString()) } oldMetadata.forEach { (id, _) -> if (id !in newMetadata) add(id.toString()) } @@ -396,12 +403,21 @@ class TxDisplayCacheService @Inject constructor( displayCacheRefreshBus.isSdkAuthoritative(entry.rowId) || (entry.valueSatoshis == 0L && existing.valueSatoshis != 0L) if (existingIsSdkStamped) { + // The swap decoration (convert icon, "Conversion"/ + // "Converted" title, swapStatus) is METADATA-authoritative + // — this very path runs BECAUSE the swap-orders table + // changed — so it must pass the freeze or the home row + // stays "Sent"/"Conversion" until a manual cache wipe + // (2026-08-05 Maya field test). Value, exchange rate and + // the filter bucket stay frozen: the dashj rebuild still + // cannot be trusted for those, swap or not. + val swapDecorated = entry.swapStatus != null result = result.copy( valueSatoshis = existing.valueSatoshis, - iconType = existing.iconType, - iconBgType = existing.iconBgType, - title = existing.title, - statusText = existing.statusText, + iconType = if (swapDecorated) entry.iconType else existing.iconType, + iconBgType = if (swapDecorated) entry.iconBgType else existing.iconBgType, + title = if (swapDecorated) entry.title else existing.title, + statusText = if (swapDecorated) entry.statusText else existing.statusText, filterFlags = existing.filterFlags ) } @@ -504,7 +520,14 @@ class TxDisplayCacheService @Inject constructor( // and no more disruptive to scroll than any normal data change. Pre-cutover nothing // writes the cache, so the bus never fires and this is inert. displayCacheRefreshBus.changes - .onEach { _currentPagingSource.value?.invalidate() } + .onEach { + // The SDK writer authors rows with no notion of swap orders, so a row it + // just inserted or re-stamped may have lost (or never had) its swap + // decoration. Re-derive it from the swap orders before refreshing the + // readers, so the list never settles on a plain "Sending" row for a swap. + reconcileSwapRows(metadata) + _currentPagingSource.value?.invalidate() + } .catch { e -> log.error("display cache refresh bus flow error", e) } .launchIn(serviceScope) } @@ -1286,6 +1309,45 @@ class TxDisplayCacheService @Inject constructor( return entries.map { mergePreservingSdkStamped(it, existingByRowId[it.rowId]) } } + /** + * Re-apply the swap decoration to every already-cached row whose txid has a + * `swap_orders` record, from [snapshot] (the presentable metadata, which carries the + * joined order). See [planSwapRowDecorations] for why this is derived from the order + * rather than from a re-render of the transaction, and for the on-device latch it fixes. + * + * Cheap and idempotent: one Room read over the swap txids only (a handful), and a write + * only for rows that actually differ — a settled swap row costs one query per trigger. + */ + private suspend fun reconcileSwapRows(snapshot: Map) { + val swapMetadata = snapshot.values.filter { it.swapOrder != null } + if (swapMetadata.isEmpty()) return + // Chunked like every other reader here: SQLite's IN-clause variable cap is 999 and + // a heavy DEX user's swap count is unbounded. + val existingByRowId = HashMap(swapMetadata.size) + for (chunk in swapMetadata.map { it.txId.toString() }.chunked(500)) { + txDisplayCacheDao.getEntriesByIds(chunk).forEach { existingByRowId[it.rowId] = it } + } + val decorated = planSwapRowDecorations(swapMetadata, existingByRowId) { order -> + walletApplication.getString( + TransactionRowView.swapTitleRes(order.status), + order.fromAsset, + order.toAsset + ) + } + if (decorated.isEmpty()) return + txDisplayCacheDao.insertAll(decorated) + // Same belt-and-suspenders as the rest of this service: Room's InvalidationTracker + // can miss an upsert on-device, and a swap row that is already correct in the table + // but stale on screen is the very symptom this reconciler exists to end. + _currentPagingSource.value?.invalidate() + log.info( + "swap row reconcile: re-decorated {} of {} swap row(s) from swap_orders ({})", + decorated.size, + existingByRowId.size, + decorated.joinToString { "${it.rowId.take(8)}→${it.title}" } + ) + } + private fun computeFilterFlags(wrapper: TransactionWrapper): Int { val bag = walletData.transactionBag var flags = 0 @@ -1319,6 +1381,65 @@ class TxDisplayCacheService @Inject constructor( } } +/** + * PURE planner for the SWAP DECORATION of already-cached display rows — the + * host-testable core of [TxDisplayCacheService.reconcileSwapRows]. + * + * A swap row's decoration (convert icon on the orange halo, "Conversion …"/"Converted …" + * title, [TxDisplayCacheEntry.swapStatus] for the row chip) is derived ENTIRELY from the + * `swap_orders` record keyed by txid — exactly like the transaction-details screen + * ([de.schildbach.wallet.ui.TransactionResultViewModel.swapOrder], which observes the + * order directly). It needs NO dashj transaction, so unlike + * [TransactionRowView.fromTransaction] this planner can decorate a row for a transaction + * the held dashj wallet cannot render (an SDK-authored send whose inputs are unconnected, + * or one it does not hold at all). + * + * That independence is the fix for the verified on-device latch (2026-08-07 Maya field + * test): a Maya/SwapKit MAX sell's row was authored by the SDK writer + * ([de.schildbach.wallet.service.platform.sdk.CutoverUiDataService]) which knows nothing + * about swap orders, so it stayed titled "Sending" — permanently, because the SDK record's + * `context` never advanced past mempool AND because the only writer that DID know about + * the swap (the metadata flow) fires on a metadata DIFF and had already consumed the one + * that mattered. Re-deriving the decoration from `swap_orders` on every metadata emission + * and every display-cache write signal converges regardless of which writer authored the row. + * + * Idempotent by construction: a row that already matches is not returned, so a settled + * swap row produces no write on any later pass. Only the decoration fields are touched — + * value, exchange rate, contact identity, memo, time and the filter bucket are preserved, + * since this planner has no authority over them. + * + * @param swapMetadata presentable metadata whose [PresentableTxMetadata.swapOrder] is set. + * @param resolveTitle resolves an order to its row title; supply + * [TransactionRowView.swapTitleRes] formatted with the order's assets so this + * planner and the renderer can never disagree. + */ +internal fun planSwapRowDecorations( + swapMetadata: Collection, + existingByRowId: Map, + resolveTitle: (SwapOrder) -> String +): List = swapMetadata.mapNotNull { meta -> + val order = meta.swapOrder ?: return@mapNotNull null + // Only rows the cache already displays are decorated. A swap whose row does not exist + // yet is left to whichever writer authors it first; that write signals the refresh bus, + // which brings us straight back here with the row present. + val existing = existingByRowId[meta.txId.toString()] ?: return@mapNotNull null + val decorated = existing.copy( + title = resolveTitle(order), + iconType = TxDisplayCacheEntry.ICON_CONVERT, + iconBgType = TxDisplayCacheEntry.BG_ORANGE, + // A swap row's live state is the chip fed by swapStatus ("Processing"/"Refunded"/ + // "Failed" — see TransactionAdapter.setSwapStatus), never a secondary status line; + // this also clears the stale "Processing"/"Confirming" a plain-send writer stamped. + statusText = "", + // Keep an already-classified service when this metadata row carries none, so the + // decoration cannot un-classify a row (the service column is what keeps the SDK + // planner's plain-send re-stamp off this row). + service = meta.service ?: existing.service, + swapStatus = order.status.name + ) + decorated.takeIf { it != existing } +} + /** * PURE merge of a dashj-rebuilt display [entry] over the [existing] cached row — * the host-testable core of [TxDisplayCacheService.mergePreservingSdkStamped] @@ -1399,12 +1520,24 @@ internal fun mergeDisplayEntryPreservingSdkStamped( existing.title == sendingTitle && entry.title == sentTitle val statusCleared = allowStatusProgress && entry.statusText.isEmpty() && existing.statusText.isNotEmpty() + // The swap decoration (convert icon, "Conversion"/"Converted" title, + // swapStatus) is METADATA-authoritative — it comes from the swap-orders + // table the tracking service maintains, not from a dashj recomputation — + // so it must pass the freeze. The tracker flips PENDING→COMPLETED long + // after the row was SDK-stamped; freezing the title pinned the row at + // "Sent"/"Conversion" until a manual cache wipe (home-screen staleness + // found in the 2026-08-05 Maya field test). Value, exchange rate, + // contact identity and the filter bucket stay frozen: the dashj rebuild + // still cannot be trusted for those, swap or not. A rebuild WITHOUT + // swap metadata (entry.swapStatus == null) never undresses an existing + // swap row — the freeze keeps the cached shape as before. + val swapDecorated = entry.swapStatus != null result = result.copy( valueSatoshis = existing.valueSatoshis, - iconType = existing.iconType, - iconBgType = existing.iconBgType, - title = if (sendingToSent) entry.title else existing.title, - statusText = if (statusCleared) entry.statusText else existing.statusText, + iconType = if (swapDecorated) entry.iconType else existing.iconType, + iconBgType = if (swapDecorated) entry.iconBgType else existing.iconBgType, + title = if (swapDecorated || sendingToSent) entry.title else existing.title, + statusText = if (swapDecorated || statusCleared) entry.statusText else existing.statusText, filterFlags = existing.filterFlags ) } diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt index d3c4310f3c..5e96ba94c6 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt @@ -569,7 +569,18 @@ internal fun planL1DisplaySync( // Surgical status refresh of a dashj-era row. Never touch rows // with richer semantics than a plain send/receive. - if (existing.hasErrors || existing.service != null || + // + // A DEX swap row is one of those: its title/icon come from the `swap_orders` + // record ([de.schildbach.wallet.service.planSwapRowDecorations]) and the SDK + // record cannot reproduce them. `swapStatus` is checked as well as `service` + // because the service column alone is not proof: a rebuild that raced an + // unpopulated metadata map leaves a swap row plainly rendered with service + // null, and the definitive re-stamp below would then re-title it "Sending" — + // permanently for a Maya drain, whose SDK `context` never leaves the mempool + // (verified on-device, 2026-08-07 Maya field test). The swap reconciler + // restores swapStatus on the next display-cache write signal, so this guard + // then holds the row stable instead of flip-flopping once per sync pass. + if (existing.hasErrors || existing.service != null || existing.swapStatus != null || (existing.filterFlags and TxDisplayCacheEntry.FLAG_GIFT_CARD) != 0 || (existing.filterFlags and TxDisplayCacheEntry.FLAG_COINJOIN) != 0 ) { @@ -839,7 +850,9 @@ internal fun planL1InstantLockRowUpdate( existing: TxDisplayCacheEntry, resolve: (Int) -> String ): TxDisplayCacheEntry? { - if (existing.hasErrors || existing.service != null || + // Same never-touch set as [planL1DisplaySync]'s update path, swap rows included + // (their title comes from `swap_orders`, not from a lock). + if (existing.hasErrors || existing.service != null || existing.swapStatus != null || (existing.filterFlags and TxDisplayCacheEntry.FLAG_GIFT_CARD) != 0 || (existing.filterFlags and TxDisplayCacheEntry.FLAG_COINJOIN) != 0 ) { diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/ReservationLockMirror.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/ReservationLockMirror.kt new file mode 100644 index 0000000000..b9c9eaf41c --- /dev/null +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/ReservationLockMirror.kt @@ -0,0 +1,61 @@ +/* + * 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 . + */ + +package de.schildbach.wallet.service.platform.sdk + +import de.schildbach.wallet.Constants +import de.schildbach.wallet.data.WalletData +import javax.inject.Inject +import javax.inject.Singleton +import org.bitcoinj.core.Context +import org.bitcoinj.core.Transaction + +/** + * TRANSITION-ONLY (delete with Phase 2, #1521): mirrors an SDK deferred + * payment's engine-side UTXO reservation into the foundation dashj wallet's + * app locks ([org.bitcoinj.wallet.Wallet.lockOutput]), so dashj-side + * spenders with their own coin selection and no view of the SDK reservation + * — manual sends, the background CoinJoin mixer (the original reason + * lockOutput exists) — cannot double-select the reserved outpoints while + * the deferred payment is in flight. + * + * This class exists so SDK-routed senders (Maya swaps; BIP70 keeps its + * private twin in `SendCoinsTaskRunner` for now) stay free of dashj types: + * ALL the dashj here is transition bookkeeping that dies wholesale when the + * dashj engine is retired. Best-effort by contract — callers must treat a + * throw as non-fatal (the engine reservation, not this mirror, is the real + * double-select backstop). + */ +@Singleton +class ReservationLockMirror @Inject constructor( + private val walletData: WalletData +) { + /** Lock (or unlock) every outpoint [payment]'s signed tx spends. */ + fun setLocks(payment: SdkDeferredPayment, locked: Boolean) { + val wallet = walletData.wallet ?: return + Context.propagate(wallet.context) + val tx = Transaction(Constants.NETWORK_PARAMETERS, payment.rawTxBytes) + for (input in tx.inputs) { + val outpoint = input.outpoint + if (locked) { + wallet.lockOutput(outpoint) + } else { + wallet.unlockOutput(outpoint) + } + } + } +} diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt index 7be41d329f..50d66e6269 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt @@ -274,6 +274,52 @@ internal fun sendAllFloorDuffs( reserveDuffs: Long = SEND_ALL_FEE_RESERVE_DUFFS ): Long = (spendableDuffs - reserveDuffs).coerceAtLeast(1L) +/** + * The fee reserve to withhold from a MAX Maya deposit, mirroring the + * shielded max-shield reserve + * ([de.schildbach.wallet.ui.shielded.assetLockMaxFeeReserve]) rather than + * inventing a second sizing rule. + * + * A max deposit selects (essentially) every spendable UTXO, so the fee is + * bounded by transaction size: ~148 vbytes per input, plus the deposit's own + * outputs — vault (~34) + the OP_RETURN carrying [memoSizeBytes] + change + * (~34) + overhead — doubled as a safety margin. Unlike the shielded + * formula's flat 300-byte allowance this can size the data carrier exactly, + * because a Maya quote always knows its memo length. + * + * Over-reserving is LOSSLESS: the deposit is a fixed-amount send, so the + * builder returns the unused remainder as change. Under-reserving is the + * failing direction — the build comes up short at fee and is refused — so + * [spendableUtxoCount] must be the POST-CUTOVER overlaid count + * (`CutoverUiDataService`), never the held dashj wallet's frozen one, and the + * result is clamped to a 1000-duff minimum so a degenerate count still + * reserves something meaningful. + * + * ## Why a reserve rather than a drain + * + * A drain (`SelectionStrategy.ALL`) produced a transaction with NO + * wallet-owned output — vault, data carrier, no change. Compact block filters + * match on wallet script pubkeys only, so such a transaction is never matched + * in a block, its context never reaches `CONTEXT_IN_BLOCK`, and the wallet + * counts the spent inputs as spendable forever (mainnet `a5c99aec…`, + * `1f608a9a…`). Leaving change restores a wallet-owned output, so the deposit + * confirms and settles like any other send. Revisit once the SDK computes MAX + * internally in the wallet engine — at which point the engine, not this + * arithmetic, should own the amount. + */ +/** + * The input count a MAX Maya reserve is sized for at minimum. Guards against a + * frozen/stale post-cutover UTXO count under-reserving: over-reserving leaves a + * little more behind as change, under-reserving refuses the deposit. + */ +internal const val MAYA_MAX_RESERVE_MIN_INPUTS = 64 + +internal fun mayaMaxFeeReserveDuffs(spendableUtxoCount: Int, memoSizeBytes: Int): Long { + val inputBytes = spendableUtxoCount.coerceAtLeast(0).toLong() * 148L + val outputBytes = 34L + memoSizeBytes.coerceAtLeast(0).toLong() + 11L + 34L + 10L + return ((inputBytes + outputBytes) * 2L).coerceAtLeast(1000L) +} + /** * True iff [t] is the engine's insufficient-at-fee build failure — the ONE * failure the send-all path may retry with a lower floor. By construction @@ -422,7 +468,7 @@ class SdkDeferredPayment internal constructor( val txidHex: String, val rawTxBytes: ByteArray, val feeDuffs: Long, - internal val native: Any? + internal val native: Any?, ) // ── Source seam ─────────────────────────────────────────────────────── @@ -532,6 +578,26 @@ interface SdkL1SendSource { ): SdkDeferredPayment = throw UnsupportedOperationException("deferred (BIP70) payment not supported by this source") + /** + * [buildDeferredPayment] in the MAYACHAIN deposit shape + * (`docs.mayaprotocol.com` → "Sending Transactions", UTXO chains): + * one recipient output to the Asgard vault at VOUT0, the swap [memo] + * as a zero-value OP_RETURN at VOUT1, change routed BACK TO THE FIRST + * INPUT'S ADDRESS at VOUT2 (MAYAChain identifies the depositor by + * VIN0 and pays refunds there), no BIP-69 reordering. Same + * reservation contract as [buildDeferredPayment]: exactly one of + * [broadcastDeferredPayment] / [releaseDeferredPayment] should + * follow. Default throws: only the production source (and fakes + * exercising Maya) need it. + */ + suspend fun buildDeferredMayaDeposit( + walletIdHex: String, + vaultAddressBase58: String, + vaultDuffs: Long, + memo: ByteArray + ): SdkDeferredPayment = + throw UnsupportedOperationException("Maya deposit build not supported by this source") + /** * Broadcast a payment built by [buildDeferredPayment], consuming its * reservation, and return the broadcast txid as lowercase hex. Throws @@ -746,6 +812,42 @@ internal class DashSdkL1SendSource( return SdkDeferredPayment(signed.txidHex, signed.rawTxBytes, signed.feeDuffs, signed) } + override suspend fun buildDeferredMayaDeposit( + walletIdHex: String, + vaultAddressBase58: String, + vaultDuffs: Long, + memo: ByteArray + ): SdkDeferredPayment { + val manager = manager() + val wallet = checkNotNull(manager.wallets.value[walletIdHex]) { "SDK wallet not loaded" } + // Same deferred-build primitive as buildDeferredPayment, plus the + // three MAYACHAIN builder options. The OP_RETURN is appended after + // the vault recipient SDK-side, so preserveOutputOrder yields the + // documented vault=VOUT0 / memo=VOUT1 shape; an over-long memo + // throws pre-reservation. + // Every deposit names its own amount, max included: a MAX deposit is + // `spendable − reserve` ([maxMayaDepositDuffs]), an ordinary + // fixed-amount send. No SelectionStrategy override, so the build keeps + // a change output — which is what lets compact block filters match it + // and the transaction settle (see [mayaMaxFeeReserveDuffs] for why a + // changeless drain could not). Reinstate the drain only when the SDK + // computes MAX internally in the wallet engine. + val signed = wallet.buildSignedPayment( + recipients = listOf(vaultAddressBase58 to vaultDuffs), + network = toSdkNetwork(Constants.NETWORK_PARAMETERS), + coreSignerHandle = manager.mnemonicResolverHandle, + opReturnData = memo, + preserveOutputOrder = true, + changeToFirstInput = true + ) + return SdkDeferredPayment( + signed.txidHex, + signed.rawTxBytes, + signed.feeDuffs, + signed + ) + } + override suspend fun broadcastDeferredPayment( walletIdHex: String, payment: SdkDeferredPayment @@ -972,6 +1074,20 @@ class SdkL1SendService internal constructor( * held dashj wallet never learns of. */ private val hasAppLockedSpendableOutputs: () -> Boolean = { true }, + /** + * Spendable UTXO count, for sizing the MAX Maya deposit's fee reserve + * ([mayaMaxFeeReserveDuffs]). + * + * SDK-only by construction: [CutoverUiSource.currentSpendableUtxoCount], + * a COUNT over exactly the `txos` rows whose amounts the balance sums. + * NOT dashj's `calculateAllSpendCandidates` — this branch deletes that leg. + * + * Falls back to [MAYA_MAX_RESERVE_MIN_INPUTS] when the count is + * unavailable: over-reserving is lossless (the remainder returns as + * change) whereas under-reserving refuses the deposit, so the fallback + * errs high. + */ + private val spendableUtxoCount: suspend (String) -> Int = { MAYA_MAX_RESERVE_MIN_INPUTS }, /** * CoinJoin-drain guard ([drainCoinJoinAccountTo]), the narrow sibling of * [hasAppLockedSpendableOutputs]: does the held dashj wallet track any @@ -1040,6 +1156,15 @@ class SdkL1SendService internal constructor( wallet.isLockedOutput(it.outPointFor) } }, + spendableUtxoCount = { walletIdHex -> + // Same SDK source the cutover UI reads, constructed from the + // DashSdkService this service already injects — so no new DI edge + // and no cycle (CutoverUiDataService does not depend on this + // service). Null means "unavailable", not "zero", so fall back + // high rather than under-reserving. + DashSdkCutoverUiSource(sdkService).currentSpendableUtxoCount(walletIdHex) + ?: MAYA_MAX_RESERVE_MIN_INPUTS + }, hasAppLockedCoinJoinOutputs = { // Narrow the same dashj-authoritative lock check to the CoinJoin // keychain: the drain selects ONLY that account's UTXOs, so a @@ -1166,26 +1291,7 @@ class SdkL1SendService internal constructor( // blocks. Real fix: an upstream SDK UTXO lock/exclusion API // (iOS's add_inputs_from_outpoints binding is the porting // candidate). - val hasLockedOutputs = try { - hasAppLockedSpendableOutputs() - } catch (t: Throwable) { - if (t is CancellationException) throw t - log.warn("SDK {}: app-locked-output preflight failed; blocking the drain (fail closed)", operation, t) - true - } - // B7 union: seam-registered locks (SDK-only txs — CrowdNode - // API-response outputs locked via WalletDataAdapter → - // [SeamOutputLockRegistry]) are invisible to the dashj wallet - // check above; OR them in so the drain cannot spend them - // either. Fail closed: a registry read failure also blocks. - val hasSeamLockedOutputs = try { - seamOutputLockRegistry.hasAnyLocks() - } catch (t: Throwable) { - if (t is CancellationException) throw t - log.warn("SDK {}: seam output-lock registry read failed; blocking the drain (fail closed)", operation, t) - true - } - if (hasLockedOutputs || hasSeamLockedOutputs) { + if (hasProtectedOutputs(operation)) { log.warn( "SDK {}: wallet has app-locked outputs (CrowdNode); send-all via the SDK would " + "spend them — blocked until the SDK exposes UTXO exclusion", @@ -1420,6 +1526,177 @@ class SdkL1SendService internal constructor( return payment } + /** + * FAIL-CLOSED protected-output preflight, shared by every path that + * sweeps (or all but sweeps) the wallet: true when the wallet holds any + * app-locked output — CrowdNode account locks in the held dashj wallet, + * or seam-registered locks on SDK-only txs that the dashj check cannot + * see. The FFI has no UTXO-exclusion API, so a sweep-scale build would + * spend protected funds; a check failure blocks too. + */ + private fun hasProtectedOutputs(operation: String): Boolean { + val hasLockedOutputs = try { + hasAppLockedSpendableOutputs() + } catch (t: Throwable) { + if (t is CancellationException) throw t + log.warn("SDK {}: app-locked-output preflight failed; blocking (fail closed)", operation, t) + true + } + val hasSeamLockedOutputs = try { + seamOutputLockRegistry.hasAnyLocks() + } catch (t: Throwable) { + if (t is CancellationException) throw t + log.warn("SDK {}: seam output-lock registry read failed; blocking (fail closed)", operation, t) + true + } + return hasLockedOutputs || hasSeamLockedOutputs + } + + /** + * The largest amount a MAYACHAIN deposit can pay a vault right now: + * spendable balance MINUS a fee reserve ([mayaMaxFeeReserveDuffs]). + * + * The deposit built from this figure is an ORDINARY fixed-amount send, not + * a drain: the app names the amount and the transaction pays exactly that, + * so quote and payment are equal by construction — there is no + * under-delivery gap for NEAR Intents to refuse. The reserve's unused + * remainder comes back as change, which is what makes over-reserving + * lossless. Same system as the shielded max-shield reserve and Buy + * Credits; a MAX sell therefore leaves a small remnant rather than + * emptying the wallet to zero, which is deliberate and not surfaced. + * + * ## Why not a drain + * + * A drain (`SelectionStrategy.ALL`) delivered `total − fee` with no change + * — and therefore no wallet-owned output at all. Compact block filters + * match wallet script pubkeys only, so that transaction is never matched + * in a block, its context never reaches `CONTEXT_IN_BLOCK`, and the wallet + * keeps counting the spent inputs as spendable (mainnet `a5c99aec…`, + * `1f608a9a…`: balance inflated by the whole deposit, row stuck on + * "Sending" forever). Change restores that output and the deposit settles + * like any other send. + * + * Revisit when the SDK computes MAX internally in the wallet engine — the + * engine should own the amount, not this arithmetic. Until then, do not + * reintroduce `SelectionStrategy.ALL` here. + * + * [memoSizeBytes] defaults to the 80-byte OP_RETURN ceiling and sizes the + * reserve's data carrier, so a shorter real memo only over-reserves + * slightly — the safe direction. + * + * Returns 0 when the reserve exceeds the spendable balance (the caller + * surfaces "not enough funds" rather than quoting a negative amount). + * Throws like [buildDeferredMayaDeposit] on gate/bind failures. + */ + suspend fun maxMayaDepositDuffs(memoSizeBytes: Int = MAX_MAYA_MEMO_BYTES): Long { + require(memoSizeBytes in 1..MAX_MAYA_MEMO_BYTES) { + "memoSizeBytes must be 1..$MAX_MAYA_MEMO_BYTES, got $memoSizeBytes" + } + val walletIdHex = checkNotNull(source.boundWalletIdOrNull()) { + "app wallet not bound to the SDK" + } + val gate = probeSendGate() + check(gate.allowed) { "L1 funding gate closed: ${gate.reason}" } + // FAIL-CLOSED (funds-critical): a max deposit selects (essentially) + // every spendable UTXO, so coin selection reaches app-locked outputs + // (CrowdNode) — which [spendableBalanceDuffs] deliberately INCLUDES and + // the FFI cannot be told to exclude. Sweep-scale is what matters here, + // not whether the build is technically a drain: withholding a fee + // reserve leaves change but still spends the locked coins. Same guard + // the send-all path applies, for the same reason: refuse to quote + // rather than sweep protected funds into a swap. A partial (non-max) + // deposit keeps the ordinary send's exposure. + // + // [buildDeferredMayaDeposit] enforces this too, for every max caller. + // Do NOT delete this copy as redundant: quoting must refuse loudly + // here, rather than let a later build failure read as "your maximum + // is 0". + check(!hasProtectedOutputs("l1MayaMaxDeposit")) { + "wallet has app-locked outputs (CrowdNode); a max swap deposit would spend them" + } + val spendable = source.spendableBalanceDuffs(walletIdHex) + val utxoCount = spendableUtxoCount(walletIdHex) + val reserve = mayaMaxFeeReserveDuffs(utxoCount, memoSizeBytes) + val max = (spendable - reserve).coerceAtLeast(0L) + log.info( + "SDK l1MayaMaxDeposit: max deposit {} duffs (spendable {}, reserve {}, {} utxos, {}-byte memo)", + max, spendable, reserve, utxoCount, memoSizeBytes + ) + return max + } + + /** + * [buildDeferredPayment] in the MAYACHAIN deposit shape (vault VOUT0, + * [memo] as a zero-value OP_RETURN VOUT1, change back to VIN0's + * address VOUT2, no reordering) — the Maya/SwapKit swap-send build. + * Same gate and reservation contract; the caller verifies the shape + * from [SdkDeferredPayment.rawTxBytes] and then broadcasts via + * [broadcastDeferredPayment] or abandons via [releaseDeferredPayment]. + * [memo] must fit the 80-byte OP_RETURN standardness limit — checked + * here (and re-checked engine-side) BEFORE anything is reserved. + * + * Under [isMaxDeposit] this refuses outright when the wallet holds + * app-locked outputs (CrowdNode), the same fail-closed guard the send-all + * path applies — see the check in the body for why it lives here rather + * than at the call site. The flag marks SWEEP SCALE, not a drain: a max + * deposit is an ordinary fixed-amount send of `spendable − reserve` + * ([maxMayaDepositDuffs]), so it still names its amount and still leaves + * change. + */ + suspend fun buildDeferredMayaDeposit( + vaultAddressBase58: String, + vaultDuffs: Long, + memo: ByteArray, + isMaxDeposit: Boolean = false + ): SdkDeferredPayment { + // Every build names its own amount now, max included. + check(vaultDuffs > 0) { "Maya vault amount must be positive, got $vaultDuffs" } + val vault = vaultAddressBase58.trim() + check(vault.isNotEmpty() && addressValidSafe(vault)) { + "Maya vault address is malformed or for the wrong network" + } + check(memo.size in 1..MAX_MAYA_MEMO_BYTES) { + "Maya memo must be 1..$MAX_MAYA_MEMO_BYTES bytes, got ${memo.size}" + } + val walletIdHex = checkNotNull(source.boundWalletIdOrNull()) { + "app wallet not bound to the SDK" + } + val gate = probeSendGate() + check(gate.allowed) { "L1 funding gate closed: ${gate.reason}" } + // FAIL-CLOSED GUARD (funds-critical), max deposits only: a max deposit + // selects (essentially) every spendable UTXO the pooled default reaches + // — BIP44 + BIP32 + every DashPay contact-receiving account — and the + // FFI has no exclusion API, so with any app-locked output present + // (CrowdNode) it would sweep protected funds into a vault, irreversibly + // once broadcast. Withholding a fee reserve leaves change but does NOT + // narrow which coins are selected, so the guard applies exactly as it + // did to the drain. It is WALLET-WIDE, not per-account, so it still + // covers the sweep after the pooled default widened it. Enforced HERE, + // in the primitive, rather than trusting the caller to have measured + // first: [maxMayaDepositDuffs] does check, and [MayaBlockchainApiImpl] + // does call it, but that is a call-site convention and a convention is + // one refactor away from being skipped. A partial (non-max) deposit is + // not guarded — it keeps the ordinary send's exposure, unchanged. + // + // [maxMayaDepositDuffs] keeps its own copy of this check deliberately, + // so quoting refuses loudly instead of degrading to "your maximum is 0". + if (isMaxDeposit) { + check(!hasProtectedOutputs("l1DeferredMayaBuild")) { + "wallet has app-locked outputs (CrowdNode); a max swap deposit would spend them" + } + } + val payment = source.buildDeferredMayaDeposit(walletIdHex, vault, vaultDuffs, memo) + log.info( + "SDK l1DeferredMayaBuild: built {} ({} duffs to the vault{}, {}-byte memo, fee {} duffs), inputs reserved", + payment.txidHex, + vaultDuffs, + if (isMaxDeposit) " (MAX, spendable − reserve)" else "", + memo.size, + payment.feeDuffs + ) + return payment + } + /** * Broadcast [payment]'s already-signed tx, consuming its reservation — * the "merchant acked" arm of a BIP70 flow. One attempt, classified by @@ -1563,5 +1840,12 @@ class SdkL1SendService internal constructor( * the promised amount intact. */ private const val COIN_JOIN_DRAIN_FLOOR_DUFFS = 1L + + /** + * OP_RETURN relay-standardness limit — the ceiling for a Maya swap + * memo, matching Dash Core's `-datacarriersize` default (and the + * engine's `DEFAULT_MAX_OP_RETURN_BYTES`, which re-checks). + */ + const val MAX_MAYA_MEMO_BYTES = 80 } } diff --git a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionRowView.kt b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionRowView.kt index a61841aa7c..402ebc6be0 100644 --- a/wallet/src/de/schildbach/wallet/ui/transactions/TransactionRowView.kt +++ b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionRowView.kt @@ -61,6 +61,20 @@ data class TransactionRowView( val swapStatus: SwapOrderStatus? = null ): HistoryRowView() { companion object { + /** + * The row title for a transaction that funded a DEX swap, by order [status]. + * Single-sourced so the display-cache swap reconciler + * ([de.schildbach.wallet.service.planSwapRowDecorations]) cannot drift from the + * title this renderer produces — the two must agree or a row would flip between + * them on every pass. + */ + @StringRes + fun swapTitleRes(status: SwapOrderStatus?): Int = if (status == SwapOrderStatus.COMPLETED) { + R.string.transaction_row_converted + } else { + R.string.transaction_row_conversion + } + fun fromTransactionWrapper( txWrapper: TransactionWrapper, bag: TransactionBag, @@ -154,11 +168,7 @@ data class TransactionRowView( icon = R.drawable.ic_convert_circle iconBackground = R.style.TxOrangeBackground title = ResourceString( - if (swapOrder.status == SwapOrderStatus.COMPLETED) { - R.string.transaction_row_converted - } else { - R.string.transaction_row_conversion - }, + swapTitleRes(swapOrder.status), listOf(swapOrder.fromAsset, swapOrder.toAsset) ) } else if (isInternal) { diff --git a/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt b/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt new file mode 100644 index 0000000000..4e432fa091 --- /dev/null +++ b/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt @@ -0,0 +1,235 @@ +/* + * 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 . + */ + +package de.schildbach.wallet.payments + +import org.dashfoundation.dashsdk.keywallet.DecodedTransaction +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Host coverage for [verifyMayaDepositShape] — the pre-broadcast gate that + * keeps a mis-shaped deposit (which MAYAChain would strand or mis-refund) + * from ever reaching the network. Fixtures are hand-built + * [DecodedTransaction]s — the decoder itself is pinned against the Rust + * fixture in `TransactionDecoderTest`, so this suite only owns the shape + * rules, with no wallet, native library, or dashj involved. + */ +class MayaDepositShapeTest { + + private val vaultAddress = "yMqShkrgjTRuReBGFpQr7FozEF1QcNBBYA" + private val senderAddress = "yNDj28QBMm5sY6bLjFcNdWRNef24KLQNuQ" + private val memo = "=:ETH.ETH:0x1c7b17362c84287bd1184447e6dfeaf920c31bbe".toByteArray() + private val vaultDuffs = 1_000_000L + + private fun p2pkhScript(seed: Byte): ByteArray = + byteArrayOf(0x76, 0xa9.toByte(), 0x14) + ByteArray(20) { seed } + byteArrayOf(0x88.toByte(), 0xac.toByte()) + + private fun addressOutput(address: String, duffs: Long, scriptSeed: Byte) = + DecodedTransaction.Output(address, duffs, p2pkhScript(scriptSeed)) + + private fun memoOutput(memoBytes: ByteArray = memo, duffs: Long = 0L) = + DecodedTransaction.Output(null, duffs, expectedOpReturnScript(memoBytes)) + + private fun input(senderAddr: String? = senderAddress) = + DecodedTransaction.Input(ByteArray(32), 0, senderAddr) + + private fun deposit( + vaultValue: Long = vaultDuffs, + withChange: Boolean = true, + changeAddress: String = senderAddress, + vin0Address: String? = senderAddress, + memoBytes: ByteArray = memo, + memoValue: Long = 0L + ): DecodedTransaction { + val outputs = mutableListOf( + addressOutput(vaultAddress, vaultValue, scriptSeed = 1), + memoOutput(memoBytes, memoValue) + ) + if (withChange) { + outputs += addressOutput(changeAddress, 50_000, scriptSeed = 2) + } + return DecodedTransaction(ByteArray(32), listOf(input(vin0Address)), outputs) + } + + private fun verify(tx: DecodedTransaction): String? = + verifyMayaDepositShape(tx, vaultAddress, vaultDuffs, memo) + + @Test + fun wellFormedDepositPasses() { + assertNull(verify(deposit())) + } + + @Test + fun wellFormedDepositWithoutChangePasses() { + assertNull(verify(deposit(withChange = false))) + } + + @Test + fun longMemoUsesPushdata1AndPasses() { + // 76..80 bytes crosses the OP_PUSHDATA1 boundary in the expected script. + val longMemo = ByteArray(80) { 0x41 } + val tx = deposit(memoBytes = longMemo) + assertNull(verifyMayaDepositShape(tx, vaultAddress, vaultDuffs, longMemo)) + assertEquals(0x4c.toByte(), tx.outputs[1].scriptPubkey[1]) + } + + @Test + fun wrongVaultAmountFails() { + val error = verify(deposit(vaultValue = vaultDuffs + 1)) + assertNotNull(error) + assertTrue(error!!.contains("VOUT0")) + } + + @Test + fun wrongVaultAddressFails() { + val tx = deposit() + val error = verifyMayaDepositShape(tx, senderAddress, vaultDuffs, memo) + assertNotNull(error) + assertTrue(error!!.contains("expected the Asgard vault")) + } + + @Test + fun wrongMemoFails() { + val error = verify(deposit(memoBytes = "=:ETH.ETH:0xWRONG".toByteArray())) + assertNotNull(error) + assertTrue(error!!.contains("VOUT1")) + } + + @Test + fun valueCarryingOpReturnFails() { + val error = verify(deposit(memoValue = 546L)) + assertNotNull(error) + assertTrue(error!!.contains("zero-value")) + } + + @Test + fun memoNotAtVout1Fails() { + // vault, change, memo — memo displaced to VOUT2 must fail. + val tx = DecodedTransaction( + ByteArray(32), + listOf(input()), + listOf( + addressOutput(vaultAddress, vaultDuffs, scriptSeed = 1), + addressOutput(senderAddress, 50_000, scriptSeed = 2), + memoOutput() + ) + ) + val error = verify(tx) + assertNotNull(error) + assertTrue(error!!.contains("VOUT1")) + } + + @Test + fun changeToForeignAddressFails() { + val error = verify(deposit(changeAddress = "yTForeignAddressXXXXXXXXXXXXXXXXXX")) + assertNotNull(error) + assertEquals("VOUT2 change does not pay VIN0's address", error) + } + + @Test + fun unknownVin0AddressSkipsChangeOwnershipCheck() { + // A non-P2PKH scriptSig gives the decoder no sender address; the + // engine's change_to_first_input contract is the remaining guarantee. + assertNull(verify(deposit(vin0Address = null, changeAddress = "yTForeignAddressXXXXXXXXXXXXXXXXXX"))) + } + + @Test + fun nonP2pkhChangeFails() { + val tx = DecodedTransaction( + ByteArray(32), + listOf(input()), + listOf( + addressOutput(vaultAddress, vaultDuffs, scriptSeed = 1), + memoOutput(), + // P2SH-shaped change (a9 14 <20B> 87) must be rejected. + DecodedTransaction.Output( + senderAddress, + 50_000, + byteArrayOf(0xa9.toByte(), 0x14) + ByteArray(20) { 3 } + byteArrayOf(0x87.toByte()) + ) + ) + ) + val error = verify(tx) + assertNotNull(error) + assertTrue(error!!.contains("not P2PKH")) + } + + @Test + fun extraOutputFails() { + val tx = DecodedTransaction( + ByteArray(32), + listOf(input()), + listOf( + addressOutput(vaultAddress, vaultDuffs, scriptSeed = 1), + memoOutput(), + addressOutput(senderAddress, 50_000, scriptSeed = 2), + addressOutput(senderAddress, 1_000, scriptSeed = 4) + ) + ) + val error = verify(tx) + assertNotNull(error) + assertTrue(error!!.contains("expected 2 or 3 outputs")) + } + + @Test + fun noInputsFails() { + val tx = DecodedTransaction( + ByteArray(32), + emptyList(), + listOf(addressOutput(vaultAddress, vaultDuffs, scriptSeed = 1), memoOutput()) + ) + val error = verify(tx) + assertNotNull(error) + assertTrue(error!!.contains("no inputs")) + } + // --- expectedVaultDuffs ------------------------------------------------- + // + // Regression: a MAX sell aborted pre-broadcast with "VOUT0 carries 7442734 + // duffs, expected 7442725". Both guards around the build treat the quote as + // a FLOOR (each aborts only on `<`), but the shape check demanded exact + // equality with it, so a drain that delivered 9 duffs MORE than quoted -- + // the ordinary result of the balance moving between quote and build -- was + // rejected as mis-shaped. + + @Test + fun everySellIncludingMaxIsVerifiedAgainstTheQuote() { + // A MAX sell is now an ordinary fixed-amount send of + // `spendable - reserve`, so the app chose the amount in every case and + // the quote IS the expectation. There is no separate engine figure to + // reconcile against -- which is the point: quote and payment are equal + // by construction, so under-delivery is not reachable. + val tx = deposit(vaultValue = 1_000_000L) + assertNull(verifyMayaDepositShape(tx, vaultAddress, 1_000_000L, memo)) + } + + @Test + fun aDepositPayingAnythingOtherThanTheQuoteIsMisShaped() { + // The check stays EXACT in both directions. Over-payment is no longer a + // legitimate case (it existed only because a drain's amount was the + // engine's), so a deposit that does not pay the quote to the duff is a + // defect, whichever way it differs. + val overpaying = deposit(vaultValue = 1_000_001L) + val underpaying = deposit(vaultValue = 999_999L) + assertNotNull(verifyMayaDepositShape(overpaying, vaultAddress, 1_000_000L, memo)) + assertNotNull(verifyMayaDepositShape(underpaying, vaultAddress, 1_000_000L, memo)) + } + +} diff --git a/wallet/test/de/schildbach/wallet/service/SwapRowDisplayCacheTest.kt b/wallet/test/de/schildbach/wallet/service/SwapRowDisplayCacheTest.kt new file mode 100644 index 0000000000..89ea82c402 --- /dev/null +++ b/wallet/test/de/schildbach/wallet/service/SwapRowDisplayCacheTest.kt @@ -0,0 +1,317 @@ +/* + * 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 . + */ + +package de.schildbach.wallet.service + +import de.schildbach.wallet.database.entity.TxDisplayCacheEntry +import de.schildbach.wallet.service.platform.sdk.l1TxUiRecord +import de.schildbach.wallet.service.platform.sdk.planL1DisplaySync +import de.schildbach.wallet.service.platform.sdk.planL1InstantLockRowUpdate +import de.schildbach.wallet_test.R +import org.dash.wallet.common.data.PresentableTxMetadata +import org.dash.wallet.common.data.TxId +import org.dash.wallet.common.data.entity.SwapOrder +import org.dash.wallet.common.data.entity.SwapOrderStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Host-JVM regression tests for the home-screen row of a DEX SWAP transaction. + * + * The bug these pin down (verified on-device, 2026-08-07 Maya/SwapKit MAX-sell field + * test): the row stayed titled "Sending" forever and never became + * "Conversion · DASH/RUNE", while the transaction-details screen identified the swap + * correctly the whole time. Two independent display-layer faults combined: + * + * 1. The swap decoration was only ever derived by the dashj-side writers, which need a + * renderable dashj transaction and only fire on a metadata DIFF. The row for a MAX + * sell was authored by the SDK writer + * ([de.schildbach.wallet.service.platform.sdk.CutoverUiDataService]) — which knows + * nothing about swap orders — and no later metadata diff ever arrived to re-decorate + * it. [planSwapRowDecorations] fixes this by re-deriving the decoration from the + * `swap_orders` record by txid, exactly like the details screen, with no dashj + * transaction involved. + * 2. Even once decorated, the SDK planner's definitive plain-send re-stamp would + * re-title the row from the SDK record — and for a Maya drain that record's `context` + * never leaves the mempool, so the re-title was permanently "Sending". Swap rows are + * now part of the planner's never-touch set. + */ +class SwapRowDisplayCacheTest { + + /** The drain transaction from the field test, for traceability. */ + private val txHex = "a5c99aec2d535f71c1f65a12b1d893f0c3a53a9b252bf8335a941639cddac873" + private val txId = TxId.wrap(txHex) + + /** Duffs the engine reported for the drain — the value the SDK row carries. */ + private val sdkNetDuffs = -7_443_157L + private val now = 1_786_123_074_707L + + private val sendingTitle = "Sending" + private val sentTitle = "Sent" + + // ── fixtures ────────────────────────────────────────────────────────── + + private fun order(status: SwapOrderStatus) = SwapOrder( + txId = txId, + service = "swapkit", + provider = "MAYACHAIN_STREAMING", + fromAsset = "DASH", + toAsset = "RUNE", + toAddress = "thor1cxyvsphuzx8mx8tkv7hrv0uru7fj6n0q4mpea8", + depositAddress = "XhzzCcWvvx3rFfbEgkf39rE5Bqt7TP66hR", + status = status, + timestamp = now + ) + + private fun metadata( + swapOrder: SwapOrder?, + service: String? = "swapkit", + memo: String = "" + ) = PresentableTxMetadata(txId = txId, memo = memo, service = service) + .also { it.swapOrder = swapOrder } + + /** Mirrors the real title resolution ([TransactionRowView.swapTitleRes] + assets). */ + private fun title(order: SwapOrder): String = when (order.status) { + SwapOrderStatus.COMPLETED -> "Converted · ${order.fromAsset}/${order.toAsset}" + else -> "Conversion · ${order.fromAsset}/${order.toAsset}" + } + + /** + * The row the SDK writer inserts for a freshly-broadcast MAX sell: plain send shape, + * no service, no swap status, and titled "Sending" because the SDK record is still in + * the mempool. + */ + private fun plainSendingRow( + title: String = sendingTitle, + statusText: String = "", + service: String? = null, + swapStatus: String? = null, + iconType: Int = TxDisplayCacheEntry.ICON_SENT, + iconBgType: Int = TxDisplayCacheEntry.BG_SENT + ) = TxDisplayCacheEntry( + rowId = txHex, + title = title, + valueSatoshis = sdkNetDuffs, + iconType = iconType, + iconBgType = iconBgType, + statusText = statusText, + comment = "sold the lot", + transactionAmount = 1, + time = now, + hasErrors = false, + service = service, + swapStatus = swapStatus, + exchangeRateFiatCode = "USD", + exchangeRateFiatValue = 3_097_720_000L, + contactUsername = null, + contactDisplayName = null, + contactAvatarUrl = null, + contactUserId = null, + filterFlags = TxDisplayCacheEntry.FLAG_SENT + ) + + private fun decorate( + metadata: PresentableTxMetadata, + vararg rows: TxDisplayCacheEntry + ) = planSwapRowDecorations(listOf(metadata), rows.associateBy { it.rowId }, ::title) + + /** The already-correct row: what the decoration converges on for a [status] order. */ + private fun decoratedRow(status: SwapOrderStatus) = plainSendingRow( + title = title(order(status)), + service = "swapkit", + swapStatus = status.name, + iconType = TxDisplayCacheEntry.ICON_CONVERT, + iconBgType = TxDisplayCacheEntry.BG_ORANGE + ) + + // ── the decoration itself ───────────────────────────────────────────── + + @Test + fun aPlainSendingRowIsRedecoratedFromThePendingSwapOrder() { + val decorated = decorate(metadata(order(SwapOrderStatus.PENDING)), plainSendingRow()) + assertEquals(1, decorated.size) + val row = decorated.single() + assertEquals("Conversion · DASH/RUNE", row.title) + assertEquals(TxDisplayCacheEntry.ICON_CONVERT, row.iconType) + assertEquals(TxDisplayCacheEntry.BG_ORANGE, row.iconBgType) + assertEquals(SwapOrderStatus.PENDING.name, row.swapStatus) + assertEquals("swapkit", row.service) + } + + @Test + fun aCompletedOrderTitlesTheRowConverted() { + val row = decorate(metadata(order(SwapOrderStatus.COMPLETED)), plainSendingRow()).single() + assertEquals("Converted · DASH/RUNE", row.title) + assertEquals(SwapOrderStatus.COMPLETED.name, row.swapStatus) + } + + @Test + fun aStaleSecondaryStatusIsClearedSoOnlyTheSwapChipShows() { + val row = decorate( + metadata(order(SwapOrderStatus.PENDING)), + plainSendingRow(statusText = "Processing") + ).single() + assertEquals("", row.statusText) + } + + @Test + fun anAlreadyDecoratedRowProducesNoWrite() { + for (status in SwapOrderStatus.entries) { + assertTrue( + "settled $status row must not be rewritten", + decorate(metadata(order(status)), decoratedRow(status)).isEmpty() + ) + } + } + + @Test + fun decorationPreservesEverythingItHasNoAuthorityOver() { + val existing = plainSendingRow() + val row = decorate(metadata(order(SwapOrderStatus.COMPLETED)), existing).single() + assertEquals(existing.valueSatoshis, row.valueSatoshis) + assertEquals(existing.exchangeRateFiatCode, row.exchangeRateFiatCode) + assertEquals(existing.exchangeRateFiatValue, row.exchangeRateFiatValue) + assertEquals(existing.comment, row.comment) + assertEquals(existing.time, row.time) + assertEquals(existing.filterFlags, row.filterFlags) + assertEquals(existing.contactUserId, row.contactUserId) + } + + @Test + fun aSwapWithNoCachedRowYetIsSkipped() { + assertTrue(planSwapRowDecorations( + listOf(metadata(order(SwapOrderStatus.PENDING))), + emptyMap(), + ::title + ).isEmpty()) + } + + @Test + fun metadataWithoutASwapOrderIsNeverDecorated() { + assertTrue(decorate(metadata(swapOrder = null, service = null), plainSendingRow()).isEmpty()) + } + + @Test + fun anExistingServiceIsKeptWhenTheMetadataRowCarriesNone() { + // The swap_orders record can land before setTransactionService, so the metadata + // row is briefly service-less; the decoration must not un-classify the row. + val row = decorate( + metadata(order(SwapOrderStatus.PENDING), service = null), + plainSendingRow(service = "swapkit") + ).single() + assertEquals("swapkit", row.service) + } + + // ── the SDK planner must not re-author a swap row ───────────────────── + + private val resolve: (Int) -> String = { id -> + when (id) { + R.string.transaction_row_status_sending -> sendingTitle + R.string.transaction_row_status_sent -> sentTitle + R.string.transaction_row_status_received -> "Received" + R.string.transaction_row_status_processing -> "Processing" + R.string.transaction_row_status_confirming -> "Confirming" + else -> "str:$id" + } + } + + /** An SDK `transactions` record for this txid at the given [contextCode]. */ + private fun sdkRecord(contextCode: Int) = l1TxUiRecord( + txidWireBytes = ByteArray(32) { i -> txHex.substring(i * 2, i * 2 + 2).toInt(16).toByte() } + .reversedArray(), + netAmountDuffs = sdkNetDuffs, + feeDuffs = null, + contextCode = contextCode, + directionCode = 1, // OUTGOING + firstSeenSec = now / 1000, + blockTimestampSec = 0 + ) + + private fun syncAgainst(row: TxDisplayCacheEntry, contextCode: Int) = planL1DisplaySync( + records = listOf(sdkRecord(contextCode)), + existingByRowId = mapOf(row.rowId to row), + groupedTxIds = emptySet(), + resolve = resolve, + nowMs = now + ) + + @Test + fun sdkPlannerNeverReauthorsADecoratedSwapRow() { + // context 0 = still in the mempool (the Maya drain's permanent state until the + // compact-filter fix lands), 2 = in a block, 3 = chainlocked. In every case the + // planner must leave the conversion row byte-identical rather than re-titling it + // "Sending"/"Sent" from its own record. + for (contextCode in listOf(0, 1, 2, 3)) { + val plan = syncAgainst(decoratedRow(SwapOrderStatus.PENDING), contextCode) + assertTrue("context=$contextCode must not update a swap row", plan.updates.isEmpty()) + assertTrue("context=$contextCode must not insert", plan.inserts.isEmpty()) + } + } + + @Test + fun sdkPlannerStillReauthorsAPlainRowWithTheSameShape() { + // Guard against over-broad carve-out: the same row WITHOUT swap decoration is + // still corrected, so the fix did not disable the plain-send re-stamp. + val plain = plainSendingRow(iconType = TxDisplayCacheEntry.ICON_RECEIVED) + assertTrue(syncAgainst(plain, contextCode = 2).updates.isNotEmpty()) + } + + @Test + fun instantLockRefreshLeavesASwapRowAlone() { + assertNull(planL1InstantLockRowUpdate(decoratedRow(SwapOrderStatus.PENDING), resolve)) + // …while a plain "Sending" row still flips to "Sent" on the lock. + assertEquals( + sentTitle, + planL1InstantLockRowUpdate(plainSendingRow(), resolve)?.title + ) + } + + // ── the whole story: mempool → in-block ─────────────────────────────── + + @Test + fun aSwapRowBornInTheMempoolEndsUpTitledAsAConversionAndIsNotPinnedToIt() { + // 1. The SDK writer inserts the row for the freshly-broadcast drain: context 0, + // so a plain "Sending", with no idea a swap order exists. + var row = plainSendingRow() + assertEquals(sendingTitle, row.title) + + // 2. The swap order lands (PENDING). The reconciler decorates the row from + // swap_orders alone — no dashj transaction is available for this tx, which is + // exactly why the old wrapper-based path wrote nothing here. + row = decorate(metadata(order(SwapOrderStatus.PENDING)), row).single() + assertEquals("Conversion · DASH/RUNE", row.title) + assertEquals(SwapOrderStatus.PENDING.name, row.swapStatus) + + // 3. The transaction confirms — the SDK record advances 0 → IN_BLOCK. The planner + // must not drag the row back to a plain send title. + assertTrue(syncAgainst(row, contextCode = 2).updates.isEmpty()) + assertEquals("Conversion · DASH/RUNE", row.title) + + // 4. The tracker flips the order to COMPLETED. The row is NOT pinned to its stale + // "Conversion" rendering: the reconciler re-titles it "Converted". + val completed = decorate(metadata(order(SwapOrderStatus.COMPLETED)), row).single() + assertEquals("Converted · DASH/RUNE", completed.title) + assertEquals(SwapOrderStatus.COMPLETED.name, completed.swapStatus) + assertEquals(TxDisplayCacheEntry.ICON_CONVERT, completed.iconType) + + // 5. And it settles: another pass of either writer changes nothing. + assertTrue(decorate(metadata(order(SwapOrderStatus.COMPLETED)), completed).isEmpty()) + assertTrue(syncAgainst(completed, contextCode = 3).updates.isEmpty()) + } +} diff --git a/wallet/test/de/schildbach/wallet/service/TxDisplayCacheMergeGuardTest.kt b/wallet/test/de/schildbach/wallet/service/TxDisplayCacheMergeGuardTest.kt index 25a92c2633..e1616979f9 100644 --- a/wallet/test/de/schildbach/wallet/service/TxDisplayCacheMergeGuardTest.kt +++ b/wallet/test/de/schildbach/wallet/service/TxDisplayCacheMergeGuardTest.kt @@ -49,7 +49,8 @@ class TxDisplayCacheMergeGuardTest { comment: String = "", service: String? = null, contactUserId: String? = null, - exchangeRateFiatCode: String? = null + exchangeRateFiatCode: String? = null, + swapStatus: String? = null ) = TxDisplayCacheEntry( rowId = rowId, title = title, @@ -68,7 +69,8 @@ class TxDisplayCacheMergeGuardTest { contactDisplayName = null, contactAvatarUrl = null, contactUserId = contactUserId, - filterFlags = filterFlags + filterFlags = filterFlags, + swapStatus = swapStatus ) /** The SDK-corrected row for a confirmed plain send (no contact identity on it). */ @@ -240,4 +242,70 @@ class TxDisplayCacheMergeGuardTest { assertFalse(bus.isSdkAuthoritative("row-0")) assertTrue(bus.isSdkAuthoritative("row-${overflow - 1}")) } + + // ── Swap decoration vs. the freeze ──────────────────────────────────── + // The swap-orders table is metadata-authoritative (the tracking service + // flips PENDING→COMPLETED long after the row was SDK-stamped), so a + // swap-decorated rebuild must update the SHAPE while the value stays + // frozen — the 2026-08-05 Maya field test showed the home row pinned at + // its stale title until a manual cache wipe. + + /** A swap-decorated rebuild: dashj-degenerate value, fresh swap shape. */ + private fun swapRebuild(status: String, title: String) = entry( + title = title, + valueSatoshis = 0L, + iconType = TxDisplayCacheEntry.ICON_CONVERT, + service = "swapkit", + swapStatus = status + ) + + @Test + fun swapDecorationPassesTheFreezeOnAnSdkStampedRow() { + // SDK-stamped plain "Sent" row; the swap order then lands (PENDING). + val merged = merge( + swapRebuild("PENDING", "Conversion: DASH → RUNE"), + sdkCorrected, + sdkAuthoritative = true + ) + assertEquals("Conversion: DASH → RUNE", merged.title) + assertEquals(TxDisplayCacheEntry.ICON_CONVERT, merged.iconType) + // The dashj-degenerate value never clobbers the SDK-stamped one. + assertEquals(-96_450_513L, merged.valueSatoshis) + assertEquals(TxDisplayCacheEntry.FLAG_SENT, merged.filterFlags) + } + + @Test + fun swapStatusProgressUpdatesTheFrozenTitle() { + val existingSwapRow = entry( + title = "Conversion: DASH → RUNE", + valueSatoshis = -5_319_295L, + iconType = TxDisplayCacheEntry.ICON_CONVERT, + service = "swapkit", + swapStatus = "PENDING" + ) + val merged = merge( + swapRebuild("COMPLETED", "Converted: DASH → RUNE"), + existingSwapRow, + sdkAuthoritative = true + ) + assertEquals("Converted: DASH → RUNE", merged.title) + assertEquals("COMPLETED", merged.swapStatus) + assertEquals(-5_319_295L, merged.valueSatoshis) + } + + @Test + fun rebuildWithoutSwapMetadataNeverUndressesASwapRow() { + val existingSwapRow = entry( + title = "Converted: DASH → RUNE", + valueSatoshis = -5_319_295L, + iconType = TxDisplayCacheEntry.ICON_CONVERT, + service = "swapkit", + swapStatus = "COMPLETED" + ) + // A live-tx batch rebuild that missed the metadata join keeps the shape. + val merged = merge(dashjMisread, existingSwapRow, sdkAuthoritative = true) + assertEquals("Converted: DASH → RUNE", merged.title) + assertEquals(TxDisplayCacheEntry.ICON_CONVERT, merged.iconType) + assertEquals(-5_319_295L, merged.valueSatoshis) + } } diff --git a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt index 0cdb49ab6a..e1fbe2ff5d 100644 --- a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt @@ -56,6 +56,9 @@ class SdkL1SendServiceTest { var onSendAll: (String, String, Long) -> String = { _, _, _ -> throw IllegalStateException("send-all not stubbed") }, + /** Fee the faked engine reports for a Maya deposit build. */ + var onMayaDepositFee: (Long, ByteArray) -> Long = { _, _ -> 0L }, + var externalAddress: String? = null, // Interface default: enumeration unavailable → the service uses the // flat SEND_ALL_FEE_RESERVE_DUFFS fallback reserve. var onPooledUtxoCount: () -> Int? = { null } @@ -123,6 +126,50 @@ class SdkL1SendServiceTest { sendAllFloors += floorDuffs return onSendAll(walletIdHex, addressBase58, floorDuffs) } + + // ── Maya deposit build / release (fee-probe surface) ────────────── + var mayaBuildCalls = 0 + var mayaReleaseCalls = 0 + val mayaBuiltAmounts = mutableListOf() + val mayaBuiltMemoSizes = mutableListOf() + var mayaBuiltVault: String? = null + + var mayaBuiltDrains = mutableListOf() + + /** + * Under [drain] the engine computes the deliverable amount, so the fake + * mirrors that: it reports [drainDeliverable] rather than echoing the + * caller's (ignored) [vaultDuffs]. + */ + var drainDeliverable: Long = 0 + + /** When set, the build throws it — the engine refusing an unfundable drain. */ + var failMayaBuildWith: Throwable? = null + + override suspend fun buildDeferredMayaDeposit( + walletIdHex: String, + vaultAddressBase58: String, + vaultDuffs: Long, + memo: ByteArray + ): SdkDeferredPayment { + mayaBuildCalls++ + failMayaBuildWith?.let { throw it } + mayaBuiltAmounts += vaultDuffs + mayaBuiltMemoSizes += memo.size + mayaBuiltVault = vaultAddressBase58 + return SdkDeferredPayment( + txidHex = "bb".repeat(32), + rawTxBytes = ByteArray(0), + feeDuffs = onMayaDepositFee(vaultDuffs, memo), + native = null + ) + } + + override suspend fun releaseDeferredPayment(walletIdHex: String, payment: SdkDeferredPayment) { + mayaReleaseCalls++ + } + + override suspend fun unusedExternalAddress(walletIdHex: String): String? = externalAddress } private fun config(enabled: Boolean?, cutoverState: String? = null): DashPayConfig = mockk { @@ -167,6 +214,7 @@ class SdkL1SendServiceTest { // exercisable; the production wiring (and the constructor default) // is fail-closed — covered by dedicated tests below. hasAppLockedOutputs: () -> Boolean = { false }, + utxoCount: suspend (String) -> Int = { 1 }, // Fresh empty registry by default: no seam locks, drain paths // exercisable. Seam-lock refusal is covered by dedicated tests. seamRegistry: SeamOutputLockRegistry = SeamOutputLockRegistry() @@ -176,6 +224,7 @@ class SdkL1SendServiceTest { isValidAddress = addressValid, l1Progress = progress, hasAppLockedSpendableOutputs = hasAppLockedOutputs, + spendableUtxoCount = utxoCount, seamOutputLockRegistry = seamRegistry, onSelfSpendBroadcast = { selfSpendMarks++ }, bridgeAfterBroadcast = bridgeAfterBroadcast @@ -1174,4 +1223,166 @@ class SdkL1SendServiceTest { // v41int11+ shape: no SDK-side mutex accessor. } } + + // ── Maya max-deposit measurement ────────────────────────────────────── + // The figure quoted for a MAX sell. It must NEVER come in under the real + // fee: a quote derived from a too-small reserve makes the deposit pay the + // vault less than quoted, and NEAR Intents refuses under-delivery. + + private fun mayaSource(spendable: Long, feeDuffs: Long, deliverable: Long = 0L) = FakeSource( + boundWalletId = { walletId }, + onSpendable = { spendable }, + onMayaDepositFee = { _, _ -> feeDuffs }, + externalAddress = validAddress + ).apply { drainDeliverable = deliverable } + + @Test + fun maxMayaDepositIsSpendableMinusTheFeeReserve() = runBlocking { + // The whole model: quote = spendable - reserve, an amount the app owns. + // No probe build is performed, so nothing is reserved to compute it. + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + val expected = 1_000_000L - mayaMaxFeeReserveDuffs(1, SdkL1SendService.MAX_MAYA_MEMO_BYTES) + assertEquals(expected, service(source).maxMayaDepositDuffs()) + assertEquals("quoting must not build anything", 0, source.mayaBuildCalls) + assertEquals("and must not reserve anything", 0, source.mayaReleaseCalls) + } + + @Test + fun maxMayaDepositReserveGrowsWithTheInputCount() = runBlocking { + // More inputs means a bigger transaction means a bigger fee, so the + // reserve must scale with the UTXO count -- under-reserving is the + // direction that fails the build. + val source = mayaSource(spendable = 10_000_000L, feeDuffs = 500L) + val few = service(source, utxoCount = { 1 }).maxMayaDepositDuffs() + val many = service(source, utxoCount = { 40 }).maxMayaDepositDuffs() + assertTrue("a 40-input wallet must reserve more than a 1-input one", many < few) + } + + @Test + fun maxMayaDepositReservesForTheWorstCaseMemoByDefault() = runBlocking { + // The default sizes the data carrier at the 80-byte ceiling, so a + // shorter real memo only over-reserves -- the safe direction. Needs a + // wallet big enough that the 1000-duff floor is not what decides the + // reserve; see the floor test below. + val source = mayaSource(spendable = 10_000_000L, feeDuffs = 500L) + val worstCase = service(source, utxoCount = { 40 }).maxMayaDepositDuffs() + val shortMemo = service(source, utxoCount = { 40 }).maxMayaDepositDuffs(memoSizeBytes = 10) + assertTrue("a 10-byte memo leaves more depositable", shortMemo > worstCase) + } + + @Test + fun theReserveFloorDominatesASmallWallet() = runBlocking { + // Sized purely by bytes, a one-input deposit would reserve only a few + // hundred duffs, so the 1000-duff floor is what actually applies -- and + // it makes the memo size irrelevant at that scale. Pinned so the floor + // is not mistaken for a bug when a small wallet quotes identically for + // any memo length. + assertEquals(1000L, mayaMaxFeeReserveDuffs(1, SdkL1SendService.MAX_MAYA_MEMO_BYTES)) + assertEquals(1000L, mayaMaxFeeReserveDuffs(1, 10)) + assertTrue(mayaMaxFeeReserveDuffs(40, SdkL1SendService.MAX_MAYA_MEMO_BYTES) > 1000L) + } + + @Test + fun maxMayaDepositNeverGoesNegative() = runBlocking { + // A reserve larger than the balance must read as "nothing depositable", + // never as a negative quote. + val source = mayaSource(spendable = 100L, feeDuffs = 25_000L) + assertEquals(0L, service(source, utxoCount = { 40 }).maxMayaDepositDuffs()) + } + + @Test + fun maxMayaDepositRefusesWhileAppLockedOutputsExist() = runBlocking { + // A max deposit drains the account, so selection would reach + // CrowdNode-locked outputs — the same fail-closed refusal the send-all + // drain applies. Nothing may be built or measured. + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + try { + service(source, hasAppLockedOutputs = { true }).maxMayaDepositDuffs() + fail("expected the max deposit to be refused while app-locked outputs exist") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("app-locked")) + } + assertEquals(0, source.mayaBuildCalls) + } + + @Test + fun maxMayaDepositRefusesOnSeamRegisteredLocks() = runBlocking { + // Locks on SDK-only txs are invisible to the dashj check; they block too. + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + val registry = SeamOutputLockRegistry().apply { lockOutput("ee".repeat(32), 0) } + try { + service(source, seamRegistry = registry).maxMayaDepositDuffs() + fail("expected the max deposit to be refused while seam locks exist") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("app-locked")) + } + assertEquals(0, source.mayaBuildCalls) + } + + @Test + fun maxMayaDepositFailsClosedWhenTheLockCheckThrows() = runBlocking { + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + val svc = service(source, hasAppLockedOutputs = { throw IllegalStateException("wallet unavailable") }) + try { + svc.maxMayaDepositDuffs() + fail("expected a failed lock check to block the max deposit") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("app-locked")) + } + assertEquals(0, source.mayaBuildCalls) + } + + @Test + fun maxDepositRefusesAppLockedOutputsWithoutAnyPriorMeasurement() = runBlocking { + // The guard belongs to the PRIMITIVE, not to the call-site convention + // of measuring first. A caller that goes straight to a max build — + // which no current caller does, but which one refactor could — must + // still be refused, with nothing reserved. + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + val svc = service(source, hasAppLockedOutputs = { true }) + try { + svc.buildDeferredMayaDeposit(validAddress, 50_000L, ByteArray(40), isMaxDeposit = true) + fail("expected a direct max build to be refused while app-locked outputs exist") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("app-locked")) + } + assertEquals(0, source.mayaBuildCalls) + } + + @Test + fun maxDepositRefusesSeamRegisteredLocksWithoutAnyPriorMeasurement() = runBlocking { + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + val registry = SeamOutputLockRegistry().apply { lockOutput("ee".repeat(32), 0) } + try { + service(source, seamRegistry = registry) + .buildDeferredMayaDeposit(validAddress, 50_000L, ByteArray(40), isMaxDeposit = true) + fail("expected a direct max build to be refused while seam locks exist") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("app-locked")) + } + assertEquals(0, source.mayaBuildCalls) + } + + @Test + fun partialDepositIsNotBlockedByAppLockedOutputs() = runBlocking { + // Only a DRAIN is guarded. A partial deposit keeps the ordinary send's + // exposure — guarding it too would block ordinary swaps for anyone + // holding a CrowdNode balance. + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + val svc = service(source, hasAppLockedOutputs = { true }) + svc.buildDeferredMayaDeposit(validAddress, 50_000L, ByteArray(40), isMaxDeposit = false) + assertEquals(1, source.mayaBuildCalls) + } + + @Test + fun maxMayaDepositRejectsAnOversizeMemo() = runBlocking { + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + try { + service(source).maxMayaDepositDuffs(memoSizeBytes = SdkL1SendService.MAX_MAYA_MEMO_BYTES + 1) + fail("expected an oversize memo to be rejected") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("memoSizeBytes")) + } + Unit + } }