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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,83 @@ internal object IdentityNative {
coreSignerHandle: Long,
): ByteArray

/**
* Byte capacity a [createInvitation] `outBlob` must have: the 36-byte
* outpoint prefix plus the native hard cap on an emitted
* `dashpay://invite` link. Read from the native side rather than
* hard-coded here so the two cannot drift apart across a rebuild.
*/
external fun invitationBlobCapacity(): Int

/**
* Create a DashPay invitation (DIP-13): fund a one-time asset-lock
* voucher and return a shareable `dashpay://invite` link. No identity is
* registered — this is pure voucher creation.
*
* Both out-buffers are caller-allocated and validated natively BEFORE the
* voucher is funded, so the result is published with non-allocating region
* writes that cannot fail. That is required rather than stylistic: the call
* only succeeds once the asset lock has been broadcast and persisted, and
* the link it produces is the SOLE copy of a bearer credential (Room stores
* only the outpoint and funding metadata — no URI, no key — and there is no
* regeneration API), so a fallible allocation after the funding could lose
* the credential outright.
*
* @param amountDuffs voucher amount in duffs (must be positive).
* @param fundingAccountIndex BIP-44 account the voucher is funded from.
* @param inviterIdentityId optional 32-byte inviter id enabling the
* contact-bootstrap opt-in; `null` for a pure funding voucher. When
* non-null, [inviterUsername] is required.
* @param inviterUsername inviter DPNS username carried in the link (only
* used when [inviterIdentityId] is non-null).
* @param nowUnix current unix time in seconds (must be > 0); the advisory
* ~24h expiry is derived Rust-side.
* @param coreSignerHandle `MnemonicResolverHandle` for the funding-spend
* signature (the SAME handle [registerIdentityWithFunding] takes).
* @param outBlob a `ByteArray` of at least [invitationBlobCapacity] bytes,
* receiving `outpoint[36] (txid[32] || vout_le[4]) || utf8Uri`. The URI
* embeds the bearer voucher key — never log or persist it beyond the
* share sheet, and scrub this buffer once the string has been built.
* @param outLen an `IntArray(1)` receiving the number of bytes actually
* written to [outBlob] (`36 + uri.length`); the rest is untouched.
*/
external fun createInvitation(
walletHandle: Long,
amountDuffs: Long,
fundingAccountIndex: Int,
inviterIdentityId: ByteArray?,
inviterUsername: String?,
nowUnix: Long,
coreSignerHandle: Long,
outBlob: ByteArray,
outLen: IntArray,
)

/**
* Claim a DashPay invitation (DIP-13): register a NEW identity for the
* invitee, funded by the imported voucher carried in [uri].
*
* @param uri the `dashpay://invite?…` link (a bearer secret).
* @param identityIndex identity slot for the new identity.
* @param pubkeysBlob the invitee's new-identity key rows, SAME layout as
* [registerIdentityWithFunding] (encoded by
* [org.dashfoundation.dashsdk.identity.IdentityPubkeyCodec.encode]).
* @param signerHandle identity-key `SignerHandle`. The asset-lock's outer
* signature comes from the imported voucher key, so no Core resolver is
* needed here.
* @param nowUnix accepted for ABI parity; currently unused (the legacy
* link carries no expiry).
* @return the 32-byte new identity id.
*/
external fun claimInvitation(
walletHandle: Long,
uri: String,
identityIndex: Int,
pubkeysBlob: ByteArray,
signerHandle: Long,
nowUnix: Long,
): ByteArray

/**
* Register a new identity funded by the wallet's already-committed
* Platform-payment (DIP-17) address balances — the ID-08 create path,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,40 @@ abstract class NativePersistenceBridge {
/** One 36-byte outpoint removal. Descriptor `([B[B)I`. */
open fun onPersistAssetLockRemoval(walletId: ByteArray, outPoint: ByteArray): Int = 0

// ── Invitations (DIP-13) ──────────────────────────────────────────

/**
* One `InvitationEntryFFI` upsert (`tramp_persist_invitations` in
* `persistence.rs`). Descriptor `([B[BIJJJBB)I`.
*
* Wiring this callback durably is what lets `FFIPersister` report the
* `INVITATIONS` capability, which the Rust `create_invitation` durability
* gate requires before it moves any funds — a no-op override would defeat
* the gate and risk re-exporting a one-time voucher key after a restart.
*
* @param outPoint 36-byte funding outpoint (`txid[32] || vout_le[4]`).
* @param fundingIndex DIP-13 funding index the voucher key derives from
* (unsigned, carried in an `Int`).
* @param expiryUnix advisory expiry, unix seconds (widened to `Long`).
* @param createdAtSecs creation time, unix seconds (widened to `Long`).
* @param hasInviter 1 if the link carries inviter/contact-bootstrap info, else 0.
* @param status 0 = Created, 1 = Claimed, 2 = Reclaimed.
*/
@Suppress("LongParameterList")
open fun onPersistInvitationUpsert(
walletId: ByteArray,
outPoint: ByteArray,
fundingIndex: Int,
amountDuffs: Long,
expiryUnix: Long,
createdAtSecs: Long,
hasInviter: Byte,
status: Byte,
): Int = 0

/** One 36-byte outpoint removal. Descriptor `([B[B)I`. */
open fun onPersistInvitationRemoval(walletId: ByteArray, outPoint: ByteArray): Int = 0

// ── Shielded persist ──────────────────────────────────────────────

/** One `ShieldedNoteFFI`. Descriptor `([B[BIJ[B[BJBJ[B)I`. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import org.dashfoundation.dashsdk.wallet.op
import org.dashfoundation.dashsdk.wallet.opWithCleanupOnCancellation

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import org.dashfoundation.dashsdk.credits.FundingInput
import org.dashfoundation.dashsdk.errors.mapNativeErrors
Expand Down Expand Up @@ -286,6 +289,182 @@ class IdentityRegistration internal constructor(
}
}

/**
* A freshly created DashPay invitation (DIP-13).
*
* @property outPoint 36-byte funding outpoint (`txid[32] || vout_le[4]`) —
* the same key the persistence layer stores the invitation row under.
* @property uri the shareable `dashpay://invite` link. **This is a bearer
* secret: it embeds the one-time voucher key. Never log it or persist it
* anywhere but the OS share sheet.**
*/
data class CreatedInvitation(
val outPoint: ByteArray,
val uri: String,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is CreatedInvitation) return false
return outPoint.contentEquals(other.outPoint) && uri == other.uri
}

override fun hashCode(): Int = 31 * outPoint.contentHashCode() + uri.hashCode()

/** Redacts the bearer URI so an accidental log/toString never leaks the voucher key. */
override fun toString(): String = "CreatedInvitation(outPoint=<36b>, uri=<redacted>)"
}

/**
* Create a DashPay invitation (DIP-13): fund a one-time asset-lock voucher
* and return a shareable link. No identity is registered. The Rust
* durability gate refuses to run unless invitation persistence is wired,
* so this fails closed before any funds move on a backend that can't
* durably record the voucher.
*
* @param amountDuffs voucher amount in duffs (must be positive).
* @param fundingAccountIndex BIP-44 account the voucher is funded from.
* Cancellation contract: the caller's cancellation is honored BEFORE the
* native call starts, but once it begins the operation runs to completion
* under [NonCancellable] and the result is always delivered. The native op
* may have broadcast the asset lock and generated the bearer URI by the
* time cancellation is observed; JNI cannot see Kotlin cancellation, Room
* intentionally stores no URI or voucher key, and no regeneration API
* exists — so a discarded `withContext` result would lose the only
* shareable credential after funds have moved.
*
Comment thread
bfoss765 marked this conversation as resolved.
* Delivery contract (the same hazard, one layer down): the link is
* published into a caller-allocated buffer that the native side validates
* BEFORE it funds anything, and writes with non-allocating region writes
* afterwards. No fallible allocation sits between the broadcast voucher and
* the URI arriving here, so the credential cannot be dropped on the way
* out. The buffer is scrubbed once the string has been built.
*
* @param inviterIdentityId optional 32-byte inviter id enabling the
* contact-bootstrap opt-in; `null` for a pure funding voucher. When
* non-null, [inviterUsername] is required.
* @param inviterUsername inviter DPNS username carried in the link. Only
* used when [inviterIdentityId] is non-null; passing it alone is
* rejected rather than silently discarded.
* @param nowUnix current unix time in seconds (must be > 0).
* @param coreSignerHandle `MnemonicResolverHandle` for the funding-spend
* signature (the SAME handle [registerWithWalletFunding] takes).
* @return the funding outpoint plus the bearer link — see [CreatedInvitation].
*/
suspend fun createInvitation(
walletHandle: Long,
amountDuffs: Long,
fundingAccountIndex: Int,
inviterIdentityId: ByteArray? = null,
inviterUsername: String? = null,
nowUnix: Long,
coreSignerHandle: Long,
): CreatedInvitation {
require(amountDuffs > 0) { "amountDuffs must be positive, got $amountDuffs" }
require(fundingAccountIndex >= 0) {
"fundingAccountIndex must be non-negative, got $fundingAccountIndex"
}
require(nowUnix > 0) { "nowUnix must be a positive unix timestamp, got $nowUnix" }
inviterIdentityId?.let {
require(it.size == 32) { "inviterIdentityId must be 32 bytes, got ${it.size}" }
require(inviterUsername != null) {
"inviterUsername is required when inviterIdentityId is provided"
}
}
require(inviterIdentityId != null || inviterUsername == null) {
"inviterIdentityId is required when inviterUsername is provided " +
"(the username is otherwise silently ignored by the native layer)"
}
// Honor cancellation up to here; past this point the operation is
// non-cancellable (see the KDoc cancellation contract above).
currentCoroutineContext().ensureActive()
return withContext(NonCancellable) {
gate.op {
// Caller-allocated out-buffers, validated natively BEFORE the
// voucher is funded — the native side then publishes with
// non-allocating region writes. The previous shape allocated
// the result array inside JNI *after* the asset lock had been
// broadcast and persisted, where an allocation failure would
// have destroyed the only copy of the bearer link (Room stores
// no URI or voucher key, and there is no regeneration API).
// The capacity comes from the native cap on an emitted link, so
// the buffer is always large enough and the two cannot drift.
val capacity = IdentityNative.invitationBlobCapacity()
val outBlob = ByteArray(capacity)
val outLen = IntArray(1)
try {
mapNativeErrors {
IdentityNative.createInvitation(
walletHandle,
amountDuffs,
fundingAccountIndex,
inviterIdentityId,
inviterUsername,
nowUnix,
coreSignerHandle,
outBlob,
outLen,
)
}
// Blob layout (fixed by the JNI): outpoint[36] (txid[32] ||
// vout_le[4]) then the UTF-8 URI, `len` bytes in total.
// Anything outside that window is a contract violation.
val len = outLen[0]
require(len in 36..capacity) {
"createInvitation reported a $len-byte blob; expected 36..$capacity"
}
CreatedInvitation(
outPoint = outBlob.copyOfRange(0, 36),
uri = String(outBlob, 36, len - 36, Charsets.UTF_8),
)
} finally {
// The buffer held the plaintext bearer voucher key; scrub it
// so the secret does not linger in a JVM array that outlives
// this call (same discipline as the key-preview blobs).
outBlob.fill(0)
}
}
}
Comment thread
bfoss765 marked this conversation as resolved.
}

/**
* Claim a DashPay invitation (DIP-13): register a NEW identity for the
* invitee, funded by the imported voucher carried in [uri]. The
* contact-bootstrap ("establish contact with the sender?") is NOT done
* here — the UI asks the invitee and calls the contact-request path on
* confirm. [keys] are the invitee's own new-identity rows (built via
* [RegistrationKeys.buildRegistrationRows]) — the SAME codec path
* [registerWithWalletFunding] uses.
*
* @param uri the `dashpay://invite?…` link (a bearer secret — never log it).
* @param identityIndex identity slot for the new identity.
* @param signerHandle identity-key `SignerHandle`. No Core resolver is
* needed: the asset-lock's outer signature comes from the voucher key.
* @param nowUnix accepted for ABI parity; currently unused Rust-side.
* @return the 32-byte new identity id.
*/
suspend fun claimInvitation(
walletHandle: Long,
uri: String,
identityIndex: Int,
keys: List<IdentityPubkey>,
signerHandle: Long,
nowUnix: Long,
): ByteArray = gate.op {
require(identityIndex >= 0) { "identityIndex must be non-negative, got $identityIndex" }
require(uri.isNotBlank()) { "uri must not be blank" }
require(keys.isNotEmpty()) { "keys must not be empty" }
mapNativeErrors {
IdentityNative.claimInvitation(
walletHandle,
uri,
identityIndex,
IdentityPubkeyCodec.encode(keys),
signerHandle,
nowUnix,
)
}
}

/**
* Register a new identity funded by the wallet's already-committed
* Platform-payment (DIP-17) address balances — the ID-08 path, distinct
Expand Down
Loading
Loading