diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index fe919a0d872..b2d0b1fed42 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt @@ -1,6 +1,8 @@ package org.dashfoundation.example.di import android.content.Context +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -47,6 +49,10 @@ class AppContainer(private val context: Context) { dashPayActiveIdentityStore, ) + /** Once-per-identity distributions this device knows were already claimed. */ + val oncePerIdentityClaimStore = + org.dashfoundation.example.services.tokens.OncePerIdentityClaimStore(dataStore) + val appUiState = AppUiState() /** Fast-cadence SPV progress feed for the global overlay (A-M4 wires the source). */ @@ -321,6 +327,7 @@ class AppContainer(private val context: Context) { appState.restorePreferences() appState.initializeSdk() loadKnownContractsIntoSdk() + backfillOncePerIdentityDistributions() activateManager() _bootstrapState.value = BootstrapState.Ready } catch (e: Exception) { @@ -328,6 +335,26 @@ class AppContainer(private val context: Context) { } } + /** + * One-time pass after the schema version 14 migration: token rows written + * by an earlier build have no once-per-identity block even when their + * stored contract carries one (see + * `TokenMaterializer.backfillOncePerIdentityDistributions`). Best-effort + * and non-fatal, and it only marks itself done when it ran to the end. + */ + private suspend fun backfillOncePerIdentityDistributions() { + try { + if (dataStore.data.first()[ONCE_PER_IDENTITY_BACKFILL_DONE] == true) return + val contracts = database.dataContractDao().observeWithTokens().first() + val filled = org.dashfoundation.example.services.tokens.TokenMaterializer + .backfillOncePerIdentityDistributions(contracts, database.tokenDao()) + dataStore.edit { it[ONCE_PER_IDENTITY_BACKFILL_DONE] = true } + android.util.Log.i(TAG, "Backfilled $filled once-per-identity token blocks") + } catch (e: Exception) { + android.util.Log.w(TAG, "Failed to backfill once-per-identity token blocks", e) + } + } + /** * Pre-load every locally-stored data contract into the SDK's trusted * context provider — port of `AppState.loadKnownContractsIntoSDK`. This @@ -373,5 +400,7 @@ class AppContainer(private val context: Context) { private companion object { const val TAG = "AppContainer" + val ONCE_PER_IDENTITY_BACKFILL_DONE = + booleanPreferencesKey("once_per_identity_backfill_done") } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/OncePerIdentityClaimStore.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/OncePerIdentityClaimStore.kt new file mode 100644 index 00000000000..b834cbf6e55 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/OncePerIdentityClaimStore.kt @@ -0,0 +1,69 @@ +package org.dashfoundation.example.services.tokens + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import org.dashfoundation.example.util.toHex + +/** + * Device-local memory of the once-per-identity distributions an identity + * already claimed (protocol version 14). + * + * Platform has no "has this identity claimed" query yet, and a second claim + * is a paid rejection (`TokenOncePerIdentityDistributionAlreadyClaimedError`, + * code 40722), so the app remembers what it learned: a claim it submitted + * that succeeded, or one that came back as already claimed. A fresh install + * starts empty and learns the state from the first rejection. + */ +class OncePerIdentityClaimStore( + private val dataStore: DataStore, +) { + fun observe(networkRaw: Int, tokenId: ByteArray, identityId: ByteArray): Flow { + val key = preferenceKey(networkRaw, tokenId, identityId) + return dataStore.data + .map { preferences -> preferences[key] == true } + .catch { emit(false) } + } + + suspend fun markClaimed(networkRaw: Int, tokenId: ByteArray, identityId: ByteArray) { + dataStore.edit { preferences -> + preferences[preferenceKey(networkRaw, tokenId, identityId)] = true + } + } + + companion object { + /** Consensus code of `TokenOncePerIdentityDistributionAlreadyClaimedError`. */ + const val ALREADY_CLAIMED_ERROR_CODE = 40722 + + /** + * The code as a number of its own. Error texts carry amounts and + * millisecond timestamps, and a claim time such as 1758140722000 + * contains the digits, so a plain substring match would take an + * unrelated failure for a spent claim and hide the kind for good. + */ + private val ALREADY_CLAIMED_CODE_PATTERN = + Regex("(? { val rows = ArrayList(TokenActionKind.entries.size) @@ -346,7 +353,10 @@ object TokenActionResolver { } // Claim. - rows += ResolvedTokenAction(TokenActionKind.CLAIM, resolveClaim(token, identity)) + rows += ResolvedTokenAction( + TokenActionKind.CLAIM, + resolveClaim(token, identity, oncePerIdentityClaimed), + ) // Direct purchase — allowed whenever pricing rules exist and the // token isn't paused. `PurchaseForm` fetches the configured price on @@ -423,14 +433,22 @@ object TokenActionResolver { private fun resolveClaim( token: TokenEntity, identity: IdentityEntity, + oncePerIdentityClaimed: Boolean, ): TokenActionPermission { val hasPerpetual = token.perpetualDistribution != null val hasPreProgrammed = token.preProgrammedDistribution != null - if (!hasPerpetual && !hasPreProgrammed) { + val hasOncePerIdentity = token.oncePerIdentityDistribution != null + if (!hasPerpetual && !hasPreProgrammed && !hasOncePerIdentity) { return TokenActionPermission.Denied("Token has no distribution schedule") } + // Once per identity: every identity may claim the fixed amount once. + // An identity this device does not know about is still rejected + // on-chain at submit time if it already claimed. + if (hasOncePerIdentity && !oncePerIdentityClaimed) { + return TokenActionPermission.Allowed + } - if (token.newTokensDestinationIdentity?.contentEquals(identity.identityId) == true) { + if (isDesignatedRecipient(token, identity)) { return TokenActionPermission.Allowed } // Pre-programmed releases name their recipients in the contract; @@ -439,6 +457,13 @@ object TokenActionResolver { return TokenActionPermission.Allowed } + // The single claim is spent and no other kind makes this identity + // eligible: say so instead of a recipient mismatch. + if (hasOncePerIdentity) { + return TokenActionPermission.Denied( + "Already claimed the once-per-identity distribution", + ) + } if (!hasPerpetual) { return TokenActionPermission.Denied("Not a recipient of any pre-programmed release") } @@ -448,6 +473,47 @@ object TokenActionResolver { return TokenActionPermission.Denied("Distribution eligibility not yet evaluated") } + /** + * The distribution kinds [identity] can claim under [resolveClaim]'s + * rules, in the order the claim form shows them. A kind the token merely + * declares is not enough: perpetual is listed only for the identity it + * pays, pre-programmed only for a listed recipient, and once-per-identity + * only while the identity's single claim is not known to be spent. + * Offering anything else enables a claim Drive rejects for a fee. + */ + fun claimableDistributions( + token: TokenEntity, + identity: IdentityEntity, + oncePerIdentityClaimed: Boolean, + ): List = buildList { + if (token.perpetualDistribution != null && isDesignatedRecipient(token, identity)) { + add(TokenDistributionType.PERPETUAL) + } + if (token.preProgrammedDistribution != null && isPreProgrammedRecipient(token, identity)) { + add(TokenDistributionType.PRE_PROGRAMMED) + } + if (token.oncePerIdentityDistribution != null && !oncePerIdentityClaimed) { + add(TokenDistributionType.ONCE_PER_IDENTITY) + } + } + + /** + * The kind the claim form preselects: the first the identity is eligible + * for, or null when there is none. The Claim row opens for every identity + * once a token has a once-per-identity distribution, so defaulting to a + * kind the token only declares would steer an identity that is not its + * recipient into a paid wrong-claimant rejection. + */ + fun preferredClaimDistribution( + token: TokenEntity, + identity: IdentityEntity, + oncePerIdentityClaimed: Boolean, + ): TokenDistributionType? = + claimableDistributions(token, identity, oncePerIdentityClaimed).firstOrNull() + + private fun isDesignatedRecipient(token: TokenEntity, identity: IdentityEntity): Boolean = + token.newTokensDestinationIdentity?.contentEquals(identity.identityId) == true + /** * True when [identity] is named as a recipient in ANY release of the * token's pre-programmed distribution JSON diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenAmounts.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenAmounts.kt index c606236fd53..deecbf56a02 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenAmounts.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenAmounts.kt @@ -28,6 +28,17 @@ object TokenAmounts { null } + /** + * Normalize a raw on-chain amount given as a decimal string. Null when it + * is not an integer, is negative, or does not fit in a u64. + */ + fun parseRaw(text: String): String? = try { + val raw = BigInteger(text.trim()) + if (raw.signum() >= 0 && raw <= MAX_U64) raw.toString() else null + } catch (_: NumberFormatException) { + null + } + /** Format a raw amount into display units, trimming trailing zeros. */ fun format(raw: ULong, decimals: Int): String = format(raw.toString(), decimals) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt index 102992a74e5..552dd3311f1 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenMaterializer.kt @@ -41,6 +41,29 @@ object TokenMaterializer { } } + /** + * Schema version 14 added `tokens.oncePerIdentityDistribution`, and the + * migration can only add the column: rows materialized by an earlier + * build keep a NULL block (and `hasDistribution = false`) although the + * contract JSON stored beside them may carry one. Re-read the block from + * each stored contract and fill it in where it is missing. Unlike + * [materialize] this never rewrites the rest of the row, so live columns + * such as `isPaused` keep their value. Returns the rows filled in. + */ + suspend fun backfillOncePerIdentityDistributions( + contracts: List, + dao: TokenDao, + ): Int { + var filled = 0 + for (contract in contracts) { + for (token in parse(contract)) { + val block = token.oncePerIdentityDistribution ?: continue + filled += dao.backfillOncePerIdentityDistribution(token.id, block) + } + } + return filled + } + /** Pure parse of [contract]'s tokens map into entity rows. */ fun parse(contract: DataContractEntity): List { val root = try { @@ -80,6 +103,10 @@ object TokenMaterializer { val distributionRules = dict.obj("distributionRules") val perpetual = distributionRules?.obj("perpetualDistribution") val preProgrammed = distributionRules?.obj("preProgrammedDistribution") + // Protocol version 14: a fixed amount every identity may claim once. + // Persisted raw like the two blocks above; the amount is read back + // through TokenOncePerIdentityDistribution.parse. + val oncePerIdentity = distributionRules?.obj("oncePerIdentityDistribution") val newTokensDestination = distributionRules?.str("newTokensDestinationIdentity") ?.let { Base58.decodeIdentifier(it) } val mintingAllowChoosing = distributionRules @@ -106,7 +133,8 @@ object TokenMaterializer { keepsHistoryObj?.boolean(key) ?: keepsHistoryAll ?: true val now = Date() - val hasDistribution = perpetual != null || preProgrammed != null + val hasDistribution = + perpetual != null || preProgrammed != null || oncePerIdentity != null return TokenEntity( id = tokenId(contractId, position), contractId = contractId, @@ -138,6 +166,7 @@ object TokenMaterializer { emergencyActionRules = emergencyAction?.toJson(), perpetualDistribution = perpetual?.toString(), preProgrammedDistribution = preProgrammed?.toString(), + oncePerIdentityDistribution = oncePerIdentity?.toString(), newTokensDestinationIdentity = newTokensDestination, mintingAllowChoosingDestination = mintingAllowChoosing, distributionChangeRules = distributionChange?.toJson(), @@ -149,7 +178,7 @@ object TokenMaterializer { createdAt = now, lastUpdatedAt = now, // Capability columns — MUST stay == (rules column != null), - // and hasDistribution == (perpetual || preProgrammed). + // and hasDistribution == (perpetual || preProgrammed || oncePerIdentity). canManuallyMint = manualMinting != null, canManuallyBurn = manualBurning != null, canFreeze = freeze != null, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenRules.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenRules.kt index 9c3ea9613ae..243e76c2b71 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenRules.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenRules.kt @@ -189,3 +189,41 @@ object AuthorizedActionTakers { fun group(position: Int): String = "$GROUP_PREFIX$position" fun identity(base58: String): String = "$IDENTITY_PREFIX$base58" } + +/** + * `oncePerIdentityDistribution` (protocol version 14): a fixed [amount] + * every identity may claim exactly once. Decoded view of the raw block + * [TokenMaterializer] persists in + * [org.dashfoundation.dashsdk.persistence.entities.TokenEntity.oncePerIdentityDistribution] + * (`{"$formatVersion":"0","amount":}`, the amount a JSON number or a + * decimal string). [amount] is a decimal string like every other raw token + * amount in the app. + */ +data class TokenOncePerIdentityDistribution(val amount: String) { + + companion object { + + fun parse(json: String?): TokenOncePerIdentityDistribution? { + if (json.isNullOrBlank()) return null + return try { + parse(LenientJson.parseToJsonElement(json).jsonObject) + } catch (_: Exception) { + null + } + } + + /** + * Null when the block carries no `amount` that is a raw u64, the + * type the protocol gives token amounts. The range rs-dpp admits for + * this field (1 to `i64::MAX`) is validated in Rust at registration + * and is deliberately not mirrored here. + */ + fun parse(obj: JsonObject): TokenOncePerIdentityDistribution? { + // Tolerate the enum-wrapped rendering the pre-programmed + // resolver also accepts; rs-dpp itself emits the flat shape. + val body = (obj["V0"] as? JsonObject) ?: obj + val content = (body["amount"] as? JsonPrimitive)?.content ?: return null + return TokenAmounts.parseRaw(content)?.let(::TokenOncePerIdentityDistribution) + } + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/tokens/TokenActionPermissionsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/tokens/TokenActionPermissionsScreen.kt index 7b76591e03f..33b22a44e78 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/tokens/TokenActionPermissionsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/tokens/TokenActionPermissionsScreen.kt @@ -249,9 +249,20 @@ fun TokenActionPermissionsScreen( val resolvedToken = remember(currentToken, livePaused) { livePaused?.let { currentToken.copy(isPaused = it) } ?: currentToken } - val rows = remember(resolvedToken, identity, contract) { - TokenActionResolver.resolve(resolvedToken, identity, contract) - .filter { !it.permission.isHidden } + val claimStore = container.oncePerIdentityClaimStore + val oncePerIdentityClaimed by remember( + resolvedToken.id, identity.identityId, + ) { + claimStore.observe( + identity.networkRaw, resolvedToken.id, identity.identityId, + ) + }.collectAsStateWithLifecycle(initialValue = false) + val rows = remember( + resolvedToken, identity, contract, oncePerIdentityClaimed, + ) { + TokenActionResolver.resolve( + resolvedToken, identity, contract, oncePerIdentityClaimed, + ).filter { !it.permission.isHidden } } rows.forEach { row -> val allowed = row.permission.isAllowed diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/tokens/TokenActionScreens.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/tokens/TokenActionScreens.kt index 19809235926..7197d254c9a 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/tokens/TokenActionScreens.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/tokens/TokenActionScreens.kt @@ -40,7 +40,9 @@ import org.dashfoundation.example.navigation.TokenAction import org.dashfoundation.example.services.tokens.GroupActionRuleEvaluator import org.dashfoundation.example.services.tokens.GroupActionRuleEvaluator.BannerState import org.dashfoundation.example.services.tokens.ProvenBalances +import org.dashfoundation.example.services.tokens.OncePerIdentityClaimStore import org.dashfoundation.example.services.tokens.TokenActionKind +import org.dashfoundation.example.services.tokens.TokenActionResolver import org.dashfoundation.example.services.tokens.TokenAmounts import org.dashfoundation.example.services.tokens.TokenDirectPurchasePricing import org.dashfoundation.example.services.tokens.TokenDistributionChangeRules @@ -594,17 +596,25 @@ private fun ClaimForm( navController: NavHostController, ) { val token = context.token - // Perpetual wins when both exist (matches Drive's claim ordering). - val available = buildList { - if (token.perpetualDistribution != null) add(TokenDistributionType.PERPETUAL) - if (token.preProgrammedDistribution != null) add(TokenDistributionType.PRE_PROGRAMMED) - } - var selectedOrdinal by rememberSaveable { - mutableStateOf((available.firstOrNull() ?: TokenDistributionType.PERPETUAL).ordinal) - } - val selected = TokenDistributionType.entries[selectedOrdinal] + val identity = context.identity + val claimStore = LocalAppContainer.current.oncePerIdentityClaimStore + // Null until the device-local claim memory has loaded, so the default + // below is computed once, from the real value. + val oncePerIdentityClaimed by remember(token.id, identity.identityId) { + claimStore.observe(identity.networkRaw, token.id, identity.identityId) + }.collectAsStateWithLifecycle(initialValue = null) + val claimed = oncePerIdentityClaimed ?: false + val available = TokenActionResolver.claimableDistributions(token, identity, claimed) + // The user's pick once made; until then the first kind this identity is + // eligible for (`preferredClaimDistribution`). With none, the fallback + // only keeps the value valid: `canSubmit` stays false. + var pickedOrdinal by rememberSaveable { mutableStateOf(null) } + val selected = pickedOrdinal?.let { TokenDistributionType.entries[it] } + ?.takeIf { it in available } + ?: TokenActionResolver.preferredClaimDistribution(token, identity, claimed) + ?: TokenDistributionType.PERPETUAL var note by rememberSaveable { mutableStateOf("") } - val canSubmit = available.contains(selected) + val canSubmit = oncePerIdentityClaimed != null && available.contains(selected) TokenActionScaffold( title = "Claim", @@ -620,15 +630,28 @@ private fun ClaimForm( val signer = context.signerHandle ?: return@TokenActionScaffold val noteOrNull = note.toPublicNoteOrNull() viewModel.submit { - wallet.tokens.claim( - identityId = context.identity.identityId, - tokenContractId = token.contractId, - tokenPosition = token.position, - distributionType = selected, - publicNote = noteOrNull, - signingKeyId = TokenActionContext.SIGNING_KEY_ID, - signerHandle = signer, - ) + val oncePerIdentity = selected == TokenDistributionType.ONCE_PER_IDENTITY + try { + wallet.tokens.claim( + identityId = identity.identityId, + tokenContractId = token.contractId, + tokenPosition = token.position, + distributionType = selected, + publicNote = noteOrNull, + signingKeyId = TokenActionContext.SIGNING_KEY_ID, + signerHandle = signer, + ) + } catch (e: Exception) { + // Rejected as already claimed: remember it so the kind + // is not offered to this identity again. + if (oncePerIdentity && OncePerIdentityClaimStore.isAlreadyClaimed(e)) { + claimStore.markClaimed(identity.networkRaw, token.id, identity.identityId) + } + throw e + } + if (oncePerIdentity) { + claimStore.markClaimed(identity.networkRaw, token.id, identity.identityId) + } } }, ) { @@ -646,7 +669,7 @@ private fun ClaimForm( available.forEachIndexed { index, dist -> SegmentedButton( selected = selected == dist, - onClick = { selectedOrdinal = dist.ordinal }, + onClick = { pickedOrdinal = dist.ordinal }, enabled = available.size > 1, shape = SegmentedButtonDefaults.itemShape( index = index, count = available.size, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/tokens/TokenDetailsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/tokens/TokenDetailsScreen.kt index 7aa186607b5..7875152e302 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/tokens/TokenDetailsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/tokens/TokenDetailsScreen.kt @@ -36,6 +36,7 @@ import org.dashfoundation.example.navigation.TokenActionPermissions import org.dashfoundation.example.services.tokens.ChangeControlRules import org.dashfoundation.example.services.tokens.TokenAmounts import org.dashfoundation.example.services.tokens.TokenDistributionChangeRules +import org.dashfoundation.example.services.tokens.TokenOncePerIdentityDistribution import org.dashfoundation.example.ui.components.FormSection import org.dashfoundation.example.ui.components.LabeledContent import org.dashfoundation.example.util.Base58 @@ -187,6 +188,7 @@ fun TokenDetailsScreen(tokenIdHex: String, navController: NavHostController) { if (current.perpetualDistribution != null || current.preProgrammedDistribution != null || + current.oncePerIdentityDistribution != null || current.newTokensDestinationIdentity != null ) { FormSection(title = "Distribution") { @@ -198,6 +200,12 @@ fun TokenDetailsScreen(tokenIdHex: String, navController: NavHostController) { "Pre-programmed", if (current.preProgrammedDistribution != null) "Configured" else "None", ) + LabeledContent( + "Once per identity", + TokenOncePerIdentityDistribution.parse(current.oncePerIdentityDistribution) + ?.let { "${TokenAmounts.format(it.amount, current.decimals)} per identity" } + ?: "None", + ) current.newTokensDestinationIdentity?.let { LabeledContent( "Destination Identity", diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenActionResolverClaimTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenActionResolverClaimTest.kt index 1f279c46641..11e5f69a90c 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenActionResolverClaimTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenActionResolverClaimTest.kt @@ -2,8 +2,10 @@ package org.dashfoundation.example.services.tokens import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity import org.dashfoundation.dashsdk.persistence.entities.TokenEntity +import org.dashfoundation.dashsdk.tokens.TokenDistributionType import org.dashfoundation.example.util.Base58 import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -12,7 +14,8 @@ import org.junit.Test * `newTokensDestinationIdentity` (perpetual) path plus pre-programmed * recipients named in the contract's `distributions` map * (`{"$formatVersion":"0","distributions":{"":{"":amount}}}`, - * the shape [TokenMaterializer] persists). + * the shape [TokenMaterializer] persists), and the once-per-identity kind + * (protocol version 14) that makes every identity eligible once. */ class TokenActionResolverClaimTest { @@ -28,6 +31,7 @@ class TokenActionResolverClaimTest { perpetual: String? = null, destination: ByteArray? = null, mintingAllowChoosing: Boolean = true, + oncePerIdentity: String? = null, ) = TokenEntity( id = ByteArray(36), contractId = ByteArray(32), @@ -36,19 +40,34 @@ class TokenActionResolverClaimTest { baseSupply = "1000", perpetualDistribution = perpetual, preProgrammedDistribution = preProgrammed, + oncePerIdentityDistribution = oncePerIdentity, newTokensDestinationIdentity = destination, mintingAllowChoosingDestination = mintingAllowChoosing, - hasDistribution = preProgrammed != null || perpetual != null, + hasDistribution = preProgrammed != null || perpetual != null || oncePerIdentity != null, ) - private fun claim(token: TokenEntity, identity: IdentityEntity): TokenActionPermission = - TokenActionResolver.resolve(token, identity, contract = null) + private fun claim( + token: TokenEntity, + identity: IdentityEntity, + oncePerIdentityClaimed: Boolean = false, + ): TokenActionPermission = + TokenActionResolver.resolve(token, identity, contract = null, oncePerIdentityClaimed) .first { it.kind == TokenActionKind.CLAIM } .permission + private fun preferred( + token: TokenEntity, + identity: IdentityEntity, + oncePerIdentityClaimed: Boolean = false, + ): TokenDistributionType? = + TokenActionResolver.preferredClaimDistribution(token, identity, oncePerIdentityClaimed) + private fun preProgrammedJson(recipient: String = recipientBase58): String = """{"${'$'}formatVersion":"0","distributions":{"1750000000000":{"$recipient":5000}}}""" + private fun oncePerIdentityJson(amount: String = "5000"): String = + """{"${'$'}formatVersion":"0","amount":$amount}""" + @Test fun `no distribution schedule is denied`() { assertEquals( @@ -125,6 +144,146 @@ class TokenActionResolverClaimTest { ) } + @Test + fun `once-per-identity distribution allows any identity`() { + assertTrue(claim(token(oncePerIdentity = oncePerIdentityJson()), identity()).isAllowed) + assertTrue( + claim(token(oncePerIdentity = oncePerIdentityJson()), identity(strangerId)).isAllowed, + ) + } + + @Test + fun `once-per-identity distribution allows a non-recipient of the pre-programmed releases`() { + assertTrue( + claim( + token(preProgrammed = preProgrammedJson(), oncePerIdentity = oncePerIdentityJson()), + identity(strangerId), + ).isAllowed, + ) + } + + @Test + fun `claimed once-per-identity distribution is denied when it was the only reason`() { + assertEquals( + TokenActionPermission.Denied("Already claimed the once-per-identity distribution"), + claim( + token(oncePerIdentity = oncePerIdentityJson()), + identity(strangerId), + oncePerIdentityClaimed = true, + ), + ) + // Alongside a perpetual distribution paid to someone else it is still the + // spent claim that explains the denial, not a recipient mismatch. + assertEquals( + TokenActionPermission.Denied("Already claimed the once-per-identity distribution"), + claim( + token( + perpetual = "{}", + destination = recipientId, + oncePerIdentity = oncePerIdentityJson(), + ), + identity(strangerId), + oncePerIdentityClaimed = true, + ), + ) + } + + @Test + fun `claimed once-per-identity distribution keeps the other kinds claimable`() { + assertTrue( + claim( + token( + perpetual = "{}", + destination = recipientId, + oncePerIdentity = oncePerIdentityJson(), + ), + identity(), + oncePerIdentityClaimed = true, + ).isAllowed, + ) + assertTrue( + claim( + token(preProgrammed = preProgrammedJson(), oncePerIdentity = oncePerIdentityJson()), + identity(), + oncePerIdentityClaimed = true, + ).isAllowed, + ) + } + + private fun claimable( + token: TokenEntity, + identity: IdentityEntity, + oncePerIdentityClaimed: Boolean = false, + ): List = + TokenActionResolver.claimableDistributions(token, identity, oncePerIdentityClaimed) + + @Test + fun `only the kinds the identity is eligible for are offered`() { + val perpetualAndOnce = token( + perpetual = "{}", + destination = recipientId, + oncePerIdentity = oncePerIdentityJson(), + ) + // A stranger is only eligible through the once-per-identity kind: + // offering perpetual would enable a paid wrong-claimant rejection. + assertEquals( + listOf(TokenDistributionType.ONCE_PER_IDENTITY), + claimable(perpetualAndOnce, identity(strangerId)), + ) + assertEquals( + TokenDistributionType.ONCE_PER_IDENTITY, + preferred(perpetualAndOnce, identity(strangerId)), + ) + // The identity the perpetual distribution pays keeps perpetual first. + assertEquals( + listOf(TokenDistributionType.PERPETUAL, TokenDistributionType.ONCE_PER_IDENTITY), + claimable(perpetualAndOnce, identity()), + ) + assertEquals(TokenDistributionType.PERPETUAL, preferred(perpetualAndOnce, identity())) + + val preProgrammedAndOnce = + token(preProgrammed = preProgrammedJson(), oncePerIdentity = oncePerIdentityJson()) + assertEquals( + listOf(TokenDistributionType.PRE_PROGRAMMED, TokenDistributionType.ONCE_PER_IDENTITY), + claimable(preProgrammedAndOnce, identity()), + ) + assertEquals( + listOf(TokenDistributionType.ONCE_PER_IDENTITY), + claimable(preProgrammedAndOnce, identity(strangerId)), + ) + assertEquals( + TokenDistributionType.ONCE_PER_IDENTITY, + preferred(token(oncePerIdentity = oncePerIdentityJson()), identity(strangerId)), + ) + } + + @Test + fun `a spent once-per-identity claim leaves a stranger nothing to claim`() { + val perpetualAndOnce = token( + perpetual = "{}", + destination = recipientId, + oncePerIdentity = oncePerIdentityJson(), + ) + // The perpetual distribution pays someone else, so it is not a fallback. + assertEquals( + emptyList(), + claimable(perpetualAndOnce, identity(strangerId), oncePerIdentityClaimed = true), + ) + assertNull(preferred(perpetualAndOnce, identity(strangerId), oncePerIdentityClaimed = true)) + assertNull( + preferred( + token(oncePerIdentity = oncePerIdentityJson()), + identity(strangerId), + oncePerIdentityClaimed = true, + ), + ) + // The identity it pays keeps the perpetual kind after spending the claim. + assertEquals( + listOf(TokenDistributionType.PERPETUAL), + claimable(perpetualAndOnce, identity(), oncePerIdentityClaimed = true), + ) + } + @Test fun `perpetual-only non-designated identity keeps the existing denials`() { assertEquals( diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerOncePerIdentityTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerOncePerIdentityTest.kt new file mode 100644 index 00000000000..684336ac439 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerOncePerIdentityTest.kt @@ -0,0 +1,168 @@ +package org.dashfoundation.example.services.tokens + +import org.dashfoundation.dashsdk.persistence.entities.DataContractEntity +import org.dashfoundation.dashsdk.persistence.entities.TokenEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [TokenMaterializer] coverage for the `oncePerIdentityDistribution` block + * (protocol version 14): the raw block lands in + * [TokenEntity.oncePerIdentityDistribution], flips `hasDistribution`, and + * [TokenOncePerIdentityDistribution.parse] reads the u64 amount back as a + * decimal string whether the contract encoded it as a number or a string. + */ +class TokenMaterializerOncePerIdentityTest { + + private val contractId = ByteArray(32) { 0xCD.toByte() } + + private fun contract(distributionRules: String): DataContractEntity = DataContractEntity( + id = contractId, + name = "Fixture", + serializedContract = + """{"tokens":{"0":{"baseSupply":0,"distributionRules":$distributionRules}}}""" + .encodeToByteArray(), + networkRaw = 0, + ) + + private fun parseSingleToken(distributionRules: String): TokenEntity { + val tokens = TokenMaterializer.parse(contract(distributionRules)) + assertEquals(1, tokens.size) + return tokens.single() + } + + private fun block(amount: String): String = + """{"oncePerIdentityDistribution":{"${'$'}formatVersion":"0","amount":$amount}}""" + + @Test + fun `numeric amount is persisted raw and parses to a decimal string`() { + val token = parseSingleToken(block("5000")) + + val raw = token.oncePerIdentityDistribution + assertNotNull(raw) + assertTrue(token.hasDistribution) + assertEquals("5000", TokenOncePerIdentityDistribution.parse(raw)?.amount) + } + + @Test + fun `string amount parses to the same decimal string`() { + val token = parseSingleToken(block("\"12345\"")) + + assertEquals( + "12345", + TokenOncePerIdentityDistribution.parse(token.oncePerIdentityDistribution)?.amount, + ) + } + + @Test + fun `amounts above Long MAX_VALUE survive verbatim as number and as string`() { + val huge = "18446744073709551615" // UInt64.max + + val asNumber = parseSingleToken(block(huge)) + assertEquals( + huge, + TokenOncePerIdentityDistribution.parse(asNumber.oncePerIdentityDistribution)?.amount, + ) + + val asString = parseSingleToken(block("\"$huge\"")) + assertEquals( + huge, + TokenOncePerIdentityDistribution.parse(asString.oncePerIdentityDistribution)?.amount, + ) + } + + @Test + fun `parse checks the u64 carrier and leaves the protocol range to Rust`() { + // rs-dpp validates 1..=i64::MAX at registration; the app does not mirror that + // rule, it only refuses what is not a raw u64 at all. + assertEquals("0", TokenOncePerIdentityDistribution.parse("""{"amount":0}""")?.amount) + assertEquals( + "9223372036854775808", + TokenOncePerIdentityDistribution.parse("""{"amount":"9223372036854775808"}""")?.amount, + ) + assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":"18446744073709551616"}""")) + } + + @Test + fun `absent block leaves the column null and hasDistribution false`() { + val token = parseSingleToken("""{"mintingAllowChoosingDestination":true}""") + + assertNull(token.oncePerIdentityDistribution) + assertFalse(token.hasDistribution) + } + + @Test + fun `block alongside a pre-programmed distribution keeps both`() { + val preProgrammed = + """{"${'$'}formatVersion":"0","distributions":{"1750000000000":{"abc":1}}}""" + val token = parseSingleToken( + """{"preProgrammedDistribution":$preProgrammed,""" + + """"oncePerIdentityDistribution":{"${'$'}formatVersion":"0","amount":9}}""", + ) + + assertNotNull(token.preProgrammedDistribution) + assertEquals( + "9", + TokenOncePerIdentityDistribution.parse(token.oncePerIdentityDistribution)?.amount, + ) + assertTrue(token.hasDistribution) + } + + @Test + fun `parse rejects a block without a valid amount`() { + assertNull(TokenOncePerIdentityDistribution.parse("""{"${'$'}formatVersion":"0"}""")) + assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":"abc"}""")) + assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":-1}""")) + assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":1.5}""")) + assertNull(TokenOncePerIdentityDistribution.parse("not json")) + assertNull(TokenOncePerIdentityDistribution.parse("")) + assertNull(TokenOncePerIdentityDistribution.parse(null)) + } + + @Test + fun `already-claimed rejection is recognised by code and by message`() { + assertTrue( + OncePerIdentityClaimStore.isAlreadyClaimed( + IllegalStateException("consensus error 40722: claim rejected"), + ), + ) + assertTrue( + OncePerIdentityClaimStore.isAlreadyClaimed( + RuntimeException( + "claim failed", + IllegalStateException( + "Token claim error: identity 'a' already claimed the " + + "once-per-identity distribution of token 'b' at 100", + ), + ), + ), + ) + assertTrue( + OncePerIdentityClaimStore.isAlreadyClaimed(IllegalStateException("code=40722")), + ) + assertFalse( + OncePerIdentityClaimStore.isAlreadyClaimed( + IllegalStateException("Token mint past max supply"), + ), + ) + // The digits inside a longer number are not the code: a timestamp or an + // amount in an unrelated failure must not read as a spent claim. + assertFalse( + OncePerIdentityClaimStore.isAlreadyClaimed( + IllegalStateException("Token mint past max supply: 1758140722000"), + ), + ) + assertFalse( + OncePerIdentityClaimStore.isAlreadyClaimed(IllegalStateException("amount 4072299")), + ) + } + + @Test + fun `V0-wrapped block is unwrapped`() { + assertEquals("7", TokenOncePerIdentityDistribution.parse("""{"V0":{"amount":7}}""")?.amount) + } +} diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/14.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/14.json new file mode 100644 index 00000000000..bdbc89abf04 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/14.json @@ -0,0 +1,4182 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "e4bb35de1e7b7906b5058dd03f80911f", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `lastAppliedChainLockHeight` INTEGER, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "lastAppliedChainLockHeight", + "columnName": "lastAppliedChainLockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockPosition", + "columnName": "blockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockPosition", + "columnName": "hasBlockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "transaction_account_involvements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "transactionTxid", + "columnName": "transactionTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transactionTxid", + "accountId" + ] + }, + "indices": [ + { + "name": "index_transaction_account_involvements_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "transactionTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, `supersededByTxid` BLOB, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + }, + { + "fieldPath": "supersededByTxid", + "columnName": "supersededByTxid", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "invitations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `rawOutPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `reclaimInFlight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawOutPoint", + "columnName": "rawOutPoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingIndexRaw", + "columnName": "fundingIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryUnix", + "columnName": "expiryUnix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtSecs", + "columnName": "createdAtSecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviter", + "columnName": "hasInviter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reclaimInFlight", + "columnName": "reclaimInFlight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_invitations_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `totalBudget` INTEGER, `expiresAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `contractBoundsKind` INTEGER, `privateKeyKeychainIdentifier` TEXT, `derivationIdentityIndex` INTEGER, `derivationKeyIndex` INTEGER, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "totalBudget", + "columnName": "totalBudget", + "affinity": "INTEGER" + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "contractBoundsKind", + "columnName": "contractBoundsKind", + "affinity": "INTEGER" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "derivationIdentityIndex", + "columnName": "derivationIdentityIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "derivationKeyIndex", + "columnName": "derivationKeyIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `documentId` BLOB, `isOwned` INTEGER NOT NULL, `priceCredits` INTEGER, `saleStatusRaw` INTEGER NOT NULL, `counterpartyIdentityId` BLOB, `documentCreatedAtMs` INTEGER NOT NULL, `documentUpdatedAtMs` INTEGER NOT NULL, `documentTransferredAtMs` INTEGER NOT NULL, `marketplaceUpdatedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "BLOB" + }, + { + "fieldPath": "isOwned", + "columnName": "isOwned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priceCredits", + "columnName": "priceCredits", + "affinity": "INTEGER" + }, + { + "fieldPath": "saleStatusRaw", + "columnName": "saleStatusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB" + }, + { + "fieldPath": "documentCreatedAtMs", + "columnName": "documentCreatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentUpdatedAtMs", + "columnName": "documentUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTransferredAtMs", + "columnName": "documentTransferredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "marketplaceUpdatedAt", + "columnName": "marketplaceUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_dpns_names_documentId", + "unique": false, + "columnNames": [ + "documentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` ON `${TABLE_NAME}` (`documentId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `isSweptTombstone` INTEGER NOT NULL DEFAULT 0, `winnerMinedHeight` INTEGER, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSweptTombstone", + "columnName": "isSweptTombstone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "winnerMinedHeight", + "columnName": "winnerMinedHeight", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + }, + { + "name": "index_pending_inputs_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight", + "unique": false, + "columnNames": [ + "walletId", + "isSweptTombstone", + "winnerMinedHeight" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` ON `${TABLE_NAME}` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `oncePerIdentityDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "oncePerIdentityDistribution", + "columnName": "oncePerIdentityDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` BLOB NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`walletId`, `address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId_addressHash", + "unique": true, + "columnNames": [ + "walletId", + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_walletId_addressHash` ON `${TABLE_NAME}` (`walletId`, `addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "shielded_viewing_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `fvkBytes` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fvkBytes", + "columnName": "fvkBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_viewing_keys_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_viewing_keys_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e4bb35de1e7b7906b5058dd03f80911f')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index 6af8bd25a5c..49398a2d13a 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -559,13 +559,67 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v13 → v14 adds the nullable `tokens.oncePerIdentityDistribution` + * column (additive). Pre-existing token rows must survive with a NULL + * block, and new rows must accept the JSON `TokenMaterializer` writes. + */ + @Test + fun migrate13To14AddsOncePerIdentityDistributionColumn() { + val tokenColumns = "id, contractId, position, name, baseSupply, decimals, isPaused, " + + "allowTransferToFrozenBalance, keepsTransferHistory, keepsFreezingHistory, " + + "keepsMintingHistory, keepsBurningHistory, keepsDirectPricingHistory, " + + "keepsDirectPurchaseHistory, mintingAllowChoosingDestination, tradeMode, " + + "createdAt, lastUpdatedAt, canManuallyMint, canManuallyBurn, canFreeze, " + + "canUnfreeze, canDestroyFrozenFunds, hasEmergencyActions, canChangeMaxSupply, " + + "canChangeConventions, canChangeTradeMode, hasDistribution" + helper.createDatabase(dbName, 13).apply { + // Seed the parent contract so the tokens FK target exists. + execSQL( + "INSERT INTO data_contracts (id, name, serializedContract, createdAt, " + + "lastAccessedAt, schemaData, documentTypesData, networkRaw, lastUpdated, " + + "canBeDeleted, readonly, keepsHistory, documentsKeepHistoryContractDefault, " + + "documentsMutableContractDefault, documentsCanBeDeletedContractDefault, " + + "hasTokens) " + + "VALUES (x'C0', 'c', x'7B7D', 0, 0, x'7B7D', x'5B5D', 1, 0, 0, 0, 0, 0, 1, 1, 1)", + ) + execSQL( + "INSERT INTO tokens ($tokenColumns) " + + "VALUES (x'C000000000', x'C0', 0, 't', '1000', 8, 0, 1, 1, 1, 1, 1, 1, 1, 1, " + + "'NotTradeable', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)", + ) + close() + } + + val db = helper.runMigrationsAndValidate(dbName, 14, true, DashDatabase.MIGRATION_13_14) + + // Pre-existing rows survive with a NULL block. + db.query("SELECT oncePerIdentityDistribution, name FROM tokens WHERE position = 0").use { c -> + assertTrue(c.moveToFirst()) + assertTrue(c.isNull(0)) + assertEquals("t", c.getString(1)) + } + // New rows accept the materializer's JSON block. + val block = """{"${'$'}formatVersion":"0","amount":5000}""" + db.execSQL( + "INSERT INTO tokens ($tokenColumns, oncePerIdentityDistribution) " + + "VALUES (x'C000000001', x'C0', 1, 'u', '0', 8, 0, 1, 1, 1, 1, 1, 1, 1, 1, " + + "'NotTradeable', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, '$block')", + ) + db.query("SELECT oncePerIdentityDistribution FROM tokens WHERE position = 1").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(block, c.getString(0)) + } + db.close() + } + /** The requested contiguous path from the pre-u64 v4 schema to latest. */ @Test fun migrate4ToLatest() { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 13, + 14, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -576,16 +630,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_10_11, DashDatabase.MIGRATION_11_12, DashDatabase.MIGRATION_12_13, + DashDatabase.MIGRATION_13_14, ).close() } - /** The full chain from v1 must also land on a valid v13 schema. */ + /** The full chain from v1 must also land on a valid v14 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 13, + 14, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -599,6 +654,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_10_11, DashDatabase.MIGRATION_11_12, DashDatabase.MIGRATION_12_13, + DashDatabase.MIGRATION_13_14, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index ef91b38416a..94ebbb0267f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -155,9 +155,15 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * SingleContract on the group id. The persist callback now records the * kind the native row carries; a NULL kind (legacy row) keeps the old * inference on restore. + * + * Version 14 (once-per-identity token distribution, protocol version 14): + * adds the nullable `tokens.oncePerIdentityDistribution` column holding the + * contract's `oncePerIdentityDistribution` block as JSON, so the claim + * screen can offer the third distribution kind. NULL for every pre-existing + * row; the next contract materialization fills it in. */ @Database( - version = 13, + version = 14, exportSchema = true, entities = [ WalletEntity::class, @@ -660,6 +666,19 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v13 -> v14: additive nullable `tokens.oncePerIdentityDistribution`, + * see the version-14 class doc above. NULL for every pre-existing + * row; `TokenMaterializer` fills it on the next contract parse. + */ + val MIGRATION_13_14: Migration = object : Migration(13, 14) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `tokens` ADD COLUMN `oncePerIdentityDistribution` TEXT", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -681,6 +700,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, + MIGRATION_13_14, ) .build() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TokenDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TokenDao.kt index ebe832d8fbe..a5e28a6d26e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TokenDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TokenDao.kt @@ -90,6 +90,18 @@ interface TokenDao { @Upsert suspend fun upsertToken(token: TokenEntity) + /** + * Fill in the once-per-identity block of a row that has none, keeping + * [TokenEntity.hasDistribution] in step. Rows written before schema + * version 14 have a NULL block even when their contract carries one; + * every other column of the row is left alone. Returns the rows changed. + */ + @Query( + "UPDATE tokens SET oncePerIdentityDistribution = :block, hasDistribution = 1 " + + "WHERE id = :id AND oncePerIdentityDistribution IS NULL", + ) + suspend fun backfillOncePerIdentityDistribution(id: ByteArray, block: String): Int + @Delete suspend fun deleteToken(token: TokenEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TokenEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TokenEntity.kt index b25f978a0af..8ba49efbb96 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TokenEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TokenEntity.kt @@ -30,7 +30,8 @@ import java.util.Date * (PersistentToken.swift:138-210) is ALSO persisted as [Boolean] columns * so DAO queries are plain SQL. Writers must keep each flag == * `(corresponding rules column != null)` (and [hasDistribution] == - * `perpetualDistribution != null || preProgrammedDistribution != null`). + * `perpetualDistribution != null || preProgrammedDistribution != null || + * oncePerIdentityDistribution != null`). */ @Entity( tableName = "tokens", @@ -79,6 +80,13 @@ data class TokenEntity( val perpetualDistribution: String? = null, /** JSON `TokenPreProgrammedDistribution` (UI-only). */ val preProgrammedDistribution: String? = null, + /** + * The contract's `oncePerIdentityDistribution` block as authored + * (`{"$formatVersion":"0","amount":}`, UI-only): a fixed amount + * every identity may claim exactly once (protocol version 14). Added in + * schema version 14; NULL on rows materialized before that. + */ + val oncePerIdentityDistribution: String? = null, /** 32-byte destination identity id. */ val newTokensDestinationIdentity: ByteArray? = null, val mintingAllowChoosingDestination: Boolean = true, @@ -114,7 +122,7 @@ data class TokenEntity( val canChangeConventions: Boolean = false, /** Mirror of Swift `canChangeTradeMode` (= tradeModeChangeRules != nil). */ val canChangeTradeMode: Boolean = false, - /** Mirror of Swift `hasDistribution` (= perpetual != nil || preProgrammed != nil). */ + /** Mirror of Swift `hasDistribution` (= any of the three distribution columns != nil). */ val hasDistribution: Boolean = false, ) { override fun equals(other: Any?): Boolean = diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt index afcdaa0eb10..fb2ba7896b7 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt @@ -233,14 +233,15 @@ class DashDatabaseTest { } @Test - fun schemaIsAtVersion13WithTheSweepHoldIndexes() = runTest { + fun schemaIsAtVersion14WithTheSweepHoldIndexes() = runTest { // The sweep-hold columns land in ONE migration (10 → 11), with the // two `pending_inputs` indexes the sweep's claimed-row lookup // (`spendingTxid`) and the end-of-round collector // (`walletId, isSweptTombstone, winnerMinedHeight`) rely on. - // 11 → 12 adds the identity key usage limits columns on top, and - // 12 → 13 the contract bounds kind. - assertEquals(13, db.openHelper.readableDatabase.version) + // 11 → 12 adds the identity key usage limits columns on top, + // 12 → 13 the contract bounds kind, and 13 → 14 the token + // once-per-identity distribution block. + assertEquals(14, db.openHelper.readableDatabase.version) val indexes = mutableSetOf() db.openHelper.readableDatabase.query("PRAGMA index_list('pending_inputs')").use { c -> val nameColumn = c.getColumnIndexOrThrow("name") @@ -271,6 +272,27 @@ class DashDatabaseTest { assertTrue(found) } + @Test + fun shouldHaveANullableOncePerIdentityDistributionColumnOnTokens() = runTest { + // Version 14 (13 → 14): nullable with no default, so a row written + // before it reads back NULL until its block is backfilled. + var found = false + db.openHelper.readableDatabase.query("PRAGMA table_info('tokens')").use { c -> + val name = c.getColumnIndexOrThrow("name") + val type = c.getColumnIndexOrThrow("type") + val notNull = c.getColumnIndexOrThrow("notnull") + val default = c.getColumnIndexOrThrow("dflt_value") + while (c.moveToNext()) { + if (c.getString(name) != "oncePerIdentityDistribution") continue + found = true + assertEquals("TEXT", c.getString(type)) + assertEquals(0, c.getInt(notNull)) + assertTrue(c.isNull(default)) + } + } + assertTrue(found) + } + @Test fun advanceChainLockHeightIsANarrowMonotonicMaxWrite() = runTest { db.walletDao().upsert(WalletEntity(walletId = walletId, networkRaw = 1, name = "w", syncedHeight = 7)) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift index 8fc55088a53..7fe5a40851e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift @@ -497,6 +497,80 @@ public struct DataContractParser { return nil } + /// Render a once-per-identity `amount` to a canonical decimal string, + /// accepting what the carrier type can hold: a non-negative integer that + /// fits in `u64`, the protocol's `TokenAmount`. + /// + /// This checks the encoding, not the rule. rs-dpp's + /// `validate_once_per_identity_distribution` narrows the value further + /// (1 to `i64::MAX`) and enforced that when the contract was registered, + /// so a contract that came from chain cannot carry anything outside it. + /// Mirroring that range here would be a second copy of a protocol + /// constant living where it cannot be kept in step. + /// + /// Still stricter than `stringifyDistributionAmount`, which hands any + /// string back verbatim and stringifies negative or fractional numbers. + /// That leniency is fine for the pre-programmed schedule, whose + /// malformed entries are skipped one by one, but here it would make + /// `"abc"`, `-5` or `1.5` read as a distribution the token does not + /// have. + /// + /// JSON booleans bridge to `NSNumber` and would otherwise pass as 0 or + /// 1, so they are rejected by identity against `CFBoolean` before the + /// numeric read. + private static func oncePerIdentityAmount(_ value: Any) -> String? { + if CFGetTypeID(value as CFTypeRef) == CFBooleanGetTypeID() { + return nil + } + if let string = value as? String { + // `UInt64(_:)` rejects a fractional, negative or non-numeric + // string and anything above `UInt64.max`; re-rendering the + // parsed value drops leading zeros and a leading `+`. + guard let parsed = UInt64(string.trimmingCharacters(in: .whitespaces)) else { + return nil + } + return String(parsed) + } + // `NSNumber` covers every numeric JSON value. `UInt64(exactly:)` + // fails on a negative, fractional or out-of-range number, which is + // exactly the set the carrier cannot hold. + if let number = value as? NSNumber, let exact = UInt64(exactly: number) { + return String(exact) + } + return nil + } + + /// Read a token's once-per-identity distribution out of its + /// `distributionRules` block (protocol version 14). + /// + /// rs-dpp emits the block as + /// `"oncePerIdentityDistribution": {"$formatVersion": "0", "amount": 5000}`. + /// `amount` is a protocol `u64`, so it arrives as a JSON number up to + /// 2^53 - 1 and as a decimal string above that; both normalise to an + /// exact decimal string, which is what the value type carries. + /// + /// Returns nil when the block is absent, is not a dictionary, or carries + /// an `amount` the `u64` carrier cannot hold. A malformed block + /// therefore reads the same as "this token has no once-per-identity + /// distribution" rather than claiming an amount that was never authored. + /// Which amounts the protocol itself allows (1 to `i64::MAX`) is rs-dpp's + /// rule, checked when the contract was registered, and is deliberately + /// not mirrored here. + /// + /// This is the single place that shape is parsed: + /// `PersistentToken.oncePerIdentityDistribution` derives its value by + /// calling straight back into here. + static func parseOncePerIdentityDistribution( + _ value: Any? + ) -> TokenOncePerIdentityDistribution? { + guard let dict = value as? [String: Any], + let amountValue = dict["amount"], + let amount = oncePerIdentityAmount(amountValue) else { + return nil + } + return TokenOncePerIdentityDistribution(amount: amount) + } + private static func parseTokenConfiguration(token: PersistentToken, from tokenDict: [String: Any]) { // Basic properties let maxSupplyStr = extractTokenSupply(from: tokenDict, key: "maxSupply") @@ -626,6 +700,15 @@ public struct DataContractParser { token.perpetualDistribution = dist } + // The once-per-identity distribution is parsed too, but not + // here: it has no column on `PersistentToken`, so it is derived + // from the contract JSON persisted on the owning + // `PersistentDataContract` through + // `PersistentToken.oncePerIdentityDistribution`, which calls + // `parseOncePerIdentityDistribution` above. Adding a stored + // property instead would move the model's entity hash and cost a + // schema version (see `DashModelContainer.modelTypes`). + // Pre-programmed distribution if let preProgrammed = distributionRules["preProgrammedDistribution"] as? [String: Any] { var dist = TokenPreProgrammedDistribution() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift index 5aaaa079aae..ef696034fdc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift @@ -184,8 +184,49 @@ extension PersistentToken { conventionsChangeRules != nil } + /// The token's once-per-identity distribution: a fixed amount every + /// identity may claim exactly once (protocol version 14). + /// + /// Derived rather than stored in a column of its own. The owning + /// contract's `serializedContract` already holds the whole contract JSON, + /// distribution rules included, so the value is persisted with every + /// contract the parser writes, and a new stored property here would move + /// this model's entity hash. That costs a schema version and a fixture + /// store (see `DashModelContainer.modelTypes` and + /// `DashModelMigrationTests`), which a display-only amount does not + /// justify. `perpetualDistribution` and `preProgrammedDistribution` + /// predate that discipline and kept their columns. + /// + /// Nil both when the token declares no such distribution and when the + /// contract JSON cannot be read: a token row whose `dataContract` + /// relationship is unset, or a contract persisted without its JSON, has + /// nothing to derive from. + /// + /// Parsing lives in `DataContractParser.parseOncePerIdentityDistribution` + /// so the derived read and the contract parser agree on the wire shape, + /// and the decode is memoised per contract payload by + /// `TokenOncePerIdentityDistributionCache` so reading this per token row + /// per paint does not re-parse the whole contract every time. + public var oncePerIdentityDistribution: TokenOncePerIdentityDistribution? { + guard let contract = dataContract else { return nil } + return TokenOncePerIdentityDistributionCache.shared.distribution( + for: contract, + position: position + ) + } + + /// True when the token carries any distribution kind. + /// + /// The two column-backed kinds are checked first, so a token that + /// already has one answers without touching the contract JSON at all. + /// The fall-through is not free even so: the first read of a given + /// contract payload decodes it. That decode is paid once per payload + /// (see `TokenOncePerIdentityDistributionCache`), not once per call, so + /// calling this per row in a list is safe. public var hasDistribution: Bool { - perpetualDistribution != nil || preProgrammedDistribution != nil + perpetualDistribution != nil + || preProgrammedDistribution != nil + || oncePerIdentityDistribution != nil } public var canChangeTradeMode: Bool { @@ -335,12 +376,37 @@ extension PersistentToken { } } - public static func distributionTokensPredicate() -> Predicate { + /// Covers the two column-backed distribution kinds only. A `#Predicate` + /// is compiled into a store query over stored properties, so it cannot + /// see `oncePerIdentityDistribution`, which is derived from the owning + /// contract's JSON. Filter in memory on `hasDistribution` when that kind + /// has to count. + /// + /// The name says "column-backed" because the old one read as "every + /// token with a distribution" and no longer is: it disagrees with + /// `hasDistribution` on a token whose only distribution is + /// once-per-identity. + public static func columnBackedDistributionTokensPredicate() -> Predicate { #Predicate { token in token.perpetualDistribution != nil || token.preProgrammedDistribution != nil } } + @available( + *, + deprecated, + renamed: "columnBackedDistributionTokensPredicate()", + message: """ + This predicate never matched the once-per-identity kind, which is derived from the \ + contract JSON rather than stored on the token row. Fetch without it and filter on \ + `hasDistribution`, or call `columnBackedDistributionTokensPredicate()` when the two \ + column-backed kinds really are all you want. + """ + ) + public static func distributionTokensPredicate() -> Predicate { + columnBackedDistributionTokensPredicate() + } + public static func pausedTokensPredicate() -> Predicate { #Predicate { token in token.isPaused == true diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/TokenOncePerIdentityDistributionCache.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/TokenOncePerIdentityDistributionCache.swift new file mode 100644 index 00000000000..004afdd1e90 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/TokenOncePerIdentityDistributionCache.swift @@ -0,0 +1,134 @@ +import Foundation + +/// Memo for the once-per-identity distribution blocks a contract's JSON +/// declares, keyed by token position (protocol version 14). +/// +/// `PersistentToken.oncePerIdentityDistribution` has no column to read: the +/// value is derived from the owning contract's `serializedContract`, and +/// `PersistentDataContract.parsedContract` runs `JSONSerialization` over the +/// whole contract on every access. The property is read per token row per +/// paint: the token list badge, the in-memory "has distribution" filter, the +/// claim form and the claim permission resolver all reach for it, and the +/// common token declares no distribution at all, so every one of those reads +/// would otherwise decode a full contract to learn nothing. This decodes once +/// per distinct contract payload instead and answers the rest from memory. +/// +/// A stored property on the model would have been the obvious place to keep +/// the answer, but `DashSchemaV5` is frozen: a new stored property moves +/// `PersistentToken`'s entity hash and costs a schema version. A process-wide +/// memo sidesteps the schema entirely. +/// +/// Thread-safe: SwiftData rows are read from whichever actor owns their +/// context, so the map is guarded by a lock rather than pinned to the main +/// actor. +final class TokenOncePerIdentityDistributionCache: @unchecked Sendable { + static let shared = TokenOncePerIdentityDistributionCache() + + /// Cached contract payloads to retain. Entries are small (one optional + /// amount per token position) but the JSON they came from is not, and a + /// device can hold far more contracts than any one screen looks at, so + /// the oldest insertion is dropped past this many. + private static let capacity = 32 + + /// Identifies one contract payload. `id` alone is not enough: a contract + /// row is re-created on every download and SwiftData merges the new + /// values into the existing row under the unique-`id` constraint, so the + /// same id can carry different JSON over time. Nothing assigns + /// `serializedContract` outside `init` today, but relying on that + /// silently would make a future assignment serve stale amounts, so the + /// payload's byte count and the row's `lastUpdated` stamp are part of the + /// key. + private struct Key: Hashable { + let contractId: Data + let byteCount: Int + let lastUpdated: Date + } + + private let lock = NSLock() + /// Token position -> parsed block, holding only the positions that + /// declare one. A position missing from the map declares none, which is + /// what makes a cache hit able to answer nil without re-decoding. + private var entries: [Key: [Int: TokenOncePerIdentityDistribution]] = [:] + /// Insertion order of `entries`' keys, oldest first, for eviction. + private var insertionOrder: [Key] = [] + private var decodes: Int = 0 + + /// How many times contract JSON has actually been decoded. Exposed for + /// tests, which assert that repeated reads of the same contract do not + /// move it; nothing in the SDK's public surface depends on it. + var decodeCount: Int { + lock.withLock { decodes } + } + + /// Drop every cached payload. Tests use it to isolate cases; production + /// code never needs it, because a changed payload changes the key. + func removeAll() { + lock.withLock { + entries.removeAll() + insertionOrder.removeAll() + decodes = 0 + } + } + + /// The once-per-identity distribution declared by the token at + /// `position` of `contract`, or nil when it declares none (or when the + /// contract's JSON cannot be read at all). + func distribution( + for contract: PersistentDataContract, + position: Int + ) -> TokenOncePerIdentityDistribution? { + let serialized = contract.serializedContract + let key = Key( + contractId: contract.id, + byteCount: serialized.count, + lastUpdated: contract.lastUpdated + ) + + return lock.withLock { + if let cached = entries[key] { + return cached[position] + } + + // The decode runs under the lock so two callers racing on a cold + // contract decode it once between them rather than twice each. + let parsed = Self.parseAllPositions(serialized) + decodes += 1 + entries[key] = parsed + insertionOrder.append(key) + if insertionOrder.count > Self.capacity { + let evicted = insertionOrder.removeFirst() + entries.removeValue(forKey: evicted) + } + return parsed[position] + } + } + + /// Decode the contract payload once and collect every token position + /// that declares a once-per-identity distribution. Positions whose block + /// is absent or malformed are left out, so they read back as nil. + private static func parseAllPositions( + _ serialized: Data + ) -> [Int: TokenOncePerIdentityDistribution] { + guard let root = try? JSONSerialization.jsonObject(with: serialized, options: []), + let contract = root as? [String: Any], + let tokens = contract["tokens"] as? [String: Any] else { + return [:] + } + + var parsed: [Int: TokenOncePerIdentityDistribution] = [:] + for (positionKey, tokenValue) in tokens { + // Skips `$formatVersion` and any other non-numeric key rs-dpp + // puts alongside the positions. + guard let position = Int(positionKey), + let tokenDict = tokenValue as? [String: Any], + let distributionRules = tokenDict["distributionRules"] as? [String: Any], + let distribution = DataContractParser.parseOncePerIdentityDistribution( + distributionRules["oncePerIdentityDistribution"] + ) else { + continue + } + parsed[position] = distribution + } + return parsed + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift index 7c1d8857123..eeb4fb24371 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift @@ -117,6 +117,36 @@ public struct DistributionEvent: Codable, Equatable, Sendable { } } +// MARK: - Once-Per-Identity Distribution + +/// A fixed amount every identity may claim exactly once (protocol +/// version 14). +/// +/// rs-dpp serialises it inside a token's `distributionRules` as +/// `"oncePerIdentityDistribution": {"$formatVersion": "0", "amount": 5000}`. +/// +/// `amount` is a protocol `u64`, so it is carried as an exact decimal string +/// here, the same convention the other token amounts use. Values above +/// 2^53 - 1 arrive as JSON strings because a JSON number that large is not +/// exactly representable, and a fixed-width or floating-point carrier could +/// not hand them back digit for digit. rs-dpp narrows what a contract may +/// declare (1 to `i64::MAX`) and enforces that at registration; this type +/// carries whatever the wire holds rather than restating the rule. +/// +/// Unlike `TokenPerpetualDistribution` and `TokenPreProgrammedDistribution` +/// this value has no column on `PersistentToken`: it is derived from the +/// owning contract's stored JSON through +/// `PersistentToken.oncePerIdentityDistribution`. +public struct TokenOncePerIdentityDistribution: Codable, Equatable, Sendable { + /// The amount minted to an identity on its single claim, as an exact + /// decimal string. + public var amount: String + + public init(amount: String) { + self.amount = amount + } +} + // MARK: - Distribution Change Rules /// Rules governing changes to distribution configuration diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift new file mode 100644 index 00000000000..3a4d722903c --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift @@ -0,0 +1,140 @@ +import Foundation +import SwiftDashSDK + +/// "Has this identity already taken its single once-per-identity claim on +/// this token?" +/// +/// A once-per-identity distribution pays every identity a fixed amount +/// exactly once, so eligibility is universal right up to the moment the +/// identity claims, after which Drive rejects the next claim with +/// `TokenOncePerIdentityDistributionAlreadyClaimedError` (consensus state +/// error 40722) and the user pays the fee for the rejection. There is no +/// DAPI query for "has identity X claimed" yet, so the only thing this app +/// can do is remember the claims it saw itself. +/// +/// Split from `OncePerIdentityClaimRecording` so the permission resolver can +/// take the read side alone and stay pure and unit-testable. +protocol OncePerIdentityClaimReading { + func hasClaimed(token: PersistentToken, identity: PersistentIdentity) -> Bool +} + +/// The write side, held by the claim form. +protocol OncePerIdentityClaimRecording: OncePerIdentityClaimReading { + func recordClaim(token: PersistentToken, identity: PersistentIdentity) +} + +/// `UserDefaults`-backed record of once-per-identity claims this app saw +/// succeed, plus the ones Drive told us had already happened. +/// +/// Deliberately not SwiftData: `DashSchemaV5` is frozen, and a new stored +/// property or model would cost a schema version for what is a local hint, +/// not protocol state. The hint is one-way, set and never cleared, which is +/// safe because the fact it caches cannot become false again: a spent claim +/// stays spent, and identity ids are not reused. +/// +/// It is a hint, not an authority. An identity that claimed on another +/// device is absent from it, and that claim is still caught the expensive +/// way, by Drive rejecting the attempt. +/// +/// `@unchecked Sendable`: the only stored value is a `UserDefaults`, whose +/// accessors are thread-safe. +final class OncePerIdentityClaimStore: OncePerIdentityClaimRecording, @unchecked Sendable { + static let shared = OncePerIdentityClaimStore() + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func hasClaimed(token: PersistentToken, identity: PersistentIdentity) -> Bool { + defaults.bool(forKey: Self.key(token: token, identity: identity)) + } + + func recordClaim(token: PersistentToken, identity: PersistentIdentity) { + defaults.set(true, forKey: Self.key(token: token, identity: identity)) + } + + /// Keyed by network as well as token and identity: the same identity id + /// can exist on testnet and on a devnet with different claim histories, + /// and the app switches networks in place. + static func key(token: PersistentToken, identity: PersistentIdentity) -> String { + [ + "tokenOncePerIdentityClaimed", + String(identity.networkRaw), + token.contractIdBase58, + String(token.position), + identity.identityIdBase58 + ].joined(separator: ".") + } +} + +/// Recognises Drive's "this identity already claimed" rejection in the error +/// a claim submission throws. +/// +/// What reaches Swift today is text: the FFI hands back a +/// `PlatformWalletError` carrying a rendered message, and rs-drive-abci puts +/// the consensus error's `Display` text in it, which rs-dpp spells +/// "identity '' already claimed the once-per-identity distribution of +/// token '' at ". That phrase is the primary signal, with the type +/// name as a second one in case a path renders the variant rather than its +/// message. +/// +/// The consensus code is matched as well, for parity with Android. No error +/// text carries it today, because nothing on the way here renders the code +/// next to the message, but a plumbing change that starts surfacing it (a +/// structured FFI detail, a broadcast error that prints its `code`) then +/// works without a follow-up here. +/// +/// A miss is not fatal in either direction: missing the rejection only means +/// the kind stays offered until the next attempt, and a false positive is +/// guarded against by the signals being specific, the code included. +enum OncePerIdentityClaimRejection { + /// Consensus code of `TokenOncePerIdentityDistributionAlreadyClaimedError` + /// (rs-dpp `errors/consensus/codes.rs`), the state error Drive returns + /// for a second claim by the same identity. + static let alreadyClaimedConsensusCode = "40722" + + private static let messageSignals = [ + "already claimed the once-per-identity distribution", + "tokenonceperidentitydistributionalreadyclaimederror" + ] + + static func isAlreadyClaimed(_ error: Error) -> Bool { + let message = error.localizedDescription.lowercased() + if messageSignals.contains(where: { message.contains($0) }) { + return true + } + return containsStandaloneNumber(alreadyClaimedConsensusCode, in: message) + } + + /// Whether `number` occurs in `text` with no digit on either side, the + /// equivalent of the regular expression `(? Bool { + var searchStart = text.startIndex + while let range = text.range(of: number, range: searchStart.. text.startIndex + && isASCIIDigit(text[text.index(before: range.lowerBound)]) + let digitAfter = range.upperBound < text.endIndex + && isASCIIDigit(text[range.upperBound]) + if !digitBefore && !digitAfter { + return true + } + // Overlapping occurrences matter as little here as anywhere, but + // stepping one character keeps a later standalone match findable + // after a rejected one. + searchStart = text.index(after: range.lowerBound) + } + return false + } + + private static func isASCIIDigit(_ character: Character) -> Bool { + character.isASCII && character.isNumber + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index 4a32f2fc2cf..8873e66dc7b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -457,7 +457,8 @@ enum TokenActionResolver { )) } - // Claim — perpetual / pre-programmed distribution. + // Claim: perpetual / pre-programmed / once-per-identity + // distribution. // Visibility rule from the prompt: // * If `mintingAllowChoosingDestination == false` AND identity is // not the designated `newTokensDestinationIdentity` -> hide. @@ -565,11 +566,131 @@ enum TokenActionResolver { return permission } - private static func resolveClaim( + /// Whether `identity` is the recipient the contract pinned for newly + /// minted tokens, which is what makes a perpetual payout land on it. + static func isPinnedDistributionRecipient( token: PersistentToken, identity: PersistentIdentity + ) -> Bool { + token.newTokensDestinationIdentity == identity.identityId + } + + /// Whether `identity` is named in any of the token's pre-programmed + /// payout events. + static func isPreProgrammedRecipient( + token: PersistentToken, + identity: PersistentIdentity + ) -> Bool { + guard let schedule = token.preProgrammedDistribution?.distributionSchedule else { + return false + } + let identityBase58 = identity.identityIdBase58 + return schedule.contains { $0.recipient == identityBase58 } + } + + /// Whether the token has a once-per-identity distribution that + /// `identity` has not been seen to claim yet. + /// + /// Eligibility for the kind itself is universal: it pays a fixed amount + /// to every identity, so there is no recipient list to match and no + /// designated-recipient guard to apply. The single claim is the whole + /// limit, and once it is taken Drive rejects the next attempt with a + /// paid error, so `claims` subtracts the ones this app knows about. + static func canClaimOncePerIdentity( + token: PersistentToken, + identity: PersistentIdentity, + claims: OncePerIdentityClaimReading = OncePerIdentityClaimStore.shared + ) -> Bool { + guard token.oncePerIdentityDistribution != nil else { return false } + return !claims.hasClaimed(token: token, identity: identity) + } + + /// The distribution kinds this identity could actually claim, in Drive's + /// claim order (perpetual, then pre-programmed, then once-per-identity). + /// + /// A kind the token declares is not a kind this identity can claim: a + /// perpetual payout goes to the contract's pinned recipient and a + /// pre-programmed one to the identities its schedule names, so offering + /// either to anyone else buys a rejection at the user's expense. The + /// list is therefore what the token declares narrowed by who is asking, + /// and it is empty when the answer is "nothing". + /// + /// Shares its eligibility rules with `resolveClaim` so what the claim row + /// allows and what the form offers cannot drift apart. + static func claimableDistributions( + token: PersistentToken, + identity: PersistentIdentity, + claims: OncePerIdentityClaimReading = OncePerIdentityClaimStore.shared + ) -> [TokenDistributionType] { + var claimable: [TokenDistributionType] = [] + if token.perpetualDistribution != nil, + isPinnedDistributionRecipient(token: token, identity: identity) { + claimable.append(.perpetual) + } + if token.preProgrammedDistribution != nil, + isPreProgrammedRecipient(token: token, identity: identity) { + claimable.append(.preProgrammed) + } + if canClaimOncePerIdentity(token: token, identity: identity, claims: claims) { + claimable.append(.oncePerIdentity) + } + return claimable + } + + /// Which distribution kind a claim form should start on for this + /// identity, or nil when there is nothing it could claim. + /// + /// The first kind in `claimableDistributions`, so the form starts on the + /// one Drive would settle first among those the identity is eligible + /// for. Preselecting a kind on eligibility the identity does not have + /// costs a real fee: a stranger to a token with an owner-paid perpetual + /// distribution plus a once-per-identity one would otherwise open the + /// form on Perpetual and have Drive reject the claim as the wrong + /// claimant. + static func preferredClaimDistribution( + token: PersistentToken, + identity: PersistentIdentity, + claims: OncePerIdentityClaimReading = OncePerIdentityClaimStore.shared + ) -> TokenDistributionType? { + claimableDistributions(token: token, identity: identity, claims: claims).first + } + + static func resolveClaim( + token: PersistentToken, + identity: PersistentIdentity, + claims: OncePerIdentityClaimReading = OncePerIdentityClaimStore.shared ) -> TokenActionPermission { - let isDesignated = token.newTokensDestinationIdentity == identity.identityId + let hasOncePerIdentity = token.oncePerIdentityDistribution != nil + let columnBacked = resolveColumnBackedClaim(token: token, identity: identity) + + // A kind the identity is eligible for through the token's own + // recipient rules wins: it stays claimable however many times the + // once-per-identity claim has been taken. + if case .allowed = columnBacked { + return .allowed + } + + if canClaimOncePerIdentity(token: token, identity: identity, claims: claims) { + return .allowed + } + if hasOncePerIdentity { + // The once-per-identity kind was the only thing that would have + // let this identity claim, and it is spent. Say so instead of + // falling through to a perpetual/pre-programmed reason that + // describes a different kind. + return .denied(reason: "Already claimed the once-per-identity distribution") + } + return columnBacked + } + + /// The claim rules for the two kinds stored on the token row. Split out + /// so `resolveClaim` can fold the derived once-per-identity kind around + /// them without restating them. + private static func resolveColumnBackedClaim( + token: PersistentToken, + identity: PersistentIdentity + ) -> TokenActionPermission { + let isDesignated = isPinnedDistributionRecipient(token: token, identity: identity) let allowsChoosing = token.mintingAllowChoosingDestination let hasPerpetual = token.perpetualDistribution != nil let hasPreProgrammed = token.preProgrammedDistribution != nil @@ -601,9 +722,7 @@ enum TokenActionResolver { // status here — those are enforced on-chain by Drive when the // claim state transition is submitted. Mirrors the Android fix // in `TokenActionResolver.resolveClaim`. - let identityBase58 = identity.identityIdBase58 - if let schedule = token.preProgrammedDistribution?.distributionSchedule, - schedule.contains(where: { $0.recipient == identityBase58 }) { + if isPreProgrammedRecipient(token: token, identity: identity) { return .allowed } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift index 6ecc98ca2a5..95781d819d4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift @@ -4,12 +4,13 @@ import SwiftDashSDK /// Form for claiming a token distribution payout. /// -/// Inputs: distribution-type picker (`PreProgrammed` / `Perpetual`) -/// driven by which schedule the token has, plus an optional public -/// note. When only one schedule is configured the picker auto-selects -/// it and is disabled; when neither is configured the form refuses to -/// submit. Claim is not group-gated, so there's no group-action -/// banner. +/// Inputs: distribution-type picker (`Perpetual` / `PreProgrammed` / +/// `OncePerIdentity`) listing only the kinds this identity can claim, not +/// every kind the token declares, plus an optional public note. Drive +/// charges for a claim it rejects, so a kind that pays someone else is not +/// an option worth offering. When only one is available the picker +/// auto-selects it and is disabled; when none is, the form refuses to +/// submit. Claim is not group-gated, so there's no group-action banner. struct TokenClaimActionView: View { let token: PersistentToken let identity: PersistentIdentity @@ -22,33 +23,51 @@ struct TokenClaimActionView: View { @State private var publicNote: String = "" @State private var isSubmitting: Bool = false @State private var submitError: AlertMessage? + /// Whether this identity's single once-per-identity claim is already + /// spent. Seeded from the local record and re-set when Drive rejects a + /// claim as already taken, so the kind drops out of the picker for the + /// rest of the session rather than staying tappable at a fee per tap. + @State private var oncePerIdentityClaimed: Bool /// Generation counter so a late `MainActor.run` from a previous /// `submit()` Task can't write back to a re-entered view instance /// after the user pops + repushes mid-broadcast. @State private var submitGeneration: Int = 0 + /// Local record of once-per-identity claims. Injected so the form and + /// the permission resolver read the same source, and so tests can + /// supply their own. + private let claims: OncePerIdentityClaimRecording + private struct AlertMessage: Identifiable { let id = UUID() let message: String } - init(token: PersistentToken, identity: PersistentIdentity) { + init( + token: PersistentToken, + identity: PersistentIdentity, + claims: OncePerIdentityClaimRecording = OncePerIdentityClaimStore.shared + ) { self.token = token self.identity = identity - // Default to whichever schedule is present; perpetual wins - // when both exist (matches Drive's claim ordering). When - // neither exists we still need a default — `.perpetual` - // keeps the picker valid; submission is gated by - // `availableDistributions.isEmpty`. - let perpetual = token.perpetualDistribution != nil - let preProgrammed = token.preProgrammedDistribution != nil - if perpetual { - self._selectedDistribution = State(initialValue: .perpetual) - } else if preProgrammed { - self._selectedDistribution = State(initialValue: .preProgrammed) - } else { - self._selectedDistribution = State(initialValue: .perpetual) - } + self.claims = claims + // Start on the first kind this identity can claim, which is not + // always the first kind the token declares: see + // `TokenActionResolver.preferredClaimDistribution`. It returns nil + // when the identity can claim nothing, and `.perpetual` is then a + // placeholder that keeps the Picker's selection valid; nothing can + // be submitted, because `canSubmit` requires the selected kind to + // be in `availableDistributions`, which is empty in that case. + self._selectedDistribution = State( + initialValue: TokenActionResolver.preferredClaimDistribution( + token: token, + identity: identity, + claims: claims + ) ?? .perpetual + ) + self._oncePerIdentityClaimed = State( + initialValue: claims.hasClaimed(token: token, identity: identity) + ) } var body: some View { @@ -118,15 +137,22 @@ struct TokenClaimActionView: View { return walletManager.wallet(for: walletId) } + /// What this identity can actually claim, not what the token declares. + /// A kind it is not eligible for is not an option the picker should + /// offer: submitting one is a rejection Drive charges for. private var availableDistributions: [TokenDistributionType] { - var types: [TokenDistributionType] = [] - if token.perpetualDistribution != nil { - types.append(.perpetual) - } - if token.preProgrammedDistribution != nil { - types.append(.preProgrammed) - } - return types + let claimable = TokenActionResolver.claimableDistributions( + token: token, + identity: identity, + claims: claims + ) + // An identity gets one claim of the once-per-identity kind ever. The + // resolver reads that from the same store, but the filter is on the + // view's own state so the picker updates the moment a claim lands in + // this session, without depending on when the store's write becomes + // visible. + guard oncePerIdentityClaimed else { return claimable } + return claimable.filter { $0 != .oncePerIdentity } } private var canSubmit: Bool { @@ -182,16 +208,41 @@ struct TokenClaimActionView: View { ) await MainActor.run { guard self.submitGeneration == gen else { return } + if dist == .oncePerIdentity { + self.recordOncePerIdentityClaim() + } self.isSubmitting = false self.dismiss() } } catch { await MainActor.run { guard self.submitGeneration == gen else { return } + // Drive charges for a rejected claim, so a rejection + // that says the single claim is already taken is worth + // remembering: it is the only way this app learns about + // a claim it did not make itself (there is no DAPI query + // for it yet). + if OncePerIdentityClaimRejection.isAlreadyClaimed(error) { + self.recordOncePerIdentityClaim() + } self.submitError = .init(message: error.localizedDescription) self.isSubmitting = false } } } } + + /// Remember that this identity's single once-per-identity claim is + /// spent, and take the kind out of the picker. If it was the selected + /// one, move the selection to whatever is left so the form does not sit + /// on a value it can no longer submit. + @MainActor + private func recordOncePerIdentityClaim() { + claims.recordClaim(token: token, identity: identity) + oncePerIdentityClaimed = true + if selectedDistribution == .oncePerIdentity, + let fallback = availableDistributions.first { + selectedDistribution = fallback + } + } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift index 1c49ad5dd2e..98ee453d55a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift @@ -35,8 +35,10 @@ struct TokenDetailsView: View { // Control Rules controlRulesSection - // Distribution Rules - if token.perpetualDistribution != nil || token.preProgrammedDistribution != nil { + // Distribution Rules. `hasDistribution` also covers the + // once-per-identity kind, which is derived from the + // contract JSON rather than stored on the token row. + if token.hasDistribution { distributionSection } @@ -306,6 +308,20 @@ struct TokenDetailsView: View { } } + if let oncePerIdentity = token.oncePerIdentityDistribution { + Divider() + VStack(alignment: .leading, spacing: 8) { + Text("Once-per-Identity Distribution") + .font(.subheadline) + .fontWeight(.semibold) + + InfoRow( + label: "Amount per identity:", + value: formatTokenAmount(oncePerIdentity.amount) + ) + } + } + // New tokens destination if let destinationId = token.newTokensDestinationIdentityBase58 { Divider() diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenSearchView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenSearchView.swift index 6d6639d8d70..640d2961c39 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenSearchView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenSearchView.swift @@ -7,6 +7,12 @@ struct TokenSearchView: View { @State private var selectedFilter: TokenFilter = .all @State private var searchText = "" + /// The rows are filtered in memory by `filteredTokens` below, not by a + /// store `#Predicate`. There used to be a `predicate` property here that + /// mapped each case onto one; nothing read it, and its + /// `.hasDistribution` case could not have matched the once-per-identity + /// kind anyway, which is derived from the contract JSON rather than + /// stored on the token row. enum TokenFilter: String, CaseIterable { case all = "All Tokens" case mintable = "Can Mint" @@ -14,23 +20,6 @@ struct TokenSearchView: View { case freezable = "Can Freeze" case hasDistribution = "Has Distribution" case paused = "Paused" - - var predicate: Predicate? { - switch self { - case .all: - return nil - case .mintable: - return PersistentToken.mintableTokensPredicate() - case .burnable: - return PersistentToken.burnableTokensPredicate() - case .freezable: - return PersistentToken.freezableTokensPredicate() - case .hasDistribution: - return PersistentToken.distributionTokensPredicate() - case .paused: - return PersistentToken.pausedTokensPredicate() - } - } } var filteredTokens: [PersistentToken] { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift new file mode 100644 index 00000000000..35363a0615f --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift @@ -0,0 +1,579 @@ +import XCTest +import SwiftData +import SwiftDashSDK +@testable import SwiftExampleApp + +/// `TokenActionResolver`'s claim rules, and the default the claim form +/// starts on, for the three distribution kinds a token can carry. +/// +/// The once-per-identity kind (protocol version 14) pays every identity a +/// fixed amount exactly once, so it makes strangers eligible where the other +/// two kinds would not. That is also what makes the default matter: a +/// stranger to a token with an owner-paid perpetual distribution plus a +/// once-per-identity one must not open the form on Perpetual, because Drive +/// charges for the rejected claim. +/// +/// The kind has no column on `PersistentToken`, so every fixture here seeds +/// the owning contract's `serializedContract`: that JSON is where +/// `oncePerIdentityDistribution` is derived from. +@MainActor +final class TokenClaimResolverTests: XCTestCase { + + /// Distinct per fixture so two contracts in one test (or one per test) + /// never share a row id. + private var nextContractByte: UInt8 = 0x10 + + private func makeContext() throws -> ModelContext { + let container = try DashModelContainer.createInMemory() + return ModelContext(container) + } + + private func makeIdentity( + byte: UInt8, + in context: ModelContext + ) -> PersistentIdentity { + let identity = PersistentIdentity( + identityId: Data(repeating: byte, count: 32), + network: .testnet + ) + context.insert(identity) + return identity + } + + /// Persist a contract whose single token declares the requested + /// distributions, and hand back the token row. + /// + /// `perpetualRecipient` becomes the pinned `newTokensDestinationIdentity` + /// the perpetual rules key off; `preProgrammedRecipients` are written as + /// scheduled payout recipients. `oncePerIdentityAmount` goes into the + /// contract JSON, since that is the only place the derived kind is read + /// from. + private func makeToken( + oncePerIdentityAmount: String? = nil, + perpetual: Bool = false, + perpetualRecipient: PersistentIdentity? = nil, + preProgrammedRecipients: [PersistentIdentity] = [], + allowsChoosingDestination: Bool = true, + in context: ModelContext + ) throws -> PersistentToken { + nextContractByte &+= 1 + let contractId = Data(repeating: nextContractByte, count: 32) + + var distributionRules: [String: Any] = [:] + if let oncePerIdentityAmount { + distributionRules["oncePerIdentityDistribution"] = [ + "$formatVersion": "0", + "amount": oncePerIdentityAmount + ] + } + let contractData: [String: Any] = [ + "tokens": [ + "0": [ + "baseSupply": 0, + "distributionRules": distributionRules + ] + ] + ] + + let contract = PersistentDataContract( + id: contractId, + name: "Fixture", + serializedContract: try JSONSerialization.data( + withJSONObject: contractData, + options: [] + ), + network: .testnet + ) + context.insert(contract) + + let token = PersistentToken( + contractId: contractId, + position: 0, + name: "Fixture Token", + baseSupply: "0" + ) + token.dataContract = contract + token.mintingAllowChoosingDestination = allowsChoosingDestination + if perpetual { + token.perpetualDistribution = TokenPerpetualDistribution() + } + if let perpetualRecipient { + token.newTokensDestinationIdentity = perpetualRecipient.identityId + } + if !preProgrammedRecipients.isEmpty { + var distribution = TokenPreProgrammedDistribution() + distribution.distributionSchedule = preProgrammedRecipients.map { recipient in + DistributionEvent( + triggerTime: Date(timeIntervalSince1970: 1_750_000_000), + amount: "7", + recipient: recipient.identityIdBase58 + ) + } + token.preProgrammedDistribution = distribution + } + context.insert(token) + try context.save() + return token + } + + // MARK: - Nothing to claim + + func testTokenWithoutAnyDistributionDeniesAndHasNoDefault() throws { + let context = try makeContext() + let identity = makeIdentity(byte: 0x01, in: context) + let token = try makeToken(in: context) + let claims = StubClaimStore() + + XCTAssertEqual( + TokenActionResolver.resolveClaim(token: token, identity: identity, claims: claims), + .denied(reason: "Token has no distribution schedule") + ) + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: identity, + claims: claims + ), + [] + ) + XCTAssertNil( + TokenActionResolver.preferredClaimDistribution( + token: token, + identity: identity, + claims: claims + ) + ) + } + + // MARK: - Once-per-identity makes strangers eligible + + func testStrangerMayClaimOncePerIdentityAlone() throws { + let context = try makeContext() + let identity = makeIdentity(byte: 0x02, in: context) + let token = try makeToken(oncePerIdentityAmount: "5000", in: context) + let claims = StubClaimStore() + + XCTAssertEqual( + TokenActionResolver.resolveClaim(token: token, identity: identity, claims: claims), + .allowed + ) + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: identity, + claims: claims + ), + [.oncePerIdentity] + ) + XCTAssertEqual( + TokenActionResolver.preferredClaimDistribution( + token: token, + identity: identity, + claims: claims + ), + .oncePerIdentity + ) + } + + /// The regression this suite exists for: with a perpetual distribution + /// alongside, a stranger is eligible only for the once-per-identity kind, + /// so that is the only kind the form may offer and the one it must start + /// on. Offering Perpetual by declaration order sends a claim Drive + /// rejects as the wrong claimant, at the user's expense. + func testStrangerPrefersOncePerIdentityOverPerpetual() throws { + let context = try makeContext() + let owner = makeIdentity(byte: 0x03, in: context) + let stranger = makeIdentity(byte: 0x04, in: context) + let token = try makeToken( + oncePerIdentityAmount: "5000", + perpetual: true, + perpetualRecipient: owner, + in: context + ) + let claims = StubClaimStore() + + XCTAssertEqual( + TokenActionResolver.resolveClaim(token: token, identity: stranger, claims: claims), + .allowed + ) + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: stranger, + claims: claims + ), + [.oncePerIdentity], + "the perpetual payout is pinned to the owner, so it is not on offer here" + ) + XCTAssertEqual( + TokenActionResolver.preferredClaimDistribution( + token: token, + identity: stranger, + claims: claims + ), + .oncePerIdentity + ) + } + + /// The pinned recipient is eligible for both kinds and keeps Drive's + /// ordering: perpetual first. + func testDesignatedRecipientPrefersPerpetual() throws { + let context = try makeContext() + let owner = makeIdentity(byte: 0x05, in: context) + let token = try makeToken( + oncePerIdentityAmount: "5000", + perpetual: true, + perpetualRecipient: owner, + in: context + ) + let claims = StubClaimStore() + + XCTAssertEqual( + TokenActionResolver.resolveClaim(token: token, identity: owner, claims: claims), + .allowed + ) + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: owner, + claims: claims + ), + [.perpetual, .oncePerIdentity] + ) + XCTAssertEqual( + TokenActionResolver.preferredClaimDistribution( + token: token, + identity: owner, + claims: claims + ), + .perpetual + ) + } + + /// A listed pre-programmed recipient is eligible for that kind, which + /// outranks once-per-identity the same way Drive orders them. + func testListedPreProgrammedRecipientPrefersPreProgrammed() throws { + let context = try makeContext() + let recipient = makeIdentity(byte: 0x06, in: context) + let token = try makeToken( + oncePerIdentityAmount: "5000", + preProgrammedRecipients: [recipient], + in: context + ) + let claims = StubClaimStore() + + XCTAssertEqual( + TokenActionResolver.resolveClaim(token: token, identity: recipient, claims: claims), + .allowed + ) + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: recipient, + claims: claims + ), + [.preProgrammed, .oncePerIdentity] + ) + XCTAssertEqual( + TokenActionResolver.preferredClaimDistribution( + token: token, + identity: recipient, + claims: claims + ), + .preProgrammed + ) + } + + /// An identity absent from the schedule is offered the universal kind + /// only, never the pre-programmed one it is not listed in. + func testUnlistedIdentityPrefersOncePerIdentityOverPreProgrammed() throws { + let context = try makeContext() + let recipient = makeIdentity(byte: 0x07, in: context) + let stranger = makeIdentity(byte: 0x08, in: context) + let token = try makeToken( + oncePerIdentityAmount: "5000", + preProgrammedRecipients: [recipient], + in: context + ) + let claims = StubClaimStore() + + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: stranger, + claims: claims + ), + [.oncePerIdentity] + ) + XCTAssertEqual( + TokenActionResolver.preferredClaimDistribution( + token: token, + identity: stranger, + claims: claims + ), + .oncePerIdentity + ) + } + + // MARK: - Already claimed + + func testAlreadyClaimedOncePerIdentityAloneIsDenied() throws { + let context = try makeContext() + let identity = makeIdentity(byte: 0x09, in: context) + let token = try makeToken(oncePerIdentityAmount: "5000", in: context) + let claims = StubClaimStore() + claims.recordClaim(token: token, identity: identity) + + XCTAssertEqual( + TokenActionResolver.resolveClaim(token: token, identity: identity, claims: claims), + .denied(reason: "Already claimed the once-per-identity distribution") + ) + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: identity, + claims: claims + ), + [] + ) + XCTAssertNil( + TokenActionResolver.preferredClaimDistribution( + token: token, + identity: identity, + claims: claims + ) + ) + } + + /// The claim that was recorded belongs to one identity: another one on + /// the same token is untouched. + func testAlreadyClaimedIsPerIdentity() throws { + let context = try makeContext() + let claimed = makeIdentity(byte: 0x0A, in: context) + let other = makeIdentity(byte: 0x0B, in: context) + let token = try makeToken(oncePerIdentityAmount: "5000", in: context) + let claims = StubClaimStore() + claims.recordClaim(token: token, identity: claimed) + + XCTAssertEqual( + TokenActionResolver.resolveClaim(token: token, identity: other, claims: claims), + .allowed + ) + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: other, + claims: claims + ), + [.oncePerIdentity] + ) + XCTAssertEqual( + TokenActionResolver.preferredClaimDistribution( + token: token, + identity: other, + claims: claims + ), + .oncePerIdentity + ) + } + + /// Spending the single claim does not take away an eligibility the + /// identity holds through another kind, and only the spent kind leaves + /// the list. + func testAlreadyClaimedDesignatedRecipientStaysAllowedOnPerpetual() throws { + let context = try makeContext() + let owner = makeIdentity(byte: 0x0C, in: context) + let token = try makeToken( + oncePerIdentityAmount: "5000", + perpetual: true, + perpetualRecipient: owner, + in: context + ) + let claims = StubClaimStore() + claims.recordClaim(token: token, identity: owner) + + XCTAssertEqual( + TokenActionResolver.resolveClaim(token: token, identity: owner, claims: claims), + .allowed + ) + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: owner, + claims: claims + ), + [.perpetual] + ) + XCTAssertEqual( + TokenActionResolver.preferredClaimDistribution( + token: token, + identity: owner, + claims: claims + ), + .perpetual + ) + } + + /// A stranger whose single claim is spent loses the only eligibility it + /// had: nothing is left to offer, and the denial names the kind rather + /// than falling through to the perpetual kind's "not the designated + /// recipient" reason. Offering the perpetual kind as a fallback would + /// send a claim that pays the pinned recipient, which Drive rejects at + /// this identity's expense. + func testAlreadyClaimedStrangerIsDeniedEvenWithPerpetualPresent() throws { + let context = try makeContext() + let owner = makeIdentity(byte: 0x0D, in: context) + let stranger = makeIdentity(byte: 0x0E, in: context) + let token = try makeToken( + oncePerIdentityAmount: "5000", + perpetual: true, + perpetualRecipient: owner, + allowsChoosingDestination: false, + in: context + ) + let claims = StubClaimStore() + claims.recordClaim(token: token, identity: stranger) + + XCTAssertEqual( + TokenActionResolver.resolveClaim(token: token, identity: stranger, claims: claims), + .denied(reason: "Already claimed the once-per-identity distribution") + ) + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: stranger, + claims: claims + ), + [], + "the perpetual kind is not a fallback: it pays the pinned recipient" + ) + XCTAssertNil( + TokenActionResolver.preferredClaimDistribution( + token: token, + identity: stranger, + claims: claims + ) + ) + } + + // MARK: - The local record + + /// The store keys by network, token and identity, so none of the three + /// bleeds into another. + func testClaimStoreKeysByNetworkTokenAndIdentity() throws { + let context = try makeContext() + let identity = makeIdentity(byte: 0x0F, in: context) + let otherIdentity = makeIdentity(byte: 0x11, in: context) + let token = try makeToken(oncePerIdentityAmount: "5000", in: context) + let otherToken = try makeToken(oncePerIdentityAmount: "5000", in: context) + + let suiteName = "TokenClaimResolverTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = OncePerIdentityClaimStore(defaults: defaults) + + XCTAssertFalse(store.hasClaimed(token: token, identity: identity)) + store.recordClaim(token: token, identity: identity) + + XCTAssertTrue(store.hasClaimed(token: token, identity: identity)) + XCTAssertFalse(store.hasClaimed(token: token, identity: otherIdentity)) + XCTAssertFalse(store.hasClaimed(token: otherToken, identity: identity)) + + // A second store over the same defaults sees it: the record has to + // survive the form being dismissed and reopened. + let reopened = OncePerIdentityClaimStore(defaults: defaults) + XCTAssertTrue(reopened.hasClaimed(token: token, identity: identity)) + } + + /// Drive's rejection is recognised from the message, which is all the + /// FFI hands back for a consensus error today. + func testAlreadyClaimedRejectionIsRecognisedFromTheMessage() { + let rejection = PlatformWalletError.unknown( + """ + Token claim failed: state transition broadcast error: Token claim error: identity \ + '5r5MYEznyc9UtKQZpmM1DUisVDwtPhUpaBxNpeTBQEHi' already claimed the once-per-identity \ + distribution of token '6vk7Xk3dLFdBfNkAvj6vSpk6NNAoBEMRkGbdBHSqzmPu' at 1750000000000 + """ + ) + XCTAssertTrue(OncePerIdentityClaimRejection.isAlreadyClaimed(rejection)) + + XCTAssertTrue( + OncePerIdentityClaimRejection.isAlreadyClaimed( + PlatformWalletError.unknown( + "TokenOncePerIdentityDistributionAlreadyClaimedError { token_id: .. }" + ) + ) + ) + XCTAssertFalse( + OncePerIdentityClaimRejection.isAlreadyClaimed( + PlatformWalletError.unknown( + "Token claim failed: Token claim error: no current rewards" + ) + ) + ) + } + + /// The consensus code counts as a signal too, for parity with Android, + /// but only where it stands as a number of its own. + func testAlreadyClaimedRejectionIsRecognisedFromTheConsensusCode() { + let recognised = [ + "Token claim failed: consensus error 40722: claim rejected", + "Token claim failed: code=40722", + "Token claim failed: broadcast rejected (code 40722)" + ] + for message in recognised { + XCTAssertTrue( + OncePerIdentityClaimRejection.isAlreadyClaimed( + PlatformWalletError.unknown(message) + ), + "should recognise the standalone code in: \(message)" + ) + } + } + + /// The digits of the code inside a longer number are not the code. Claim + /// errors quote millisecond timestamps and token amounts, and reading one + /// of those as the rejection would record a claim that never happened and + /// hide the kind from that identity permanently. + func testDigitsOfTheConsensusCodeInsideALongerNumberAreNotTheCode() { + let notRecognised = [ + "Token mint past max supply: 1758140722000", + "Token claim failed: amount 4072299", + "Token claim failed: identity balance 407220 is too low", + "Token claim failed: at 40722000" + ] + for message in notRecognised { + XCTAssertFalse( + OncePerIdentityClaimRejection.isAlreadyClaimed( + PlatformWalletError.unknown(message) + ), + "should not read a longer number as the code in: \(message)" + ) + } + + // The same message with the code standing on its own still counts, + // so the guard above is about digit boundaries and not about the + // words around them. + XCTAssertTrue( + OncePerIdentityClaimRejection.isAlreadyClaimed( + PlatformWalletError.unknown( + "Token claim failed: 40722 at 1758140722000" + ) + ) + ) + } +} + +/// In-memory stand-in for `OncePerIdentityClaimStore`, so the resolver tests +/// never touch the shared `UserDefaults`. +private final class StubClaimStore: OncePerIdentityClaimRecording { + private var claimed: Set = [] + + func hasClaimed(token: PersistentToken, identity: PersistentIdentity) -> Bool { + claimed.contains(OncePerIdentityClaimStore.key(token: token, identity: identity)) + } + + func recordClaim(token: PersistentToken, identity: PersistentIdentity) { + claimed.insert(OncePerIdentityClaimStore.key(token: token, identity: identity)) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift new file mode 100644 index 00000000000..62f75619c78 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift @@ -0,0 +1,455 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +/// Coverage for the once-per-identity token distribution on iOS: a fixed +/// amount every identity may claim exactly once (protocol version 14). +/// +/// rs-dpp serialises it inside a token's `distributionRules`, next to the +/// perpetual and pre-programmed blocks: +/// +/// ```json +/// "distributionRules": { +/// "oncePerIdentityDistribution": { "$formatVersion": "0", "amount": 5000 } +/// } +/// ``` +/// +/// `amount` is a protocol `u64`, so it arrives as a JSON number up to +/// 2^53 - 1 and as a decimal string above that. Both must land on the model +/// as the same exact decimal string, and a value the carrier cannot hold +/// must read as "no distribution". +/// +/// Which amounts a contract may actually declare (1 to `i64::MAX`) is +/// rs-dpp's rule, enforced at registration, so these tests do not assert it: +/// the parser reads what is on the wire and does not keep a second copy of a +/// protocol constant. +/// +/// Unlike the perpetual and pre-programmed kinds this one has no column on +/// `PersistentToken`: `DashSchemaV5` is frozen, and a new stored property +/// would move the model's entity hash (see `DashModelContainer.modelTypes` +/// and `DashModelMigrationTests`). `PersistentToken.oncePerIdentityDistribution` +/// therefore derives the value from the contract JSON stored on the owning +/// `PersistentDataContract`, which is why these tests seed +/// `serializedContract` rather than leaving it empty like the sibling parser +/// suites do. +@MainActor +final class DataContractParserOncePerIdentityTests: XCTestCase { + + private let contractId = Data(repeating: 0xEF, count: 32) + + /// 18446744073709551615 == `UInt64.max`, the largest amount the wire + /// type holds. Far above 2^53 - 1, so it only ever arrives as a decimal + /// string. + private let uInt64MaxAmount = "18446744073709551615" + + /// One past `UInt64.max`: no longer a `u64`, so nothing can carry it. + private let aboveUInt64MaxAmount = "18446744073709551616" + + /// One past `Int64.max`. Still a `u64`, so the parser takes it even + /// though rs-dpp would not have let a contract declare it. + private let aboveInt64MaxAmount = "9223372036854775808" + + private func makeContext() throws -> ModelContext { + let container = try DashModelContainer.createInMemory() + return ModelContext(container) + } + + /// Seed the `PersistentDataContract` row the parser needs (tokens hang off + /// the contract relationship, and `parseTokens` bails out without it), + /// then run the parser and hand back every persisted token. + /// + /// The contract row carries the JSON-serialised `contractData` on + /// `serializedContract`, which is what both of `ContractDownloader`'s + /// persist paths do before calling the parser. The derived property reads + /// it back through `TokenOncePerIdentityDistributionCache`. + @discardableResult + private func parseTokens( + _ tokens: [String: Any], + in context: ModelContext + ) throws -> [PersistentToken] { + let contractData: [String: Any] = ["tokens": tokens] + let serialized = try JSONSerialization.data( + withJSONObject: contractData, + options: [] + ) + + let contract = PersistentDataContract( + id: contractId, + name: "Fixture", + serializedContract: serialized, + network: .testnet + ) + context.insert(contract) + try context.save() + + try DataContractParser.parseDataContract( + contractData: contractData, + contractId: contractId, + modelContext: context + ) + + let id = contractId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.contractId == id } + ) + return try context.fetch(descriptor) + } + + private func parseSingleToken( + tokenDict: [String: Any], + in context: ModelContext + ) throws -> PersistentToken { + let tokens = try parseTokens(["0": tokenDict], in: context) + XCTAssertEqual(tokens.count, 1, "fixture declares exactly one token") + return try XCTUnwrap(tokens.first, "parser should have persisted one token") + } + + /// Wrap a `oncePerIdentityDistribution` payload in the minimal token dict + /// the parser expects. Passing nil leaves `distributionRules` present but + /// empty, which is the "token declares no distribution at all" case. + private func tokenDict(oncePerIdentity: [String: Any]?) -> [String: Any] { + var rules: [String: Any] = [:] + if let oncePerIdentity { + rules["oncePerIdentityDistribution"] = oncePerIdentity + } + return [ + "baseSupply": 0, + "distributionRules": rules + ] + } + + // MARK: - 1. Amount encodings + + /// The common shape: `amount` as a JSON number. + func testNumericAmountParsesToDecimalString() throws { + let context = try makeContext() + + let token = try parseSingleToken( + tokenDict: tokenDict(oncePerIdentity: [ + "$formatVersion": "0", + "amount": 5000 + ]), + in: context + ) + + let distribution = try XCTUnwrap(token.oncePerIdentityDistribution) + XCTAssertEqual(distribution.amount, "5000") + } + + /// `amount` as a JSON string, the encoding rs-dpp uses for large values, + /// lands unchanged. + func testStringAmountParsesVerbatim() throws { + let context = try makeContext() + + let token = try parseSingleToken( + tokenDict: tokenDict(oncePerIdentity: [ + "$formatVersion": "0", + "amount": "12345" + ]), + in: context + ) + + let distribution = try XCTUnwrap(token.oncePerIdentityDistribution) + XCTAssertEqual(distribution.amount, "12345") + } + + /// An amount above `Int64.max` survives with every digit intact: no + /// truncation, no overflow, no round trip through a floating-point type. + /// The carrier is a `u64`, so `UInt64.max` itself reads back verbatim. + func testAmountAboveInt64MaxPreservedVerbatim() throws { + let context = try makeContext() + + let token = try parseSingleToken( + tokenDict: tokenDict(oncePerIdentity: [ + "$formatVersion": "0", + "amount": uInt64MaxAmount + ]), + in: context + ) + XCTAssertEqual( + try XCTUnwrap(token.oncePerIdentityDistribution).amount, + uInt64MaxAmount + ) + XCTAssertNil( + Int64(uInt64MaxAmount), + "fixture must exceed Int64.max for this test to mean anything" + ) + + let numericContext = try makeContext() + let asNumber = try parseSingleToken( + tokenDict: tokenDict(oncePerIdentity: [ + "$formatVersion": "0", + "amount": Int64.max + ]), + in: numericContext + ) + XCTAssertEqual( + try XCTUnwrap(asNumber.oncePerIdentityDistribution).amount, + String(Int64.max) + ) + } + + /// An amount the `u64` carrier cannot hold reads as "no once-per-identity + /// distribution" rather than as a claimable one, because there is no + /// exact value to report. + func testAmountAboveTheCarrierTypeIsRejected() throws { + let context = try makeContext() + + let token = try parseSingleToken( + tokenDict: tokenDict(oncePerIdentity: [ + "$formatVersion": "0", + "amount": aboveUInt64MaxAmount + ]), + in: context + ) + + XCTAssertNil(token.oncePerIdentityDistribution) + XCTAssertFalse(token.hasDistribution) + } + + // MARK: - 2. Presence drives `hasDistribution` + + /// A token whose only distribution is once-per-identity still reports + /// `hasDistribution`, so the search filter and the details section find it + /// even though no column is set. + func testOncePerIdentityAloneMakesHasDistributionTrue() throws { + let context = try makeContext() + + let token = try parseSingleToken( + tokenDict: tokenDict(oncePerIdentity: [ + "$formatVersion": "0", + "amount": 1 + ]), + in: context + ) + + XCTAssertNil(token.perpetualDistribution) + XCTAssertNil(token.preProgrammedDistribution) + XCTAssertNotNil(token.oncePerIdentityDistribution) + XCTAssertTrue(token.hasDistribution) + } + + /// No `oncePerIdentityDistribution` key: the property stays nil, and with + /// no other distribution the token reports none. + func testNoOncePerIdentityKeyLeavesPropertyNilAndNoDistribution() throws { + let context = try makeContext() + + let token = try parseSingleToken( + tokenDict: tokenDict(oncePerIdentity: nil), + in: context + ) + + XCTAssertNil(token.oncePerIdentityDistribution) + XCTAssertNil(token.perpetualDistribution) + XCTAssertNil(token.preProgrammedDistribution) + XCTAssertFalse(token.hasDistribution) + } + + /// A malformed block, present but carrying no `amount`, reads as "no + /// once-per-identity distribution" rather than as an amount of zero. + func testBlockWithoutAmountYieldsNil() throws { + let context = try makeContext() + + let token = try parseSingleToken( + tokenDict: tokenDict(oncePerIdentity: ["$formatVersion": "0"]), + in: context + ) + + XCTAssertNil(token.oncePerIdentityDistribution) + XCTAssertFalse(token.hasDistribution) + } + + /// The kind coexists with the column-backed ones rather than replacing + /// them, and each token derives the block at its own position key. + func testEachTokenPositionDerivesItsOwnAmount() throws { + let context = try makeContext() + + let tokens = try parseTokens( + [ + "0": tokenDict(oncePerIdentity: ["amount": 100]), + "1": tokenDict(oncePerIdentity: ["amount": 200]), + "2": tokenDict(oncePerIdentity: nil) + ], + in: context + ) + XCTAssertEqual(tokens.count, 3) + + let first = try XCTUnwrap(tokens.first { $0.position == 0 }) + let second = try XCTUnwrap(tokens.first { $0.position == 1 }) + let third = try XCTUnwrap(tokens.first { $0.position == 2 }) + + XCTAssertEqual(first.oncePerIdentityDistribution?.amount, "100") + XCTAssertEqual(second.oncePerIdentityDistribution?.amount, "200") + XCTAssertNil(third.oncePerIdentityDistribution) + } + + // MARK: - 3. The shared parse function + + /// The derived property and the contract parser share one function, so + /// pin its behaviour directly rather than only through a persisted token. + func testParseOncePerIdentityDistributionAcceptsAndRejectsInputShapes() throws { + XCTAssertNil( + DataContractParser.parseOncePerIdentityDistribution(nil), + "absent block" + ) + XCTAssertNil( + DataContractParser.parseOncePerIdentityDistribution("oncePerIdentity"), + "block that is not a dictionary" + ) + let emptyBlock: [String: Any] = [:] + XCTAssertNil( + DataContractParser.parseOncePerIdentityDistribution(emptyBlock), + "dictionary without an amount" + ) + XCTAssertNil( + DataContractParser.parseOncePerIdentityDistribution(["amount": ["nested": 1]]), + "amount that is neither a number nor a string" + ) + + XCTAssertEqual( + DataContractParser.parseOncePerIdentityDistribution(["amount": 7])?.amount, + "7" + ) + XCTAssertEqual( + DataContractParser.parseOncePerIdentityDistribution([ + "$formatVersion": "0", + "amount": uInt64MaxAmount + ])?.amount, + uInt64MaxAmount + ) + } + + /// An `amount` the `u64` carrier cannot hold is refused, so iOS never + /// reports a distribution whose amount it could not state exactly. The + /// lenient `stringifyDistributionAmount` used by the pre-programmed + /// schedule would have accepted most of these. + func testParseOncePerIdentityDistributionRejectsAmountsTheCarrierCannotHold() throws { + let rejected: [(String, Any)] = [ + ("negative number", -1), + ("negative string", "-1"), + ("fractional number", 1.5), + ("fractional string", "1.5"), + ("non-numeric string", "abc"), + ("empty string", ""), + ("one past UInt64.max", aboveUInt64MaxAmount), + ("boolean, which bridges to NSNumber and would read as 0 or 1", true) + ] + for (description, amount) in rejected { + XCTAssertNil( + DataContractParser.parseOncePerIdentityDistribution(["amount": amount]), + "should reject \(description)" + ) + } + + // Everything the carrier can hold is read back, including values + // rs-dpp's own rule would not let a contract declare: that rule is + // enforced at registration, not mirrored here. + let accepted: [(Any, String)] = [ + (0, "0"), + (1, "1"), + (aboveInt64MaxAmount, aboveInt64MaxAmount), + (uInt64MaxAmount, uInt64MaxAmount), + // A non-canonical spelling normalises rather than being handed + // back verbatim. + ("0005", "5") + ] + for (amount, expected) in accepted { + XCTAssertEqual( + DataContractParser.parseOncePerIdentityDistribution(["amount": amount])?.amount, + expected, + "should accept \(amount)" + ) + } + } + + // MARK: - 4. The decode is paid once per contract payload + + /// Reading the derived property decodes the contract JSON on the first + /// read and answers from the memo afterwards. Without this the common + /// token, which declares no distribution at all, would re-decode a whole + /// contract per row per paint: `hasDistribution` falls through to this + /// property for the badge in `TokenSearchView` and again in its filter, + /// and the claim form and permission resolver read it too. + func testRepeatedReadsDecodeTheContractOnce() throws { + let cache = TokenOncePerIdentityDistributionCache.shared + cache.removeAll() + + let context = try makeContext() + let tokens = try parseTokens( + [ + "0": tokenDict(oncePerIdentity: ["amount": 100]), + "1": tokenDict(oncePerIdentity: nil) + ], + in: context + ) + XCTAssertEqual( + cache.decodeCount, + 0, + "the contract parser writes the rows without deriving anything" + ) + + let withDistribution = try XCTUnwrap(tokens.first { $0.position == 0 }) + let withoutDistribution = try XCTUnwrap(tokens.first { $0.position == 1 }) + + XCTAssertEqual(withDistribution.oncePerIdentityDistribution?.amount, "100") + XCTAssertEqual(cache.decodeCount, 1, "first read decodes the payload") + + for _ in 0..<5 { + XCTAssertEqual(withDistribution.oncePerIdentityDistribution?.amount, "100") + XCTAssertNil(withoutDistribution.oncePerIdentityDistribution) + XCTAssertTrue(withDistribution.hasDistribution) + XCTAssertFalse(withoutDistribution.hasDistribution) + } + XCTAssertEqual( + cache.decodeCount, + 1, + "later reads, including the sibling position that has none, come from the memo" + ) + } + + /// Two contracts are two payloads: the memo is keyed per contract, not + /// shared across them. + func testSeparateContractsDecodeSeparately() throws { + let cache = TokenOncePerIdentityDistributionCache.shared + cache.removeAll() + + let context = try makeContext() + let first = try parseSingleToken( + tokenDict: tokenDict(oncePerIdentity: ["amount": 11]), + in: context + ) + + let otherId = Data(repeating: 0xAB, count: 32) + let otherContractData: [String: Any] = [ + "tokens": ["0": tokenDict(oncePerIdentity: ["amount": 22])] + ] + let otherContract = PersistentDataContract( + id: otherId, + name: "Other", + serializedContract: try JSONSerialization.data( + withJSONObject: otherContractData, + options: [] + ), + network: .testnet + ) + context.insert(otherContract) + try context.save() + try DataContractParser.parseDataContract( + contractData: otherContractData, + contractId: otherId, + modelContext: context + ) + let otherDescriptor = FetchDescriptor( + predicate: #Predicate { $0.contractId == otherId } + ) + let second = try XCTUnwrap(try context.fetch(otherDescriptor).first) + + XCTAssertEqual(first.oncePerIdentityDistribution?.amount, "11") + XCTAssertEqual(second.oncePerIdentityDistribution?.amount, "22") + XCTAssertEqual(cache.decodeCount, 2) + + XCTAssertEqual(first.oncePerIdentityDistribution?.amount, "11") + XCTAssertEqual(second.oncePerIdentityDistribution?.amount, "22") + XCTAssertEqual(cache.decodeCount, 2, "both payloads stay memoised") + } +}