Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3dc04f1
feat(platform-wallet): own the shielded-tip payment decisions in the …
PastaPastaPasta Nov 1, 2026
3a21dd5
feat(platform-wallet): parse shielded-tip amounts in the wallet
PastaPastaPasta Nov 1, 2026
a73ce5b
fix(platform-wallet): accept keypad decimals and report pre-native ti…
PastaPastaPasta Nov 1, 2026
c3ec759
fix(swift-sdk): convert the tip disposition and review constants to U…
PastaPastaPasta Nov 1, 2026
688c75e
fix(platform-wallet): keep a durable barrier per shielded tip until i…
PastaPastaPasta Nov 1, 2026
5e9971b
fix(platform-wallet): trust only local proof that a shielded tip failed
PastaPastaPasta Nov 1, 2026
1309b51
feat(platform-wallet): rebind discovered tip accounts and report tip …
PastaPastaPasta Nov 1, 2026
c293c94
fix(platform-wallet): report an unscanned tip account as unavailable,…
PastaPastaPasta Nov 1, 2026
84cfd41
fix(platform-wallet): refuse Clear during a tip send, and never relea…
PastaPastaPasta Nov 1, 2026
0dfe151
fix(platform-wallet): hold identity placement through a tip-balance read
PastaPastaPasta Nov 1, 2026
ca92a58
fix(platform-wallet): drain tip sends outside the lifecycle lock, ref…
PastaPastaPasta Nov 2, 2026
9fb9160
fix(platform-wallet): fence tip abandonment against removed wallets, …
PastaPastaPasta Nov 2, 2026
ba68897
feat(swift-sdk,kotlin-sdk): refresh shielded accounts off the main ac…
PastaPastaPasta Nov 2, 2026
bc99e1e
fix(kotlin-sdk): put isShieldedTipAccount on PlatformWalletManager's …
PastaPastaPasta Nov 2, 2026
ffcabad
fix(platform-wallet): refuse dropping accounts while tip barriers are…
PastaPastaPasta Nov 2, 2026
21d3110
fix(platform-wallet): keep an executed tip's notes spent across a sta…
PastaPastaPasta Nov 2, 2026
c2b4ca9
fix(platform-wallet): record each observed spend, not a tip's whole i…
PastaPastaPasta Nov 2, 2026
1b30099
fix(platform-wallet): record every owned spend, and keep a recovery r…
PastaPastaPasta Nov 2, 2026
50c24fb
fix(platform-wallet): keep partly confirmed spends bound, and refuse …
PastaPastaPasta Nov 2, 2026
4654d67
fix(platform-wallet): record a spend before marking it, and keep reco…
PastaPastaPasta Nov 2, 2026
158ae6a
fix(platform-wallet): record a spend before settling its tip, scope s…
PastaPastaPasta Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,24 @@ package org.dashfoundation.dashsdk.ffi
*/
internal object FundingNative {
external fun tipAccountIndex(identityIndex: Int): Int
external fun parseShieldedTipAmount(text: String): Long
external fun prepareShieldedTipAddress(
manager: Long, walletId: ByteArray, resolver: Long, identityId: ByteArray,
): ByteArray
external fun resolveShieldedTip(wallet: Long, username: String): ByteArray
/** A non-null [sourceIdentityId] spends that identity's tip account; otherwise [sourceAccount]. */
/**
* A non-null [sourceIdentityId] spends that identity's tip account; otherwise [sourceAccount].
* [outDisposition] (one element) receives Rust's retry decision, also when this throws.
*/
external fun sendShieldedTip(
manager: Long, walletId: ByteArray, resolver: Long, sourceIdentityId: ByteArray?, sourceAccount: Int,
username: String, expectedId: ByteArray, expectedAddress: ByteArray,
amount: Long, memo: String?,
amount: Long, memo: String?, outDisposition: IntArray,
)
/** 152 bytes: identity, address, change kind, previous identity, previous address, unresolved. */
external fun reviewShieldedTip(manager: Long, walletId: ByteArray, username: String): ByteArray
/** `[account, verified]` for a wallet identity's dedicated tip account. */
external fun identityTipAccount(wallet: Long, identityId: ByteArray): IntArray


external fun shieldedIdentityDebitRecoveryRecords(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ class Dashpay internal constructor(private val walletHandle: Long,
ShieldedTipRecipient(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 75))
}

/** The identity's dedicated tip account, and whether Rust has verified its slot this session. */
suspend fun identityTipAccount(identityId: ByteArray): IdentityTipAccount = gate.op {
val values = mapNativeErrors {
org.dashfoundation.dashsdk.ffi.FundingNative.identityTipAccount(walletHandle, identityId)
}
check(values.size == 2) { "Invalid tip account response" }
IdentityTipAccount(values[0], values[1] != 0)
}

/**
* Send a contact request to [recipientIdentityId], signing the document
* state-transition with [signerHandle] and keying the contact crypto
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,62 @@ sealed class ShieldedTipSource {
}
}
}

/** Rust's decision whether a failed tip may be sent again. */
enum class ShieldedTipDisposition {
/** Nothing was broadcast, or it was definitively rejected: review and send again. */
NOT_SENT,

/** The payment may have executed or may still execute: do not send it again. */
UNRESOLVED;

internal companion object {
/** The native `SHIELDED_TIP_DISPOSITION_NOT_SENT`; every other value is unresolved. */
const val NATIVE_NOT_SENT = 1

fun fromNative(value: Int): ShieldedTipDisposition =
if (value == NATIVE_NOT_SENT) NOT_SENT else UNRESOLVED
}
}

/** A tip send that failed; [disposition] says whether it may be sent again. */
class ShieldedTipFailure(val disposition: ShieldedTipDisposition, cause: Throwable) :
Exception(cause.message, cause)

/** How a tip recipient compares with the one last confirmed for the same name from this wallet. */
sealed class ShieldedTipRecipientChange {
object New : ShieldedTipRecipientChange()
object Unchanged : ShieldedTipRecipientChange()
class Changed(val previous: ShieldedTipRecipient) : ShieldedTipRecipientChange()
}

/**
* What a user confirms before tipping a name. [unresolved] means an earlier payment to
* this address is unsettled, and a send is refused until shielded sync settles it.
*/
class ShieldedTipReview(
val recipient: ShieldedTipRecipient,
val change: ShieldedTipRecipientChange,
val unresolved: Boolean,
) {
internal companion object {
fun fromNative(bytes: ByteArray): ShieldedTipReview {
check(bytes.size == 152) { "Invalid tip review response" }
val recipient = ShieldedTipRecipient(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 75))
val change = when (bytes[75].toInt()) {
0 -> ShieldedTipRecipientChange.New
1 -> ShieldedTipRecipientChange.Unchanged
else -> ShieldedTipRecipientChange.Changed(
ShieldedTipRecipient(bytes.copyOfRange(76, 108), bytes.copyOfRange(108, 151)),
)
}
return ShieldedTipReview(recipient, change, bytes[151].toInt() != 0)
}
}
}

/**
* A wallet identity's dedicated tip account. Show its balance as the identity's only
* when [verified]: unverified, the account may belong to another identity.
*/
data class IdentityTipAccount(val account: Int, val verified: Boolean)
Original file line number Diff line number Diff line change
Expand Up @@ -2015,29 +2015,63 @@ class PlatformWalletManager(
// — the caller must NOT retry (the spent notes stay reserved Rust-side;
// the next shielded sync reconciles the outcome).

/** Exact credits for a tip amount typed in DASH, parsed by Rust; throws on ambiguous input. */
fun parseShieldedTipAmount(text: String): Long =
mapNativeErrors { FundingNative.parseShieldedTipAmount(text) }

/** Dedicated account index derived by Rust's wallet convention. */
fun shieldedTipAccountIndex(identityIndex: Int): Int = mapNativeErrors { FundingNative.tipAccountIndex(identityIndex) }

suspend fun prepareShieldedTipAddress(walletId: ByteArray, identityId: ByteArray): ByteArray = teardownGate.op {
mapNativeErrors { FundingNative.prepareShieldedTipAddress(managerHandle, walletId, mnemonicResolver.nativeHandle, identityId) }
}

/** Send only if fresh resolution still matches the recipient shown in confirmation. */
/**
* Resolve [username] for the user to confirm: its recipient, whether it changed since this
* wallet last confirmed the name, and whether an earlier payment to it is unresolved.
*/
suspend fun reviewShieldedTip(
walletId: ByteArray, username: String,
): org.dashfoundation.dashsdk.tokens.ShieldedTipReview = teardownGate.op {
org.dashfoundation.dashsdk.tokens.ShieldedTipReview.fromNative(
mapNativeErrors { FundingNative.reviewShieldedTip(managerHandle, walletId, username) },
)
}

/**
* Send only if fresh resolution still matches the recipient shown in confirmation. Every
* failure is thrown as [org.dashfoundation.dashsdk.tokens.ShieldedTipFailure] carrying Rust's
* decision whether the tip may be sent again; one raised before the native send is not sent.
*/
suspend fun sendShieldedTip(
walletId: ByteArray, username: String,
recipient: org.dashfoundation.dashsdk.tokens.ShieldedTipRecipient,
amount: Long, source: org.dashfoundation.dashsdk.tokens.ShieldedTipSource,
memo: String? = null,
): Unit = teardownGate.op {
require(amount > 0) { "amount must be positive, got $amount" }
val (sourceIdentityId, sourceAccount) = when (source) {
is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Identity -> source.identityId to 0
is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Account -> null to source.index
}
mapNativeErrors {
FundingNative.sendShieldedTip(managerHandle, walletId, mnemonicResolver.nativeHandle,
sourceIdentityId, sourceAccount, username, recipient.identityId, recipient.address,
amount, memo)
) {
// A failure before JNI is entered sent nothing; JNI overwrites this with Rust's decision.
val disposition = intArrayOf(
org.dashfoundation.dashsdk.tokens.ShieldedTipDisposition.NATIVE_NOT_SENT,
)
try {
teardownGate.op {
require(amount > 0) { "amount must be positive, got $amount" }
val (sourceIdentityId, sourceAccount) = when (source) {
is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Identity -> source.identityId to 0
is org.dashfoundation.dashsdk.tokens.ShieldedTipSource.Account -> null to source.index
}
mapNativeErrors {
FundingNative.sendShieldedTip(managerHandle, walletId, mnemonicResolver.nativeHandle,
sourceIdentityId, sourceAccount, username, recipient.identityId, recipient.address,
amount, memo, disposition)
}
}
} catch (error: kotlinx.coroutines.CancellationException) {
throw error
Comment on lines +2108 to +2109

@thepastaclaw thepastaclaw Nov 1, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Resolved at 5e9971ba; see the replies below.

🟡 Suggestion: Carry the native tip disposition through coroutine cancellation

TeardownGate.withOp uses withContext(Dispatchers.IO). Cancelling the caller does not interrupt the blocking native send, and prompt cancellation can discard its completed result when dispatching back to the caller. JNI has already copied Rust's disposition into the array, but this catch throws a bare CancellationException, making cancellation before native entry indistinguishable from cancellation after an executed payment. After success the barrier can already be settled, so another review need not report an unresolved payment either. Preserve the completed native outcome in a cancellation-compatible exception or result without adding a Kotlin retry policy. Add a deterministic completed-send/cancelled-handoff test using the sequence already exercised by TeardownGateTest for discarded secret results.

source: gpt-6.1-sol (phase2-reviewer: ffi-engineer)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 38bfe13. PlatformWalletManager.sendShieldedTip runs the gate and the native send under withContext(NonCancellable). A cancelled caller therefore still gets the completed outcome, and a failure still carries the JNI-written disposition. Cancellation takes effect at the caller's next suspension point.

There is no Kotlin test for this. Gradle doesn't run in this environment, and the safety itself doesn't depend on it, because the Rust barrier is what refuses a second payment.


🤖 Posted autonomously by Claude on behalf of pasta.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (re-reviewed at 5e9971ba): Your NonCancellable scope now encloses both teardownGate.op and the native send, so the IO handoff returns into a non-cancelled context and preserves the completed outcome and JNI-written disposition. The deterministic cancellation regression remains absent, but the reported result-discard path is addressed.

} catch (error: Exception) {
throw org.dashfoundation.dashsdk.tokens.ShieldedTipFailure(
org.dashfoundation.dashsdk.tokens.ShieldedTipDisposition.fromNative(disposition[0]), error,
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.dashfoundation.dashsdk.tokens

import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

/** Marshalling of the native tip review and retry decision; the decisions themselves are Rust's. */
class ShieldedTipReviewTest {
private fun review(change: Int, unresolved: Boolean): ByteArray =
ByteArray(32) { 1 } + ByteArray(43) { 2 } + byteArrayOf(change.toByte()) +
ByteArray(32) { 3 } + ByteArray(43) { 4 } + byteArrayOf(if (unresolved) 1 else 0)

@Test
fun shouldDecodeEachRecipientChange() {
val new = ShieldedTipReview.fromNative(review(0, unresolved = false))
assertEquals(ShieldedTipRecipientChange.New, new.change)
assertArrayEquals(ByteArray(32) { 1 }, new.recipient.identityId)
assertArrayEquals(ByteArray(43) { 2 }, new.recipient.address)
assertFalse(new.unresolved)

assertEquals(
ShieldedTipRecipientChange.Unchanged,
ShieldedTipReview.fromNative(review(1, unresolved = true)).change,
)

val changed = ShieldedTipReview.fromNative(review(2, unresolved = true))
val previous = (changed.change as ShieldedTipRecipientChange.Changed).previous
assertArrayEquals(ByteArray(32) { 3 }, previous.identityId)
assertArrayEquals(ByteArray(43) { 4 }, previous.address)
assertTrue(changed.unresolved)
}

@Test
fun shouldTreatAnyDispositionButNotSentAsUnresolved() {
assertEquals(ShieldedTipDisposition.NOT_SENT, ShieldedTipDisposition.fromNative(1))
for (value in listOf(0, 2, -1)) {
assertEquals(ShieldedTipDisposition.UNRESOLVED, ShieldedTipDisposition.fromNative(value))
}
}
}
Loading
Loading