From 892d42eea482c70787fd9df72757bf6b5e97926a Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 4 Aug 2026 16:01:02 -0700 Subject: [PATCH 01/15] =?UTF-8?q?feat(maya)!:=20swap=20deposits=20on=20the?= =?UTF-8?q?=20SDK=20deferred=20surface=20=E2=80=94=20dashj=20construction?= =?UTF-8?q?=20deleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MayaBlockchainApiImpl now builds the MAYACHAIN deposit with the Kotlin SDK's deferred build/broadcast primitive (buildDeferredMayaDeposit: vault VOUT0, OP_RETURN memo VOUT1, change back to VIN0's address VOUT2, no BIP-69 reordering — the new builder controls from platform#4286/#4288, engine work in rust-dashcore#922), verifies the deposit shape from the signed bytes BEFORE broadcasting (verifyMayaDepositShape — a mis-shaped vault deposit strands funds), mirrors the reservation into wallet locks for the transition window, and bridges the broadcast tx for display. The SwapKit Maya-protocol legs converge automatically — they delegate to the same buildAndSendSwapTx; the NEAR-Intents legs already ride the neutral SDK-routed send. Replace-then-delete, as with BIP70: the dashj leg is gone — manual SendRequest/OP_RETURN script construction, output clearing/re-signing, the fresh-Transaction confidence workaround, the post-completeTx output checks, and with it the now-orphaned manual-tx surface (WalletSendPaymentService.completeTransaction/signTransaction/ sendTransaction and IncorrectSwapOutputCount) whose only consumer was this path. Failure semantics: build/verify failures release the reservation (recoverable, nothing moved); a provably pre-network broadcast refusal releases; an AMBIGUOUS broadcast outcome keeps the reservation and reports non-retryable — releasing would let a rebuilt retry pay the vault twice (the BIP70 field-test lesson). Max sells retry the build once with a 10k-duff fee reserve carved out on an engine-reported shortfall (pre-broadcast by construction). Pins dash-sdk-android 0.1.0-v41int13-maya2-SNAPSHOT (qa5-plus-maya + the buildSignedPayment Maya options). Unit tests: 10-case MayaDepositShapeTest; payments + sdk-service suites 747/747 green. Co-Authored-By: Claude Fable 5 --- .../maya/api/MayaBlockchainApi.kt | 4 +- .../wallet/integrations/maya/di/MayaModule.kt | 4 +- .../maya/model/MayaErrorResponse.kt | 2 - .../wallet/payments/FakeDashSpendService.kt | 11 - .../wallet/payments/MayaBlockchainApiImpl.kt | 440 ++++++++++++------ .../wallet/payments/SendCoinsTaskRunner.kt | 25 - .../payments/WalletSendPaymentService.kt | 5 - .../service/platform/sdk/SdkL1SendService.kt | 87 ++++ .../wallet/payments/MayaDepositShapeTest.kt | 182 ++++++++ 9 files changed, 561 insertions(+), 199 deletions(-) create mode 100644 wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt 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..4895f75d06 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 @@ -24,7 +24,9 @@ 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 { /** 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/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..179ae4139a 100644 --- a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt +++ b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt @@ -17,26 +17,26 @@ package de.schildbach.wallet.payments +import de.schildbach.wallet.Constants +import de.schildbach.wallet.data.WalletData +import de.schildbach.wallet.service.platform.sdk.BridgedTxResult +import de.schildbach.wallet.service.platform.sdk.SdkBridgedTransactionFactory +import de.schildbach.wallet.service.platform.sdk.SdkDeferredPayment +import de.schildbach.wallet.service.platform.sdk.SdkL1SendService +import de.schildbach.wallet.service.platform.sdk.SdkWriteResult +import de.schildbach.wallet.util.toDashjCoin import kotlinx.coroutines.CancellationException -import org.bitcoinj.core.Address -import org.bitcoinj.core.Coin -import org.bitcoinj.core.InsufficientMoneyException +import org.bitcoinj.core.Context +import org.bitcoinj.core.NetworkParameters import org.bitcoinj.core.Transaction -import org.bitcoinj.core.TransactionOutput -import org.bitcoinj.script.ScriptBuilder +import org.bitcoinj.core.Utils 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.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.slf4j.Logger @@ -45,21 +45,130 @@ 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. + * Pre-broadcast verification of the MAYACHAIN UTXO deposit shape + * (https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/sending-transactions, + * "UTXO Chains") against the SDK-built signed bytes: + * + * - `VOUT0` pays [vaultAddressBase58] exactly [vaultDuffs]; + * - `VOUT1` is a zero-value OP_RETURN carrying exactly [memo]; + * - 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 its + * inputs — host-testable without a wallet. + */ +internal fun verifyMayaDepositShape( + rawTxBytes: ByteArray, + params: NetworkParameters, + vaultAddressBase58: String, + vaultDuffs: Long, + memo: ByteArray +): String? { + val tx = try { + Transaction(params, rawTxBytes) + } catch (e: Exception) { + return "unparseable transaction: ${e.message}" + } + 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] + val vaultPaysTo = try { + vaultOutput.scriptPubKey.getToAddress(params).toBase58() + } catch (e: Exception) { + return "VOUT0 is not a plain address output" + } + if (vaultPaysTo != vaultAddressBase58) { + return "VOUT0 pays $vaultPaysTo, expected the Asgard vault $vaultAddressBase58" + } + if (vaultOutput.value.value != vaultDuffs) { + return "VOUT0 carries ${vaultOutput.value.value} duffs, expected $vaultDuffs" + } + + val memoOutput = tx.outputs[1] + if (!ScriptPattern.isOpReturn(memoOutput.scriptPubKey)) { + return "VOUT1 is not an OP_RETURN" + } + if (memoOutput.value.value != 0L) { + return "VOUT1 OP_RETURN must be zero-value, carries ${memoOutput.value.value} duffs" + } + val payload = memoOutput.scriptPubKey.chunks.getOrNull(1)?.data + if (payload == null || !payload.contentEquals(memo)) { + return "VOUT1 memo does not match the swap memo" + } + + if (tx.outputs.size == 3) { + val change = tx.outputs[2] + if (!ScriptPattern.isP2PKH(change.scriptPubKey)) { + return "VOUT2 change is not P2PKH" + } + // change-to-VIN0: a signed P2PKH input's scriptSig is , + // so VIN0's address hash is HASH160 of its second chunk. Only + // checkable when the input really is P2PKH-signed; a missing pubkey + // chunk is left to the engine's own change_to_first_input contract. + val vin0PubKey = tx.getInput(0).scriptSig.chunks.getOrNull(1)?.data + if (vin0PubKey != null) { + val changeHash = ScriptPattern.extractHashFromP2PKH(change.scriptPubKey) + if (!Utils.sha256hash160(vin0PubKey).contentEquals(changeHash)) { + 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]), then broadcasts. Lives + * here so integrations/maya stays free of wallet-engine types. + * + * The dashj transaction-construction leg (manual `SendRequest`, output + * clearing/re-signing, the fresh-Transaction confidence workaround) is + * DELETED per the replace-then-delete policy — the same treatment BIP70 + * got. The dashj foundation wallet is still used for two bounded jobs: + * the transition-only reservation mirror (below) and parsing the signed + * bytes in [verifyMayaDepositShape]. + * + * 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. */ class MayaBlockchainApiImpl @Inject constructor( - private val sendPaymentService: WalletSendPaymentService, + private val sdkL1SendService: SdkL1SendService, private val mayaWebApi: MayaWebApi, - private val walletProviderData: WalletData + private val walletData: WalletData, + 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 + + /** + * Adjust-down reserve for a MAX sell, in duffs. A max quote is + * derived from the spendable balance, which leaves nothing for the + * mining fee — when the engine reports the shortfall (a provably + * pre-broadcast build failure; nothing was reserved or sent), the + * build retries ONCE with this reserve carved out of the vault + * amount. 10 000 duffs covers the fee of a deposit spending ~67 + * inputs at the engine's default rate; any unspent remainder + * returns as VOUT2 change. + */ + private const val MAX_SELL_FEE_RESERVE_DUFFS = 10_000L } override suspend fun commitSwapTransaction( @@ -87,171 +196,196 @@ class MayaBlockchainApiImpl @Inject constructor( override suspend fun buildAndSendSwapTx( swapTradeUIModel: SwapTradeUIModel ): ResponseResource { - val params = walletProviderData.networkParameters + val params = Constants.NETWORK_PARAMETERS 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. + val quotedDuffs = if (!swapTradeUIModel.maximum) { + swapTradeUIModel.amount.dash + swapTradeUIModel.feeAmount.dash } else { - sendRequest = SendRequest.emptyWallet(Address.fromBase58(params, swapTradeUIModel.vaultAddress)) - } - - // 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 + swapTradeUIModel.amount.dash + }.setScale(8, RoundingMode.HALF_UP).toCoin().toDashjCoin().value - // 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. + // Any throw here is pre-broadcast by construction, so the MAX + // sell's mining-fee shortfall may be retried once, adjusted + // down by the reserve (nothing has moved). + var vaultDuffs = quotedDuffs + val payment = try { + sdkL1SendService.buildDeferredMayaDeposit(swapTradeUIModel.vaultAddress, vaultDuffs, memoBytes) + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + if (swapTradeUIModel.maximum && isInsufficientFunds(t) && + quotedDuffs > MAX_SELL_FEE_RESERVE_DUFFS + ) { + vaultDuffs = quotedDuffs - MAX_SELL_FEE_RESERVE_DUFFS + log.info( + "maya max sell: {} duffs not fundable with the fee; retrying at {} duffs", + quotedDuffs, vaultDuffs + ) + sdkL1SendService.buildDeferredMayaDeposit(swapTradeUIModel.vaultAddress, vaultDuffs, memoBytes) + } else { + throw t + } + } - // 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, so this replaces (and strengthens) the old + // post-completeTx output checks. + val shapeError = verifyMayaDepositShape( + payment.rawTxBytes, params, swapTradeUIModel.vaultAddress, vaultDuffs, memoBytes + ) + 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 + // Reservation mirror — TRANSITION-ONLY, same rationale and + // lifetime as the BIP70 mirror in SendCoinsTaskRunner: 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 { setReservedOutpointLocks(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, vaultDuffs) + return when (val result = sdkL1SendService.broadcastDeferredPayment(payment)) { + is SdkWriteResult.Broadcast -> { + // 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 { setReservedOutpointLocks(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 ) - ) - // 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"), + } + 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 ) - 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() + } catch (e: CancellationException) { + // 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 (t: Throwable) { + if (isInsufficientFunds(t)) { + // Neutral exception so the maya module can detect it without + // wallet-engine types — the same contract the dashj path kept + // by converting InsufficientMoneyException. + 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 + ) + } + } - log.info("maya swap transaction: {}", sendRequest.tx) - - sendPaymentService.signTransaction(sendRequest) - log.info("maya swap transaction resigned: {}", sendRequest.tx) + /** + * The engine's pre-broadcast funding shortfall, however it is phrased + * across the build layers (key-wallet's `Insufficient funds` Display + * inside the FFI's build-failure wrapper). Only ever consulted for + * throws from the BUILD step, which never broadcasts. + */ + private fun isInsufficientFunds(t: Throwable): Boolean = + generateSequence(t) { it.cause?.takeIf { cause -> cause !== it } } + .take(5) + .any { it.message?.contains("insufficient funds", ignoreCase = true) == true } - // 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) - } - } - // 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) + /** + * Lock/unlock the outpoints [payment]'s signed tx spends in the + * foundation dashj wallet — the app-side mirror of the SDK's engine + * reservation, identical to the BIP70 mirror (TRANSITION-ONLY, dies + * with Phase 2). Pure bookkeeping on the Phase-3 foundation object. + */ + private fun setReservedOutpointLocks(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) } - - // 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. - throw e - } catch (e: Exception) { - log.error("failed to build/send maya swap transaction", e) - return ResponseResource.Failure(e, false, 0, e.message) } } } 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/platform/sdk/SdkL1SendService.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt index 900cab2c6d..a269a426c7 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt @@ -631,6 +631,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 @@ -1266,6 +1286,30 @@ 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. + 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 @@ -1956,6 +2000,42 @@ class SdkL1SendService internal constructor( return payment } + /** + * [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. + */ + suspend fun buildDeferredMayaDeposit( + vaultAddressBase58: String, + vaultDuffs: Long, + memo: ByteArray + ): SdkDeferredPayment { + 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}" } + 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, 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 @@ -2099,5 +2179,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/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt b/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt new file mode 100644 index 0000000000..7ea214416d --- /dev/null +++ b/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt @@ -0,0 +1,182 @@ +/* + * 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.bitcoinj.core.Address +import org.bitcoinj.core.Coin +import org.bitcoinj.core.ECKey +import org.bitcoinj.core.Sha256Hash +import org.bitcoinj.core.Transaction +import org.bitcoinj.core.TransactionOutPoint +import org.bitcoinj.core.TransactionOutput +import org.bitcoinj.params.TestNet3Params +import org.bitcoinj.script.ScriptBuilder +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 with dashj, the + * same library the verifier parses with. + */ +class MayaDepositShapeTest { + + private val params = TestNet3Params.get() + private val vaultKey = ECKey() + private val vaultAddress = Address.fromKey(params, vaultKey) + private val senderKey = ECKey() + private val memo = "=:ETH.ETH:0x1c7b17362c84287bd1184447e6dfeaf920c31bbe".toByteArray() + private val vaultDuffs = 1_000_000L + + /** + * A Maya-shaped deposit: vault at VOUT0, OP_RETURN memo at VOUT1, + * optional change at VOUT2, one input carrying a P2PKH-style scriptSig + * (` `) so the change-to-VIN0 check has a pubkey to hash. + */ + private fun buildDeposit( + vaultValue: Long = vaultDuffs, + memoBytes: ByteArray = memo, + changeKey: ECKey? = senderKey, + memoValue: Long = 0L + ): Transaction { + val tx = Transaction(params) + tx.addOutput(Coin.valueOf(vaultValue), vaultAddress) + tx.addOutput( + TransactionOutput( + params, + tx, + Coin.valueOf(memoValue), + ScriptBuilder.createOpReturnScript(memoBytes).program + ) + ) + if (changeKey != null) { + tx.addOutput(Coin.valueOf(50_000), Address.fromKey(params, changeKey)) + } + // A fake signed P2PKH input: 71 zero bytes stand in for the DER + // signature; the pubkey chunk is real so HASH160 comparisons work. + val scriptSig = ScriptBuilder() + .data(ByteArray(71)) + .data(senderKey.pubKey) + .build() + tx.addInput( + org.bitcoinj.core.TransactionInput( + params, + tx, + scriptSig.program, + TransactionOutPoint(params, 0, Sha256Hash.ZERO_HASH) + ) + ) + return tx + } + + private fun verify(tx: Transaction, expectedVaultDuffs: Long = vaultDuffs, memoBytes: ByteArray = memo): String? = + verifyMayaDepositShape(tx.bitcoinSerialize(), params, vaultAddress.toBase58(), expectedVaultDuffs, memoBytes) + + @Test + fun wellFormedDepositPasses() { + assertNull(verify(buildDeposit())) + } + + @Test + fun wellFormedDepositWithoutChangePasses() { + assertNull(verify(buildDeposit(changeKey = null))) + } + + @Test + fun wrongVaultAmountFails() { + val error = verify(buildDeposit(vaultValue = vaultDuffs + 1)) + assertNotNull(error) + assertTrue(error!!.contains("VOUT0")) + } + + @Test + fun wrongVaultAddressFails() { + val otherVault = Address.fromKey(params, ECKey()) + val tx = buildDeposit() + val error = verifyMayaDepositShape( + tx.bitcoinSerialize(), params, otherVault.toBase58(), vaultDuffs, memo + ) + assertNotNull(error) + assertTrue(error!!.contains("expected the Asgard vault")) + } + + @Test + fun wrongMemoFails() { + val error = verify(buildDeposit(memoBytes = "=:ETH.ETH:0xWRONG".toByteArray())) + assertNotNull(error) + assertTrue(error!!.contains("memo")) + } + + @Test + fun valueCarryingOpReturnFails() { + val error = verify(buildDeposit(memoValue = 546L)) + assertNotNull(error) + assertTrue(error!!.contains("zero-value")) + } + + @Test + fun memoNotAtVout1Fails() { + // vault, change, memo — memo displaced to VOUT2 (what BIP-69 + // sorting would do to a zero-value OP_RETURN is the opposite, but + // any displacement must fail). + val tx = Transaction(params) + tx.addOutput(Coin.valueOf(vaultDuffs), vaultAddress) + tx.addOutput(Coin.valueOf(50_000), Address.fromKey(params, senderKey)) + tx.addOutput( + TransactionOutput(params, tx, Coin.ZERO, ScriptBuilder.createOpReturnScript(memo).program) + ) + val scriptSig = ScriptBuilder().data(ByteArray(71)).data(senderKey.pubKey).build() + tx.addInput( + org.bitcoinj.core.TransactionInput( + params, tx, scriptSig.program, TransactionOutPoint(params, 0, Sha256Hash.ZERO_HASH) + ) + ) + val error = verify(tx) + assertNotNull(error) + assertTrue(error!!.contains("VOUT1")) + } + + @Test + fun changeToForeignAddressFails() { + val error = verify(buildDeposit(changeKey = ECKey())) + assertNotNull(error) + assertEquals("VOUT2 change does not pay VIN0's address", error) + } + + @Test + fun extraOutputFails() { + val tx = buildDeposit() + tx.addOutput(Coin.valueOf(1_000), Address.fromKey(params, ECKey())) + val error = verify(tx) + assertNotNull(error) + assertTrue(error!!.contains("expected 2 or 3 outputs")) + } + + @Test + fun garbageBytesFail() { + val error = verifyMayaDepositShape( + ByteArray(32) { 0x42 }, params, vaultAddress.toBase58(), vaultDuffs, memo + ) + assertNotNull(error) + assertTrue(error!!.contains("unparseable")) + } +} From f678d207ffe648424c8c30760ee1952438727bcc Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 4 Aug 2026 16:40:46 -0700 Subject: [PATCH 02/15] refactor(maya): MayaBlockchainApiImpl is now dashj-free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shape verification runs on the SDK's own consensus decoder (TransactionDecoder) instead of bitcoinj parsing; the memo check is now byte-for-byte against the expected OP_RETURN script, and the change-to-VIN0 check uses the decoder's recovered sender address. A decode failure counts as a failed shape check (released, recoverable), never a broadcastable pass. - The transition-only lockOutput reservation mirror moves to its own clearly-marked helper (ReservationLockMirror) so the last dashj on this flow is quarantined in one Phase-2-deletable class. - Duffs conversion is pure decimal arithmetic (no Coin types). - MayaDepositShapeTest rebuilt on hand-built DecodedTransaction fixtures — 13 host cases, no dashj, no native library. Co-Authored-By: Claude Fable 5 --- .../wallet/payments/MayaBlockchainApiImpl.kt | 177 ++++++++--------- .../platform/sdk/ReservationLockMirror.kt | 61 ++++++ .../wallet/payments/MayaDepositShapeTest.kt | 185 ++++++++++-------- 3 files changed, 251 insertions(+), 172 deletions(-) create mode 100644 wallet/src/de/schildbach/wallet/service/platform/sdk/ReservationLockMirror.kt diff --git a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt index 179ae4139a..5384508194 100644 --- a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt +++ b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt @@ -18,60 +18,67 @@ package de.schildbach.wallet.payments import de.schildbach.wallet.Constants -import de.schildbach.wallet.data.WalletData 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.SdkDeferredPayment import de.schildbach.wallet.service.platform.sdk.SdkL1SendService import de.schildbach.wallet.service.platform.sdk.SdkWriteResult -import de.schildbach.wallet.util.toDashjCoin +import de.schildbach.wallet.service.platform.sdk.toSdkNetwork import kotlinx.coroutines.CancellationException -import org.bitcoinj.core.Context -import org.bitcoinj.core.NetworkParameters -import org.bitcoinj.core.Transaction -import org.bitcoinj.core.Utils -import org.bitcoinj.script.ScriptPattern import org.dash.wallet.common.data.ResponseResource import org.dash.wallet.common.services.InsufficientFundsException -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.SwapQuoteRequest import org.dash.wallet.integrations.maya.model.SwapTradeUIModel +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 +/** + * 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-built signed bytes: + * "UTXO Chains") against the SDK-decoded signed transaction: * * - `VOUT0` pays [vaultAddressBase58] exactly [vaultDuffs]; - * - `VOUT1` is a zero-value OP_RETURN carrying exactly [memo]; + * - `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 its - * inputs — host-testable without a wallet. + * recoverable: release the reservation and surface the error. Pure over the + * decoded transaction — host-testable without a wallet or native library. */ internal fun verifyMayaDepositShape( - rawTxBytes: ByteArray, - params: NetworkParameters, + tx: DecodedTransaction, vaultAddressBase58: String, vaultDuffs: Long, memo: ByteArray ): String? { - val tx = try { - Transaction(params, rawTxBytes) - } catch (e: Exception) { - return "unparseable transaction: ${e.message}" - } if (tx.inputs.isEmpty()) { return "transaction has no inputs" } @@ -80,45 +87,41 @@ internal fun verifyMayaDepositShape( } val vaultOutput = tx.outputs[0] - val vaultPaysTo = try { - vaultOutput.scriptPubKey.getToAddress(params).toBase58() - } catch (e: Exception) { + if (vaultOutput.address == null) { return "VOUT0 is not a plain address output" } - if (vaultPaysTo != vaultAddressBase58) { - return "VOUT0 pays $vaultPaysTo, expected the Asgard vault $vaultAddressBase58" + if (vaultOutput.address != vaultAddressBase58) { + return "VOUT0 pays ${vaultOutput.address}, expected the Asgard vault $vaultAddressBase58" } - if (vaultOutput.value.value != vaultDuffs) { - return "VOUT0 carries ${vaultOutput.value.value} duffs, expected $vaultDuffs" + if (vaultOutput.valueDuffs != vaultDuffs) { + return "VOUT0 carries ${vaultOutput.valueDuffs} duffs, expected $vaultDuffs" } val memoOutput = tx.outputs[1] - if (!ScriptPattern.isOpReturn(memoOutput.scriptPubKey)) { - return "VOUT1 is not an OP_RETURN" + if (memoOutput.valueDuffs != 0L) { + return "VOUT1 OP_RETURN must be zero-value, carries ${memoOutput.valueDuffs} duffs" } - if (memoOutput.value.value != 0L) { - return "VOUT1 OP_RETURN must be zero-value, carries ${memoOutput.value.value} duffs" - } - val payload = memoOutput.scriptPubKey.chunks.getOrNull(1)?.data - if (payload == null || !payload.contentEquals(memo)) { - return "VOUT1 memo does not match the swap memo" + 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] - if (!ScriptPattern.isP2PKH(change.scriptPubKey)) { + // 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: a signed P2PKH input's scriptSig is , - // so VIN0's address hash is HASH160 of its second chunk. Only - // checkable when the input really is P2PKH-signed; a missing pubkey - // chunk is left to the engine's own change_to_first_input contract. - val vin0PubKey = tx.getInput(0).scriptSig.chunks.getOrNull(1)?.data - if (vin0PubKey != null) { - val changeHash = ScriptPattern.extractHashFromP2PKH(change.scriptPubKey) - if (!Utils.sha256hash160(vin0PubKey).contentEquals(changeHash)) { - return "VOUT2 change does not pay VIN0's address" - } + // 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 @@ -130,15 +133,14 @@ internal fun verifyMayaDepositShape( * 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]), then broadcasts. Lives - * here so integrations/maya stays free of wallet-engine types. + * reaches the network ([verifyMayaDepositShape], over the SDK's own + * [TransactionDecoder]), then broadcasts. Lives here so integrations/maya + * stays free of wallet-engine types. * - * The dashj transaction-construction leg (manual `SendRequest`, output - * clearing/re-signing, the fresh-Transaction confidence workaround) is - * DELETED per the replace-then-delete policy — the same treatment BIP70 - * got. The dashj foundation wallet is still used for two bounded jobs: - * the transition-only reservation mirror (below) and parsing the signed - * bytes in [verifyMayaDepositShape]. + * 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 @@ -152,7 +154,7 @@ internal fun verifyMayaDepositShape( class MayaBlockchainApiImpl @Inject constructor( private val sdkL1SendService: SdkL1SendService, private val mayaWebApi: MayaWebApi, - private val walletData: WalletData, + private val reservationLockMirror: ReservationLockMirror, private val bridgedTransactionFactory: SdkBridgedTransactionFactory ) : MayaBlockchainApi { companion object { @@ -169,6 +171,9 @@ class MayaBlockchainApiImpl @Inject constructor( * returns as VOUT2 change. */ private const val MAX_SELL_FEE_RESERVE_DUFFS = 10_000L + + /** Duffs per DASH as a decimal shift (1 DASH = 1e8 duffs). */ + private const val DUFFS_DECIMAL_SHIFT = 8 } override suspend fun commitSwapTransaction( @@ -196,7 +201,6 @@ class MayaBlockchainApiImpl @Inject constructor( override suspend fun buildAndSendSwapTx( swapTradeUIModel: SwapTradeUIModel ): ResponseResource { - val params = Constants.NETWORK_PARAMETERS try { // memo documentation: // https://docs.mayaprotocol.com/mayachain-dev-docs/concepts/transaction-memos#swap @@ -229,11 +233,15 @@ class MayaBlockchainApiImpl @Inject constructor( // 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 { swapTradeUIModel.amount.dash - }.setScale(8, RoundingMode.HALF_UP).toCoin().toDashjCoin().value + }.setScale(DUFFS_DECIMAL_SHIFT, RoundingMode.HALF_UP) + .movePointRight(DUFFS_DECIMAL_SHIFT) + .longValueExact() // Build + sign with the funding inputs RESERVED, no broadcast. // Any throw here is pre-broadcast by construction, so the MAX @@ -261,11 +269,21 @@ class MayaBlockchainApiImpl @Inject constructor( // Assert the deposit shape from the signed bytes BEFORE any // broadcast decision — a mis-shaped deposit to a Maya vault - // strands funds, so this replaces (and strengthens) the old - // post-completeTx output checks. - val shapeError = verifyMayaDepositShape( - payment.rawTxBytes, params, swapTradeUIModel.vaultAddress, vaultDuffs, memoBytes - ) + // 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. + val shapeError = try { + verifyMayaDepositShape( + TransactionDecoder.decode(payment.rawTxBytes, toSdkNetwork(Constants.NETWORK_PARAMETERS)), + swapTradeUIModel.vaultAddress, + vaultDuffs, + 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) @@ -278,11 +296,11 @@ class MayaBlockchainApiImpl @Inject constructor( } // Reservation mirror — TRANSITION-ONLY, same rationale and - // lifetime as the BIP70 mirror in SendCoinsTaskRunner: 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 { setReservedOutpointLocks(payment, locked = true) } + // 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, vaultDuffs) @@ -306,7 +324,7 @@ class MayaBlockchainApiImpl @Inject constructor( is SdkWriteResult.NotBroadcast -> { // Provably never reached the network: free the inputs so a // retry can rebuild cleanly. - runCatching { setReservedOutpointLocks(payment, locked = false) } + runCatching { reservationLockMirror.setLocks(payment, locked = false) } .onFailure { log.warn("failed to clear the mirrored maya reservation locks", it) } sdkL1SendService.releaseDeferredPayment(payment) ResponseResource.Failure( @@ -344,8 +362,7 @@ class MayaBlockchainApiImpl @Inject constructor( } catch (t: Throwable) { if (isInsufficientFunds(t)) { // Neutral exception so the maya module can detect it without - // wallet-engine types — the same contract the dashj path kept - // by converting InsufficientMoneyException. + // wallet-engine types. return ResponseResource.Failure(InsufficientFundsException(t.message, t), false, 0, t.message) } log.error("failed to build/send maya swap deposit", t) @@ -368,24 +385,4 @@ class MayaBlockchainApiImpl @Inject constructor( generateSequence(t) { it.cause?.takeIf { cause -> cause !== it } } .take(5) .any { it.message?.contains("insufficient funds", ignoreCase = true) == true } - - /** - * Lock/unlock the outpoints [payment]'s signed tx spends in the - * foundation dashj wallet — the app-side mirror of the SDK's engine - * reservation, identical to the BIP70 mirror (TRANSITION-ONLY, dies - * with Phase 2). Pure bookkeeping on the Phase-3 foundation object. - */ - private fun setReservedOutpointLocks(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/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/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt b/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt index 7ea214416d..c350430a0d 100644 --- a/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt +++ b/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt @@ -17,15 +17,7 @@ package de.schildbach.wallet.payments -import org.bitcoinj.core.Address -import org.bitcoinj.core.Coin -import org.bitcoinj.core.ECKey -import org.bitcoinj.core.Sha256Hash -import org.bitcoinj.core.Transaction -import org.bitcoinj.core.TransactionOutPoint -import org.bitcoinj.core.TransactionOutput -import org.bitcoinj.params.TestNet3Params -import org.bitcoinj.script.ScriptBuilder +import org.dashfoundation.dashsdk.keywallet.DecodedTransaction import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -35,119 +27,109 @@ 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 with dashj, the - * same library the verifier parses with. + * 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 params = TestNet3Params.get() - private val vaultKey = ECKey() - private val vaultAddress = Address.fromKey(params, vaultKey) - private val senderKey = ECKey() + private val vaultAddress = "yMqShkrgjTRuReBGFpQr7FozEF1QcNBBYA" + private val senderAddress = "yNDj28QBMm5sY6bLjFcNdWRNef24KLQNuQ" private val memo = "=:ETH.ETH:0x1c7b17362c84287bd1184447e6dfeaf920c31bbe".toByteArray() private val vaultDuffs = 1_000_000L - /** - * A Maya-shaped deposit: vault at VOUT0, OP_RETURN memo at VOUT1, - * optional change at VOUT2, one input carrying a P2PKH-style scriptSig - * (` `) so the change-to-VIN0 check has a pubkey to hash. - */ - private fun buildDeposit( + 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, - changeKey: ECKey? = senderKey, memoValue: Long = 0L - ): Transaction { - val tx = Transaction(params) - tx.addOutput(Coin.valueOf(vaultValue), vaultAddress) - tx.addOutput( - TransactionOutput( - params, - tx, - Coin.valueOf(memoValue), - ScriptBuilder.createOpReturnScript(memoBytes).program - ) + ): DecodedTransaction { + val outputs = mutableListOf( + addressOutput(vaultAddress, vaultValue, scriptSeed = 1), + memoOutput(memoBytes, memoValue) ) - if (changeKey != null) { - tx.addOutput(Coin.valueOf(50_000), Address.fromKey(params, changeKey)) + if (withChange) { + outputs += addressOutput(changeAddress, 50_000, scriptSeed = 2) } - // A fake signed P2PKH input: 71 zero bytes stand in for the DER - // signature; the pubkey chunk is real so HASH160 comparisons work. - val scriptSig = ScriptBuilder() - .data(ByteArray(71)) - .data(senderKey.pubKey) - .build() - tx.addInput( - org.bitcoinj.core.TransactionInput( - params, - tx, - scriptSig.program, - TransactionOutPoint(params, 0, Sha256Hash.ZERO_HASH) - ) - ) - return tx + return DecodedTransaction(ByteArray(32), listOf(input(vin0Address)), outputs) } - private fun verify(tx: Transaction, expectedVaultDuffs: Long = vaultDuffs, memoBytes: ByteArray = memo): String? = - verifyMayaDepositShape(tx.bitcoinSerialize(), params, vaultAddress.toBase58(), expectedVaultDuffs, memoBytes) + private fun verify(tx: DecodedTransaction): String? = + verifyMayaDepositShape(tx, vaultAddress, vaultDuffs, memo) @Test fun wellFormedDepositPasses() { - assertNull(verify(buildDeposit())) + assertNull(verify(deposit())) } @Test fun wellFormedDepositWithoutChangePasses() { - assertNull(verify(buildDeposit(changeKey = null))) + 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(buildDeposit(vaultValue = vaultDuffs + 1)) + val error = verify(deposit(vaultValue = vaultDuffs + 1)) assertNotNull(error) assertTrue(error!!.contains("VOUT0")) } @Test fun wrongVaultAddressFails() { - val otherVault = Address.fromKey(params, ECKey()) - val tx = buildDeposit() - val error = verifyMayaDepositShape( - tx.bitcoinSerialize(), params, otherVault.toBase58(), vaultDuffs, memo - ) + 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(buildDeposit(memoBytes = "=:ETH.ETH:0xWRONG".toByteArray())) + val error = verify(deposit(memoBytes = "=:ETH.ETH:0xWRONG".toByteArray())) assertNotNull(error) - assertTrue(error!!.contains("memo")) + assertTrue(error!!.contains("VOUT1")) } @Test fun valueCarryingOpReturnFails() { - val error = verify(buildDeposit(memoValue = 546L)) + val error = verify(deposit(memoValue = 546L)) assertNotNull(error) assertTrue(error!!.contains("zero-value")) } @Test fun memoNotAtVout1Fails() { - // vault, change, memo — memo displaced to VOUT2 (what BIP-69 - // sorting would do to a zero-value OP_RETURN is the opposite, but - // any displacement must fail). - val tx = Transaction(params) - tx.addOutput(Coin.valueOf(vaultDuffs), vaultAddress) - tx.addOutput(Coin.valueOf(50_000), Address.fromKey(params, senderKey)) - tx.addOutput( - TransactionOutput(params, tx, Coin.ZERO, ScriptBuilder.createOpReturnScript(memo).program) - ) - val scriptSig = ScriptBuilder().data(ByteArray(71)).data(senderKey.pubKey).build() - tx.addInput( - org.bitcoinj.core.TransactionInput( - params, tx, scriptSig.program, TransactionOutPoint(params, 0, Sha256Hash.ZERO_HASH) + // 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) @@ -157,26 +139,65 @@ class MayaDepositShapeTest { @Test fun changeToForeignAddressFails() { - val error = verify(buildDeposit(changeKey = ECKey())) + 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 = buildDeposit() - tx.addOutput(Coin.valueOf(1_000), Address.fromKey(params, ECKey())) + 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 garbageBytesFail() { - val error = verifyMayaDepositShape( - ByteArray(32) { 0x42 }, params, vaultAddress.toBase58(), vaultDuffs, memo + fun noInputsFails() { + val tx = DecodedTransaction( + ByteArray(32), + emptyList(), + listOf(addressOutput(vaultAddress, vaultDuffs, scriptSeed = 1), memoOutput()) ) + val error = verify(tx) assertNotNull(error) - assertTrue(error!!.contains("unparseable")) + assertTrue(error!!.contains("no inputs")) } } From 979fd288ca985e405a7300be57d13b5bf95772ba Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 5 Aug 2026 09:31:32 -0700 Subject: [PATCH 03/15] fix(maya): swap confirmation no longer burns its full 10s lock timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field-tested on mainnet 2026-08-05 (tx 59f7d755…d8ce): the engine had the InstantSend lock 1.6s after broadcast, but the confirmation screen still waited out its whole 10s timeout. Two compounding causes: - The ViewModel watched observeTransactionLocked, a change-only stream — on the SDK route the lock usually lands BEFORE the send call returns, so the subscription missed the event and nothing ever re-fired. It now calls waitUntilLocked, which returns immediately for an already-locked tx (same timeout as the outer bound). - WalletDataAdapter.waitUntilLocked preferred the held dashj wallet whenever the tx existed there — true for every bridged SDK send — and a bridged copy's confidence is frozen (no peergroup ever delivers it an IS-lock), so that wait could never complete post-cutover. The seam path (live engine lock state, race-free current-state replay) now runs first; the dashj-confidence path remains for pre-cutover and for txs the SDK store never learned. Also benefits CrowdNode's top-up lock wait, the other waitUntilLocked caller, which had the same frozen-confidence exposure for bridged sends. Co-Authored-By: Claude Fable 5 --- .../maya/ui/MayaConversionPreviewViewModel.kt | 17 +++++++++---- .../wallet/data/WalletDataAdapter.kt | 24 +++++++++++-------- 2 files changed, 26 insertions(+), 15 deletions(-) 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..b32df258b8 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,21 @@ 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) + } != null + } 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") From c0db65fca4270ae4367ec85ae7830fe6c4efcb73 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 5 Aug 2026 10:37:52 -0700 Subject: [PATCH 04/15] fix(maya): home-screen swap rows update without a manual cache wipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-08-05 mainnet field test left the swap row stale until the long-tap History rebuild. The chain was intact up to the last step: the swap-orders write re-emitted presentable metadata, the display cache computed the changed row and rebuilt it with the fresh swap decoration — and then the SDK-stamped shape freeze threw the new title/icon/status away (both the inline metadata-path merge and mergeDisplayEntryPreservingSdkStamped keep the CACHED shape for SDK-authoritative rows, because a dashj rebuild cannot be trusted to re-derive value or direction). The swap decoration is the exception the freeze must admit: it is metadata-authoritative — the very swap-orders table whose change triggered the rebuild — not a dashj recomputation. Both merge sites now pass icon/title/status through when the rebuild carries swap metadata (entry.swapStatus != null), while value, exchange rate, contact identity and the filter bucket stay frozen. A rebuild WITHOUT swap metadata still never undresses an existing swap row. Covered by three new TxDisplayCacheMergeGuardTest cases (decoration passes the freeze; PENDING→COMPLETED retitles; a metadata-less rebuild keeps the swap shape); service suite green. Co-Authored-By: Claude Fable 5 --- .../wallet/service/TxDisplayCacheService.kt | 37 +++++++--- .../service/TxDisplayCacheMergeGuardTest.kt | 72 ++++++++++++++++++- 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt b/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt index cff85e10ae..353965ea69 100644 --- a/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt +++ b/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt @@ -369,12 +369,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 ) } @@ -1267,12 +1276,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/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) + } } From 497ac1badba13fb4963b622ebc62e3d5de8d7903 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 5 Aug 2026 15:36:26 -0700 Subject: [PATCH 05/15] =?UTF-8?q?fix(maya)!:=20max=20sells=20reserve=20a?= =?UTF-8?q?=20MEASURED=20fee=20=E2=80=94=20no=20under-delivery,=20no=20das?= =?UTF-8?q?hj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The max-sell fee reserve could come in under the real fee, which makes the deposit pay the vault LESS than quoted. That is never acceptable: NEAR Intents refuses under-delivery (the deposit sits ~1h, then refunds minus 0.001 DASH) and Maya would execute a swap for an amount the user never agreed to. Three problems, one cause — nobody had revisited the max path for the cutover: - SwapKit sized the sweep with dashj (estimateNetworkFee → completeTx on the HELD wallet). Post-cutover incoming SDK transactions never reach that wallet, so its coin set is frozen at cutover time: the estimate either throws (any funds received since the cutover are invisible to it, so a max quote fails outright) or prices the wrong shape (no OP_RETURN). - The direct Maya route had no reserve at all — it quoted the full balance and relied on a silent adjust-down retry at build time, i.e. exactly the under-delivery this commit refuses. - The retry itself masked the problem instead of reporting it. Replaced with a MEASURED figure: SdkL1SendService.maxMayaDepositDuffs builds a throwaway deposit through the real engine (same builder, same three options, the wallet's own address as a size stand-in), reads the fee off the reservation and releases it. Biased HIGH by construction — a worst-case 80-byte memo, a change output kept in the probe so the measured size matches the real one, and 1 000 duffs of headroom to keep that change clear of dust — so the reserve can never fall short. Exposed neutrally as MayaBlockchainApi.maxSwapDepositAmount. Both routes now quote exactly that figure and deposit exactly what they quoted. The silent retry is gone: if the balance drops between quote and build, the deposit aborts with a re-quote error. Re-measuring at build time uses the REAL memo, which can only raise the ceiling set by the worst-case quote, so the guard cannot fire spuriously. The maya module no longer calls estimateNetworkFee anywhere — the last dashj on the swap path is gone. 7 new SdkL1SendServiceTest cases; payments + service suites and ktlint green. Co-Authored-By: Claude Fable 5 --- .../maya/api/MayaBlockchainApi.kt | 15 +++ .../maya/swapkit/SwapKitApiAggregator.kt | 47 ++++---- .../wallet/payments/MayaBlockchainApiImpl.kt | 101 +++++++++++----- .../service/platform/sdk/SdkL1SendService.kt | 86 +++++++++++++ .../platform/sdk/SdkL1SendServiceTest.kt | 114 +++++++++++++++++- 5 files changed, 308 insertions(+), 55 deletions(-) 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 4895f75d06..e2591b3cbc 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,6 +18,7 @@ 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 /** @@ -46,4 +47,18 @@ interface MayaBlockchainApi { suspend fun buildAndSendSwapTx( swapTradeUIModel: SwapTradeUIModel ): ResponseResource + + /** + * The largest amount a swap deposit can pay a vault right now: spendable + * balance minus the deposit's own mining fee (MEASURED through the real + * SDK builder, never estimated by the retired dashj engine) minus a small + * change-output headroom. + * + * Quote a MAX sell at exactly this figure. The measurement is biased HIGH + * — worst-case memo size, change output included — because a reserve that + * came in under the real fee 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 the balance cannot fund a deposit at all. + */ + suspend fun maxSwapDepositAmount(): Dash } 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/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt index 5384508194..1e545ed6be 100644 --- a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt +++ b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt @@ -26,6 +26,7 @@ import de.schildbach.wallet.service.platform.sdk.SdkWriteResult import de.schildbach.wallet.service.platform.sdk.toSdkNetwork import kotlinx.coroutines.CancellationException import org.dash.wallet.common.data.ResponseResource +import org.dash.wallet.common.money.Dash import org.dash.wallet.common.services.InsufficientFundsException import org.dash.wallet.integrations.maya.api.MayaBlockchainApi import org.dash.wallet.integrations.maya.api.MayaException @@ -150,6 +151,11 @@ internal fun verifyMayaDepositShape( * 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 set to [maxSwapDepositAmount] + * (balance − a MEASURED fee − change headroom), the deposit pays exactly that, + * and a balance drop between quote and build aborts instead of quietly paying + * the vault less than quoted. */ class MayaBlockchainApiImpl @Inject constructor( private val sdkL1SendService: SdkL1SendService, @@ -160,30 +166,46 @@ class MayaBlockchainApiImpl @Inject constructor( companion object { private val log: Logger = LoggerFactory.getLogger(MayaBlockchainApiImpl::class.java) - /** - * Adjust-down reserve for a MAX sell, in duffs. A max quote is - * derived from the spendable balance, which leaves nothing for the - * mining fee — when the engine reports the shortfall (a provably - * pre-broadcast build failure; nothing was reserved or sent), the - * build retries ONCE with this reserve carved out of the vault - * amount. 10 000 duffs covers the fee of a deposit spending ~67 - * inputs at the engine's default rate; any unspent remainder - * returns as VOUT2 change. - */ - private const val MAX_SELL_FEE_RESERVE_DUFFS = 10_000L - /** 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) { + val maxDeposit = maxSwapDepositAmount() + 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, @@ -243,30 +265,43 @@ class MayaBlockchainApiImpl @Inject constructor( .movePointRight(DUFFS_DECIMAL_SHIFT) .longValueExact() - // Build + sign with the funding inputs RESERVED, no broadcast. - // Any throw here is pre-broadcast by construction, so the MAX - // sell's mining-fee shortfall may be retried once, adjusted - // down by the reserve (nothing has moved). - var vaultDuffs = quotedDuffs - val payment = try { - sdkL1SendService.buildDeferredMayaDeposit(swapTradeUIModel.vaultAddress, vaultDuffs, memoBytes) - } catch (e: CancellationException) { - throw e - } catch (t: Throwable) { - if (swapTradeUIModel.maximum && isInsufficientFunds(t) && - quotedDuffs > MAX_SELL_FEE_RESERVE_DUFFS - ) { - vaultDuffs = quotedDuffs - MAX_SELL_FEE_RESERVE_DUFFS - log.info( - "maya max sell: {} duffs not fundable with the fee; retrying at {} duffs", - quotedDuffs, vaultDuffs + val vaultDuffs = quotedDuffs + + // A MAX sell was quoted at the measured maximum deposit (balance − + // measured fee − headroom). 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 ) - sdkL1SendService.buildDeferredMayaDeposit(swapTradeUIModel.vaultAddress, vaultDuffs, memoBytes) - } else { - throw t } } + // Build + sign with the funding inputs RESERVED, no broadcast. + val payment = + sdkL1SendService.buildDeferredMayaDeposit(swapTradeUIModel.vaultAddress, vaultDuffs, memoBytes) + // 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; 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 a269a426c7..776c2288a5 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt @@ -22,6 +22,7 @@ import de.schildbach.wallet.ui.dashpay.utils.DashPayConfig import de.schildbach.wallet_test.BuildConfig import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex @@ -2000,6 +2001,73 @@ class SdkL1SendService internal constructor( return payment } + /** + * The largest amount a MAYACHAIN deposit can pay a vault right now: + * spendable balance MINUS the deposit's own mining fee MINUS a small + * change-output headroom. + * + * The fee is MEASURED, not guessed: a throwaway deposit is built through + * the real engine (same builder, same three options, the wallet's own + * address as a size stand-in) and its reported fee read off the + * reservation, which is then released. An estimate 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 (~1h wait, then a refund minus 0.001 DASH), so the + * measurement is deliberately biased high: + * + * - [memoSizeBytes] defaults to the 80-byte OP_RETURN ceiling, so a + * shorter real memo can only make the real transaction smaller; + * - the probe keeps a change output (as the real deposit will, thanks to + * the headroom), so the measured size matches the real one instead of + * under-counting by an output; + * - [MAYA_DEPOSIT_CHANGE_HEADROOM_DUFFS] of headroom keeps that change + * output comfortably above dust rather than at the threshold. + * + * Returns 0 when the balance cannot cover a deposit at all (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}" } + val spendable = source.spendableBalanceDuffs(walletIdHex) + // The probe itself must be fundable, with a change output, before any + // fee can be measured. + if (spendable <= MAYA_DEPOSIT_PROBE_RESERVE_DUFFS) { + log.info("SDK l1MayaMaxDeposit: spendable {} too small to probe a deposit", spendable) + return 0L + } + val probeAddress = checkNotNull(source.unusedExternalAddress(walletIdHex)) { + "no SDK address available to size a Maya deposit" + } + val probe = source.buildDeferredMayaDeposit( + walletIdHex, + probeAddress, + spendable - MAYA_DEPOSIT_PROBE_RESERVE_DUFFS, + ByteArray(memoSizeBytes) + ) + val feeDuffs = try { + probe.feeDuffs + } finally { + // NonCancellable: the probe holds a real engine reservation, and + // leaving it to the TTL sweep would make the very next real build + // fail to fund. + withContext(NonCancellable) { releaseDeferredPayment(probe) } + } + val max = spendable - feeDuffs - MAYA_DEPOSIT_CHANGE_HEADROOM_DUFFS + log.info( + "SDK l1MayaMaxDeposit: spendable {}, measured fee {} ({}-byte memo), max deposit {}", + spendable, feeDuffs, memoSizeBytes, max + ) + return max.coerceAtLeast(0L) + } + /** * [buildDeferredPayment] in the MAYACHAIN deposit shape (vault VOUT0, * [memo] as a zero-value OP_RETURN VOUT1, change back to VIN0's @@ -2186,5 +2254,23 @@ class SdkL1SendService internal constructor( * engine's `DEFAULT_MAX_OP_RETURN_BYTES`, which re-checks). */ const val MAX_MAYA_MEMO_BYTES = 80 + + /** + * Held back from the probe deposit in [maxMayaDepositDuffs] so it is + * fundable AND carries a change output (matching the real deposit's + * shape, so the measured fee is not an output short). Never reaches a + * quote: only the MEASURED fee plus + * [MAYA_DEPOSIT_CHANGE_HEADROOM_DUFFS] is withheld from the user. + */ + private const val MAYA_DEPOSIT_PROBE_RESERVE_DUFFS = 20_000L + + /** + * Left in the wallet by a max Maya deposit, on top of the measured + * fee: the deposit's change output must stay clear of the dust + * threshold (546 duffs), and a couple of duffs of slack absorbs a + * shorter-than-worst-case memo. 0.00001 DASH — negligible to the + * user, and it makes UNDER-reserving impossible. + */ + private const val MAYA_DEPOSIT_CHANGE_HEADROOM_DUFFS = 1_000L } } 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 0dc7ed26ae..ef6e472923 100644 --- a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt @@ -56,7 +56,10 @@ class SdkL1SendServiceTest { var onSendAll: (String, String, Long) -> String = { _, _, _ -> throw IllegalStateException("send-all not stubbed") }, - var onSweepReceival: () -> Long = { 0L } + var onSweepReceival: () -> Long = { 0L }, + /** Fee the faked engine reports for a probe/real Maya deposit build. */ + var onMayaDepositFee: (Long, ByteArray) -> Long = { _, _ -> 0L }, + var externalAddress: String? = null ) : SdkL1SendSource { var boundCalls = 0 var sendCalls = 0 @@ -125,6 +128,37 @@ 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 + + override suspend fun buildDeferredMayaDeposit( + walletIdHex: String, + vaultAddressBase58: String, + vaultDuffs: Long, + memo: ByteArray + ): SdkDeferredPayment { + mayaBuildCalls++ + 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 { @@ -1210,4 +1244,82 @@ class SdkL1SendServiceTest { assertTrue(java.lang.reflect.Modifier.isNative(jni.modifiers)) assertEquals(ByteArray::class.java, jni.returnType) } + + // ── 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) = FakeSource( + boundWalletId = { walletId }, + onSpendable = { spendable }, + onMayaDepositFee = { _, _ -> feeDuffs }, + externalAddress = validAddress + ) + + @Test + fun maxMayaDepositSubtractsTheMeasuredFeeAndTheChangeHeadroom() = runBlocking { + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + // 1_000_000 − 500 measured fee − 1_000 headroom + assertEquals(998_500L, service(source).maxMayaDepositDuffs()) + } + + @Test + fun maxMayaDepositProbesWithAFundableChangeCarryingBuildAndReleasesIt() = runBlocking { + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + service(source).maxMayaDepositDuffs() + assertEquals(1, source.mayaBuildCalls) + // Probe holds back enough to stay fundable AND keep a change output, so + // the measured size matches the real deposit's three-output shape. + assertEquals(980_000L, source.mayaBuiltAmounts.single()) + assertEquals(validAddress, source.mayaBuiltVault) + // The probe's reservation must not leak — the very next real build + // would otherwise fail to fund. + assertEquals(1, source.mayaReleaseCalls) + } + + @Test + fun maxMayaDepositProbesWithTheWorstCaseMemoByDefault() = runBlocking { + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + service(source).maxMayaDepositDuffs() + // Worst case: a shorter real memo can only shrink the real tx, so the + // reserve can never end up under the real fee. + assertEquals(SdkL1SendService.MAX_MAYA_MEMO_BYTES, source.mayaBuiltMemoSizes.single()) + } + + @Test + fun maxMayaDepositHonoursAnExplicitMemoSize() = runBlocking { + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + service(source).maxMayaDepositDuffs(memoSizeBytes = 72) + assertEquals(72, source.mayaBuiltMemoSizes.single()) + } + + @Test + fun maxMayaDepositIsZeroWhenTheBalanceCannotFundAProbe() = runBlocking { + val source = mayaSource(spendable = 20_000L, feeDuffs = 500L) + assertEquals(0L, service(source).maxMayaDepositDuffs()) + // Nothing is built or reserved when there is nothing to measure. + assertEquals(0, source.mayaBuildCalls) + } + + @Test + fun maxMayaDepositNeverGoesNegative() = runBlocking { + // A fee larger than what is left after the probe reserve would make the + // arithmetic negative; the caller must see 0 ("not enough funds"), + // never a negative quote. + val source = mayaSource(spendable = 20_001L, feeDuffs = 25_000L) + assertEquals(0L, service(source).maxMayaDepositDuffs()) + } + + @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 + } } From 2c71384b2ce384bff15b214de148a45f6cd6ae1e Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Wed, 5 Aug 2026 15:43:25 -0700 Subject: [PATCH 06/15] fix(maya): a max swap deposit must not sweep CrowdNode-locked outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A max deposit spends all but the change headroom, so coin selection reaches every UTXO — including app-locked ones. spendableBalanceDuffs deliberately INCLUDES app-locked outputs (the engine has no lock concept and the FFI exposes no exclusion API), which is exactly why the send-all drain refuses to run while any lock exists. The Maya max path bypassed that guard and would have swept CrowdNode-protected funds into a swap. Extracted the drain's fail-closed preflight (dashj wallet locks OR seam-registered locks on SDK-only txs, blocking on a check failure too) into hasProtectedOutputs, and applied it in maxMayaDepositDuffs: refuse to quote rather than build a deposit that spends protected funds. A partial (non-max) deposit is unchanged — it carries the same exposure as any ordinary send. commitSwapTransaction contains the refusal as a recoverable failure instead of letting it escape the caller's scope. 3 new tests (dashj lock, seam lock, check-throws); nothing is built or measured in any of those cases. Suites and ktlint green. Co-Authored-By: Claude Fable 5 --- .../wallet/payments/MayaBlockchainApiImpl.kt | 18 +++++- .../service/platform/sdk/SdkL1SendService.kt | 57 ++++++++++++------- .../platform/sdk/SdkL1SendServiceTest.kt | 42 ++++++++++++++ 3 files changed, 96 insertions(+), 21 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt index 1e545ed6be..6ae897003a 100644 --- a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt +++ b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt @@ -185,7 +185,23 @@ class MayaBlockchainApiImpl @Inject constructor( // 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) { - val maxDeposit = maxSwapDepositAmount() + // 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"), 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 776c2288a5..61b43ea2f3 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt @@ -1746,26 +1746,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", @@ -2001,6 +1982,32 @@ 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 the deposit's own mining fee MINUS a small @@ -2036,6 +2043,16 @@ class SdkL1SendService internal constructor( } val gate = probeSendGate() check(gate.allowed) { "L1 funding gate closed: ${gate.reason}" } + // FAIL-CLOSED (funds-critical): a max deposit spends all but + // [MAYA_DEPOSIT_CHANGE_HEADROOM_DUFFS] of the wallet, so coin + // selection will reach app-locked outputs (CrowdNode) — which + // [spendableBalanceDuffs] deliberately INCLUDES and the FFI cannot be + // told to exclude. Same guard the send-all drain 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. + check(!hasProtectedOutputs("l1MayaMaxDeposit")) { + "wallet has app-locked outputs (CrowdNode); a max swap deposit would spend them" + } val spendable = source.spendableBalanceDuffs(walletIdHex) // The probe itself must be fundable, with a change output, before any // fee can be measured. 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 ef6e472923..86516f0be0 100644 --- a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt @@ -1311,6 +1311,48 @@ class SdkL1SendServiceTest { assertEquals(0L, service(source).maxMayaDepositDuffs()) } + @Test + fun maxMayaDepositRefusesWhileAppLockedOutputsExist() = runBlocking { + // A max deposit spends all but the headroom, 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 maxMayaDepositRejectsAnOversizeMemo() = runBlocking { val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) From a272da29ca783b2167ee32de11e6ea70f76453dd Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 6 Aug 2026 11:54:58 -0700 Subject: [PATCH 07/15] fix(maya): measure a max deposit by draining, not by estimating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maxMayaDepositDuffs read the WALLET-WIDE spendable balance, subtracted a probe-measured fee and a change-headroom constant, and quoted the result — but the deposit funds from BIP44 account 0 alone. Any wallet holding DIP-15 contact-received or CoinJoin funds therefore quoted against money the build could not reach: the probe failed with CoreInsufficientFunds and MAX died. It failed safely, but it failed. A max deposit IS a drain, so build one and read what the engine says it delivers: every BIP44 UTXO selected, this memo's bytes priced into the fee, no change, the vault output set to (total inputs - fee). That is the same computation the real deposit performs, so quote and deposit cannot disagree — which subtracting a guess from the wrong pool could not promise. - SdkDeferredPayment carries `deliverableDuffs`: what the transaction actually pays. Supplied by the caller for an explicit build, computed by the engine for a drain. - buildDeferredMayaDeposit takes `drain`, passing SelectionStrategy.ALL. A drain supplies no amount, so 0 is passed and the engine sets the output (requires the maya7 AAR, whose JNI accepts a zero output). - maxMayaDepositDuffs builds a drain to an own address, reads its deliverable, and releases the reservation. Gone: the probe reserve, the change headroom, the spendable-minus-fee arithmetic. A drain the engine will not fund means "nothing depositable" and returns 0. - A MAX sell now BUILDS as a drain, and the built transaction is checked against the quote before any broadcast decision. The pre-build guard compared a re-measurement; this compares the signed transaction that would actually reach the vault, so nothing moving in between can defeat it. Under-delivery is refused: Maya would execute a swap the user never agreed to, and NEAR Intents rejects it outright. The CrowdNode app-locked-output refusal and the funding-gate check are unchanged — a max deposit still will not sweep protected outputs. Pin moved to the maya7 AAR. Tests: five rewritten to the drain contract (the engine-computed amount, the drain-shaped probe and its release, worst-case and explicit memo sizing, and an unfundable drain reporting 0), plus a fake that throws to model the engine's refusal. Wallet unit suite shows no regression against the same baseline; the pre-existing failures are an unrelated leaked dashj Context between test classes. Verified on-device (testnet, emulator): drain-measured max deposit 27442985 duffs, real fee 432, 80-byte memo. Co-Authored-By: Claude Opus 5 --- .../wallet/payments/MayaBlockchainApiImpl.kt | 36 +++++- .../service/platform/sdk/SdkL1SendService.kt | 108 +++++++++++++----- .../platform/sdk/SdkL1SendServiceTest.kt | 63 ++++++---- 3 files changed, 157 insertions(+), 50 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt index 6ae897003a..6240fa20f2 100644 --- a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt +++ b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt @@ -315,8 +315,40 @@ class MayaBlockchainApiImpl @Inject constructor( } // Build + sign with the funding inputs RESERVED, no broadcast. - val payment = - sdkL1SendService.buildDeferredMayaDeposit(swapTradeUIModel.vaultAddress, vaultDuffs, memoBytes) + // A MAX sell builds as a DRAIN: the engine sets the vault output to + // (total inputs - fee) with no change, so the deposit delivers the + // whole account rather than an amount the app derived separately. + val payment = sdkL1SendService.buildDeferredMayaDeposit( + swapTradeUIModel.vaultAddress, + vaultDuffs, + memoBytes, + drain = swapTradeUIModel.maximum + ) + + // A drain's amount is the ENGINE's, so check the built transaction + // against the quote before deciding to broadcast. The pre-build + // guard above compared a re-measurement; this compares THIS signed + // transaction — the one that would actually go to the vault — and + // so cannot be defeated by anything that moved in between. Paying + // the vault less than quoted is under-delivery: Maya would execute + // a swap the user never agreed to, and NEAR Intents refuses it + // outright (~1h wait, then a refund minus 0.001 DASH). + if (swapTradeUIModel.maximum && payment.deliverableDuffs < vaultDuffs) { + log.warn( + "maya max sell aborted after build: the drain delivers {} duffs, below the quoted {}", + payment.deliverableDuffs, vaultDuffs + ) + sdkL1SendService.releaseDeferredPayment(payment) + return ResponseResource.Failure( + MayaException( + "wallet balance changed; the deposit would fall below the quoted " + + "amount — please request a new quote" + ), + false, + 0, + null + ) + } // Assert the deposit shape from the signed bytes BEFORE any // broadcast decision — a mis-shaped deposit to a Maya vault 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 61b43ea2f3..2fd9b2c690 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt @@ -512,7 +512,21 @@ class SdkDeferredPayment internal constructor( val txidHex: String, val rawTxBytes: ByteArray, val feeDuffs: Long, - internal val native: Any? + internal val native: Any?, + /** + * What the single non-OP_RETURN output actually pays, in duffs. + * + * For an explicit-amount build this is the amount the caller asked for. + * For a DRAIN it is the figure the ENGINE computed (total inputs − fee, + * no change) — the caller never supplied it, so this is the only way to + * learn what the transaction will deliver. A max swap deposit must check + * this against the quoted amount BEFORE broadcasting: paying a vault less + * than quoted is under-delivery, which Maya and NEAR Intents refuse. + * + * 0 when the source could not report it (fakes, or an SDK too old to + * expose it); callers treat 0 as "unknown" rather than "pays nothing". + */ + val deliverableDuffs: Long = 0 ) // ── Source seam ─────────────────────────────────────────────────────── @@ -648,7 +662,8 @@ interface SdkL1SendSource { walletIdHex: String, vaultAddressBase58: String, vaultDuffs: Long, - memo: ByteArray + memo: ByteArray, + drain: Boolean = false ): SdkDeferredPayment = throw UnsupportedOperationException("Maya deposit build not supported by this source") @@ -1291,7 +1306,8 @@ internal class DashSdkL1SendSource( walletIdHex: String, vaultAddressBase58: String, vaultDuffs: Long, - memo: ByteArray + memo: ByteArray, + drain: Boolean ): SdkDeferredPayment { val manager = manager() val wallet = checkNotNull(manager.wallets.value[walletIdHex]) { "SDK wallet not loaded" } @@ -1300,15 +1316,33 @@ internal class DashSdkL1SendSource( // the vault recipient SDK-side, so preserveOutputOrder yields the // documented vault=VOUT0 / memo=VOUT1 shape; an over-long memo // throws pre-reservation. + // A drain supplies NO amount: the engine sets the vault output to + // (total inputs − fee), so 0 is the honest value to pass and anything + // else would be a number the engine discards. val signed = wallet.buildSignedPayment( recipients = listOf(vaultAddressBase58 to vaultDuffs), network = toSdkNetwork(Constants.NETWORK_PARAMETERS), coreSignerHandle = manager.mnemonicResolverHandle, opReturnData = memo, preserveOutputOrder = true, - changeToFirstInput = true + changeToFirstInput = true, + // A DRAIN spends every BIP44 UTXO and has the engine set the vault + // output to (total inputs - fee), memo bytes priced in, no change: + // `vaultDuffs` is ignored. That is what a MAX deposit means, and it + // removes the guess the probe-measured path had to make. + selectionStrategy = if (drain) { + org.dashfoundation.dashsdk.wallet.CoreTransactionBuilder.SelectionStrategy.ALL + } else { + null + } + ) + return SdkDeferredPayment( + signed.txidHex, + signed.rawTxBytes, + signed.feeDuffs, + signed, + deliverableDuffs = signed.deliverableAmountDuffs ) - return SdkDeferredPayment(signed.txidHex, signed.rawTxBytes, signed.feeDuffs, signed) } override suspend fun broadcastDeferredPayment( @@ -2053,34 +2087,45 @@ class SdkL1SendService internal constructor( check(!hasProtectedOutputs("l1MayaMaxDeposit")) { "wallet has app-locked outputs (CrowdNode); a max swap deposit would spend them" } - val spendable = source.spendableBalanceDuffs(walletIdHex) - // The probe itself must be fundable, with a change output, before any - // fee can be measured. - if (spendable <= MAYA_DEPOSIT_PROBE_RESERVE_DUFFS) { - log.info("SDK l1MayaMaxDeposit: spendable {} too small to probe a deposit", spendable) - return 0L - } + // MEASURE BY DRAINING, don't estimate. A max deposit IS a drain, so + // build one and read what the engine says it delivers: every BIP44 UTXO + // selected, this memo's bytes priced into the fee, no change. That is + // the same computation the real deposit will perform, so quote and + // deposit cannot disagree — which subtracting a guessed fee and a + // change-headroom constant from the wallet-wide spendable could not + // promise. The probe is built to an OWN address; the destination does + // not change the fee (same P2PKH output size as a vault), and it is + // released immediately either way. val probeAddress = checkNotNull(source.unusedExternalAddress(walletIdHex)) { "no SDK address available to size a Maya deposit" } - val probe = source.buildDeferredMayaDeposit( - walletIdHex, - probeAddress, - spendable - MAYA_DEPOSIT_PROBE_RESERVE_DUFFS, - ByteArray(memoSizeBytes) - ) - val feeDuffs = try { - probe.feeDuffs + val probe = try { + source.buildDeferredMayaDeposit( + walletIdHex, + probeAddress, + 0L, // ignored under a drain + ByteArray(memoSizeBytes), + drain = true + ) + } catch (t: Throwable) { + if (t is CancellationException) throw t + // The engine refuses a drain whose inputs cannot cover the fee + // (typed InsufficientFunds), which is exactly "nothing depositable" + // — the floor the probe-reserve constant used to approximate. + log.info("SDK l1MayaMaxDeposit: no drain is fundable; max deposit 0 ({})", t.message) + return 0L + } + val max = try { + probe.deliverableDuffs } finally { // NonCancellable: the probe holds a real engine reservation, and // leaving it to the TTL sweep would make the very next real build // fail to fund. withContext(NonCancellable) { releaseDeferredPayment(probe) } } - val max = spendable - feeDuffs - MAYA_DEPOSIT_CHANGE_HEADROOM_DUFFS log.info( - "SDK l1MayaMaxDeposit: spendable {}, measured fee {} ({}-byte memo), max deposit {}", - spendable, feeDuffs, memoSizeBytes, max + "SDK l1MayaMaxDeposit: drain-measured max deposit {} duffs (fee {}, {}-byte memo)", + max, probe.feeDuffs, memoSizeBytes ) return max.coerceAtLeast(0L) } @@ -2098,9 +2143,12 @@ class SdkL1SendService internal constructor( suspend fun buildDeferredMayaDeposit( vaultAddressBase58: String, vaultDuffs: Long, - memo: ByteArray + memo: ByteArray, + drain: Boolean = false ): SdkDeferredPayment { - check(vaultDuffs > 0) { "Maya vault amount must be positive, got $vaultDuffs" } + // A drain has the engine compute the vault output, so no amount is + // supplied; every other build must name a positive one. + check(drain || 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" @@ -2113,10 +2161,14 @@ class SdkL1SendService internal constructor( } val gate = probeSendGate() check(gate.allowed) { "L1 funding gate closed: ${gate.reason}" } - val payment = source.buildDeferredMayaDeposit(walletIdHex, vault, vaultDuffs, memo) + val payment = source.buildDeferredMayaDeposit(walletIdHex, vault, vaultDuffs, memo, drain) log.info( - "SDK l1DeferredMayaBuild: built {} ({} duffs to the vault, {}-byte memo, fee {} duffs), inputs reserved", - payment.txidHex, vaultDuffs, memo.size, payment.feeDuffs + "SDK l1DeferredMayaBuild: built {} ({} duffs to the vault{}, {}-byte memo, fee {} duffs), inputs reserved", + payment.txidHex, + if (drain) payment.deliverableDuffs else vaultDuffs, + if (drain) " by DRAIN (engine-computed)" else "", + memo.size, + payment.feeDuffs ) return payment } 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 86516f0be0..c21027d209 100644 --- a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt @@ -136,21 +136,37 @@ class SdkL1SendServiceTest { 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 + memo: ByteArray, + drain: Boolean ): SdkDeferredPayment { mayaBuildCalls++ + failMayaBuildWith?.let { throw it } mayaBuiltAmounts += vaultDuffs mayaBuiltMemoSizes += memo.size mayaBuiltVault = vaultAddressBase58 + mayaBuiltDrains += drain return SdkDeferredPayment( txidHex = "bb".repeat(32), rawTxBytes = ByteArray(0), feeDuffs = onMayaDepositFee(vaultDuffs, memo), - native = null + native = null, + deliverableDuffs = if (drain) drainDeliverable else vaultDuffs ) } @@ -1250,28 +1266,31 @@ class SdkL1SendServiceTest { // 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) = FakeSource( + private fun mayaSource(spendable: Long, feeDuffs: Long, deliverable: Long = 0L) = FakeSource( boundWalletId = { walletId }, onSpendable = { spendable }, onMayaDepositFee = { _, _ -> feeDuffs }, externalAddress = validAddress - ) + ).apply { drainDeliverable = deliverable } @Test - fun maxMayaDepositSubtractsTheMeasuredFeeAndTheChangeHeadroom() = runBlocking { - val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) - // 1_000_000 − 500 measured fee − 1_000 headroom - assertEquals(998_500L, service(source).maxMayaDepositDuffs()) + fun maxMayaDepositIsTheDrainsEngineComputedAmount() = runBlocking { + // The max IS what a drain delivers. The engine computes it (total inputs + // − fee, no change), so the service must report that figure verbatim + // rather than deriving one from the wallet-wide spendable. + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L, deliverable = 999_500L) + assertEquals(999_500L, service(source).maxMayaDepositDuffs()) } @Test - fun maxMayaDepositProbesWithAFundableChangeCarryingBuildAndReleasesIt() = runBlocking { - val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + fun maxMayaDepositMeasuresWithADrainAndReleasesTheReservation() = runBlocking { + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L, deliverable = 999_500L) service(source).maxMayaDepositDuffs() assertEquals(1, source.mayaBuildCalls) - // Probe holds back enough to stay fundable AND keep a change output, so - // the measured size matches the real deposit's three-output shape. - assertEquals(980_000L, source.mayaBuiltAmounts.single()) + // Measured by DRAINING: no amount is supplied (the engine sets the + // output), so the probe passes 0 and asks for the drain strategy. + assertEquals(0L, source.mayaBuiltAmounts.single()) + assertTrue(source.mayaBuiltDrains.single()) assertEquals(validAddress, source.mayaBuiltVault) // The probe's reservation must not leak — the very next real build // would otherwise fail to fund. @@ -1280,26 +1299,30 @@ class SdkL1SendServiceTest { @Test fun maxMayaDepositProbesWithTheWorstCaseMemoByDefault() = runBlocking { - val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L, deliverable = 999_500L) service(source).maxMayaDepositDuffs() // Worst case: a shorter real memo can only shrink the real tx, so the - // reserve can never end up under the real fee. + // quote can never end up above what the real deposit can deliver. assertEquals(SdkL1SendService.MAX_MAYA_MEMO_BYTES, source.mayaBuiltMemoSizes.single()) } @Test fun maxMayaDepositHonoursAnExplicitMemoSize() = runBlocking { - val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) + val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L, deliverable = 999_500L) service(source).maxMayaDepositDuffs(memoSizeBytes = 72) assertEquals(72, source.mayaBuiltMemoSizes.single()) } @Test - fun maxMayaDepositIsZeroWhenTheBalanceCannotFundAProbe() = runBlocking { - val source = mayaSource(spendable = 20_000L, feeDuffs = 500L) + fun maxMayaDepositIsZeroWhenNoDrainIsFundable() = runBlocking { + // The engine refuses a drain whose inputs cannot cover the fee. That + // refusal IS "nothing depositable" — surface 0, not an exception, and + // leave nothing reserved. + val source = mayaSource(spendable = 20_000L, feeDuffs = 500L).apply { + failMayaBuildWith = IllegalStateException("insufficient funds for a drain") + } assertEquals(0L, service(source).maxMayaDepositDuffs()) - // Nothing is built or reserved when there is nothing to measure. - assertEquals(0, source.mayaBuildCalls) + assertEquals(0, source.mayaReleaseCalls) } @Test From 48eac3dd7b87fcae5c379905e02f25fc866d202f Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Fri, 7 Aug 2026 10:06:36 -0700 Subject: [PATCH 08/15] fix(maya): verify a MAX deposit against the engine's amount, not the quote A max sell aborted pre-broadcast with "VOUT0 carries 7442734 duffs, expected 7442725" and released a perfectly good deposit. A MAX sell builds as a DRAIN, so the ENGINE sets the vault output to (total inputs - fee); the app never supplies that number and the quote is only a FLOOR. Both guards around the build already treat it that way -- each aborts on `<` and neither on `>` -- but verifyMayaDepositShape demanded exact equality with the quote, so a drain delivering MORE than quoted was rejected as mis-shaped. Nine duffs, and the ordinary result of the balance moving between quote and build. Verify a max sell against payment.deliverableDuffs instead: the value Rust computes from the REGISTERED transaction. That keeps the check exact rather than loosening it to a range, and turns it into a cross-check -- the decoded host bytes must agree with what the engine registered, which is the disagreement the gate exists to catch. Ordinary sells are unchanged: the app chose the amount, so the quote is the expectation. Four tests, including the failing transaction end to end: rejected against the quote, accepted against the engine's amount, and still rejected when the bytes disagree with the engine by one duff. Co-Authored-By: Claude Opus 5 --- .../wallet/payments/MayaBlockchainApiImpl.kt | 36 +++++++++- .../wallet/payments/MayaDepositShapeTest.kt | 67 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt index 6240fa20f2..29a8d50f41 100644 --- a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt +++ b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt @@ -128,6 +128,33 @@ internal fun verifyMayaDepositShape( return null } +/** + * The vault amount [verifyMayaDepositShape] must find at VOUT0. + * + * For an ordinary sell the app chose the amount, so the quote IS the + * expectation and any deviation is a defect. + * + * A MAX sell is a DRAIN: the ENGINE sets the vault output to + * (total inputs − fee), so the app never supplied that number and the quote + * is only a FLOOR — which is exactly how the two guards around the build + * treat it (both abort on `<`, never on `>`). Holding the shape check to + * the quote instead would reject a drain that legitimately delivers MORE + * than quoted, which is what the balance moving between quote and build + * normally produces. + * + * So a max sell is verified against [drainDeliverableDuffs] — the value Rust + * computed from the REGISTERED transaction. That keeps the check exact + * rather than loosening it to a range, and makes it a genuine cross-check: + * the decoded host bytes must agree with what the engine registered, and a + * disagreement between those two is precisely the failure the check exists + * to catch. + */ +internal fun expectedVaultDuffs( + isMaxSell: Boolean, + quotedDuffs: Long, + drainDeliverableDuffs: Long +): Long = if (isMaxSell) drainDeliverableDuffs else quotedDuffs + /** * Wallet-module implementation of the Maya integration's [MayaBlockchainApi]: * builds the swap deposit on the Kotlin SDK's deferred build/broadcast @@ -355,11 +382,16 @@ class MayaBlockchainApiImpl @Inject constructor( // 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. + val depositDuffs = expectedVaultDuffs( + isMaxSell = swapTradeUIModel.maximum, + quotedDuffs = vaultDuffs, + drainDeliverableDuffs = payment.deliverableDuffs + ) val shapeError = try { verifyMayaDepositShape( TransactionDecoder.decode(payment.rawTxBytes, toSdkNetwork(Constants.NETWORK_PARAMETERS)), swapTradeUIModel.vaultAddress, - vaultDuffs, + depositDuffs, memoBytes ) } catch (e: CancellationException) { @@ -386,7 +418,7 @@ class MayaBlockchainApiImpl @Inject constructor( 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, vaultDuffs) + log.info("maya swap deposit {}: broadcasting ({} duffs to the vault)", payment.txidHex, depositDuffs) return when (val result = sdkL1SendService.broadcastDeferredPayment(payment)) { is SdkWriteResult.Broadcast -> { // Synchronous display bridge (same mechanism as every SDK diff --git a/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt b/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt index c350430a0d..a93ad165c4 100644 --- a/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt +++ b/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt @@ -200,4 +200,71 @@ class MayaDepositShapeTest { 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 anOrdinarySellIsVerifiedAgainstTheQuote() { + // The app chose this amount, so the quote is the expectation and the + // engine's drain figure is irrelevant (it is 0 for a non-drain build). + assertEquals( + 1_000_000L, + expectedVaultDuffs(isMaxSell = false, quotedDuffs = 1_000_000L, drainDeliverableDuffs = 0L) + ) + } + + @Test + fun aMaxSellIsVerifiedAgainstTheEngineNotTheQuote() { + // The exact numbers from the failing deposit. + assertEquals( + 7_442_734L, + expectedVaultDuffs(isMaxSell = true, quotedDuffs = 7_442_725L, drainDeliverableDuffs = 7_442_734L) + ) + } + + @Test + fun aMaxSellDeliveringMoreThanQuotedNowPassesTheShapeCheck() { + // End to end over the real gate: the transaction that was rejected. + val quoted = 7_442_725L + val delivered = 7_442_734L + val tx = deposit(vaultValue = delivered) + + assertNotNull( + "the quote alone must still reject it -- that is the bug being fixed", + verifyMayaDepositShape(tx, vaultAddress, quoted, memo) + ) + assertNull( + "verified against the engine's amount it is well-formed", + verifyMayaDepositShape( + tx, + vaultAddress, + expectedVaultDuffs(isMaxSell = true, quotedDuffs = quoted, drainDeliverableDuffs = delivered), + memo + ) + ) + } + + @Test + fun aMaxSellStillFailsWhenTheBytesDisagreeWithTheEngine() { + // The check stays exact, so it keeps its real job: the decoded host + // bytes must agree with what Rust computed from the REGISTERED + // transaction. A drain paying one duff less than the engine reported + // is still a failure. + val tx = deposit(vaultValue = 7_442_733L) + assertNotNull( + verifyMayaDepositShape( + tx, + vaultAddress, + expectedVaultDuffs(isMaxSell = true, quotedDuffs = 7_442_725L, drainDeliverableDuffs = 7_442_734L), + memo + ) + ) + } + } From 681190f7f410b79b394be8e302b64260c4470b8a Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 10 Aug 2026 17:54:00 -0700 Subject: [PATCH 09/15] fix(maya): enforce the drain guard in the primitive; correct the retired-model docs Review items 1, 4, 5 and 7 from #1535. The max-deposit guard was a call-site convention: buildDeferredMayaDeposit would happily drain the wallet, and the only thing standing between a MAX deposit and CrowdNode-locked funds was the caller having measured first. MayaBlockchainApiImpl does measure, but a convention is one refactor away from being skipped and the money does not come back. Move the fail-closed check into the primitive under drain = true, where no caller can miss it. maxMayaDepositDuffs keeps its own copy deliberately -- its probe runs inside a catch-all that turns any failure into a quote of 0, which would otherwise swallow the refusal and report "your maximum is 0" instead of "you hold locked funds". Three tests cover the gap: a direct drain build is refused for dashj locks and for seam-registered locks with nothing reserved, and a partial deposit is still allowed through. The docs described a model the drain rework deleted. Two constants (MAYA_DEPOSIT_PROBE_RESERVE_DUFFS, MAYA_DEPOSIT_CHANGE_HEADROOM_DUFFS) survived only in KDoc references, and three doc blocks still explained the max deposit as "spendable - measured fee - headroom". Left alone, the next reader would trust them and reintroduce the headroom arithmetic that the drain measurement exists to remove. Delete the constants and rewrite maxMayaDepositDuffs, MayaBlockchainApi.maxSwapDepositAmount and the MayaBlockchainApiImpl class doc to the drain model, including why a headroom must not come back. Also: say in the Broadcast arm that the mirrored reservation locks are left in place on purpose -- post-cutover the held dashj wallet never learns the deposit spent those outpoints, so on a NotBridged result the stale lock is the only thing keeping the mixer off an already-spent coin. And make the IS-lock timeout read as what it is rather than relying on Unit != null. Co-Authored-By: Claude Opus 5 --- .../maya/api/MayaBlockchainApi.kt | 20 ++-- .../maya/ui/MayaConversionPreviewViewModel.kt | 5 +- .../wallet/payments/MayaBlockchainApiImpl.kt | 24 +++- .../service/platform/sdk/SdkL1SendService.kt | 107 ++++++++++-------- .../platform/sdk/SdkL1SendServiceTest.kt | 50 +++++++- 5 files changed, 140 insertions(+), 66 deletions(-) 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 e2591b3cbc..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 @@ -49,16 +49,18 @@ interface MayaBlockchainApi { ): ResponseResource /** - * The largest amount a swap deposit can pay a vault right now: spendable - * balance minus the deposit's own mining fee (MEASURED through the real - * SDK builder, never estimated by the retired dashj engine) minus a small - * change-output headroom. + * 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 measurement is biased HIGH - * — worst-case memo size, change output included — because a reserve that - * came in under the real fee 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 the balance cannot fund a deposit at all. + * 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/ui/MayaConversionPreviewViewModel.kt b/integrations/maya/src/main/java/org/dash/wallet/integrations/maya/ui/MayaConversionPreviewViewModel.kt index b32df258b8..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 @@ -135,7 +135,10 @@ class MayaConversionPreviewViewModel @Inject constructor( val locked = try { withTimeoutOrNull(IS_LOCK_TIMEOUT_MS) { walletDataProvider.waitUntilLocked(txId) - } != null + // 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 diff --git a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt index 29a8d50f41..d7c9a8f89a 100644 --- a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt +++ b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt @@ -179,10 +179,12 @@ internal fun expectedVaultDuffs( * 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 set to [maxSwapDepositAmount] - * (balance − a MEASURED fee − change headroom), the deposit pays exactly that, - * and a balance drop between quote and build aborts instead of quietly paying - * the vault less than quoted. + * MAX sells never under-deliver: the quote is set to [maxSwapDepositAmount], + * which is a drain measured through the engine rather than balance arithmetic, + * and the deposit that follows runs the same drain. Before broadcasting, the + * amount the SIGNED transaction actually delivers is checked against the quote + * — not a re-measurement, so nothing that moved in between can defeat it — and + * a shortfall aborts rather than quietly paying the vault less than quoted. */ class MayaBlockchainApiImpl @Inject constructor( private val sdkL1SendService: SdkL1SendService, @@ -310,8 +312,8 @@ class MayaBlockchainApiImpl @Inject constructor( val vaultDuffs = quotedDuffs - // A MAX sell was quoted at the measured maximum deposit (balance − - // measured fee − headroom). Re-measure with the REAL memo before + // 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 @@ -421,6 +423,16 @@ class MayaBlockchainApiImpl @Inject constructor( 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 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 2fd9b2c690..f3db69de85 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt @@ -2043,30 +2043,34 @@ class SdkL1SendService internal constructor( } /** - * The largest amount a MAYACHAIN deposit can pay a vault right now: - * spendable balance MINUS the deposit's own mining fee MINUS a small - * change-output headroom. + * The largest amount a MAYACHAIN deposit can pay a vault right now: what + * a DRAIN of the funding account delivers, read off the engine. * - * The fee is MEASURED, not guessed: a throwaway deposit is built through - * the real engine (same builder, same three options, the wallet's own - * address as a size stand-in) and its reported fee read off the - * reservation, which is then released. An estimate 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 (~1h wait, then a refund minus 0.001 DASH), so the - * measurement is deliberately biased high: + * Nothing here is estimated and nothing is withheld. A max deposit IS a + * drain, so this builds one — same builder, same three options, the + * wallet's own address standing in for the vault — and reads the + * deliverable amount the engine reports, then releases the reservation. + * The engine sets that output to `total inputs − fee` itself, with this + * memo's bytes priced in and no change, so the quote and the deposit that + * follows perform the identical computation and cannot disagree. * - * - [memoSizeBytes] defaults to the 80-byte OP_RETURN ceiling, so a - * shorter real memo can only make the real transaction smaller; - * - the probe keeps a change output (as the real deposit will, thanks to - * the headroom), so the measured size matches the real one instead of - * under-counting by an output; - * - [MAYA_DEPOSIT_CHANGE_HEADROOM_DUFFS] of headroom keeps that change - * output comfortably above dust rather than at the threshold. + * That equality is the point. The retired model subtracted a guessed fee + * and a change-headroom constant from the wallet-wide spendable balance, + * which could only ever approximate what the deposit would really pay. A + * quote that comes in OVER the real deliverable makes the deposit pay the + * vault less than quoted, and NEAR Intents refuses under-delivery (~1h + * wait, then a refund minus 0.001 DASH). Do not reintroduce a headroom or + * reserve constant here: it would reopen exactly that gap. * - * Returns 0 when the balance cannot cover a deposit at all (the caller - * surfaces "not enough funds" rather than quoting a negative amount). - * Throws like [buildDeferredMayaDeposit] on gate/bind failures. + * [memoSizeBytes] defaults to the 80-byte OP_RETURN ceiling, so a shorter + * real memo can only leave the real transaction smaller and its + * deliverable no lower than quoted. + * + * Returns 0 when no drain is fundable at all — the engine's typed + * refusal when the inputs cannot cover the fee, which is precisely + * "nothing depositable" (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) { @@ -2077,13 +2081,19 @@ class SdkL1SendService internal constructor( } val gate = probeSendGate() check(gate.allowed) { "L1 funding gate closed: ${gate.reason}" } - // FAIL-CLOSED (funds-critical): a max deposit spends all but - // [MAYA_DEPOSIT_CHANGE_HEADROOM_DUFFS] of the wallet, so coin - // selection will reach app-locked outputs (CrowdNode) — which - // [spendableBalanceDuffs] deliberately INCLUDES and the FFI cannot be - // told to exclude. Same guard the send-all drain 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. + // FAIL-CLOSED (funds-critical): a max deposit drains the funding + // account outright, so coin selection will reach app-locked outputs + // (CrowdNode) — which [spendableBalanceDuffs] deliberately INCLUDES + // and the FFI cannot be told to exclude. Same guard the send-all + // drain 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 drain caller. + // Do NOT delete this copy as redundant: the probe below runs inside a + // catch-all that converts any failure into a quote of 0, so relying on + // the primitive alone would turn "refuse, you hold locked funds" into + // a silent "your maximum is 0". check(!hasProtectedOutputs("l1MayaMaxDeposit")) { "wallet has app-locked outputs (CrowdNode); a max swap deposit would spend them" } @@ -2139,6 +2149,11 @@ class SdkL1SendService internal constructor( * [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 [drain] this refuses outright when the wallet holds app-locked + * outputs (CrowdNode), the same fail-closed guard the send-all drain + * applies — see the check in the body for why it lives here rather than + * at the call site. */ suspend fun buildDeferredMayaDeposit( vaultAddressBase58: String, @@ -2161,6 +2176,24 @@ class SdkL1SendService internal constructor( } val gate = probeSendGate() check(gate.allowed) { "L1 funding gate closed: ${gate.reason}" } + // FAIL-CLOSED GUARD (funds-critical), drain only: a drain selects + // every spendable UTXO 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. 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: + // its probe runs inside a catch-all that turns any failure into a + // quote of 0, which would silently swallow this refusal. + if (drain) { + check(!hasProtectedOutputs("l1DeferredMayaBuild")) { + "wallet has app-locked outputs (CrowdNode); a max swap deposit would spend them" + } + } val payment = source.buildDeferredMayaDeposit(walletIdHex, vault, vaultDuffs, memo, drain) log.info( "SDK l1DeferredMayaBuild: built {} ({} duffs to the vault{}, {}-byte memo, fee {} duffs), inputs reserved", @@ -2323,23 +2356,5 @@ class SdkL1SendService internal constructor( * engine's `DEFAULT_MAX_OP_RETURN_BYTES`, which re-checks). */ const val MAX_MAYA_MEMO_BYTES = 80 - - /** - * Held back from the probe deposit in [maxMayaDepositDuffs] so it is - * fundable AND carries a change output (matching the real deposit's - * shape, so the measured fee is not an output short). Never reaches a - * quote: only the MEASURED fee plus - * [MAYA_DEPOSIT_CHANGE_HEADROOM_DUFFS] is withheld from the user. - */ - private const val MAYA_DEPOSIT_PROBE_RESERVE_DUFFS = 20_000L - - /** - * Left in the wallet by a max Maya deposit, on top of the measured - * fee: the deposit's change output must stay clear of the dust - * threshold (546 duffs), and a couple of duffs of slack absorbs a - * shorter-than-worst-case memo. 0.00001 DASH — negligible to the - * user, and it makes UNDER-reserving impossible. - */ - private const val MAYA_DEPOSIT_CHANGE_HEADROOM_DUFFS = 1_000L } } 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 c21027d209..2e7b6633c4 100644 --- a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt @@ -1327,16 +1327,16 @@ class SdkL1SendServiceTest { @Test fun maxMayaDepositNeverGoesNegative() = runBlocking { - // A fee larger than what is left after the probe reserve would make the - // arithmetic negative; the caller must see 0 ("not enough funds"), - // never a negative quote. + // A fee larger than the inputs leaves the drain with nothing to + // deliver; the caller must see 0 ("not enough funds"), never a + // negative quote. val source = mayaSource(spendable = 20_001L, feeDuffs = 25_000L) assertEquals(0L, service(source).maxMayaDepositDuffs()) } @Test fun maxMayaDepositRefusesWhileAppLockedOutputsExist() = runBlocking { - // A max deposit spends all but the headroom, so selection would reach + // 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) @@ -1376,6 +1376,48 @@ class SdkL1SendServiceTest { assertEquals(0, source.mayaBuildCalls) } + @Test + fun drainDepositRefusesAppLockedOutputsWithoutAnyPriorMeasurement() = runBlocking { + // The guard belongs to the PRIMITIVE, not to the call-site convention + // of measuring first. A caller that goes straight to a drain 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, 0L, ByteArray(40), drain = true) + fail("expected a direct drain build to be refused while app-locked outputs exist") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("app-locked")) + } + assertEquals(0, source.mayaBuildCalls) + } + + @Test + fun drainDepositRefusesSeamRegisteredLocksWithoutAnyPriorMeasurement() = 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, 0L, ByteArray(40), drain = true) + fail("expected a direct drain 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), drain = false) + assertEquals(1, source.mayaBuildCalls) + } + @Test fun maxMayaDepositRejectsAnOversizeMemo() = runBlocking { val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L) From ec48350f9e2b610ad0cf2fdfe25ebd5393f9bb58 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 10 Aug 2026 20:40:41 -0700 Subject: [PATCH 10/15] feat(sdk): move to v41int21 and fund sends from every account type Pins 0.1.0-v41int21-SNAPSHOT -- the official integration AAR, with no local suffix, so this branch builds for anyone once int21 is published. Testing was done against a locally built 0.1.0-v41int21-maya10-SNAPSHOT (int21 with rust-dashcore#928, platform #4286/#4288/#4324, the asset-lock fixes #4336/#4337, and message signing #4319/#4321), which exists only in the author's local Maven repository and is therefore deliberately NOT committed. The SDK's send APIs default accountType to ALL_SPENDABLE from #4329, so this pin changes funding scope without a call-site edit: sends and the MAX Maya drain now draw on BIP44 + BIP32 + every DashPay contact-receiving account, with change returning to BIP44. That is the intent -- a send should reach the user's whole spendable balance, and the app-side sweep-then-send machinery exists only because the SDK could not do this before. No call site names an account type, deliberately. Comments updated where they still asserted the old single-account scope: sendToAddress's "BIP44 account 0 is the default", and buildDeferredMayaDeposit's "a DRAIN spends every BIP44 UTXO". Also records that the protected-outputs guard is wallet-wide rather than per-account, so it still covers the sweep now that the pooled default has widened it -- checked against hasAppLockedSpendableOutputs, whose narrow per-account sibling is the separate CoinJoin-drain guard. Co-Authored-By: Claude Opus 5 --- build.gradle | 2 +- .../service/platform/sdk/SdkL1SendService.kt | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/build.gradle b/build.gradle index 26d0275e0e..bd02fc68e2 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ buildscript { // truth for both the dependency coordinate (wallet/build.gradle) and the // DASH_SDK_VERSION BuildConfig field shown on the About screen once the // cutover hands L1 ownership to the SDK. - dashSdkVersion = '0.1.0-v41int18-SNAPSHOT' + dashSdkVersion = '0.1.0-v41int21-SNAPSHOT' dppVersion = "4.0.0" hiltVersion = '2.53' hiltCompilerVersion = '1.2.0' 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 f3db69de85..9bbda323b8 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt @@ -813,7 +813,11 @@ internal class DashSdkL1SendSource( // builder defaults for fee rate / selection strategy / change handling // (setFunding sets inputs AND the change address Rust-side), signed via // the manager's mnemonic resolver — no private key crosses the - // boundary. BIP44 account 0 is sendToAddresses' default. + // boundary. Funding is the SDK's pooled default (AccountType + // .ALL_SPENDABLE): BIP44 + BIP32 + every DashPay contact-receiving + // account, with change returning to BIP44. Deliberately not narrowed + // to one family — a send should be able to reach the user's whole + // spendable balance. return try { wallet.sendToAddresses( recipients = listOf(addressBase58 to amountDuffs), @@ -1326,7 +1330,9 @@ internal class DashSdkL1SendSource( opReturnData = memo, preserveOutputOrder = true, changeToFirstInput = true, - // A DRAIN spends every BIP44 UTXO and has the engine set the vault + // A DRAIN spends every spendable UTXO the pooled default reaches — + // BIP44 + BIP32 + every DashPay contact-receiving account — and + // has the engine set the vault // output to (total inputs - fee), memo bytes priced in, no change: // `vaultDuffs` is ignored. That is what a MAX deposit means, and it // removes the guess the probe-measured path had to make. @@ -2177,9 +2183,12 @@ class SdkL1SendService internal constructor( val gate = probeSendGate() check(gate.allowed) { "L1 funding gate closed: ${gate.reason}" } // FAIL-CLOSED GUARD (funds-critical), drain only: a drain selects - // every spendable UTXO 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. Enforced HERE, in the + // 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. The guard 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 From 75cc1514b54ff1bca9e36dd701cfd9f316466232 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 10 Aug 2026 20:58:17 -0700 Subject: [PATCH 11/15] fix(maya): detect a funding shortfall by type, not by message text Review item 2(b) from #1535. MayaBlockchainApiImpl.isInsufficientFunds still walked the cause chain looking for the substring "insufficient funds" -- written when the shortfall only reached us as key-wallet's Display wrapped in a build failure. The SDK types it now, so match DashSdkError.PlatformWallet.CoreInsufficientFunds (FFI 22) instead. A matcher keyed on wording stops recognising the shortfall the moment the wording changes, and the failure is silent: "not enough funds" degrades into an opaque swap failure with no route to the familiar UI. The cause chain is still walked, since the typed error can arrive wrapped. Co-Authored-By: Claude Opus 5 --- .../wallet/payments/MayaBlockchainApiImpl.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt index d7c9a8f89a..b85838c56e 100644 --- a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt +++ b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt @@ -33,6 +33,7 @@ import org.dash.wallet.integrations.maya.api.MayaException import org.dash.wallet.integrations.maya.api.MayaWebApi 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 @@ -503,13 +504,19 @@ class MayaBlockchainApiImpl @Inject constructor( } /** - * The engine's pre-broadcast funding shortfall, however it is phrased - * across the build layers (key-wallet's `Insufficient funds` Display - * inside the FFI's build-failure wrapper). Only ever consulted for + * 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.message?.contains("insufficient funds", ignoreCase = true) == true } + .any { it is DashSdkError.PlatformWallet.CoreInsufficientFunds } } From ab1bd05dbdbead1494fc95598eb23ed15b2a09b2 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 11 Aug 2026 10:04:48 -0700 Subject: [PATCH 12/15] feat(maya)!: MAX deposits reserve a fee instead of draining A MAX Maya sell is now an ordinary fixed-amount send of `spendable - mayaMaxFeeReserveDuffs(...)`, the same system the shielded max-shield and Buy Credits already use, replacing SelectionStrategy.ALL. The drain had to go because of what it produced, not what it computed: vault output, zero-value OP_RETURN, no change -- a transaction with NO wallet-owned script. Compact block filters match 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. Two mainnet drains proved it: a5c99aec (balance inflated by 0.07443157) and 1f608a9a on the pooled build (reported 0.05 for a wallet it had just emptied, row stuck on "Sending" across restarts). A rescan cannot recover either -- it re-runs the same script-only matching. Withholding a reserve restores a change output, so the deposit confirms and settles like any other send. It also makes quoting stricter rather than looser: the app names the amount and the transaction pays exactly that, so quote and payment are equal by construction and under-delivery is unreachable. The reserve's unused remainder returns as change, which is what makes over-reserving lossless. A MAX sell therefore leaves a small remnant rather than emptying to zero -- deliberate, and not surfaced, matching shielded. Sizing mirrors assetLockMaxFeeReserve (~148 vbytes per input, doubled) but sizes the data carrier exactly, since a Maya quote always knows its memo length. Floored at MAYA_MAX_RESERVE_MIN_INPUTS because the reachable dashj UTXO count freezes post-cutover and under-reserving is the failing direction; the overlaid count behind WalletDataProvider is the eventual source. Removed: the drain parameter through SdkL1SendSource, the probe build in maxMayaDepositDuffs, and expectedVaultDuffs -- max sells verify against the quote again, like every other sell. The app-locked-output guard STAYS and now keys on isMaxDeposit: a reserve leaves change but does not narrow which coins are selected, so a max deposit is still sweep-scale and can still reach CrowdNode-locked outputs. Revisit when the SDK computes MAX internally in the wallet engine; the engine should own the amount rather than this arithmetic. Co-Authored-By: Claude Opus 5 --- .../wallet/payments/MayaBlockchainApiImpl.kt | 83 ++---- .../service/platform/sdk/SdkL1SendService.kt | 280 ++++++++++-------- .../wallet/payments/MayaDepositShapeTest.kt | 75 ++--- .../platform/sdk/SdkL1SendServiceTest.kt | 109 ++++--- 4 files changed, 248 insertions(+), 299 deletions(-) diff --git a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt index b85838c56e..03def54c02 100644 --- a/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt +++ b/wallet/src/de/schildbach/wallet/payments/MayaBlockchainApiImpl.kt @@ -129,32 +129,6 @@ internal fun verifyMayaDepositShape( return null } -/** - * The vault amount [verifyMayaDepositShape] must find at VOUT0. - * - * For an ordinary sell the app chose the amount, so the quote IS the - * expectation and any deviation is a defect. - * - * A MAX sell is a DRAIN: the ENGINE sets the vault output to - * (total inputs − fee), so the app never supplied that number and the quote - * is only a FLOOR — which is exactly how the two guards around the build - * treat it (both abort on `<`, never on `>`). Holding the shape check to - * the quote instead would reject a drain that legitimately delivers MORE - * than quoted, which is what the balance moving between quote and build - * normally produces. - * - * So a max sell is verified against [drainDeliverableDuffs] — the value Rust - * computed from the REGISTERED transaction. That keeps the check exact - * rather than loosening it to a range, and makes it a genuine cross-check: - * the decoded host bytes must agree with what the engine registered, and a - * disagreement between those two is precisely the failure the check exists - * to catch. - */ -internal fun expectedVaultDuffs( - isMaxSell: Boolean, - quotedDuffs: Long, - drainDeliverableDuffs: Long -): Long = if (isMaxSell) drainDeliverableDuffs else quotedDuffs /** * Wallet-module implementation of the Maya integration's [MayaBlockchainApi]: @@ -180,12 +154,14 @@ internal fun expectedVaultDuffs( * 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 set to [maxSwapDepositAmount], - * which is a drain measured through the engine rather than balance arithmetic, - * and the deposit that follows runs the same drain. Before broadcasting, the - * amount the SIGNED transaction actually delivers is checked against the quote - * — not a re-measurement, so nothing that moved in between can defeat it — and - * a shortfall aborts rather than quietly paying the vault less than quoted. + * 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 sdkL1SendService: SdkL1SendService, @@ -345,51 +321,26 @@ class MayaBlockchainApiImpl @Inject constructor( } // Build + sign with the funding inputs RESERVED, no broadcast. - // A MAX sell builds as a DRAIN: the engine sets the vault output to - // (total inputs - fee) with no change, so the deposit delivers the - // whole account rather than an amount the app derived separately. + // 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, - drain = swapTradeUIModel.maximum + isMaxDeposit = swapTradeUIModel.maximum ) - // A drain's amount is the ENGINE's, so check the built transaction - // against the quote before deciding to broadcast. The pre-build - // guard above compared a re-measurement; this compares THIS signed - // transaction — the one that would actually go to the vault — and - // so cannot be defeated by anything that moved in between. Paying - // the vault less than quoted is under-delivery: Maya would execute - // a swap the user never agreed to, and NEAR Intents refuses it - // outright (~1h wait, then a refund minus 0.001 DASH). - if (swapTradeUIModel.maximum && payment.deliverableDuffs < vaultDuffs) { - log.warn( - "maya max sell aborted after build: the drain delivers {} duffs, below the quoted {}", - payment.deliverableDuffs, vaultDuffs - ) - sdkL1SendService.releaseDeferredPayment(payment) - return ResponseResource.Failure( - MayaException( - "wallet balance changed; the deposit would fall below the quoted " + - "amount — please request a new quote" - ), - false, - 0, - null - ) - } - // 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. - val depositDuffs = expectedVaultDuffs( - isMaxSell = swapTradeUIModel.maximum, - quotedDuffs = vaultDuffs, - drainDeliverableDuffs = payment.deliverableDuffs - ) + // 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)), 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 9bbda323b8..7f36198359 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt @@ -260,6 +260,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 @@ -662,8 +708,7 @@ interface SdkL1SendSource { walletIdHex: String, vaultAddressBase58: String, vaultDuffs: Long, - memo: ByteArray, - drain: Boolean = false + memo: ByteArray ): SdkDeferredPayment = throw UnsupportedOperationException("Maya deposit build not supported by this source") @@ -1310,8 +1355,7 @@ internal class DashSdkL1SendSource( walletIdHex: String, vaultAddressBase58: String, vaultDuffs: Long, - memo: ByteArray, - drain: Boolean + memo: ByteArray ): SdkDeferredPayment { val manager = manager() val wallet = checkNotNull(manager.wallets.value[walletIdHex]) { "SDK wallet not loaded" } @@ -1320,27 +1364,20 @@ internal class DashSdkL1SendSource( // the vault recipient SDK-side, so preserveOutputOrder yields the // documented vault=VOUT0 / memo=VOUT1 shape; an over-long memo // throws pre-reservation. - // A drain supplies NO amount: the engine sets the vault output to - // (total inputs − fee), so 0 is the honest value to pass and anything - // else would be a number the engine discards. + // 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, - // A DRAIN spends every spendable UTXO the pooled default reaches — - // BIP44 + BIP32 + every DashPay contact-receiving account — and - // has the engine set the vault - // output to (total inputs - fee), memo bytes priced in, no change: - // `vaultDuffs` is ignored. That is what a MAX deposit means, and it - // removes the guess the probe-measured path had to make. - selectionStrategy = if (drain) { - org.dashfoundation.dashsdk.wallet.CoreTransactionBuilder.SelectionStrategy.ALL - } else { - null - } + changeToFirstInput = true ) return SdkDeferredPayment( signed.txidHex, @@ -1577,6 +1614,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 @@ -1653,6 +1704,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 @@ -2049,34 +2109,40 @@ class SdkL1SendService internal constructor( } /** - * The largest amount a MAYACHAIN deposit can pay a vault right now: what - * a DRAIN of the funding account delivers, read off the engine. + * 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. * - * Nothing here is estimated and nothing is withheld. A max deposit IS a - * drain, so this builds one — same builder, same three options, the - * wallet's own address standing in for the vault — and reads the - * deliverable amount the engine reports, then releases the reservation. - * The engine sets that output to `total inputs − fee` itself, with this - * memo's bytes priced in and no change, so the quote and the deposit that - * follows perform the identical computation and cannot disagree. + * ## Why not a drain * - * That equality is the point. The retired model subtracted a guessed fee - * and a change-headroom constant from the wallet-wide spendable balance, - * which could only ever approximate what the deposit would really pay. A - * quote that comes in OVER the real deliverable makes the deposit pay the - * vault less than quoted, and NEAR Intents refuses under-delivery (~1h - * wait, then a refund minus 0.001 DASH). Do not reintroduce a headroom or - * reserve constant here: it would reopen exactly that gap. + * 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. * - * [memoSizeBytes] defaults to the 80-byte OP_RETURN ceiling, so a shorter - * real memo can only leave the real transaction smaller and its - * deliverable no lower than quoted. + * 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. * - * Returns 0 when no drain is fundable at all — the engine's typed - * refusal when the inputs cannot cover the fee, which is precisely - * "nothing depositable" (the caller surfaces "not enough funds" rather - * than quoting a negative amount). Throws like [buildDeferredMayaDeposit] - * on gate/bind failures. + * [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) { @@ -2087,63 +2153,32 @@ class SdkL1SendService internal constructor( } val gate = probeSendGate() check(gate.allowed) { "L1 funding gate closed: ${gate.reason}" } - // FAIL-CLOSED (funds-critical): a max deposit drains the funding - // account outright, so coin selection will reach app-locked outputs - // (CrowdNode) — which [spendableBalanceDuffs] deliberately INCLUDES - // and the FFI cannot be told to exclude. Same guard the send-all - // drain 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. + // 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 drain caller. - // Do NOT delete this copy as redundant: the probe below runs inside a - // catch-all that converts any failure into a quote of 0, so relying on - // the primitive alone would turn "refuse, you hold locked funds" into - // a silent "your maximum is 0". + // [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" } - // MEASURE BY DRAINING, don't estimate. A max deposit IS a drain, so - // build one and read what the engine says it delivers: every BIP44 UTXO - // selected, this memo's bytes priced into the fee, no change. That is - // the same computation the real deposit will perform, so quote and - // deposit cannot disagree — which subtracting a guessed fee and a - // change-headroom constant from the wallet-wide spendable could not - // promise. The probe is built to an OWN address; the destination does - // not change the fee (same P2PKH output size as a vault), and it is - // released immediately either way. - val probeAddress = checkNotNull(source.unusedExternalAddress(walletIdHex)) { - "no SDK address available to size a Maya deposit" - } - val probe = try { - source.buildDeferredMayaDeposit( - walletIdHex, - probeAddress, - 0L, // ignored under a drain - ByteArray(memoSizeBytes), - drain = true - ) - } catch (t: Throwable) { - if (t is CancellationException) throw t - // The engine refuses a drain whose inputs cannot cover the fee - // (typed InsufficientFunds), which is exactly "nothing depositable" - // — the floor the probe-reserve constant used to approximate. - log.info("SDK l1MayaMaxDeposit: no drain is fundable; max deposit 0 ({})", t.message) - return 0L - } - val max = try { - probe.deliverableDuffs - } finally { - // NonCancellable: the probe holds a real engine reservation, and - // leaving it to the TTL sweep would make the very next real build - // fail to fund. - withContext(NonCancellable) { releaseDeferredPayment(probe) } - } + val spendable = source.spendableBalanceDuffs(walletIdHex) + val utxoCount = spendableUtxoCount(walletIdHex) + val reserve = mayaMaxFeeReserveDuffs(utxoCount, memoSizeBytes) + val max = (spendable - reserve).coerceAtLeast(0L) log.info( - "SDK l1MayaMaxDeposit: drain-measured max deposit {} duffs (fee {}, {}-byte memo)", - max, probe.feeDuffs, memoSizeBytes + "SDK l1MayaMaxDeposit: max deposit {} duffs (spendable {}, reserve {}, {} utxos, {}-byte memo)", + max, spendable, reserve, utxoCount, memoSizeBytes ) - return max.coerceAtLeast(0L) + return max } /** @@ -2156,20 +2191,22 @@ class SdkL1SendService internal constructor( * [memo] must fit the 80-byte OP_RETURN standardness limit — checked * here (and re-checked engine-side) BEFORE anything is reserved. * - * Under [drain] this refuses outright when the wallet holds app-locked - * outputs (CrowdNode), the same fail-closed guard the send-all drain - * applies — see the check in the body for why it lives here rather than - * at the call site. + * 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, - drain: Boolean = false + isMaxDeposit: Boolean = false ): SdkDeferredPayment { - // A drain has the engine compute the vault output, so no amount is - // supplied; every other build must name a positive one. - check(drain || vaultDuffs > 0) { "Maya vault amount must be positive, got $vaultDuffs" } + // 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" @@ -2182,33 +2219,34 @@ class SdkL1SendService internal constructor( } val gate = probeSendGate() check(gate.allowed) { "L1 funding gate closed: ${gate.reason}" } - // FAIL-CLOSED GUARD (funds-critical), drain only: a drain selects - // 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. The guard 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. + // 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: - // its probe runs inside a catch-all that turns any failure into a - // quote of 0, which would silently swallow this refusal. - if (drain) { + // [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, drain) + 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, - if (drain) payment.deliverableDuffs else vaultDuffs, - if (drain) " by DRAIN (engine-computed)" else "", + vaultDuffs, + if (isMaxDeposit) " (MAX, spendable − reserve)" else "", memo.size, payment.feeDuffs ) diff --git a/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt b/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt index a93ad165c4..4e432fa091 100644 --- a/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt +++ b/wallet/test/de/schildbach/wallet/payments/MayaDepositShapeTest.kt @@ -210,61 +210,26 @@ class MayaDepositShapeTest { // rejected as mis-shaped. @Test - fun anOrdinarySellIsVerifiedAgainstTheQuote() { - // The app chose this amount, so the quote is the expectation and the - // engine's drain figure is irrelevant (it is 0 for a non-drain build). - assertEquals( - 1_000_000L, - expectedVaultDuffs(isMaxSell = false, quotedDuffs = 1_000_000L, drainDeliverableDuffs = 0L) - ) - } - - @Test - fun aMaxSellIsVerifiedAgainstTheEngineNotTheQuote() { - // The exact numbers from the failing deposit. - assertEquals( - 7_442_734L, - expectedVaultDuffs(isMaxSell = true, quotedDuffs = 7_442_725L, drainDeliverableDuffs = 7_442_734L) - ) - } - - @Test - fun aMaxSellDeliveringMoreThanQuotedNowPassesTheShapeCheck() { - // End to end over the real gate: the transaction that was rejected. - val quoted = 7_442_725L - val delivered = 7_442_734L - val tx = deposit(vaultValue = delivered) - - assertNotNull( - "the quote alone must still reject it -- that is the bug being fixed", - verifyMayaDepositShape(tx, vaultAddress, quoted, memo) - ) - assertNull( - "verified against the engine's amount it is well-formed", - verifyMayaDepositShape( - tx, - vaultAddress, - expectedVaultDuffs(isMaxSell = true, quotedDuffs = quoted, drainDeliverableDuffs = delivered), - memo - ) - ) - } - - @Test - fun aMaxSellStillFailsWhenTheBytesDisagreeWithTheEngine() { - // The check stays exact, so it keeps its real job: the decoded host - // bytes must agree with what Rust computed from the REGISTERED - // transaction. A drain paying one duff less than the engine reported - // is still a failure. - val tx = deposit(vaultValue = 7_442_733L) - assertNotNull( - verifyMayaDepositShape( - tx, - vaultAddress, - expectedVaultDuffs(isMaxSell = true, quotedDuffs = 7_442_725L, drainDeliverableDuffs = 7_442_734L), - memo - ) - ) + 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/platform/sdk/SdkL1SendServiceTest.kt b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt index 2e7b6633c4..e45fa76d64 100644 --- a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt @@ -152,21 +152,21 @@ class SdkL1SendServiceTest { walletIdHex: String, vaultAddressBase58: String, vaultDuffs: Long, - memo: ByteArray, - drain: Boolean + memo: ByteArray ): SdkDeferredPayment { mayaBuildCalls++ failMayaBuildWith?.let { throw it } mayaBuiltAmounts += vaultDuffs mayaBuiltMemoSizes += memo.size mayaBuiltVault = vaultAddressBase58 - mayaBuiltDrains += drain return SdkDeferredPayment( txidHex = "bb".repeat(32), rawTxBytes = ByteArray(0), feeDuffs = onMayaDepositFee(vaultDuffs, memo), native = null, - deliverableDuffs = if (drain) drainDeliverable else vaultDuffs + // Every deposit names its own amount now, so the SDK's + // sole-deliverable figure is the vault output itself. + deliverableDuffs = vaultDuffs ) } @@ -219,6 +219,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() @@ -228,6 +229,7 @@ class SdkL1SendServiceTest { isValidAddress = addressValid, l1Progress = progress, hasAppLockedSpendableOutputs = hasAppLockedOutputs, + spendableUtxoCount = utxoCount, seamOutputLockRegistry = seamRegistry, onSelfSpendBroadcast = { selfSpendMarks++ }, bridgeAfterBroadcast = bridgeAfterBroadcast @@ -1274,64 +1276,57 @@ class SdkL1SendServiceTest { ).apply { drainDeliverable = deliverable } @Test - fun maxMayaDepositIsTheDrainsEngineComputedAmount() = runBlocking { - // The max IS what a drain delivers. The engine computes it (total inputs - // − fee, no change), so the service must report that figure verbatim - // rather than deriving one from the wallet-wide spendable. - val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L, deliverable = 999_500L) - assertEquals(999_500L, service(source).maxMayaDepositDuffs()) - } - - @Test - fun maxMayaDepositMeasuresWithADrainAndReleasesTheReservation() = runBlocking { - val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L, deliverable = 999_500L) - service(source).maxMayaDepositDuffs() - assertEquals(1, source.mayaBuildCalls) - // Measured by DRAINING: no amount is supplied (the engine sets the - // output), so the probe passes 0 and asks for the drain strategy. - assertEquals(0L, source.mayaBuiltAmounts.single()) - assertTrue(source.mayaBuiltDrains.single()) - assertEquals(validAddress, source.mayaBuiltVault) - // The probe's reservation must not leak — the very next real build - // would otherwise fail to fund. - assertEquals(1, source.mayaReleaseCalls) + 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 maxMayaDepositProbesWithTheWorstCaseMemoByDefault() = runBlocking { - val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L, deliverable = 999_500L) - service(source).maxMayaDepositDuffs() - // Worst case: a shorter real memo can only shrink the real tx, so the - // quote can never end up above what the real deposit can deliver. - assertEquals(SdkL1SendService.MAX_MAYA_MEMO_BYTES, source.mayaBuiltMemoSizes.single()) + 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 maxMayaDepositHonoursAnExplicitMemoSize() = runBlocking { - val source = mayaSource(spendable = 1_000_000L, feeDuffs = 500L, deliverable = 999_500L) - service(source).maxMayaDepositDuffs(memoSizeBytes = 72) - assertEquals(72, source.mayaBuiltMemoSizes.single()) + 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 maxMayaDepositIsZeroWhenNoDrainIsFundable() = runBlocking { - // The engine refuses a drain whose inputs cannot cover the fee. That - // refusal IS "nothing depositable" — surface 0, not an exception, and - // leave nothing reserved. - val source = mayaSource(spendable = 20_000L, feeDuffs = 500L).apply { - failMayaBuildWith = IllegalStateException("insufficient funds for a drain") - } - assertEquals(0L, service(source).maxMayaDepositDuffs()) - assertEquals(0, source.mayaReleaseCalls) + 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 fee larger than the inputs leaves the drain with nothing to - // deliver; the caller must see 0 ("not enough funds"), never a - // negative quote. - val source = mayaSource(spendable = 20_001L, feeDuffs = 25_000L) - assertEquals(0L, service(source).maxMayaDepositDuffs()) + // 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 @@ -1377,16 +1372,16 @@ class SdkL1SendServiceTest { } @Test - fun drainDepositRefusesAppLockedOutputsWithoutAnyPriorMeasurement() = runBlocking { + 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 drain build — + // 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, 0L, ByteArray(40), drain = true) - fail("expected a direct drain build to be refused while app-locked outputs exist") + 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")) } @@ -1394,13 +1389,13 @@ class SdkL1SendServiceTest { } @Test - fun drainDepositRefusesSeamRegisteredLocksWithoutAnyPriorMeasurement() = runBlocking { + 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, 0L, ByteArray(40), drain = true) - fail("expected a direct drain build to be refused while seam locks exist") + .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")) } @@ -1414,7 +1409,7 @@ class SdkL1SendServiceTest { // 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), drain = false) + svc.buildDeferredMayaDeposit(validAddress, 50_000L, ByteArray(40), isMaxDeposit = false) assertEquals(1, source.mayaBuildCalls) } From e6cee93ca60d1f99f18606e1cfc3b54ef0a888c8 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Tue, 11 Aug 2026 10:50:13 -0700 Subject: [PATCH 13/15] fix(maya): keep a swap's home-screen row titled as a conversion A Maya/SwapKit MAX sell stayed titled "Sending" on the home screen forever (across app restarts) and never rendered "Conversion DASH/RUNE", while the transaction-details screen identified the swap correctly the whole time. Reproduced on-device by rewinding the cached row and restarting: it did not recover. Two independent display-layer faults combined. 1. The swap decoration was only derivable from a dashj transaction. TransactionRowView.fromTransaction was the sole reader of metadata.swapOrder, so every writer that produced it needed a resolvable dashj wrapper. TxDisplayCacheService's metadata flow silently writes nothing when no wrapper resolves -- after already assigning this.metadata -- so the swap-order change is consumed and never seen again. The row for this MAX sell was authored by CutoverUiDataService (the SDK path), which has no notion of swap orders at all. 2. The SDK planner then re-titled the row on every pass. Its never-touch carve-out keys off existing.service, but a row whose decoration was already lost carries service = null, so the definitive plain-send re-stamp claimed it -- and with the SDK record's context stuck at mempool, that re-stamp is permanently "Sending". Fix: derive the decoration (convert icon, "Conversion ..."/"Converted ..." title, swapStatus) from the swap_orders record by txid, with no dashj transaction involved -- the same source the details screen observes. The new pure planSwapRowDecorations runs from reconcileSwapRows on every metadata emission (not gated on the diff) and on every DisplayCacheRefreshBus tick, so an SDK-authored insert or re-stamp is corrected whichever writer got there first. Idempotent: only decoration fields are touched, and a settled row produces no write. Swap rows also join the SDK planner's never-touch set (swapStatus != null, in both planL1DisplaySync and planL1InstantLockRowUpdate) so a decorated row holds stable instead of flip-flopping once per sync pass. The title choice moves to TransactionRowView.swapTitleRes so renderer and reconciler cannot drift. Adds 12 host-JVM regression tests, including the full mempool -> in-block story: a row born plain gets decorated, survives the context advance without being re-titled, follows PENDING -> COMPLETED rather than being pinned to a stale rendering, then settles. One test asserts a non-swap row of the same shape is still re-stamped, so the carve-out is not over-broad. Not addressed: the swap vault address is still marked TaxCategory.Expense (MayaConversionPreviewViewModel) -- TaxCategory has no Trade value, and adding one reaches into the CSV export and the category picker. Co-Authored-By: Claude Opus 5 --- .../wallet/service/TxDisplayCacheService.kt | 114 ++++++- .../platform/sdk/CutoverUiDataService.kt | 17 +- .../ui/transactions/TransactionRowView.kt | 20 +- .../wallet/service/SwapRowDisplayCacheTest.kt | 317 ++++++++++++++++++ 4 files changed, 460 insertions(+), 8 deletions(-) create mode 100644 wallet/test/de/schildbach/wallet/service/SwapRowDisplayCacheTest.kt diff --git a/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt b/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt index 353965ea69..3ee2d5cf71 100644 --- a/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt +++ b/wallet/src/de/schildbach/wallet/service/TxDisplayCacheService.kt @@ -73,6 +73,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 @@ -270,6 +271,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()) } @@ -461,7 +468,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) } @@ -1163,6 +1177,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 @@ -1196,6 +1249,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] 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 8ee19582e5..f8c0a52367 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt @@ -541,7 +541,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 ) { @@ -779,7 +790,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/ui/transactions/TransactionRowView.kt b/wallet/src/de/schildbach/wallet/ui/transactions/TransactionRowView.kt index 345c737ddd..34389df162 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/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()) + } +} From 064957d7209315b13d78f82672f76ed483a64279 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sun, 16 Aug 2026 22:36:06 -0700 Subject: [PATCH 14/15] style(crowdnode): restore lexicographic import order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit freshReceiveAddressStringOffMain landed above the common.data.* imports (d < f), which fails ktlintMainSourceSetCheck — the red 'check' job on this PR. Came in with the phase1 merge, so the base branch trips it too. Co-Authored-By: Claude Opus 5 --- .../dash/wallet/integrations/crowdnode/ui/CrowdNodeViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/ui/CrowdNodeViewModel.kt b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/ui/CrowdNodeViewModel.kt index 2e4400e37b..c972ce6166 100644 --- a/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/ui/CrowdNodeViewModel.kt +++ b/integrations/crowdnode/src/main/java/org/dash/wallet/integrations/crowdnode/ui/CrowdNodeViewModel.kt @@ -27,11 +27,11 @@ import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.dash.wallet.common.Configuration import org.dash.wallet.common.WalletDataProvider -import org.dash.wallet.common.freshReceiveAddressStringOffMain import org.dash.wallet.common.data.SingleLiveEvent import org.dash.wallet.common.data.Status import org.dash.wallet.common.data.WalletUIConfig import org.dash.wallet.common.data.entity.ExchangeRate +import org.dash.wallet.common.freshReceiveAddressStringOffMain import org.dash.wallet.common.money.Dash import org.dash.wallet.common.money.MoneyFormat import org.dash.wallet.common.money.moneyFormat From 842021323248e61d8741f83a073c869ce708fd2e Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Mon, 17 Aug 2026 09:35:05 -0700 Subject: [PATCH 15/15] =?UTF-8?q?refactor(sdk):=20drop=20the=20unused=20de?= =?UTF-8?q?liverableDuffs=20field=20=E2=80=94=20the=20last=20#4324=20depen?= =?UTF-8?q?dency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SdkDeferredPayment.deliverableDuffs was the drain era's channel for the engine-computed amount; since MAX became a fixed-amount reserve send it has had no consumer, and the one line still populating it (signed.deliverableAmountDuffs) was the only symbol this branch used from platform#4324. Removing it lets the wallet compile against the published v42int4 AAR — the first published artifact this branch builds with — and takes #4324 out of the wallet's dependency chain entirely (it remains the SDK's drain surface, approved and pending merge, just no longer a build prerequisite here). Co-Authored-By: Claude Opus 5 --- .../service/platform/sdk/SdkL1SendService.kt | 17 +---------------- .../platform/sdk/SdkL1SendServiceTest.kt | 5 +---- 2 files changed, 2 insertions(+), 20 deletions(-) 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 226dd80194..50d66e6269 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkL1SendService.kt @@ -469,20 +469,6 @@ class SdkDeferredPayment internal constructor( val rawTxBytes: ByteArray, val feeDuffs: Long, internal val native: Any?, - /** - * What the single non-OP_RETURN output actually pays, in duffs. - * - * For an explicit-amount build this is the amount the caller asked for. - * For a DRAIN it is the figure the ENGINE computed (total inputs − fee, - * no change) — the caller never supplied it, so this is the only way to - * learn what the transaction will deliver. A max swap deposit must check - * this against the quoted amount BEFORE broadcasting: paying a vault less - * than quoted is under-delivery, which Maya and NEAR Intents refuse. - * - * 0 when the source could not report it (fakes, or an SDK too old to - * expose it); callers treat 0 as "unknown" rather than "pays nothing". - */ - val deliverableDuffs: Long = 0 ) // ── Source seam ─────────────────────────────────────────────────────── @@ -858,8 +844,7 @@ internal class DashSdkL1SendSource( signed.txidHex, signed.rawTxBytes, signed.feeDuffs, - signed, - deliverableDuffs = signed.deliverableAmountDuffs + signed ) } 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 2a58d63ec1..e1fbe2ff5d 100644 --- a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkL1SendServiceTest.kt @@ -161,10 +161,7 @@ class SdkL1SendServiceTest { txidHex = "bb".repeat(32), rawTxBytes = ByteArray(0), feeDuffs = onMayaDepositFee(vaultDuffs, memo), - native = null, - // Every deposit names its own amount now, so the SDK's - // sole-deliverable figure is the vault output itself. - deliverableDuffs = vaultDuffs + native = null ) }