Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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,61 @@ internal object IdentityNative {
coreSignerHandle: Long,
): ByteArray

/**
* 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.
*
* @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).
* @return a blob: `outpoint[36] (txid[32] || vout_le[4]) || utf8Uri`. The
* URI embeds the bearer voucher key — never log or persist it beyond
* the share sheet.
*/
external fun createInvitation(
walletHandle: Long,
amountDuffs: Long,
fundingAccountIndex: Int,
inviterIdentityId: ByteArray?,
inviterUsername: String?,
nowUnix: Long,
coreSignerHandle: Long,
): ByteArray

/**
* 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 @@ -279,6 +282,152 @@ 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 on lines +317 to +327

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.

💬 Nitpick: Move the cancellation contract out of the fundingAccountIndex tag

KDoc treats untagged lines after @param fundingAccountIndex as that parameter's description until the next block tag, so the cancellation contract is rendered as part of fundingAccountIndex instead of the method overview. Move the paragraph above the first @param tag so generated documentation presents it as method-level behavior.

Suggested change
* @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.
*
* 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.
*
* @param amountDuffs voucher amount in duffs (must be positive).
* @param fundingAccountIndex BIP-44 account the voucher is funded from.
*

source: ['claude']

* @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 {
val blob = mapNativeErrors {
IdentityNative.createInvitation(
walletHandle,
amountDuffs,
fundingAccountIndex,
inviterIdentityId,
inviterUsername,
nowUnix,
coreSignerHandle,
)
}
// Blob layout (fixed by the JNI): outpoint[36] (txid[32] || vout_le[4])
// then the UTF-8 URI. Anything shorter is a contract violation.
require(blob.size >= 36) {
"createInvitation returned a ${blob.size}-byte blob; expected >= 36"
}
CreatedInvitation(
outPoint = blob.copyOfRange(0, 36),
uri = String(blob.copyOfRange(36, blob.size), Charsets.UTF_8),
)
}
}
Comment on lines +365 to +389

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.

🟡 Suggestion: Regression-test the non-cancellable bearer-URI handoff

The fix correctly keeps the gated JNI call, blob validation, URI decoding, and CreatedInvitation construction inside withContext(NonCancellable), but no createInvitation test or injectable native-call seam exercises cancellation during the blocking call. Add an internal create-invitation native-call dependency, cancel after the fake native call starts, then release it and verify that caller-visible code receives the exact outpoint and URI and that the teardown-gate lease is released. This protects the method-level guarantee from a future refactor that narrows or removes the protected region.

source: ['claude', 'codex']

}

/**
* 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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import org.dashfoundation.dashsdk.persistence.converters.Converters
import org.dashfoundation.dashsdk.persistence.dao.AccountDao
import org.dashfoundation.dashsdk.persistence.dao.AssetLockDao
import org.dashfoundation.dashsdk.persistence.dao.InvitationDao
import org.dashfoundation.dashsdk.persistence.dao.CoreAddressDao
import org.dashfoundation.dashsdk.persistence.dao.DashpayDao
import org.dashfoundation.dashsdk.persistence.dao.DataContractDao
Expand All @@ -27,6 +28,7 @@ import org.dashfoundation.dashsdk.persistence.dao.WalletDao
import org.dashfoundation.dashsdk.persistence.dao.WalletManagerMetadataDao
import org.dashfoundation.dashsdk.persistence.entities.AccountEntity
import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity
import org.dashfoundation.dashsdk.persistence.entities.InvitationEntity
import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity
import org.dashfoundation.dashsdk.persistence.entities.DashpayContactProfileEntity
import org.dashfoundation.dashsdk.persistence.entities.DashpayContactRequestEntity
Expand Down Expand Up @@ -99,9 +101,16 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti
* Version 7 (provider restore): adds transaction block position and an
* explicit transaction↔typed-account involvement table for payload-only
* provider transactions.
*
* Version 8 (DIP-13 sent invitations): adds the `invitations` table — one
* row per funded one-time asset-lock voucher, keyed by its 36-byte funding
* outpoint. Durable storage here is what lets the Rust `create_invitation`
* durability gate mint a voucher (a non-durable store could re-export the
* same one-time key after a restart). Rows die with their wallet via the
* `deleteWalletData` cascade.
*/
@Database(
version = 7,
version = 8,
exportSchema = true,
entities = [
WalletEntity::class,
Expand All @@ -111,6 +120,7 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti
TxoEntity::class,
CoreAddressEntity::class,
AssetLockEntity::class,
InvitationEntity::class,
IdentityEntity::class,
PublicKeyEntity::class,
DpnsNameEntity::class,
Expand Down Expand Up @@ -148,6 +158,7 @@ abstract class DashDatabase : RoomDatabase() {
abstract fun txoDao(): TxoDao
abstract fun coreAddressDao(): CoreAddressDao
abstract fun assetLockDao(): AssetLockDao
abstract fun invitationDao(): InvitationDao
abstract fun identityDao(): IdentityDao
abstract fun publicKeyDao(): PublicKeyDao
abstract fun dpnsNameDao(): DpnsNameDao
Expand Down Expand Up @@ -449,6 +460,37 @@ abstract class DashDatabase : RoomDatabase() {
}
}

/**
* v7 → v8: new `invitations` table (DIP-13 sent-invitation vouchers),
* one row per 36-byte funding outpoint. Additive — creates the table
* plus its `walletId` index. SQL mirrors the exported
* `schemas/.../8.json` `createSql` for the `invitations` entity
* exactly. Durability here is load-bearing: wiring the Rust
* `tramp_persist_invitations` callback flips `FFIPersister` to report
* the `INVITATIONS` capability, which the `create_invitation`
* durability gate requires before minting a one-time voucher.
*/
val MIGRATION_7_8: Migration = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `invitations` (" +
"`outPoint` BLOB NOT NULL, " +
"`walletId` BLOB NOT NULL, " +
"`fundingIndex` INTEGER NOT NULL, " +
"`amountDuffs` INTEGER NOT NULL, " +
"`expiryUnix` INTEGER NOT NULL, " +
"`createdAtSecs` INTEGER NOT NULL, " +
"`hasInviter` INTEGER NOT NULL, " +
"`statusRaw` INTEGER NOT NULL, " +
"PRIMARY KEY(`outPoint`))",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_invitations_walletId` " +
"ON `invitations` (`walletId`)",
)
}
}

/**
* Build the on-disk database. WAL is Room's default journal mode on
* API 16+; writes go through the persistence handler inside
Expand All @@ -464,6 +506,7 @@ abstract class DashDatabase : RoomDatabase() {
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
)
.build()

Expand Down
Loading
Loading