Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions docs/dashpay/KOTLIN_MIGRATION_LEFTOVERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,15 @@ when the four stacked PRs collapse into one.
mixed old-native/new-Kotlin builds, which the completion JNI arity change
(3→4 args) makes unsupported outright; delete it (and `MESSAGE_MARKER`'s
matcher role) in the next minor release. Accepted residual until rs-dpp grows a typed variant: the
Rust-internal segment rides the `signer_error:key_unavailable: ` prefix
Rust-internal segment rides the `signer_error:key_unavailable:` prefix
through `ProtocolError::Generic` (typed at both ABI edges, one Rust-owned
constant bridging the string segment).

- **On-device `KeyPermanentlyInvalidatedException` coverage.** The
invalidation recovery (generation-checked alias deletion + re-derive via
forced repair) is pinned at the unit tier through the fake Keystore seam;
a REAL KPIE requires biometric re-enrollment mid-test, which CI's emulator
cannot do — same residual #4172 accepted. Exercise manually per the device
cannot do — the same residual accepted in #4172. Exercise manually per the device
test plan when touching the invalidation path.

## Environment-bound (cannot be code-fixed here)
Expand Down
2 changes: 1 addition & 1 deletion docs/sdk/KOTLIN_SWIFT_SHARED_PARITY_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,7 @@ Recorded in `sdk-parity-manifest.json`; rationale here:
signer completion carries a typed `error_code` (rs-sdk-ffi
`DashSDKSignerErrorCode`), restored as platform-wallet code 31 on both
hosts. The Rust-internal segment rides the machine prefix
`signer_error:key_unavailable: ` through `ProtocolError::Generic` (a typed
`signer_error:key_unavailable:` through `ProtocolError::Generic` (a typed
rs-dpp variant was rejected for serialization blast radius — accepted
residual). The Kotlin `MESSAGE_MARKER` text sniff survives ONLY as a
deprecated fallback for the #4191 merge-order transition (marker-based
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,25 @@ sealed class DashSdkError(
class NotFound(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* `ErrorShieldedInviteAlreadyClaimed` (native code 37). A one-time-key
* (shielded invitation) claim found the invitation note's nullifier
* already spent on chain, and could NOT produce positive evidence that
* this claim's Type-20 transition created an identity — the spend was
* finalized to the creation-failure address, or another holder of the
* same bearer one-time key won the race, or the id is not re-derivable.
*
* TERMINAL and NOT retryable (the inherited [isRetryable] `false`):
* the note is consumed, so no retry can spend it again. Distinct from
* [ShieldedCreateUnconfirmed], which means "executed, not yet
* resolvable, hold the slot". No identity id is produced — this wallet
* has no identity to hold a slot for, and claiming one would be the
* false-ownership assertion this code exists to prevent. Hosts should
* surface the invitation as spent rather than registering an identity.
*/
class ShieldedInviteAlreadyClaimed(message: String, cause: Throwable? = null) :
PlatformWallet(message, cause)

/**
* Any other `PlatformWalletFFIResultCode` without a dedicated type.
* Carries the platform-wallet [nativeCode] (already de-offset) and
Expand Down Expand Up @@ -351,6 +370,10 @@ sealed class DashSdkError(
// sniffing involved. (Codes 26-30 are reserved by sibling PRs
// #4185 / #4184 — see PlatformWalletFFIResultCode.)
31 -> PlatformWallet.SigningKeyUnavailable(message, cause)
// ErrorShieldedInviteAlreadyClaimed. Allocated 37 (not 32, which
// belongs to ErrorTransactionBuild — dashpay/platform#4247/#4256);
// see packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md.
37 -> PlatformWallet.ShieldedInviteAlreadyClaimed(message, cause)
else ->
// @Deprecated fallback — see the code-6 arm; code 31 is the
// real discriminator.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,55 @@ internal object FundingNative {
signerAddressHandle: Long,
): ByteArray

/**
* Create an identity funded from a ONE-TIME Orchard key, Type 20 (bridges
* `platform_wallet_manager_shielded_identity_create_from_one_time_key`) —
* the L2-invitation *claim* side. Like [shieldedIdentityCreateFromPool],
* but the Orchard spend authority is the invitation's single-use 32-byte
* spending key [oneTimeSk] rather than the wallet's own bound pool. The
* wallet derives the key's viewing keys, transiently scans the network for
* the note(s) funded to it, and spends them. [changeAddressRaw43] is the
* claimer's OWN 43-byte default Orchard address that receives any
* over-funding change note (zero for a well-formed invitation).
* [fundingBirthHeight] is an advisory hint: a negative value means "no
* hint". [pubkeysBlob] / [denomination] / [fallbackAddress] /
* [identityIndex] / [signerAddressHandle] match the pool variant. Blocks
* for the ~30s Halo 2 proof; returns the new 32-byte identity id.
*/
external fun shieldedIdentityCreateFromOneTimeKey(
managerHandle: Long,
walletId: ByteArray,
oneTimeSk: ByteArray,
fundingBirthHeight: Int,
changeAddressRaw43: ByteArray,
identityIndex: Int,
pubkeysBlob: ByteArray,
denomination: Long,
fallbackAddress: ByteArray,
signerAddressHandle: Long,
): ByteArray

/**
* Generate a fresh one-time Orchard spending key + its default payment
* address (bridges `platform_wallet_generate_one_time_orchard_key`) — the
* *inviter* side of an L2 shielded invitation. Handle-less: a one-time key
* is process-local Orchard crypto, not bound to any wallet.
*
* Returns a single 75-byte blob: bytes `[0, 32)` are the 32-byte one-time
* spending key and bytes `[32, 75)` are the 43-byte raw default Orchard
* address to fund. The inviter funds a note to the address; a claimer given
* the spending key spends it via [shieldedIdentityCreateFromOneTimeKey].
*/
external fun generateOneTimeOrchardKey(): ByteArray

/**
* Derive the default 43-byte raw Orchard address from a 32-byte one-time
* spending key (bridges `platform_wallet_orchard_address_from_spending_key`)
* — the RNG-free counterpart of [generateOneTimeOrchardKey]. Handle-less;
* throws if [spendingKey] is not a valid Orchard spending key.
*/
external fun orchardAddressFromSpendingKey(spendingKey: ByteArray): ByteArray

// ── Shielded outgoing spends (types 16/17/19) ─────────────────────
//
// Manager-handle calls like the funding submits above; each signs with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,35 @@ interface ShieldedDao {
@Query("SELECT * FROM shielded_notes WHERE walletId = :walletId AND isSpent = 0")
fun observeUnspentNotesByWallet(walletId: ByteArray): Flow<List<ShieldedNoteEntity>>

/**
* Shielded-username confirmation gate: the earliest-anchored unspent
* funding note's `blockHeight` for [walletId]. Only mined notes count
* (`blockHeight > 0` excludes mempool/height-0 rows); `MIN` yields the
* most-confirmed anchor. Returns null when the wallet has no anchored
* unspent note. Wallet scoping mirrors [observeUnspentNotesByWallet]
* (`walletId = :walletId AND isSpent = 0`).
*/
@Query(
"SELECT MIN(blockHeight) FROM shielded_notes " +
"WHERE walletId = :walletId AND isSpent = 0 AND blockHeight > 0"
)
suspend fun minUnspentAnchoredBlockHeight(walletId: ByteArray): Long?

/**
* Companion to [minUnspentAnchoredBlockHeight] for the gate's
* denomination-coverage check: every unspent, anchored (mined) note for
* [walletId], youngest anchor first (`blockHeight DESC`), so the app can
* decide whether an anchored note set covers the required amount and
* inspect each note's `value` / `blockHeight` / `createdAt`. Wallet
* scoping mirrors [observeUnspentNotesByWallet].
*/
@Query(
"SELECT * FROM shielded_notes " +
"WHERE walletId = :walletId AND isSpent = 0 AND blockHeight > 0 " +
"ORDER BY blockHeight DESC"
)
suspend fun getUnspentAnchoredNotesByWallet(walletId: ByteArray): List<ShieldedNoteEntity>

@Upsert
suspend fun upsertNote(note: ShieldedNoteEntity)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ open class KeystoreManager(
return try {
decrypt(blob, KEYS_ALIAS_DEVICE_BOUND).fill(0)
true
} catch (e: GeneralSecurityException) {
} catch (_: GeneralSecurityException) {
false
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ class WalletStorage(
// suppresses the biometric retry and the next write/repair
// regenerates the alias.
throw e
} catch (e: GeneralSecurityException) {
} catch (_: GeneralSecurityException) {
// Rotation race / provider quirk: fall through to the
// recovery ladder rather than failing the read outright.
recoverEmptyIvRsaBlob(pubkeyHex, blob, encoded)
Expand Down Expand Up @@ -638,7 +638,7 @@ class WalletStorage(
throw e
} catch (e: KeyPermanentlyInvalidatedException) {
throw e
} catch (e: GeneralSecurityException) {
} catch (_: GeneralSecurityException) {
null
}

Expand Down Expand Up @@ -894,9 +894,9 @@ class WalletStorage(
} else {
false
}
} catch (e: UserNotAuthenticatedException) {
} catch (_: UserNotAuthenticatedException) {
unaeProvesRecoverable
} catch (e: GeneralSecurityException) {
} catch (_: GeneralSecurityException) {
false
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1512,6 +1512,65 @@ class PlatformWalletManager(
decodeShieldedCreatePayload(packed)
}

/**
* Create an identity funded from a ONE-TIME Orchard key (Type 20) — the
* L2-invitation *claim* side. Like [shieldedIdentityCreateFromPool], but the
* Orchard spend authority is the invitation's single-use 32-byte spending
* key [oneTimeSk] rather than the wallet's own bound pool: the wallet
* derives that key's viewing keys, transiently scans the network for the
* note(s) funded to it, and spends a note of the fixed exit [denomination]
* to fund a new identity at [identityIndex]. [changeAddressRaw43] is the
* claimer's OWN 43-byte default Orchard address that receives any
* over-funding change note (zero for a well-formed invitation).
* [fundingBirthHeight] is an advisory scan hint; pass `null` when unknown.
* [keys] are the rich registration rows (built via
* `RegistrationKeys.buildRegistrationRows`), encoded to the same blob every
* registration path uses; each row's private half must already be
* persisted. [fallbackAddress] is the REQUIRED 21-byte PlatformAddress that
* receives the value (minus a penalty) if creation fails a stateful check.
* Signed by the Keystore identity signer ([signerHandle]). Blocks for the
* ~30s Halo 2 proof.
*
* @return the new 32-byte identity id.
*/
suspend fun shieldedIdentityCreateFromOneTimeKey(
walletId: ByteArray,
oneTimeSk: ByteArray,
changeAddressRaw43: ByteArray,
identityIndex: Int,
keys: List<org.dashfoundation.dashsdk.identity.IdentityPubkey>,
denomination: Long,
fallbackAddress: ByteArray,
fundingBirthHeight: Int? = null,
): ByteArray = teardownGate.op {
require(oneTimeSk.size == 32) { "oneTimeSk must be 32 bytes, got ${oneTimeSk.size}" }
require(changeAddressRaw43.size == 43) {
"changeAddressRaw43 must be 43 bytes, got ${changeAddressRaw43.size}"
}
require(identityIndex >= 0) { "identityIndex must be non-negative, got $identityIndex" }
require(denomination > 0) { "denomination must be positive, got $denomination" }
require(fallbackAddress.size == 21) {
"fallbackAddress must be 21 bytes, got ${fallbackAddress.size}"
}
require(keys.isNotEmpty()) { "keys must not be empty" }
val packed = mapNativeErrors {
FundingNative.shieldedIdentityCreateFromOneTimeKey(
managerHandle,
walletId,
oneTimeSk,
// A negative birth-height signals "no hint" across JNI.
fundingBirthHeight ?: -1,
changeAddressRaw43,
identityIndex,
org.dashfoundation.dashsdk.identity.IdentityPubkeyCodec.encode(keys),
denomination,
fallbackAddress,
signerHandle,
)
}
decodeShieldedCreatePayload(packed)
}

/**
* Resume a stuck shielded fund-from-asset-lock from an already-tracked
* lock — port of Swift's `shieldedResumeFundFromAssetLock`.
Expand Down Expand Up @@ -2227,6 +2286,68 @@ class PlatformWalletManager(
}
}

/**
* A freshly generated one-time Orchard key for an L2 shielded invitation —
* the *inviter* side. Returned by [generateOneTimeOrchardKey].
*
* The inviter funds an Orchard note to [address]; a claimer handed
* [spendingKey] re-derives its viewing keys and spends that note via
* [PlatformWalletManager.shieldedIdentityCreateFromOneTimeKey]. All Orchard
* key material is generated in Rust — the app only ever sees these bytes.
*/
data class OneTimeOrchardKey(
/** The 32-byte one-time Orchard spending key (the claimer's spend authority). */
val spendingKey: ByteArray,
/** The 43-byte raw default Orchard payment address the inviter funds. */
val address: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is OneTimeOrchardKey) return false
return spendingKey.contentEquals(other.spendingKey) &&
address.contentEquals(other.address)
}

override fun hashCode(): Int = 31 * spendingKey.contentHashCode() + address.contentHashCode()
}

/**
* Generate a fresh one-time Orchard spending key together with the default
* Orchard address it funds — the *inviter* side of an L2 shielded invitation.
*
* Handle-less (process-local Orchard crypto). The inviter funds a note to the
* returned [OneTimeOrchardKey.address]; the claimer, handed
* [OneTimeOrchardKey.spendingKey], spends it. The spending key is exactly the
* 32-byte value [PlatformWalletManager.shieldedIdentityCreateFromOneTimeKey]
* accepts.
*/
fun generateOneTimeOrchardKey(): OneTimeOrchardKey {
val blob = mapNativeErrors { FundingNative.generateOneTimeOrchardKey() }
// The blob's first 32 bytes are bearer spend authority; wipe the transient
// JVM copy once the two owned arrays have been sliced out (#4204 key-hygiene).
try {
require(blob.size == 75) { "expected a 75-byte sk||address blob, got ${blob.size}" }
return OneTimeOrchardKey(
spendingKey = blob.copyOfRange(0, 32),
address = blob.copyOfRange(32, 75),
)
} finally {
blob.fill(0)
}
}

/**
* Derive the default 43-byte raw Orchard payment address from a 32-byte
* one-time Orchard [spendingKey] — the RNG-free counterpart of
* [generateOneTimeOrchardKey], for round-trip validation and recomputing the
* recipient an inviter must fund for a given key. Handle-less; throws if
* [spendingKey] is not a valid Orchard spending key.
*/
fun orchardAddressFromSpendingKey(spendingKey: ByteArray): ByteArray {
require(spendingKey.size == 32) { "spendingKey must be 32 bytes, got ${spendingKey.size}" }
return mapNativeErrors { FundingNative.orchardAddressFromSpendingKey(spendingKey) }
}

/**
* Per-wallet seedless-unlock status — Swift `DashPayUnlockStatus`.
* Published on [PlatformWalletManager.dashPayUnlockStatus]; drives the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ class DashSdkErrorTest {
// The message must warn against retrying, like the broadcast sibling.
assertTrue(spendUnconfirmed.message!!.contains("do NOT retry"))

// Code 37, NOT 32: 32 is ErrorTransactionBuild (dashpay/platform#4247,
// #4256). This assertion is the mirror's guard against the collision —
// if the Rust discriminant is ever moved back onto a claimed number,
// the host silently reclassifies an already-claimed invite as some
// other branch's error. See ERROR_CODE_REGISTRY.md (#4261).
val inviteClaimed =
DashSdkError.fromNative(DashSDKException(offset + 37, "nullifier already spent"))
assertTrue(inviteClaimed is DashSdkError.PlatformWallet.ShieldedInviteAlreadyClaimed)
assertFalse(
"ShieldedInviteAlreadyClaimed is TERMINAL — the note is consumed",
inviteClaimed.isRetryable,
)

val broadcastUnconfirmed =
DashSdkError.fromNative(DashSDKException(offset + 20, "ambiguous broadcast"))
assertTrue(
Expand Down
Loading
Loading