-
Notifications
You must be signed in to change notification settings - Fork 56
feat(kotlin-sdk): add L1 invitation create/claim JNI bridge + DIP-13 invitation persistence #4240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v4.2-dev
Are you sure you want to change the base?
Changes from 4 commits
7085a51
904a117
e061707
638430b
9328609
9829857
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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. | ||
| * | ||
| * @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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 | ||
|
|
||
There was a problem hiding this comment.
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 fundingAccountIndexas that parameter's description until the next block tag, so the cancellation contract is rendered as part offundingAccountIndexinstead of the method overview. Move the paragraph above the first@paramtag so generated documentation presents it as method-level behavior.source: ['claude']