From 4d9a55ad507b93ad818644410aec38de44407c23 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 04:34:23 +0700 Subject: [PATCH 1/6] feat(sdk): once-per-identity token distribution in the mobile example apps Kotlin: Room 13 -> 14 adds the nullable tokens.oncePerIdentityDistribution column holding the contract's block as JSON, like the perpetual and pre-programmed columns. TokenMaterializer persists it and folds it into hasDistribution; TokenOncePerIdentityDistribution.parse reads the u64 amount back as a decimal string for the details screen. The claim form offers ONCE_PER_IDENTITY and the claim resolver allows every identity, since an already-claimed identity is rejected on-chain. 14.json was exported by KSP; a 13 -> 14 case joins DashDatabaseMigrationTest. Swift: no new SwiftData column, because DashSchemaV5 is frozen. The value is derived from the owning contract's persisted JSON through DataContractParser.parseOncePerIdentityDistribution, following the immutable-properties precedent. hasDistribution includes it; distributionTokensPredicate() cannot and says so. The claim view, the claim permission resolver and the details view pick up the kind. Tests: 7 materializer and 2 resolver cases on Android, 8 parser cases on iOS. Co-Authored-By: Claude Fable 5.1 --- .../services/tokens/TokenActionResolver.kt | 8 +- .../services/tokens/TokenMaterializer.kt | 10 +- .../example/services/tokens/TokenRules.kt | 39 + .../example/ui/tokens/TokenActionScreens.kt | 3 + .../example/ui/tokens/TokenDetailsScreen.kt | 8 + .../tokens/TokenActionResolverClaimTest.kt | 28 +- .../TokenMaterializerOncePerIdentityTest.kt | 118 + .../14.json | 4182 +++++++++++++++++ .../persistence/DashDatabaseMigrationTest.kt | 62 +- .../dashsdk/persistence/DashDatabase.kt | 22 +- .../persistence/entities/TokenEntity.kt | 12 +- .../Core/Utils/DataContractParser.swift | 38 + .../Persistence/Models/PersistentToken.swift | 46 +- .../Persistence/Types/TokenTypes.swift | 27 + .../Views/TokenActionPermissionsView.swift | 14 +- .../TokenActions/TokenClaimActionView.swift | 28 +- .../Views/TokenDetailsView.swift | 20 +- ...taContractParserOncePerIdentityTests.swift | 274 ++ 18 files changed, 4913 insertions(+), 26 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerOncePerIdentityTest.kt create mode 100644 packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/14.json create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenActionResolver.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenActionResolver.kt index a22c1f411c2..5d175806657 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenActionResolver.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenActionResolver.kt @@ -426,9 +426,15 @@ object TokenActionResolver { ): 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 that already claimed is rejected on-chain at submit time. + if (hasOncePerIdentity) { + return TokenActionPermission.Allowed + } if (token.newTokensDestinationIdentity?.contentEquals(identity.identityId) == true) { return TokenActionPermission.Allowed 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..2f9a2c92b32 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 @@ -80,6 +80,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 +110,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 +143,7 @@ object TokenMaterializer { emergencyActionRules = emergencyAction?.toJson(), perpetualDistribution = perpetual?.toString(), preProgrammedDistribution = preProgrammed?.toString(), + oncePerIdentityDistribution = oncePerIdentity?.toString(), newTokensDestinationIdentity = newTokensDestination, mintingAllowChoosingDestination = mintingAllowChoosing, distributionChangeRules = distributionChange?.toJson(), @@ -149,7 +155,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..ab4288778d0 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,42 @@ 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] stays a decimal string because u64 values + * overflow Long. + */ +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 non-negative integer `amount`. */ + 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 + val amount = try { + java.math.BigInteger(content) + } catch (_: NumberFormatException) { + return null + } + if (amount.signum() < 0) return null + return TokenOncePerIdentityDistribution(amount.toString()) + } + } +} 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..9eb27a88caa 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 @@ -598,6 +598,9 @@ private fun ClaimForm( val available = buildList { if (token.perpetualDistribution != null) add(TokenDistributionType.PERPETUAL) if (token.preProgrammedDistribution != null) add(TokenDistributionType.PRE_PROGRAMMED) + if (token.oncePerIdentityDistribution != null) { + add(TokenDistributionType.ONCE_PER_IDENTITY) + } } var selectedOrdinal by rememberSaveable { mutableStateOf((available.firstOrNull() ?: TokenDistributionType.PERPETUAL).ordinal) 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..78298d6d818 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 @@ -12,7 +12,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 +29,7 @@ class TokenActionResolverClaimTest { perpetual: String? = null, destination: ByteArray? = null, mintingAllowChoosing: Boolean = true, + oncePerIdentity: String? = null, ) = TokenEntity( id = ByteArray(36), contractId = ByteArray(32), @@ -36,9 +38,10 @@ 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 = @@ -49,6 +52,9 @@ class TokenActionResolverClaimTest { 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 +131,24 @@ 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 `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..588dd0f1506 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/tokens/TokenMaterializerOncePerIdentityTest.kt @@ -0,0 +1,118 @@ +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 `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 `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/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/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift index 8fc55088a53..df3adfcfd55 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift @@ -497,6 +497,35 @@ public struct DataContractParser { 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; + /// `stringifyDistributionAmount` normalises both 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 + /// no readable `amount`. 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. + /// + /// 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 = stringifyDistributionAmount(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 +655,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..31b4217f8c4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift @@ -184,8 +184,47 @@ 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. + public var oncePerIdentityDistribution: TokenOncePerIdentityDistribution? { + guard let contract = dataContract, + let tokens = contract.parsedContract?["tokens"] as? [String: Any], + let tokenDict = tokens[String(position)] as? [String: Any], + let distributionRules = tokenDict["distributionRules"] as? [String: Any] else { + return nil + } + return DataContractParser.parseOncePerIdentityDistribution( + distributionRules["oncePerIdentityDistribution"] + ) + } + + /// True when the token carries any distribution kind. + /// + /// The two column-backed kinds are checked first on purpose: `||` short + /// circuits, so a token that already has one never pays for the contract + /// JSON decode behind `oncePerIdentityDistribution`. public var hasDistribution: Bool { - perpetualDistribution != nil || preProgrammedDistribution != nil + perpetualDistribution != nil + || preProgrammedDistribution != nil + || oncePerIdentityDistribution != nil } public var canChangeTradeMode: Bool { @@ -335,6 +374,11 @@ extension PersistentToken { } } + /// 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. public static func distributionTokensPredicate() -> Predicate { #Predicate { token in token.perpetualDistribution != nil || token.preProgrammedDistribution != nil diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift index 7c1d8857123..fdad7f728b1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift @@ -117,6 +117,33 @@ 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 +/// `Int64.max` arrive as JSON strings and must survive verbatim, which a +/// fixed-width or floating-point carrier could not guarantee. +/// +/// 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/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index 4a32f2fc2cf..66b6bc8cf0b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -573,6 +573,7 @@ enum TokenActionResolver { let allowsChoosing = token.mintingAllowChoosingDestination let hasPerpetual = token.perpetualDistribution != nil let hasPreProgrammed = token.preProgrammedDistribution != nil + let hasOncePerIdentity = token.oncePerIdentityDistribution != nil // Match the rest of the screen's pattern: rows stay visible // and surface a denial reason instead of disappearing — @@ -585,10 +586,21 @@ enum TokenActionResolver { // nothing to claim — surface that as the disabled-state // reason rather than offering a tappable action that // would round-trip into a "no distribution" error. - if !hasPerpetual && !hasPreProgrammed { + if !hasPerpetual && !hasPreProgrammed && !hasOncePerIdentity { return .denied(reason: "Token has no distribution schedule") } + // A once-per-identity distribution pays a fixed amount to every + // identity, so there is no recipient list to match and no + // designated-recipient guard to apply: eligibility is universal. + // Whether this identity has already taken its single claim is + // enforced on-chain by Drive when the claim state transition is + // submitted, the same way maturity and already-claimed status are + // left to Drive for the pre-programmed case below. + if hasOncePerIdentity { + return .allowed + } + // Pre-programmed distributions pin their payouts to explicit // recipient identities at each scheduled timestamp rather than // to `newTokensDestinationIdentity` / `mintingAllowChoosingDestination` diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift index 6ecc98ca2a5..2b1a5970b0d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift @@ -4,12 +4,12 @@ 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`) driven by which distributions the token has, plus +/// an optional public note. When only one is configured the picker +/// auto-selects it and is disabled; when none is configured 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 @@ -35,17 +35,20 @@ struct TokenClaimActionView: View { init(token: PersistentToken, identity: PersistentIdentity) { 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`. + // Default to whichever distribution is present; perpetual wins + // when several exist (matches Drive's claim ordering), then + // pre-programmed, then once-per-identity. When none 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 + let oncePerIdentity = token.oncePerIdentityDistribution != nil if perpetual { self._selectedDistribution = State(initialValue: .perpetual) } else if preProgrammed { self._selectedDistribution = State(initialValue: .preProgrammed) + } else if oncePerIdentity { + self._selectedDistribution = State(initialValue: .oncePerIdentity) } else { self._selectedDistribution = State(initialValue: .perpetual) } @@ -126,6 +129,9 @@ struct TokenClaimActionView: View { if token.preProgrammedDistribution != nil { types.append(.preProgrammed) } + if token.oncePerIdentityDistribution != nil { + types.append(.oncePerIdentity) + } return types } 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/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift new file mode 100644 index 00000000000..66365d85c67 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift @@ -0,0 +1,274 @@ +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. +/// +/// 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`, well above `Int64.max`. A value + /// this large only ever arrives as a JSON string. + private let hugeAmount = "18446744073709551615" + + 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 `PersistentDataContract.parsedContract`. + @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. + func testAmountAboveInt64MaxPreservedVerbatim() throws { + let context = try makeContext() + + let token = try parseSingleToken( + tokenDict: tokenDict(oncePerIdentity: [ + "$formatVersion": "0", + "amount": hugeAmount + ]), + in: context + ) + + let distribution = try XCTUnwrap(token.oncePerIdentityDistribution) + XCTAssertEqual(distribution.amount, hugeAmount) + XCTAssertNil( + Int64(distribution.amount), + "fixture must exceed Int64.max for this test to mean anything" + ) + } + + // 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": hugeAmount + ])?.amount, + hugeAmount + ) + } +} From 452c2e876ed1d35441221b51065e87abef16ad7e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 11:39:31 +0700 Subject: [PATCH 2/6] fix(sdk): once-per-identity claims on Android default to the eligible kind The Claim row opens for every identity once a token has a once-per-identity distribution, but the claim form still preselected perpetual, then pre-programmed. On a token that also pays a perpetual distribution to its owner, a stranger landed on Perpetual and its claim was a paid wrong-claimant rejection. `TokenActionResolver.preferredClaimDistribution` now picks the kind that makes the identity eligible under the resolver's own rules, and the form uses it until the user picks another. A second once-per-identity claim is a paid rejection too (40722) and Platform has no query for it, so `OncePerIdentityClaimStore` remembers on the device a claim that succeeded or came back as already claimed. The resolver then stops counting the kind for that identity, denies with a reason when it was the only one, and the form stops offering it. Rows materialized before Room version 14 kept a NULL block and `hasDistribution = false` although their stored contract may carry one. A one-time bootstrap pass fills the two columns in from the stored contract JSON through a DAO update that leaves the rest of the row alone. The amount parser reuses the new `TokenAmounts.parseRaw` and admits only 1 to i64::MAX, the range rs-dpp validates at registration. `DashDatabaseTest` pinned the schema at version 13 and failed CI; it now expects 14 and checks the new nullable tokens column. Co-Authored-By: Claude Fable 5.1 --- .../dashfoundation/example/di/AppContainer.kt | 29 ++++ .../tokens/OncePerIdentityClaimStore.kt | 60 +++++++++ .../services/tokens/TokenActionResolver.kt | 74 ++++++++++- .../example/services/tokens/TokenAmounts.kt | 11 ++ .../services/tokens/TokenMaterializer.kt | 23 ++++ .../example/services/tokens/TokenRules.kt | 18 +-- .../ui/tokens/TokenActionPermissionsScreen.kt | 17 ++- .../example/ui/tokens/TokenActionScreens.kt | 65 +++++---- .../tokens/TokenActionResolverClaimTest.kt | 124 +++++++++++++++++- .../TokenMaterializerOncePerIdentityTest.kt | 52 ++++++-- .../dashsdk/persistence/dao/TokenDao.kt | 12 ++ .../dashsdk/persistence/DashDatabaseTest.kt | 30 ++++- 12 files changed, 461 insertions(+), 54 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/OncePerIdentityClaimStore.kt 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..d99fa8033c2 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/OncePerIdentityClaimStore.kt @@ -0,0 +1,60 @@ +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 + + /** + * True when [error] is the already-claimed rejection. The native + * layer surfaces consensus errors as text, so match the code and the + * message rs-dpp renders for it. + */ + fun isAlreadyClaimed(error: Throwable): Boolean { + val message = generateSequence(error) { it.cause } + .mapNotNull { it.message } + .joinToString(" ") + return message.contains(ALREADY_CLAIMED_ERROR_CODE.toString()) || + message.contains("already claimed the once-per-identity distribution") + } + + private fun preferenceKey(networkRaw: Int, tokenId: ByteArray, identityId: ByteArray) = + booleanPreferencesKey( + "once_per_identity_claimed.$networkRaw.${tokenId.toHex()}.${identityId.toHex()}", + ) + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenActionResolver.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenActionResolver.kt index 5d175806657..11657f8ad53 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenActionResolver.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenActionResolver.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.json.jsonObject import org.dashfoundation.dashsdk.persistence.entities.DataContractEntity 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.dashfoundation.example.util.LenientJson @@ -224,10 +225,16 @@ object TokenActionEvaluator { */ object TokenActionResolver { + /** + * [oncePerIdentityClaimed] is what the device knows about [identity]'s + * single once-per-identity claim on [token] + * ([OncePerIdentityClaimStore]); Platform has no query for it yet. + */ fun resolve( token: TokenEntity, identity: IdentityEntity, contract: DataContractEntity?, + oncePerIdentityClaimed: Boolean = false, ): List { 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,6 +433,7 @@ object TokenActionResolver { private fun resolveClaim( token: TokenEntity, identity: IdentityEntity, + oncePerIdentityClaimed: Boolean, ): TokenActionPermission { val hasPerpetual = token.perpetualDistribution != null val hasPreProgrammed = token.preProgrammedDistribution != null @@ -430,13 +441,14 @@ object TokenActionResolver { 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 that already claimed is rejected on-chain at submit time. - if (hasOncePerIdentity) { + // 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; @@ -445,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") } @@ -454,6 +473,51 @@ object TokenActionResolver { return TokenActionPermission.Denied("Distribution eligibility not yet evaluated") } + /** + * The distribution kinds the claim form offers [identity], in display + * order. A once-per-identity distribution the identity already claimed + * is left out: claiming it again is a paid rejection. + */ + fun claimableDistributions( + token: TokenEntity, + oncePerIdentityClaimed: Boolean, + ): List = buildList { + if (token.perpetualDistribution != null) add(TokenDistributionType.PERPETUAL) + if (token.preProgrammedDistribution != null) add(TokenDistributionType.PRE_PROGRAMMED) + if (token.oncePerIdentityDistribution != null && !oncePerIdentityClaimed) { + add(TokenDistributionType.ONCE_PER_IDENTITY) + } + } + + /** + * The kind the claim form preselects: the one that makes [identity] + * eligible under [resolveClaim]'s rules. The Claim row opens for every + * identity once a token has a once-per-identity distribution, so + * defaulting to perpetual there would steer an identity that is not the + * perpetual recipient into a paid wrong-claimant rejection. Perpetual + * still wins for the identity it pays, then pre-programmed for a listed + * recipient. + */ + fun preferredClaimDistribution( + token: TokenEntity, + identity: IdentityEntity, + oncePerIdentityClaimed: Boolean, + ): TokenDistributionType? { + val available = claimableDistributions(token, oncePerIdentityClaimed) + return when { + TokenDistributionType.PERPETUAL in available && + isDesignatedRecipient(token, identity) -> TokenDistributionType.PERPETUAL + TokenDistributionType.PRE_PROGRAMMED in available && + isPreProgrammedRecipient(token, identity) -> TokenDistributionType.PRE_PROGRAMMED + TokenDistributionType.ONCE_PER_IDENTITY in available -> + TokenDistributionType.ONCE_PER_IDENTITY + else -> available.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 2f9a2c92b32..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 { 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 ab4288778d0..989508909b7 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 @@ -196,8 +196,8 @@ object AuthorizedActionTakers { * [TokenMaterializer] persists in * [org.dashfoundation.dashsdk.persistence.entities.TokenEntity.oncePerIdentityDistribution] * (`{"$formatVersion":"0","amount":}`, the amount a JSON number or a - * decimal string). [amount] stays a decimal string because u64 values - * overflow Long. + * decimal string). [amount] is a decimal string like every other raw token + * amount in the app. */ data class TokenOncePerIdentityDistribution(val amount: String) { @@ -212,18 +212,18 @@ data class TokenOncePerIdentityDistribution(val amount: String) { } } - /** Null when the block carries no non-negative integer `amount`. */ + /** + * Null when the block carries no `amount` the protocol admits: an + * integer from 1 to `i64::MAX`, the range rs-dpp validates at + * registration, so nothing else can come from a contract on chain. + */ 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 - val amount = try { - java.math.BigInteger(content) - } catch (_: NumberFormatException) { - return null - } - if (amount.signum() < 0) return null + val amount = TokenAmounts.parseRaw(content)?.toULong() ?: return null + if (amount == 0UL || amount > Long.MAX_VALUE.toULong()) return null return TokenOncePerIdentityDistribution(amount.toString()) } } 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 9eb27a88caa..b5823aee679 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,20 +596,24 @@ 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) - if (token.oncePerIdentityDistribution != null) { - add(TokenDistributionType.ONCE_PER_IDENTITY) - } - } - 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, claimed) + // The user's pick once made; until then the kind that makes this + // identity eligible (see `preferredClaimDistribution`). + 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", @@ -623,15 +629,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) + } } }, ) { @@ -649,7 +668,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/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 78298d6d818..ca258e0c0b3 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 @@ -44,11 +46,22 @@ class TokenActionResolverClaimTest { 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}}}""" @@ -149,6 +162,113 @@ class TokenActionResolverClaimTest { ) } + @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, + ) + } + + @Test + fun `preferred kind is the one that makes the identity eligible`() { + val perpetualAndOnce = token( + perpetual = "{}", + destination = recipientId, + oncePerIdentity = oncePerIdentityJson(), + ) + // A stranger is only eligible through the once-per-identity kind: + // defaulting to perpetual would be a paid wrong-claimant rejection. + assertEquals( + TokenDistributionType.ONCE_PER_IDENTITY, + preferred(perpetualAndOnce, identity(strangerId)), + ) + // The identity the perpetual distribution pays keeps perpetual first. + assertEquals(TokenDistributionType.PERPETUAL, preferred(perpetualAndOnce, identity())) + + val preProgrammedAndOnce = + token(preProgrammed = preProgrammedJson(), oncePerIdentity = oncePerIdentityJson()) + assertEquals( + TokenDistributionType.PRE_PROGRAMMED, + preferred(preProgrammedAndOnce, identity()), + ) + assertEquals( + TokenDistributionType.ONCE_PER_IDENTITY, + preferred(preProgrammedAndOnce, identity(strangerId)), + ) + assertEquals( + TokenDistributionType.ONCE_PER_IDENTITY, + preferred(token(oncePerIdentity = oncePerIdentityJson()), identity(strangerId)), + ) + } + + @Test + fun `claimed once-per-identity distribution is no longer offered`() { + val perpetualAndOnce = token( + perpetual = "{}", + destination = recipientId, + oncePerIdentity = oncePerIdentityJson(), + ) + assertEquals( + listOf(TokenDistributionType.PERPETUAL), + TokenActionResolver.claimableDistributions( + perpetualAndOnce, oncePerIdentityClaimed = true, + ), + ) + // Nothing left that makes the stranger eligible: fall back to what exists. + assertEquals( + TokenDistributionType.PERPETUAL, + preferred(perpetualAndOnce, identity(strangerId), oncePerIdentityClaimed = true), + ) + assertNull( + preferred( + token(oncePerIdentity = oncePerIdentityJson()), + identity(strangerId), + 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 index 588dd0f1506..b14fd64e570 100644 --- 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 @@ -13,8 +13,9 @@ 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. + * [TokenOncePerIdentityDistribution.parse] reads the amount back as a + * decimal string whether the contract encoded it as a number or a string, + * and only inside the protocol's 1 to i64::MAX range. */ class TokenMaterializerOncePerIdentityTest { @@ -59,22 +60,32 @@ class TokenMaterializerOncePerIdentityTest { } @Test - fun `amounts above Long MAX_VALUE survive verbatim as number and as string`() { - val huge = "18446744073709551615" // UInt64.max + fun `the largest protocol amount survives verbatim as number and as string`() { + val max = Long.MAX_VALUE.toString() // i64::MAX, the most rs-dpp admits - val asNumber = parseSingleToken(block(huge)) + val asNumber = parseSingleToken(block(max)) assertEquals( - huge, + max, TokenOncePerIdentityDistribution.parse(asNumber.oncePerIdentityDistribution)?.amount, ) - val asString = parseSingleToken(block("\"$huge\"")) + val asString = parseSingleToken(block("\"$max\"")) assertEquals( - huge, + max, TokenOncePerIdentityDistribution.parse(asString.oncePerIdentityDistribution)?.amount, ) } + @Test + fun `parse rejects amounts outside the protocol range`() { + // rs-dpp admits 1..=i64::MAX, so nothing else can come from a contract on chain. + assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":0}""")) + assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":"9223372036854775808"}""")) + assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":"18446744073709551615"}""")) + assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":"18446744073709551616"}""")) + assertEquals("1", TokenOncePerIdentityDistribution.parse("""{"amount":1}""")?.amount) + } + @Test fun `absent block leaves the column null and hasDistribution false`() { val token = parseSingleToken("""{"mintingAllowChoosingDestination":true}""") @@ -111,6 +122,31 @@ class TokenMaterializerOncePerIdentityTest { 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", + ), + ), + ), + ) + assertFalse( + OncePerIdentityClaimStore.isAlreadyClaimed( + IllegalStateException("Token mint past max supply"), + ), + ) + } + @Test fun `V0-wrapped block is unwrapped`() { assertEquals("7", TokenOncePerIdentityDistribution.parse("""{"V0":{"amount":7}}""")?.amount) 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/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)) From 5708e559b2fbee669f369a159c81c3eeab1396ea Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 11:56:53 +0700 Subject: [PATCH 3/6] fix(sdk): once-per-identity claims on iOS default to the eligible kind The Claim row opens for every identity once a token has a once-per-identity distribution, but the claim form still preselected perpetual, then pre-programmed, so a stranger to a token that also pays its owner a perpetual distribution landed on Perpetual and paid for a wrong-claimant rejection. `TokenActionResolver.preferredClaimDistribution` picks the kind that makes the identity eligible under the resolver's own rules, and the form starts there. A second once-per-identity claim is a paid rejection too and Platform has no query for it, so a small UserDefaults-backed store remembers a claim that succeeded or came back as already claimed. The resolver stops counting the kind for that identity, denies with a reason when it was the only one, and the form stops offering it. The store sits behind a protocol so the resolver stays pure; its rules and the form default are covered by new app tests. `hasDistribution` fell through to the derived property, which decoded the whole contract JSON on every read, for every token without a column-backed distribution, per list row and per filter pass. A lock-guarded memo keyed by contract id, payload size and `lastUpdated` decodes each contract payload once. No stored property is added, so the frozen schema is untouched. The parser admits only an integer from 1 to Int64.max, the range rs-dpp validates at registration, where it used to take any string verbatim. `distributionTokensPredicate()` can not see the derived kind, so it is renamed `columnBackedDistributionTokensPredicate()` with the old name kept as a deprecated wrapper, and the unused `TokenFilter.predicate` is removed. Co-Authored-By: Claude Fable 5.1 --- .../Core/Utils/DataContractParser.swift | 62 ++- .../Persistence/Models/PersistentToken.swift | 48 +- ...okenOncePerIdentityDistributionCache.swift | 134 ++++++ .../Persistence/Types/TokenTypes.swift | 9 +- .../Services/OncePerIdentityClaimStore.swift | 96 ++++ .../Views/TokenActionPermissionsView.swift | 136 +++++- .../TokenActions/TokenClaimActionView.swift | 90 +++- .../Views/TokenSearchView.swift | 23 +- .../TokenClaimResolverTests.swift | 435 ++++++++++++++++++ ...taContractParserOncePerIdentityTests.swift | 211 ++++++++- 10 files changed, 1140 insertions(+), 104 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/TokenOncePerIdentityDistributionCache.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift index df3adfcfd55..00bcbc59513 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift @@ -497,20 +497,66 @@ public struct DataContractParser { return nil } + /// Render a once-per-identity `amount` to a canonical decimal string, + /// accepting only what the protocol admits: an integer in + /// `1 ... Int64.max`. + /// + /// rs-dpp's `validate_once_per_identity_distribution` rejects `0` (every + /// claim would be a paid no-op) and anything above `i64::MAX` (a claim + /// mints into a signed balance), so no contract the chain accepted can + /// carry a value outside that range, and anything outside it in a + /// persisted payload is garbage rather than a distribution. + /// + /// Deliberately 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. Android's `TokenAmounts.parseRaw` applies the same range, so + /// iOS has to as well or the two apps disagree about which tokens are + /// claimable. + /// + /// JSON booleans bridge to `NSNumber` and would otherwise pass as 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 { + // `Int64(_:)` rejects a fractional, negative or non-numeric + // string and anything above `Int64.max`; re-rendering the parsed + // value drops leading zeros and a leading `+`, the same + // canonical form Android's `BigInteger.toString()` produces. + guard let parsed = Int64(string.trimmingCharacters(in: .whitespaces)), + parsed > 0 else { + return nil + } + return String(parsed) + } + // `NSNumber` covers every numeric JSON value. `Int64(exactly:)` + // fails on a fractional number and on one outside `Int64`, which + // with the positivity check is exactly the set to reject. + if let number = value as? NSNumber, let exact = Int64(exactly: number), exact > 0 { + 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; - /// `stringifyDistributionAmount` normalises both to an exact decimal - /// string, which is what the value type carries. + /// `amount` is a protocol `u64` capped at `i64::MAX` by validation, 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 - /// no readable `amount`. 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. + /// an `amount` outside `1 ... Int64.max`. 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. /// /// This is the single place that shape is parsed: /// `PersistentToken.oncePerIdentityDistribution` derives its value by @@ -520,7 +566,7 @@ public struct DataContractParser { ) -> TokenOncePerIdentityDistribution? { guard let dict = value as? [String: Any], let amountValue = dict["amount"], - let amount = stringifyDistributionAmount(amountValue) else { + let amount = oncePerIdentityAmount(amountValue) else { return nil } return TokenOncePerIdentityDistribution(amount: amount) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift index 31b4217f8c4..ef696034fdc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift @@ -203,24 +203,26 @@ extension PersistentToken { /// nothing to derive from. /// /// Parsing lives in `DataContractParser.parseOncePerIdentityDistribution` - /// so the derived read and the contract parser agree on the wire shape. + /// 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, - let tokens = contract.parsedContract?["tokens"] as? [String: Any], - let tokenDict = tokens[String(position)] as? [String: Any], - let distributionRules = tokenDict["distributionRules"] as? [String: Any] else { - return nil - } - return DataContractParser.parseOncePerIdentityDistribution( - distributionRules["oncePerIdentityDistribution"] + 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 on purpose: `||` short - /// circuits, so a token that already has one never pays for the contract - /// JSON decode behind `oncePerIdentityDistribution`. + /// 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 @@ -379,12 +381,32 @@ extension PersistentToken { /// see `oncePerIdentityDistribution`, which is derived from the owning /// contract's JSON. Filter in memory on `hasDistribution` when that kind /// has to count. - public static func distributionTokensPredicate() -> Predicate { + /// + /// 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 fdad7f728b1..b599f9947c1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift @@ -125,10 +125,11 @@ public struct DistributionEvent: Codable, Equatable, Sendable { /// 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 -/// `Int64.max` arrive as JSON strings and must survive verbatim, which a -/// fixed-width or floating-point carrier could not guarantee. +/// `amount` is a protocol `u64` that validation caps at `i64::MAX`, 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 floating-point +/// carrier could not hand them back digit for digit. /// /// Unlike `TokenPerpetualDistribution` and `TokenPreProgrammedDistribution` /// this value has no column on `PersistentToken`: it is derived from the 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..11cc7ea9b94 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift @@ -0,0 +1,96 @@ +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. +/// +/// The consensus error's numeric code (40722) does not survive the trip: the +/// FFI hands Swift a `PlatformWalletError` carrying a rendered message, and +/// rs-drive-abci puts the consensus error's `Display` text in it. So the +/// match is on that text, which rs-dpp spells "identity '' already +/// claimed the once-per-identity distribution of token '' at ". The +/// type name is checked too, in case a path renders the variant rather than +/// its message. +/// +/// A miss is not fatal in either direction: missing the error only means the +/// kind stays offered until the next attempt, and the phrase is specific +/// enough that a false positive would take a deliberately crafted message. +enum OncePerIdentityClaimRejection { + private static let messageSignals = [ + "already claimed the once-per-identity distribution", + "tokenonceperidentitydistributionalreadyclaimederror" + ] + + static func isAlreadyClaimed(_ error: Error) -> Bool { + let message = error.localizedDescription.lowercased() + return messageSignals.contains { message.contains($0) } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index 66b6bc8cf0b..1f627a35102 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,15 +566,125 @@ 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) + } + + /// Which distribution kind a claim form should start on for this + /// identity, or nil when there is nothing it could claim. + /// + /// Drive's claim ordering (perpetual, then pre-programmed, then + /// once-per-identity) only applies among the kinds the identity is + /// actually 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. + /// + /// Falling back to the first kind the token declares is the old + /// behaviour, kept for when no kind is known to fit: the form still + /// needs a valid picker value, and its own submit gate keeps an + /// impossible claim from being sent. + /// + /// Shares its eligibility rules with `resolveClaim` so the row's + /// permission and the form's default cannot drift apart. + static func preferredClaimDistribution( + token: PersistentToken, + identity: PersistentIdentity, + claims: OncePerIdentityClaimReading = OncePerIdentityClaimStore.shared + ) -> TokenDistributionType? { + let hasPerpetual = token.perpetualDistribution != nil + let hasPreProgrammed = token.preProgrammedDistribution != nil + + if hasPerpetual, isPinnedDistributionRecipient(token: token, identity: identity) { + return .perpetual + } + if hasPreProgrammed, isPreProgrammedRecipient(token: token, identity: identity) { + return .preProgrammed + } + if canClaimOncePerIdentity(token: token, identity: identity, claims: claims) { + return .oncePerIdentity + } + + if hasPerpetual { return .perpetual } + if hasPreProgrammed { return .preProgrammed } + return nil + } + + 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 - let hasOncePerIdentity = token.oncePerIdentityDistribution != nil // Match the rest of the screen's pattern: rows stay visible // and surface a denial reason instead of disappearing — @@ -586,21 +697,10 @@ enum TokenActionResolver { // nothing to claim — surface that as the disabled-state // reason rather than offering a tappable action that // would round-trip into a "no distribution" error. - if !hasPerpetual && !hasPreProgrammed && !hasOncePerIdentity { + if !hasPerpetual && !hasPreProgrammed { return .denied(reason: "Token has no distribution schedule") } - // A once-per-identity distribution pays a fixed amount to every - // identity, so there is no recipient list to match and no - // designated-recipient guard to apply: eligibility is universal. - // Whether this identity has already taken its single claim is - // enforced on-chain by Drive when the claim state transition is - // submitted, the same way maturity and already-claimed status are - // left to Drive for the pre-programmed case below. - if hasOncePerIdentity { - return .allowed - } - // Pre-programmed distributions pin their payouts to explicit // recipient identities at each scheduled timestamp rather than // to `newTokensDestinationIdentity` / `mintingAllowChoosingDestination` @@ -613,9 +713,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 2b1a5970b0d..5695f0bd78d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift @@ -5,11 +5,13 @@ import SwiftDashSDK /// Form for claiming a token distribution payout. /// /// Inputs: distribution-type picker (`Perpetual` / `PreProgrammed` / -/// `OncePerIdentity`) driven by which distributions the token has, plus -/// an optional public note. When only one is configured the picker -/// auto-selects it and is disabled; when none is configured the form -/// refuses to submit. Claim is not group-gated, so there's no -/// group-action banner. +/// `OncePerIdentity`) driven by which distributions the token has and +/// which of them this identity can claim, plus an optional public note. +/// The picker starts on the kind the identity is eligible for rather than +/// on the token's first, because Drive charges for a claim it rejects. +/// 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,36 +24,49 @@ 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 distribution is present; perpetual wins - // when several exist (matches Drive's claim ordering), then - // pre-programmed, then once-per-identity. When none 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 - let oncePerIdentity = token.oncePerIdentityDistribution != nil - if perpetual { - self._selectedDistribution = State(initialValue: .perpetual) - } else if preProgrammed { - self._selectedDistribution = State(initialValue: .preProgrammed) - } else if oncePerIdentity { - self._selectedDistribution = State(initialValue: .oncePerIdentity) - } else { - self._selectedDistribution = State(initialValue: .perpetual) - } + self.claims = claims + // Start on the kind this identity can actually claim, which is not + // always the first kind the token declares: see + // `TokenActionResolver.preferredClaimDistribution`. It returns nil + // only when nothing fits, and `.perpetual` then keeps the picker + // valid; submission is gated by `availableDistributions`. + 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 { @@ -129,7 +144,9 @@ struct TokenClaimActionView: View { if token.preProgrammedDistribution != nil { types.append(.preProgrammed) } - if token.oncePerIdentityDistribution != nil { + // An identity gets one claim of this kind ever, so once it is spent + // the option is gone for this identity rather than merely ineligible. + if token.oncePerIdentityDistribution != nil && !oncePerIdentityClaimed { types.append(.oncePerIdentity) } return types @@ -188,16 +205,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/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..73067f0760d --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift @@ -0,0 +1,435 @@ +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") + ) + 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.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 what the form must start on. Preselecting 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.preferredClaimDistribution( + token: token, + identity: stranger, + claims: claims + ), + .oncePerIdentity + ) + } + + /// The pinned recipient 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.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.preferredClaimDistribution( + token: token, + identity: recipient, + claims: claims + ), + .preProgrammed + ) + } + + /// An identity absent from the schedule falls back to the universal + /// kind rather than to 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.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") + ) + 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.preferredClaimDistribution( + token: token, + identity: other, + claims: claims + ), + .oncePerIdentity + ) + } + + /// Spending the single claim does not take away an eligibility the + /// identity holds through another kind. + 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.preferredClaimDistribution( + token: token, + identity: owner, + claims: claims + ), + .perpetual + ) + } + + /// A stranger whose single claim is spent loses the only eligibility it + /// had, and is told which one rather than being handed the perpetual + /// kind's "not the designated recipient" reason. + 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") + ) + } + + // 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. + 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" + ) + ) + ) + } +} + +/// 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 index 66365d85c67..17bef24817c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift @@ -14,9 +14,11 @@ import SwiftData /// } /// ``` /// -/// `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. +/// `amount` is a protocol `u64` that rs-dpp's +/// `validate_once_per_identity_distribution` narrows to `1 ... i64::MAX`, 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 +/// anything outside the range must read as "no distribution". /// /// Unlike the perpetual and pre-programmed kinds this one has no column on /// `PersistentToken`: `DashSchemaV5` is frozen, and a new stored property @@ -31,9 +33,17 @@ final class DataContractParserOncePerIdentityTests: XCTestCase { private let contractId = Data(repeating: 0xEF, count: 32) - /// 18446744073709551615 == `UInt64.max`, well above `Int64.max`. A value - /// this large only ever arrives as a JSON string. - private let hugeAmount = "18446744073709551615" + /// 9223372036854775807 == `Int64.max`, the largest amount rs-dpp's + /// `validate_once_per_identity_distribution` admits. Above 2^53 - 1, so + /// it also exercises the decimal-string encoding. + private let maxAmount = "9223372036854775807" + + /// One past the protocol's ceiling. + private let aboveMaxAmount = "9223372036854775808" + + /// `UInt64.max`: fits the wire type, but no contract on chain can carry + /// it. + private let uInt64MaxAmount = "18446744073709551615" private func makeContext() throws -> ModelContext { let container = try DashModelContainer.createInMemory() @@ -47,7 +57,7 @@ final class DataContractParserOncePerIdentityTests: XCTestCase { /// 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 `PersistentDataContract.parsedContract`. + /// it back through `TokenOncePerIdentityDistributionCache`. @discardableResult private func parseTokens( _ tokens: [String: Any], @@ -139,25 +149,60 @@ final class DataContractParserOncePerIdentityTests: XCTestCase { 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. - func testAmountAboveInt64MaxPreservedVerbatim() throws { + /// The largest amount the protocol admits survives with every digit + /// intact, as a JSON string and as a JSON number: no truncation, no + /// overflow, no round trip through a floating-point type. + func testLargestProtocolAmountPreservedVerbatim() throws { let context = try makeContext() - let token = try parseSingleToken( + let asString = try parseSingleToken( tokenDict: tokenDict(oncePerIdentity: [ "$formatVersion": "0", - "amount": hugeAmount + "amount": maxAmount ]), in: context ) + XCTAssertEqual( + try XCTUnwrap(asString.oncePerIdentityDistribution).amount, + maxAmount + ) + XCTAssertGreaterThan( + try XCTUnwrap(Int64(maxAmount)), + 1 << 53, + "fixture must exceed the exactly-representable JSON number range" + ) - let distribution = try XCTUnwrap(token.oncePerIdentityDistribution) - XCTAssertEqual(distribution.amount, hugeAmount) - XCTAssertNil( - Int64(distribution.amount), - "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, + maxAmount + ) + } + + /// An amount the protocol cannot carry reads as "no once-per-identity + /// distribution" rather than as a claimable one. rs-dpp admits + /// `1 ... i64::MAX`, so a bigger value never came from a contract the + /// chain accepted. + func testAmountAboveProtocolMaximumIsRejected() throws { + let context = try makeContext() + + let token = try parseSingleToken( + tokenDict: tokenDict(oncePerIdentity: [ + "$formatVersion": "0", + "amount": aboveMaxAmount + ]), + in: context ) + + XCTAssertNil(token.oncePerIdentityDistribution) + XCTAssertFalse(token.hasDistribution) } // MARK: - 2. Presence drives `hasDistribution` @@ -266,9 +311,137 @@ final class DataContractParserOncePerIdentityTests: XCTestCase { XCTAssertEqual( DataContractParser.parseOncePerIdentityDistribution([ "$formatVersion": "0", - "amount": hugeAmount + "amount": maxAmount ])?.amount, - hugeAmount + maxAmount + ) + } + + /// Everything outside the protocol's `1 ... i64::MAX` range is refused, + /// so iOS never reports a distribution Android's `TokenAmounts.parseRaw` + /// would reject. The lenient `stringifyDistributionAmount` used by the + /// pre-programmed schedule would have accepted most of these. + func testParseOncePerIdentityDistributionRejectsOutOfRangeAmounts() throws { + let rejected: [(String, Any)] = [ + ("zero, which rs-dpp refuses because every claim would be a no-op", 0), + ("zero as a string", "0"), + ("negative number", -1), + ("negative string", "-1"), + ("fractional number", 1.5), + ("fractional string", "1.5"), + ("non-numeric string", "abc"), + ("empty string", ""), + ("one past Int64.max", aboveMaxAmount), + ("UInt64.max, which fits the wire type but not the rules", uInt64MaxAmount), + ("boolean, which bridges to NSNumber and would read as 1", true) + ] + for (description, amount) in rejected { + XCTAssertNil( + DataContractParser.parseOncePerIdentityDistribution(["amount": amount]), + "should reject \(description)" + ) + } + + // The edges of the accepted range still pass, and a non-canonical + // spelling of one normalises rather than being handed back verbatim. + XCTAssertEqual( + DataContractParser.parseOncePerIdentityDistribution(["amount": 1])?.amount, + "1" + ) + XCTAssertEqual( + DataContractParser.parseOncePerIdentityDistribution(["amount": "0005"])?.amount, + "5" ) } + + // 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") + } } From c5dbccf3547c52711d3f550d6f9db2ed3926f483 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 14:36:55 +0700 Subject: [PATCH 4/6] fix(sdk): recognise the already-claimed code 40722 as a number of its own The iOS classifier only matched the message rs-dpp renders for `TokenOncePerIdentityDistributionAlreadyClaimedError`, because today's FFI error text does not carry the consensus code. It now matches 40722 too, so it keeps working if the plumbing starts surfacing the code, as Android already does. Both apps match the code only at digit boundaries. Error texts carry amounts and millisecond timestamps, and a claim time such as 1758140722000 contains the digits, so the substring match Android used would take an unrelated failure for a spent claim and hide the kind for that identity for good, since the record is never cleared. Co-Authored-By: Claude Fable 5.1 --- .../tokens/OncePerIdentityClaimStore.kt | 11 +++- .../TokenMaterializerOncePerIdentityTest.kt | 13 ++++ .../Services/OncePerIdentityClaimStore.swift | 66 +++++++++++++++---- .../TokenClaimResolverTests.swift | 52 ++++++++++++++- 4 files changed, 129 insertions(+), 13 deletions(-) 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 index d99fa8033c2..b834cbf6e55 100644 --- 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 @@ -39,6 +39,15 @@ class OncePerIdentityClaimStore( /** 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("(?' already -/// claimed the once-per-identity distribution of token '' at ". The -/// type name is checked too, in case a path renders the variant rather than -/// its message. +/// 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. /// -/// A miss is not fatal in either direction: missing the error only means the -/// kind stays offered until the next attempt, and the phrase is specific -/// enough that a false positive would take a deliberately crafted 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" @@ -91,6 +102,39 @@ enum OncePerIdentityClaimRejection { static func isAlreadyClaimed(_ error: Error) -> Bool { let message = error.localizedDescription.lowercased() - return messageSignals.contains { message.contains($0) } + 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/SwiftExampleAppTests/TokenClaimResolverTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift index 73067f0760d..bf673af6f6c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift @@ -392,7 +392,7 @@ final class TokenClaimResolverTests: XCTestCase { } /// Drive's rejection is recognised from the message, which is all the - /// FFI hands back for a consensus error. + /// FFI hands back for a consensus error today. func testAlreadyClaimedRejectionIsRecognisedFromTheMessage() { let rejection = PlatformWalletError.unknown( """ @@ -418,6 +418,56 @@ final class TokenClaimResolverTests: XCTestCase { ) ) } + + /// 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 From 58e63b584e8ae271bf7d040c4c854c725fce125c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 14:53:56 +0700 Subject: [PATCH 5/6] fix(sdk): offer only the claim kinds the identity is eligible for on Android `claimableDistributions` listed every kind the token declares, so an identity that was only eligible through the once-per-identity kind could still pick Perpetual, and once it had spent its claim the form fell back to a kind that pays someone else: an enabled submit for a guaranteed paid rejection. The list is now filtered by the resolver's own eligibility rules (perpetual for the identity it pays, pre-programmed for a listed recipient, once-per-identity while the claim is not known to be spent), the preferred kind is its first element, and with none the form refuses to submit. The amount parser no longer mirrors the protocol's 1 to i64::MAX range. The Kotlin SDK guidance forbids re-implementing protocol constants; rs-dpp owns that rule and enforced it at registration for any contract that came from chain. The parser checks only that the value is a raw u64, the type token amounts have. Co-Authored-By: Claude Fable 5.1 --- .../services/tokens/TokenActionResolver.kt | 44 ++++++++--------- .../example/services/tokens/TokenRules.kt | 11 ++--- .../example/ui/tokens/TokenActionScreens.kt | 7 +-- .../tokens/TokenActionResolverClaimTest.kt | 47 ++++++++++++------- .../TokenMaterializerOncePerIdentityTest.kt | 31 ++++++------ 5 files changed, 76 insertions(+), 64 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenActionResolver.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenActionResolver.kt index 11657f8ad53..f45a3b5d59f 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenActionResolver.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/tokens/TokenActionResolver.kt @@ -474,46 +474,42 @@ object TokenActionResolver { } /** - * The distribution kinds the claim form offers [identity], in display - * order. A once-per-identity distribution the identity already claimed - * is left out: claiming it again is a paid rejection. + * 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) add(TokenDistributionType.PERPETUAL) - if (token.preProgrammedDistribution != null) add(TokenDistributionType.PRE_PROGRAMMED) + 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 one that makes [identity] - * eligible under [resolveClaim]'s rules. The Claim row opens for every - * identity once a token has a once-per-identity distribution, so - * defaulting to perpetual there would steer an identity that is not the - * perpetual recipient into a paid wrong-claimant rejection. Perpetual - * still wins for the identity it pays, then pre-programmed for a listed - * recipient. + * 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? { - val available = claimableDistributions(token, oncePerIdentityClaimed) - return when { - TokenDistributionType.PERPETUAL in available && - isDesignatedRecipient(token, identity) -> TokenDistributionType.PERPETUAL - TokenDistributionType.PRE_PROGRAMMED in available && - isPreProgrammedRecipient(token, identity) -> TokenDistributionType.PRE_PROGRAMMED - TokenDistributionType.ONCE_PER_IDENTITY in available -> - TokenDistributionType.ONCE_PER_IDENTITY - else -> available.firstOrNull() - } - } + ): TokenDistributionType? = + claimableDistributions(token, identity, oncePerIdentityClaimed).firstOrNull() private fun isDesignatedRecipient(token: TokenEntity, identity: IdentityEntity): Boolean = token.newTokensDestinationIdentity?.contentEquals(identity.identityId) == true 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 989508909b7..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 @@ -213,18 +213,17 @@ data class TokenOncePerIdentityDistribution(val amount: String) { } /** - * Null when the block carries no `amount` the protocol admits: an - * integer from 1 to `i64::MAX`, the range rs-dpp validates at - * registration, so nothing else can come from a contract on chain. + * 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 - val amount = TokenAmounts.parseRaw(content)?.toULong() ?: return null - if (amount == 0UL || amount > Long.MAX_VALUE.toULong()) return null - return TokenOncePerIdentityDistribution(amount.toString()) + return TokenAmounts.parseRaw(content)?.let(::TokenOncePerIdentityDistribution) } } } 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 b5823aee679..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 @@ -604,9 +604,10 @@ private fun ClaimForm( claimStore.observe(identity.networkRaw, token.id, identity.identityId) }.collectAsStateWithLifecycle(initialValue = null) val claimed = oncePerIdentityClaimed ?: false - val available = TokenActionResolver.claimableDistributions(token, claimed) - // The user's pick once made; until then the kind that makes this - // identity eligible (see `preferredClaimDistribution`). + 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 } 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 ca258e0c0b3..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 @@ -210,31 +210,46 @@ class TokenActionResolverClaimTest { ) } + private fun claimable( + token: TokenEntity, + identity: IdentityEntity, + oncePerIdentityClaimed: Boolean = false, + ): List = + TokenActionResolver.claimableDistributions(token, identity, oncePerIdentityClaimed) + @Test - fun `preferred kind is the one that makes the identity eligible`() { + 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: - // defaulting to perpetual would be a paid wrong-claimant rejection. + // 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( - TokenDistributionType.PRE_PROGRAMMED, - preferred(preProgrammedAndOnce, identity()), + listOf(TokenDistributionType.PRE_PROGRAMMED, TokenDistributionType.ONCE_PER_IDENTITY), + claimable(preProgrammedAndOnce, identity()), ) assertEquals( - TokenDistributionType.ONCE_PER_IDENTITY, - preferred(preProgrammedAndOnce, identity(strangerId)), + listOf(TokenDistributionType.ONCE_PER_IDENTITY), + claimable(preProgrammedAndOnce, identity(strangerId)), ) assertEquals( TokenDistributionType.ONCE_PER_IDENTITY, @@ -243,23 +258,18 @@ class TokenActionResolverClaimTest { } @Test - fun `claimed once-per-identity distribution is no longer offered`() { + 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( - listOf(TokenDistributionType.PERPETUAL), - TokenActionResolver.claimableDistributions( - perpetualAndOnce, oncePerIdentityClaimed = true, - ), - ) - // Nothing left that makes the stranger eligible: fall back to what exists. - assertEquals( - TokenDistributionType.PERPETUAL, - preferred(perpetualAndOnce, identity(strangerId), oncePerIdentityClaimed = true), + emptyList(), + claimable(perpetualAndOnce, identity(strangerId), oncePerIdentityClaimed = true), ) + assertNull(preferred(perpetualAndOnce, identity(strangerId), oncePerIdentityClaimed = true)) assertNull( preferred( token(oncePerIdentity = oncePerIdentityJson()), @@ -267,6 +277,11 @@ class TokenActionResolverClaimTest { oncePerIdentityClaimed = true, ), ) + // The identity it pays keeps the perpetual kind after spending the claim. + assertEquals( + listOf(TokenDistributionType.PERPETUAL), + claimable(perpetualAndOnce, identity(), oncePerIdentityClaimed = true), + ) } @Test 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 index 929f4fc6a8d..684336ac439 100644 --- 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 @@ -13,9 +13,8 @@ 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 amount back as a - * decimal string whether the contract encoded it as a number or a string, - * and only inside the protocol's 1 to i64::MAX range. + * [TokenOncePerIdentityDistribution.parse] reads the u64 amount back as a + * decimal string whether the contract encoded it as a number or a string. */ class TokenMaterializerOncePerIdentityTest { @@ -60,30 +59,32 @@ class TokenMaterializerOncePerIdentityTest { } @Test - fun `the largest protocol amount survives verbatim as number and as string`() { - val max = Long.MAX_VALUE.toString() // i64::MAX, the most rs-dpp admits + fun `amounts above Long MAX_VALUE survive verbatim as number and as string`() { + val huge = "18446744073709551615" // UInt64.max - val asNumber = parseSingleToken(block(max)) + val asNumber = parseSingleToken(block(huge)) assertEquals( - max, + huge, TokenOncePerIdentityDistribution.parse(asNumber.oncePerIdentityDistribution)?.amount, ) - val asString = parseSingleToken(block("\"$max\"")) + val asString = parseSingleToken(block("\"$huge\"")) assertEquals( - max, + huge, TokenOncePerIdentityDistribution.parse(asString.oncePerIdentityDistribution)?.amount, ) } @Test - fun `parse rejects amounts outside the protocol range`() { - // rs-dpp admits 1..=i64::MAX, so nothing else can come from a contract on chain. - assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":0}""")) - assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":"9223372036854775808"}""")) - assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":"18446744073709551615"}""")) + 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"}""")) - assertEquals("1", TokenOncePerIdentityDistribution.parse("""{"amount":1}""")?.amount) } @Test From f29028ba08003ebba52c62894e4b41e1d1a1c89b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 14:58:27 +0700 Subject: [PATCH 6/6] fix(sdk): offer only the claim kinds the identity is eligible for on iOS The claim form listed every kind the token declares and `preferredClaimDistribution` fell back to perpetual or pre-programmed when nothing fit, so an identity that was only eligible through the once-per-identity kind could still pick Perpetual, and once it had spent its claim the form fell back to a kind that pays someone else: a guaranteed paid rejection. `TokenActionResolver.claimableDistributions` now lists only the kinds the identity is eligible for under the resolver's own rules, the preferred kind is its first element (nil when empty), and the form offers exactly that list. The amount parser no longer mirrors the protocol's 1 to i64::MAX range. The Swift SDK rules forbid re-implementing protocol constants; rs-dpp owns that rule and enforced it at registration for any contract that came from chain. The parser checks only that the value is a non-negative integer that fits the u64 carrier. Co-Authored-By: Claude Fable 5.1 --- .../Core/Utils/DataContractParser.swift | 65 +++++----- .../Persistence/Types/TokenTypes.swift | 12 +- .../Views/TokenActionPermissionsView.swift | 67 +++++----- .../TokenActions/TokenClaimActionView.swift | 49 ++++---- .../TokenClaimResolverTests.swift | 112 +++++++++++++++-- ...taContractParserOncePerIdentityTests.swift | 118 ++++++++++-------- 6 files changed, 269 insertions(+), 154 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift index 00bcbc59513..7fe5a40851e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift @@ -498,46 +498,43 @@ public struct DataContractParser { } /// Render a once-per-identity `amount` to a canonical decimal string, - /// accepting only what the protocol admits: an integer in - /// `1 ... Int64.max`. + /// accepting what the carrier type can hold: a non-negative integer that + /// fits in `u64`, the protocol's `TokenAmount`. /// - /// rs-dpp's `validate_once_per_identity_distribution` rejects `0` (every - /// claim would be a paid no-op) and anything above `i64::MAX` (a claim - /// mints into a signed balance), so no contract the chain accepted can - /// carry a value outside that range, and anything outside it in a - /// persisted payload is garbage rather than a distribution. + /// 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. /// - /// Deliberately 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 + /// 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. Android's `TokenAmounts.parseRaw` applies the same range, so - /// iOS has to as well or the two apps disagree about which tokens are - /// claimable. + /// have. /// - /// JSON booleans bridge to `NSNumber` and would otherwise pass as 1, so - /// they are rejected by identity against `CFBoolean` before the numeric - /// read. + /// 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 { - // `Int64(_:)` rejects a fractional, negative or non-numeric - // string and anything above `Int64.max`; re-rendering the parsed - // value drops leading zeros and a leading `+`, the same - // canonical form Android's `BigInteger.toString()` produces. - guard let parsed = Int64(string.trimmingCharacters(in: .whitespaces)), - parsed > 0 else { + // `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. `Int64(exactly:)` - // fails on a fractional number and on one outside `Int64`, which - // with the positivity check is exactly the set to reject. - if let number = value as? NSNumber, let exact = Int64(exactly: number), exact > 0 { + // `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 @@ -548,15 +545,17 @@ public struct DataContractParser { /// /// rs-dpp emits the block as /// `"oncePerIdentityDistribution": {"$formatVersion": "0", "amount": 5000}`. - /// `amount` is a protocol `u64` capped at `i64::MAX` by validation, 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. + /// `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` outside `1 ... Int64.max`. 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. + /// 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift index b599f9947c1..eeb4fb24371 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift @@ -125,11 +125,13 @@ public struct DistributionEvent: Codable, Equatable, Sendable { /// rs-dpp serialises it inside a token's `distributionRules` as /// `"oncePerIdentityDistribution": {"$formatVersion": "0", "amount": 5000}`. /// -/// `amount` is a protocol `u64` that validation caps at `i64::MAX`, 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 floating-point -/// carrier could not hand them back digit for digit. +/// `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 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift index 1f627a35102..8873e66dc7b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActionPermissionsView.swift @@ -605,45 +605,54 @@ enum TokenActionResolver { return !claims.hasClaimed(token: token, identity: identity) } - /// Which distribution kind a claim form should start on for this - /// identity, or nil when there is nothing it could claim. - /// - /// Drive's claim ordering (perpetual, then pre-programmed, then - /// once-per-identity) only applies among the kinds the identity is - /// actually 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. + /// The distribution kinds this identity could actually claim, in Drive's + /// claim order (perpetual, then pre-programmed, then once-per-identity). /// - /// Falling back to the first kind the token declares is the old - /// behaviour, kept for when no kind is known to fit: the form still - /// needs a valid picker value, and its own submit gate keeps an - /// impossible claim from being sent. + /// 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 the row's - /// permission and the form's default cannot drift apart. - static func preferredClaimDistribution( + /// 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? { - let hasPerpetual = token.perpetualDistribution != nil - let hasPreProgrammed = token.preProgrammedDistribution != nil - - if hasPerpetual, isPinnedDistributionRecipient(token: token, identity: identity) { - return .perpetual + ) -> [TokenDistributionType] { + var claimable: [TokenDistributionType] = [] + if token.perpetualDistribution != nil, + isPinnedDistributionRecipient(token: token, identity: identity) { + claimable.append(.perpetual) } - if hasPreProgrammed, isPreProgrammedRecipient(token: token, identity: identity) { - return .preProgrammed + if token.preProgrammedDistribution != nil, + isPreProgrammedRecipient(token: token, identity: identity) { + claimable.append(.preProgrammed) } if canClaimOncePerIdentity(token: token, identity: identity, claims: claims) { - return .oncePerIdentity + claimable.append(.oncePerIdentity) } + return claimable + } - if hasPerpetual { return .perpetual } - if hasPreProgrammed { return .preProgrammed } - return nil + /// 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( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift index 5695f0bd78d..95781d819d4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenActions/TokenClaimActionView.swift @@ -5,13 +5,12 @@ import SwiftDashSDK /// Form for claiming a token distribution payout. /// /// Inputs: distribution-type picker (`Perpetual` / `PreProgrammed` / -/// `OncePerIdentity`) driven by which distributions the token has and -/// which of them this identity can claim, plus an optional public note. -/// The picker starts on the kind the identity is eligible for rather than -/// on the token's first, because Drive charges for a claim it rejects. -/// 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. +/// `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 @@ -52,11 +51,13 @@ struct TokenClaimActionView: View { self.token = token self.identity = identity self.claims = claims - // Start on the kind this identity can actually claim, which is not + // 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 - // only when nothing fits, and `.perpetual` then keeps the picker - // valid; submission is gated by `availableDistributions`. + // 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, @@ -136,20 +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) - } - // An identity gets one claim of this kind ever, so once it is spent - // the option is gone for this identity rather than merely ineligible. - if token.oncePerIdentityDistribution != nil && !oncePerIdentityClaimed { - types.append(.oncePerIdentity) - } - 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 { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift index bf673af6f6c..35363a0615f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenClaimResolverTests.swift @@ -128,6 +128,14 @@ final class TokenClaimResolverTests: XCTestCase { 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, @@ -149,6 +157,14 @@ final class TokenClaimResolverTests: XCTestCase { TokenActionResolver.resolveClaim(token: token, identity: identity, claims: claims), .allowed ) + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: identity, + claims: claims + ), + [.oncePerIdentity] + ) XCTAssertEqual( TokenActionResolver.preferredClaimDistribution( token: token, @@ -161,9 +177,9 @@ final class TokenClaimResolverTests: XCTestCase { /// 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 what the form must start on. Preselecting Perpetual by - /// declaration order sends a claim Drive rejects as the wrong claimant, - /// at the user's expense. + /// 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) @@ -180,6 +196,15 @@ final class TokenClaimResolverTests: XCTestCase { 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, @@ -190,7 +215,8 @@ final class TokenClaimResolverTests: XCTestCase { ) } - /// The pinned recipient keeps Drive's ordering: perpetual first. + /// 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) @@ -206,6 +232,14 @@ final class TokenClaimResolverTests: XCTestCase { 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, @@ -232,6 +266,14 @@ final class TokenClaimResolverTests: XCTestCase { 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, @@ -242,8 +284,8 @@ final class TokenClaimResolverTests: XCTestCase { ) } - /// An identity absent from the schedule falls back to the universal - /// kind rather than to the pre-programmed one it is not listed in. + /// 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) @@ -255,6 +297,14 @@ final class TokenClaimResolverTests: XCTestCase { ) let claims = StubClaimStore() + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: stranger, + claims: claims + ), + [.oncePerIdentity] + ) XCTAssertEqual( TokenActionResolver.preferredClaimDistribution( token: token, @@ -278,6 +328,14 @@ final class TokenClaimResolverTests: XCTestCase { 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, @@ -301,6 +359,14 @@ final class TokenClaimResolverTests: XCTestCase { TokenActionResolver.resolveClaim(token: token, identity: other, claims: claims), .allowed ) + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: other, + claims: claims + ), + [.oncePerIdentity] + ) XCTAssertEqual( TokenActionResolver.preferredClaimDistribution( token: token, @@ -312,7 +378,8 @@ final class TokenClaimResolverTests: XCTestCase { } /// Spending the single claim does not take away an eligibility the - /// identity holds through another kind. + /// 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) @@ -329,6 +396,14 @@ final class TokenClaimResolverTests: XCTestCase { TokenActionResolver.resolveClaim(token: token, identity: owner, claims: claims), .allowed ) + XCTAssertEqual( + TokenActionResolver.claimableDistributions( + token: token, + identity: owner, + claims: claims + ), + [.perpetual] + ) XCTAssertEqual( TokenActionResolver.preferredClaimDistribution( token: token, @@ -340,8 +415,11 @@ final class TokenClaimResolverTests: XCTestCase { } /// A stranger whose single claim is spent loses the only eligibility it - /// had, and is told which one rather than being handed the perpetual - /// kind's "not the designated recipient" reason. + /// 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) @@ -360,6 +438,22 @@ final class TokenClaimResolverTests: XCTestCase { 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 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift index 17bef24817c..62f75619c78 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift @@ -14,11 +14,15 @@ import SwiftData /// } /// ``` /// -/// `amount` is a protocol `u64` that rs-dpp's -/// `validate_once_per_identity_distribution` narrows to `1 ... i64::MAX`, 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 -/// anything outside the range must read as "no distribution". +/// `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 @@ -33,17 +37,17 @@ final class DataContractParserOncePerIdentityTests: XCTestCase { private let contractId = Data(repeating: 0xEF, count: 32) - /// 9223372036854775807 == `Int64.max`, the largest amount rs-dpp's - /// `validate_once_per_identity_distribution` admits. Above 2^53 - 1, so - /// it also exercises the decimal-string encoding. - private let maxAmount = "9223372036854775807" + /// 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 the protocol's ceiling. - private let aboveMaxAmount = "9223372036854775808" + /// One past `UInt64.max`: no longer a `u64`, so nothing can carry it. + private let aboveUInt64MaxAmount = "18446744073709551616" - /// `UInt64.max`: fits the wire type, but no contract on chain can carry - /// it. - private let uInt64MaxAmount = "18446744073709551615" + /// 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() @@ -149,27 +153,26 @@ final class DataContractParserOncePerIdentityTests: XCTestCase { XCTAssertEqual(distribution.amount, "12345") } - /// The largest amount the protocol admits survives with every digit - /// intact, as a JSON string and as a JSON number: no truncation, no - /// overflow, no round trip through a floating-point type. - func testLargestProtocolAmountPreservedVerbatim() throws { + /// 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 asString = try parseSingleToken( + let token = try parseSingleToken( tokenDict: tokenDict(oncePerIdentity: [ "$formatVersion": "0", - "amount": maxAmount + "amount": uInt64MaxAmount ]), in: context ) XCTAssertEqual( - try XCTUnwrap(asString.oncePerIdentityDistribution).amount, - maxAmount + try XCTUnwrap(token.oncePerIdentityDistribution).amount, + uInt64MaxAmount ) - XCTAssertGreaterThan( - try XCTUnwrap(Int64(maxAmount)), - 1 << 53, - "fixture must exceed the exactly-representable JSON number range" + XCTAssertNil( + Int64(uInt64MaxAmount), + "fixture must exceed Int64.max for this test to mean anything" ) let numericContext = try makeContext() @@ -182,21 +185,20 @@ final class DataContractParserOncePerIdentityTests: XCTestCase { ) XCTAssertEqual( try XCTUnwrap(asNumber.oncePerIdentityDistribution).amount, - maxAmount + String(Int64.max) ) } - /// An amount the protocol cannot carry reads as "no once-per-identity - /// distribution" rather than as a claimable one. rs-dpp admits - /// `1 ... i64::MAX`, so a bigger value never came from a contract the - /// chain accepted. - func testAmountAboveProtocolMaximumIsRejected() throws { + /// 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": aboveMaxAmount + "amount": aboveUInt64MaxAmount ]), in: context ) @@ -311,29 +313,26 @@ final class DataContractParserOncePerIdentityTests: XCTestCase { XCTAssertEqual( DataContractParser.parseOncePerIdentityDistribution([ "$formatVersion": "0", - "amount": maxAmount + "amount": uInt64MaxAmount ])?.amount, - maxAmount + uInt64MaxAmount ) } - /// Everything outside the protocol's `1 ... i64::MAX` range is refused, - /// so iOS never reports a distribution Android's `TokenAmounts.parseRaw` - /// would reject. The lenient `stringifyDistributionAmount` used by the - /// pre-programmed schedule would have accepted most of these. - func testParseOncePerIdentityDistributionRejectsOutOfRangeAmounts() throws { + /// 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)] = [ - ("zero, which rs-dpp refuses because every claim would be a no-op", 0), - ("zero as a string", "0"), ("negative number", -1), ("negative string", "-1"), ("fractional number", 1.5), ("fractional string", "1.5"), ("non-numeric string", "abc"), ("empty string", ""), - ("one past Int64.max", aboveMaxAmount), - ("UInt64.max, which fits the wire type but not the rules", uInt64MaxAmount), - ("boolean, which bridges to NSNumber and would read as 1", true) + ("one past UInt64.max", aboveUInt64MaxAmount), + ("boolean, which bridges to NSNumber and would read as 0 or 1", true) ] for (description, amount) in rejected { XCTAssertNil( @@ -342,16 +341,25 @@ final class DataContractParserOncePerIdentityTests: XCTestCase { ) } - // The edges of the accepted range still pass, and a non-canonical - // spelling of one normalises rather than being handed back verbatim. - XCTAssertEqual( - DataContractParser.parseOncePerIdentityDistribution(["amount": 1])?.amount, - "1" - ) - XCTAssertEqual( - DataContractParser.parseOncePerIdentityDistribution(["amount": "0005"])?.amount, - "5" - ) + // 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