From ed59226cfa64ab59b55a6a2f54895321ac426a95 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:11:17 -0400 Subject: [PATCH 01/10] feat(kotlin-sdk): L1 invitation create/claim JNI bridge + DIP-13 invitation persistence Adds the Android JNI + kotlin-sdk bridge for the existing (iOS-shipped) DIP-13 invitation FFI (create_invitation / claim_invitation), plus durable invitation persistence. - rs-unified-sdk-jni: JNI createInvitation/claimInvitation marshalers over platform_wallet_create_invitation / platform_wallet_claim_invitation; wire the on_persist_invitations_fn trampoline (tramp_persist_invitations). - kotlin-sdk: IdentityNative externs, IdentityRegistration create/claim wrappers, NativePersistenceBridge invitation dispatch, and PlatformWalletPersistenceHandler overrides that declare the INVITATIONS capability (0x02) gating funded invite creation. - Room: new invitations table (InvitationEntity/InvitationDao), DashDatabase v7 -> v8 with MIGRATION_7_8 and exported 8.json. Co-Authored-By: Claude Fable 5 --- .../9.json | 4036 +++++++++++++++++ .../dashsdk/ffi/IdentityNative.kt | 55 + .../dashsdk/ffi/NativePersistenceBridge.kt | 34 + .../dashsdk/identity/IdentityRegistration.kt | 124 + .../dashsdk/persistence/DashDatabase.kt | 45 +- .../PlatformWalletPersistenceHandler.kt | 38 + .../dashsdk/persistence/dao/InvitationDao.kt | 47 + .../persistence/entities/InvitationEntity.kt | 68 + packages/rs-unified-sdk-jni/src/identity.rs | 265 ++ .../rs-unified-sdk-jni/src/persistence.rs | 96 +- 10 files changed, 4800 insertions(+), 8 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/9.json create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/InvitationDao.kt create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/InvitationEntity.kt diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/9.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/9.json new file mode 100644 index 00000000000..66bc9a1579e --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/9.json @@ -0,0 +1,4036 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "ebfbca1b1b70e97e2c6fd99da5151ba0", + "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, `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": "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, 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" + } + ], + "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}` (`outPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndex` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, PRIMARY KEY(`outPoint`))", + "fields": [ + { + "fieldPath": "outPoint", + "columnName": "outPoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingIndex", + "columnName": "fundingIndex", + "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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPoint" + ] + }, + "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, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `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": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "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, `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": "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`)" + } + ], + "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, 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 + } + ], + "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`)" + } + ], + "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, `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": "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, 'ebfbca1b1b70e97e2c6fd99da5151ba0')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt index 696f8744373..2735781aec6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt @@ -172,6 +172,61 @@ internal object IdentityNative { coreSignerHandle: Long, ): ByteArray + /** + * Create a DashPay invitation (DIP-13): fund a one-time asset-lock + * voucher and return a shareable `dashpay://invite` link. No identity is + * registered — this is pure voucher creation. + * + * @param amountDuffs voucher amount in duffs (must be positive). + * @param fundingAccountIndex BIP-44 account the voucher is funded from. + * @param inviterIdentityId optional 32-byte inviter id enabling the + * contact-bootstrap opt-in; `null` for a pure funding voucher. When + * non-null, [inviterUsername] is required. + * @param inviterUsername inviter DPNS username carried in the link (only + * used when [inviterIdentityId] is non-null). + * @param nowUnix current unix time in seconds (must be > 0); the advisory + * ~24h expiry is derived Rust-side. + * @param coreSignerHandle `MnemonicResolverHandle` for the funding-spend + * signature (the SAME handle [registerIdentityWithFunding] takes). + * @return a blob: `outpoint[36] (txid[32] || vout_le[4]) || utf8Uri`. The + * URI embeds the bearer voucher key — never log or persist it beyond + * the share sheet. + */ + external fun createInvitation( + walletHandle: Long, + amountDuffs: Long, + fundingAccountIndex: Int, + inviterIdentityId: ByteArray?, + inviterUsername: String?, + nowUnix: Long, + coreSignerHandle: Long, + ): ByteArray + + /** + * Claim a DashPay invitation (DIP-13): register a NEW identity for the + * invitee, funded by the imported voucher carried in [uri]. + * + * @param uri the `dashpay://invite?…` link (a bearer secret). + * @param identityIndex identity slot for the new identity. + * @param pubkeysBlob the invitee's new-identity key rows, SAME layout as + * [registerIdentityWithFunding] (encoded by + * [org.dashfoundation.dashsdk.identity.IdentityPubkeyCodec.encode]). + * @param signerHandle identity-key `SignerHandle`. The asset-lock's outer + * signature comes from the imported voucher key, so no Core resolver is + * needed here. + * @param nowUnix accepted for ABI parity; currently unused (the legacy + * link carries no expiry). + * @return the 32-byte new identity id. + */ + external fun claimInvitation( + walletHandle: Long, + uri: String, + identityIndex: Int, + pubkeysBlob: ByteArray, + signerHandle: Long, + nowUnix: Long, + ): ByteArray + /** * Register a new identity funded by the wallet's already-committed * Platform-payment (DIP-17) address balances — the ID-08 create path, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 1e075c5cd3e..28bd535dc04 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -486,6 +486,40 @@ abstract class NativePersistenceBridge { /** One 36-byte outpoint removal. Descriptor `([B[B)I`. */ open fun onPersistAssetLockRemoval(walletId: ByteArray, outPoint: ByteArray): Int = 0 + // ── Invitations (DIP-13) ────────────────────────────────────────── + + /** + * One `InvitationEntryFFI` upsert (`tramp_persist_invitations` in + * `persistence.rs`). Descriptor `([B[BIJJJBB)I`. + * + * Wiring this callback durably is what lets `FFIPersister` report the + * `INVITATIONS` capability, which the Rust `create_invitation` durability + * gate requires before it moves any funds — a no-op override would defeat + * the gate and risk re-exporting a one-time voucher key after a restart. + * + * @param outPoint 36-byte funding outpoint (`txid[32] || vout_le[4]`). + * @param fundingIndex DIP-13 funding index the voucher key derives from + * (unsigned, carried in an `Int`). + * @param expiryUnix advisory expiry, unix seconds (widened to `Long`). + * @param createdAtSecs creation time, unix seconds (widened to `Long`). + * @param hasInviter 1 if the link carries inviter/contact-bootstrap info, else 0. + * @param status 0 = Created, 1 = Claimed, 2 = Reclaimed. + */ + @Suppress("LongParameterList") + open fun onPersistInvitationUpsert( + walletId: ByteArray, + outPoint: ByteArray, + fundingIndex: Int, + amountDuffs: Long, + expiryUnix: Long, + createdAtSecs: Long, + hasInviter: Byte, + status: Byte, + ): Int = 0 + + /** One 36-byte outpoint removal. Descriptor `([B[B)I`. */ + open fun onPersistInvitationRemoval(walletId: ByteArray, outPoint: ByteArray): Int = 0 + // ── Shielded persist ────────────────────────────────────────────── /** One `ShieldedNoteFFI`. Descriptor `([B[BIJ[B[BJBJ[B)I`. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt index 2f92c1497a4..b99ae82df3e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt @@ -286,6 +286,130 @@ class IdentityRegistration internal constructor( } } + /** + * A freshly created DashPay invitation (DIP-13). + * + * @property outPoint 36-byte funding outpoint (`txid[32] || vout_le[4]`) — + * the same key the persistence layer stores the invitation row under. + * @property uri the shareable `dashpay://invite` link. **This is a bearer + * secret: it embeds the one-time voucher key. Never log it or persist it + * anywhere but the OS share sheet.** + */ + data class CreatedInvitation( + val outPoint: ByteArray, + val uri: String, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CreatedInvitation) return false + return outPoint.contentEquals(other.outPoint) && uri == other.uri + } + + override fun hashCode(): Int = 31 * outPoint.contentHashCode() + uri.hashCode() + + /** Redacts the bearer URI so an accidental log/toString never leaks the voucher key. */ + override fun toString(): String = "CreatedInvitation(outPoint=<36b>, uri=)" + } + + /** + * Create a DashPay invitation (DIP-13): fund a one-time asset-lock voucher + * and return a shareable link. No identity is registered. The Rust + * durability gate refuses to run unless invitation persistence is wired, + * so this fails closed before any funds move on a backend that can't + * durably record the voucher. + * + * @param amountDuffs voucher amount in duffs (must be positive). + * @param fundingAccountIndex BIP-44 account the voucher is funded from. + * @param inviterIdentityId optional 32-byte inviter id enabling the + * contact-bootstrap opt-in; `null` for a pure funding voucher. When + * non-null, [inviterUsername] is required. + * @param inviterUsername inviter DPNS username carried in the link. + * @param nowUnix current unix time in seconds (must be > 0). + * @param coreSignerHandle `MnemonicResolverHandle` for the funding-spend + * signature (the SAME handle [registerWithWalletFunding] takes). + * @return the funding outpoint plus the bearer link — see [CreatedInvitation]. + */ + suspend fun createInvitation( + walletHandle: Long, + amountDuffs: Long, + fundingAccountIndex: Int, + inviterIdentityId: ByteArray? = null, + inviterUsername: String? = null, + nowUnix: Long, + coreSignerHandle: Long, + ): CreatedInvitation = gate.op { + require(amountDuffs > 0) { "amountDuffs must be positive, got $amountDuffs" } + require(fundingAccountIndex >= 0) { + "fundingAccountIndex must be non-negative, got $fundingAccountIndex" + } + require(nowUnix > 0) { "nowUnix must be a positive unix timestamp, got $nowUnix" } + inviterIdentityId?.let { + require(it.size == 32) { "inviterIdentityId must be 32 bytes, got ${it.size}" } + require(inviterUsername != null) { + "inviterUsername is required when inviterIdentityId is provided" + } + } + val blob = mapNativeErrors { + IdentityNative.createInvitation( + walletHandle, + amountDuffs, + fundingAccountIndex, + inviterIdentityId, + inviterUsername, + nowUnix, + coreSignerHandle, + ) + } + // Blob layout (fixed by the JNI): outpoint[36] (txid[32] || vout_le[4]) + // then the UTF-8 URI. Anything shorter is a contract violation. + require(blob.size >= 36) { + "createInvitation returned a ${blob.size}-byte blob; expected >= 36" + } + CreatedInvitation( + outPoint = blob.copyOfRange(0, 36), + uri = String(blob.copyOfRange(36, blob.size), Charsets.UTF_8), + ) + } + + /** + * Claim a DashPay invitation (DIP-13): register a NEW identity for the + * invitee, funded by the imported voucher carried in [uri]. The + * contact-bootstrap ("establish contact with the sender?") is NOT done + * here — the UI asks the invitee and calls the contact-request path on + * confirm. [keys] are the invitee's own new-identity rows (built via + * [RegistrationKeys.buildRegistrationRows]) — the SAME codec path + * [registerWithWalletFunding] uses. + * + * @param uri the `dashpay://invite?…` link (a bearer secret — never log it). + * @param identityIndex identity slot for the new identity. + * @param signerHandle identity-key `SignerHandle`. No Core resolver is + * needed: the asset-lock's outer signature comes from the voucher key. + * @param nowUnix accepted for ABI parity; currently unused Rust-side. + * @return the 32-byte new identity id. + */ + suspend fun claimInvitation( + walletHandle: Long, + uri: String, + identityIndex: Int, + keys: List, + signerHandle: Long, + nowUnix: Long, + ): ByteArray = gate.op { + require(identityIndex >= 0) { "identityIndex must be non-negative, got $identityIndex" } + require(uri.isNotBlank()) { "uri must not be blank" } + require(keys.isNotEmpty()) { "keys must not be empty" } + mapNativeErrors { + IdentityNative.claimInvitation( + walletHandle, + uri, + identityIndex, + IdentityPubkeyCodec.encode(keys), + signerHandle, + nowUnix, + ) + } + } + /** * Register a new identity funded by the wallet's already-committed * Platform-payment (DIP-17) address balances — the ID-08 path, distinct 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 7dc3dfa525a..e0d0397a104 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 @@ -10,6 +10,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase import org.dashfoundation.dashsdk.persistence.converters.Converters import org.dashfoundation.dashsdk.persistence.dao.AccountDao import org.dashfoundation.dashsdk.persistence.dao.AssetLockDao +import org.dashfoundation.dashsdk.persistence.dao.InvitationDao import org.dashfoundation.dashsdk.persistence.dao.CoreAddressDao import org.dashfoundation.dashsdk.persistence.dao.DashpayDao import org.dashfoundation.dashsdk.persistence.dao.DataContractDao @@ -27,6 +28,7 @@ import org.dashfoundation.dashsdk.persistence.dao.WalletDao import org.dashfoundation.dashsdk.persistence.dao.WalletManagerMetadataDao import org.dashfoundation.dashsdk.persistence.entities.AccountEntity import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity +import org.dashfoundation.dashsdk.persistence.entities.InvitationEntity import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayContactProfileEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayContactRequestEntity @@ -107,9 +109,16 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * process restart. Room is the durability substrate deliberately: the * wallet-deletion cascade removes these rows, so pending entries die with * their wallet automatically (a DataStore side-table would leak). + * + * Version 9 (DIP-13 sent invitations): adds the `invitations` table — one + * row per funded one-time asset-lock voucher, keyed by its 36-byte funding + * outpoint. Durable storage here is what lets the Rust `create_invitation` + * durability gate mint a voucher (a non-durable store could re-export the + * same one-time key after a restart). Rows die with their wallet via the + * `deleteWalletData` cascade. */ @Database( - version = 8, + version = 9, exportSchema = true, entities = [ WalletEntity::class, @@ -119,6 +128,7 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti TxoEntity::class, CoreAddressEntity::class, AssetLockEntity::class, + InvitationEntity::class, IdentityEntity::class, PublicKeyEntity::class, DpnsNameEntity::class, @@ -156,6 +166,7 @@ abstract class DashDatabase : RoomDatabase() { abstract fun txoDao(): TxoDao abstract fun coreAddressDao(): CoreAddressDao abstract fun assetLockDao(): AssetLockDao + abstract fun invitationDao(): InvitationDao abstract fun identityDao(): IdentityDao abstract fun publicKeyDao(): PublicKeyDao abstract fun dpnsNameDao(): DpnsNameDao @@ -475,6 +486,37 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v8 → v9: new `invitations` table (DIP-13 sent-invitation vouchers), + * one row per 36-byte funding outpoint. Additive — creates the table + * plus its `walletId` index. SQL mirrors the exported + * `schemas/.../9.json` `createSql` for the `invitations` entity + * exactly. Durability here is load-bearing: wiring the Rust + * `tramp_persist_invitations` callback flips `FFIPersister` to report + * the `INVITATIONS` capability, which the `create_invitation` + * durability gate requires before minting a one-time voucher. + */ + val MIGRATION_8_9: Migration = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `invitations` (" + + "`outPoint` BLOB NOT NULL, " + + "`walletId` BLOB NOT NULL, " + + "`fundingIndex` INTEGER NOT NULL, " + + "`amountDuffs` INTEGER NOT NULL, " + + "`expiryUnix` INTEGER NOT NULL, " + + "`createdAtSecs` INTEGER NOT NULL, " + + "`hasInviter` INTEGER NOT NULL, " + + "`statusRaw` INTEGER NOT NULL, " + + "PRIMARY KEY(`outPoint`))", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` " + + "ON `invitations` (`walletId`)", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -491,6 +533,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, + MIGRATION_8_9, ) .build() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 3cd48fa4b12..850e9dbabec 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -37,6 +37,7 @@ import org.dashfoundation.dashsdk.ffi.UnresolvedAssetLockTxRecordData import org.dashfoundation.dashsdk.ffi.WalletRestoreData import org.dashfoundation.dashsdk.persistence.entities.AccountEntity import org.dashfoundation.dashsdk.persistence.entities.AssetLockEntity +import org.dashfoundation.dashsdk.persistence.entities.InvitationEntity import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayContactProfileEntity import org.dashfoundation.dashsdk.persistence.entities.DashpayContactRequestEntity @@ -127,6 +128,7 @@ class PlatformWalletPersistenceHandler( override fun persistenceCapabilitiesBits(): Long = CAPABILITY_ATOMIC_CHANGESETS or + CAPABILITY_INVITATIONS or CAPABILITY_ASSET_LOCK_FUNDING_INDICES or CAPABILITY_SHIELDED_VIEWING_KEYS or CAPABILITY_PROVIDER_TRANSACTIONS or @@ -1503,6 +1505,40 @@ class PlatformWalletPersistenceHandler( 0 } + // ── Invitations (DIP-13) ────────────────────────────────────────── + + override fun onPersistInvitationUpsert( + walletId: ByteArray, + outPoint: ByteArray, + fundingIndex: Int, + amountDuffs: Long, + expiryUnix: Long, + createdAtSecs: Long, + hasInviter: Byte, + status: Byte, + ): Int = guarded { + stage(walletId) { db -> + db.invitationDao().upsert( + InvitationEntity( + outPoint = outPoint, + walletId = walletId, + fundingIndex = fundingIndex, + amountDuffs = amountDuffs, + expiryUnix = expiryUnix, + createdAtSecs = createdAtSecs, + hasInviter = hasInviter.toInt() and 0xFF, + statusRaw = status.toInt() and 0xFF, + ), + ) + } + 0 + } + + override fun onPersistInvitationRemoval(walletId: ByteArray, outPoint: ByteArray): Int = guarded { + stage(walletId) { db -> db.invitationDao().deleteByOutPoint(outPoint) } + 0 + } + // ── Shielded persist ────────────────────────────────────────────── override fun onPersistShieldedNote( @@ -2557,6 +2593,7 @@ class PlatformWalletPersistenceHandler( database.txoDao().deleteByWallet(walletId) database.documentDao().deletePendingInputsByWallet(walletId) database.assetLockDao().deleteByWallet(walletId) + database.invitationDao().deleteByWallet(walletId) database.platformAddressDao().deleteByWallet(walletId) database.shieldedDao().deleteNotesByWallet(walletId) database.shieldedDao().deleteOutgoingNotesByWallet(walletId) @@ -3004,6 +3041,7 @@ class PlatformWalletPersistenceHandler( companion object { internal const val PERSISTENCE_CAPABILITIES_VERSION: Int = 1 internal const val CAPABILITY_ATOMIC_CHANGESETS: Long = 0x01 + internal const val CAPABILITY_INVITATIONS: Long = 0x02 internal const val CAPABILITY_ASSET_LOCK_FUNDING_INDICES: Long = 0x04 internal const val CAPABILITY_SHIELDED_VIEWING_KEYS: Long = 0x08 internal const val CAPABILITY_PROVIDER_TRANSACTIONS: Long = 0x10 diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/InvitationDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/InvitationDao.kt new file mode 100644 index 00000000000..d2bff02ec5f --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/InvitationDao.kt @@ -0,0 +1,47 @@ +package org.dashfoundation.dashsdk.persistence.dao + +import androidx.room.Dao +import androidx.room.OnConflictStrategy +import androidx.room.Insert +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import org.dashfoundation.dashsdk.persistence.entities.InvitationEntity + +/** + * Queries over [InvitationEntity], mirroring the invitation persistence + * bridge call sites: the handler's upsert (`onPersistInvitationUpsert`) and + * delete-by-outpoint (`onPersistInvitationRemoval`), plus wallet teardown. + * + * Upsert is REPLACE-on-conflict keyed by the 36-byte `outPoint` PK so a + * status transition (Created → Claimed → Reclaimed) overwrites the row in + * place. + */ +@Dao +interface InvitationDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(invitation: InvitationEntity) + + @Query("SELECT * FROM invitations WHERE outPoint = :outPoint") + suspend fun getByOutPoint(outPoint: ByteArray): InvitationEntity? + + @Query("SELECT * FROM invitations WHERE walletId = :walletId") + fun observeByWallet(walletId: ByteArray): Flow> + + /** Removal path (`onPersistInvitationRemoval`). */ + @Query("DELETE FROM invitations WHERE outPoint = :outPoint") + suspend fun deleteByOutPoint(outPoint: ByteArray) + + /** Wallet teardown mirror of `deleteWalletData`. */ + @Query("DELETE FROM invitations WHERE walletId = :walletId") + suspend fun deleteByWallet(walletId: ByteArray) + + @Query("DELETE FROM invitations") + suspend fun deleteAll() + + @Query("SELECT COUNT(*) FROM invitations") + fun count(): Flow + + @Query("SELECT COUNT(*) FROM invitations WHERE walletId = :walletId") + fun countByWallet(walletId: ByteArray): Flow +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/InvitationEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/InvitationEntity.kt new file mode 100644 index 00000000000..cf417c264c0 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/InvitationEntity.kt @@ -0,0 +1,68 @@ +package org.dashfoundation.dashsdk.persistence.entities + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * One sent DashPay invitation (DIP-13) — a funded one-time asset-lock + * voucher — keyed by its 36-byte funding outpoint. Durable mirror of the + * `InvitationChangeSet` rows forwarded from Rust through + * `NativePersistenceBridge.onPersistInvitationUpsert`. + * + * Durability matters: the Rust `create_invitation` durability gate only lets + * the voucher be minted because this row is genuinely persisted. A row that + * were lost across a restart could let the same one-time voucher key be + * re-exported, so this must never be a no-op store. + * + * The PK is the raw 36-byte outpoint (`txid[32] || vout_le[4]`) — the same + * encoding the FFI keys every invitation upsert/removal by — so there is one + * outpoint shape across the whole bridge, no display-hex conversion. + */ +@Entity( + tableName = "invitations", + indices = [Index(value = ["walletId"])], +) +data class InvitationEntity( + /** 36-byte funding outpoint: `txid[32] || vout_le[4]`. */ + @PrimaryKey val outPoint: ByteArray, + /** 32-byte owning wallet id. */ + val walletId: ByteArray, + /** DIP-13 funding index the voucher key derives from (unsigned, in an Int). */ + val fundingIndex: Int, + /** Voucher amount in duffs. */ + val amountDuffs: Long, + /** Advisory expiry, unix seconds. */ + val expiryUnix: Long, + /** Creation time, unix seconds. */ + val createdAtSecs: Long, + /** 1 if the link carries inviter/contact-bootstrap info, else 0. */ + val hasInviter: Int, + /** `InvitationStatus` discriminant: 0 Created, 1 Claimed, 2 Reclaimed. */ + val statusRaw: Int, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is InvitationEntity) return false + return outPoint.contentEquals(other.outPoint) && + walletId.contentEquals(other.walletId) && + fundingIndex == other.fundingIndex && + amountDuffs == other.amountDuffs && + expiryUnix == other.expiryUnix && + createdAtSecs == other.createdAtSecs && + hasInviter == other.hasInviter && + statusRaw == other.statusRaw + } + + override fun hashCode(): Int { + var result = outPoint.contentHashCode() + result = 31 * result + walletId.contentHashCode() + result = 31 * result + fundingIndex + result = 31 * result + amountDuffs.hashCode() + result = 31 * result + expiryUnix.hashCode() + result = 31 * result + createdAtSecs.hashCode() + result = 31 * result + hasInviter + result = 31 * result + statusRaw + return result + } +} diff --git a/packages/rs-unified-sdk-jni/src/identity.rs b/packages/rs-unified-sdk-jni/src/identity.rs index 3cb77bf1ef9..46c8b4e0919 100644 --- a/packages/rs-unified-sdk-jni/src/identity.rs +++ b/packages/rs-unified-sdk-jni/src/identity.rs @@ -686,6 +686,271 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_regist }) } +// ── DashPay invitations (DIP-13) ────────────────────────────────────── + +/// Create a DashPay invitation (DIP-13): fund a one-time asset-lock voucher +/// at the invitation derivation path and return a shareable +/// `dashpay://invite` link. Thin marshaler over +/// `platform_wallet_create_invitation`; the whole +/// fund/broadcast/persist/proof/export pipeline lives in platform-wallet. +/// +/// Funds `amountDuffs` from BIP-44 account `fundingAccountIndex`, signed by +/// the Core-side resolver `coreSignerHandle` (a `MnemonicResolverHandle` — +/// the SAME handle `registerIdentityWithFunding` passes as its trailing +/// `coreSignerHandle`). No identity / `SignerHandle` is needed: this is pure +/// voucher creation, no identity is registered. +/// +/// The contact-bootstrap opt-in is OPTIONAL. Pass a 32-byte +/// `inviterIdentityId` (and then a non-null `inviterUsername`) to embed the +/// inviter so the invitee can send a contact request back; pass a null +/// `inviterIdentityId` for a pure funding voucher (`inviterUsername` is then +/// ignored). Only the username is carried in the link — the id bytes drive +/// the opt-in flag but are not embedded (the invitee resolves the id from the +/// username via DPNS). +/// +/// `nowUnix` is the current unix time in seconds (the FFI can't read the +/// clock deterministically); it must be `> 0`. The advisory ~24h expiry is +/// derived Rust-side. +/// +/// ## Durability gate +/// +/// `create_invitation` refuses to run unless the persistence backend reports +/// the full `INVITATION_CREATION` capability — which, on Android, requires the +/// `onPersistInvitationUpsert` bridge callback to be wired (see +/// `tramp_persist_invitations` in `persistence.rs`). The voucher's one-time +/// key is HD-derived from the persisted funding index, so a backend that +/// can't durably record the invitation could re-export the same bearer key +/// after a restart; the call fails closed BEFORE any funds move when the +/// bridge doesn't implement invitation persistence. +/// +/// ## Return +/// +/// A `byte[]` blob: the first 36 bytes are the funding outpoint +/// (`txid[32] || vout_le[4]` — the same 36-byte encoding the persistence +/// layer keys invitation rows by), and the remaining bytes are the UTF-8 +/// `dashpay://invite` URI. **The URI embeds the bearer voucher key — the +/// Kotlin caller MUST NOT log it or persist it anywhere but the share sheet.** +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_createInvitation( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + amount_duffs: jlong, + funding_account_index: jint, + inviter_identity_id: JByteArray, + inviter_username: JString, + now_unix: jlong, + core_signer_handle: jlong, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + // Reject sign / range errors at the boundary before they bit-cast to + // huge unsigned values across the FFI. + if amount_duffs <= 0 { + throw_sdk_exception(env, 1, "amountDuffs must be positive"); + return ptr::null_mut(); + } + if funding_account_index < 0 { + throw_sdk_exception(env, 1, "fundingAccountIndex must be non-negative"); + return ptr::null_mut(); + } + if now_unix <= 0 || now_unix > u32::MAX as jlong { + throw_sdk_exception( + env, + 1, + "nowUnix must be a valid unix timestamp (1..=u32::MAX)", + ); + return ptr::null_mut(); + } + if core_signer_handle == 0 { + throw_sdk_exception(env, 1, "coreSignerHandle must be non-null"); + return ptr::null_mut(); + } + + // Optional contact-bootstrap opt-in: a null `inviterIdentityId` ⇒ pure + // funding voucher (username ignored). When present it must be 32 bytes, + // and the username is then required — enforced here for a clear boundary + // error before the call (the FFI enforces the same rule). + let inviter_id: Option<[u8; 32]> = if inviter_identity_id.is_null() { + None + } else { + match read_id32(env, &inviter_identity_id, "inviterIdentityId") { + Some(id) => Some(id), + None => return ptr::null_mut(), // read_id32 already threw + } + }; + let inviter_username_c = + match read_optional_cstring(env, &inviter_username, "inviterUsername") { + Ok(c) => c, + Err(()) => return ptr::null_mut(), // already threw + }; + if inviter_id.is_some() && inviter_username_c.is_none() { + throw_sdk_exception( + env, + 1, + "inviterUsername is required when inviterIdentityId is provided", + ); + return ptr::null_mut(); + } + + let mut out_uri: *mut c_char = ptr::null_mut(); + let mut out_outpoint = OutPointFFI { + txid: [0u8; 32], + vout: 0, + }; + let result = unsafe { + platform_wallet_ffi::platform_wallet_create_invitation( + wallet_handle as Handle, + amount_duffs as u64, + funding_account_index as u32, + inviter_id.as_ref().map_or(ptr::null(), |a| a.as_ptr()), + inviter_username_c + .as_ref() + .map_or(ptr::null(), |c| c.as_ptr()), + now_unix as u32, + core_signer_handle as *mut MnemonicResolverHandle, + &mut out_uri as *mut *mut c_char, + &mut out_outpoint as *mut OutPointFFI, + ) + }; + // `inviter_id` / `inviter_username_c` own the buffers the pointers above + // referenced; they stay in scope through the FFI call. + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + if out_uri.is_null() { + throw_sdk_exception(env, 99, "createInvitation returned success but no URI"); + return ptr::null_mut(); + } + + // Copy the (secret) URI out, then free the Rust string. It is copied + // straight into the return blob and never logged. + let uri_bytes = unsafe { CStr::from_ptr(out_uri) }.to_bytes().to_vec(); + unsafe { platform_wallet_ffi::platform_wallet_string_free(out_uri) }; + + // Blob: outpoint (`txid[32] || vout_le[4]`) then the UTF-8 URI. The + // outpoint uses the same 36-byte encoding the persistence layer keys + // invitation rows by, so Kotlin has ONE outpoint shape everywhere. + let mut blob = Vec::with_capacity(36 + uri_bytes.len()); + blob.extend_from_slice(&out_outpoint.txid); + blob.extend_from_slice(&out_outpoint.vout.to_le_bytes()); + blob.extend_from_slice(&uri_bytes); + + env.byte_array_from_slice(&blob) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// Claim a DashPay invitation (DIP-13): register a NEW identity for the +/// invitee, funded by the imported voucher carried in `uri`. Thin marshaler +/// over `platform_wallet_claim_invitation`. +/// +/// `uri` is the `dashpay://invite?…` link. `pubkeysBlob` is the invitee's own +/// new-identity keys in the SAME flat layout `registerIdentityWithFunding` +/// consumes (`u32 rowCount` then per row `u32 keyId, u16 pubkeyLen, pubkey`), +/// each key stamped with its canonical DPP role by `keyId`. `signerHandle` +/// signs those identity keys; the asset-lock's outer state-transition +/// signature is produced from the imported raw voucher key, so NO Core-side +/// resolver signer is needed here. `nowUnix` is accepted for C-ABI parity but +/// currently unused (the legacy link carries no expiry, so claim has no time +/// gate). +/// +/// The contact-bootstrap ("establish contact with the sender?") is NOT done +/// here — the UI asks the invitee and, on confirm, calls the existing +/// contact-request path. +/// +/// Returns the 32-byte new identity id. The standalone `ManagedIdentity` +/// handle the FFI produces is destroyed here — Room learns of the new +/// identity through the persistence changeset, not through this handle +/// (mirrors `registerIdentityWithFunding`). +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_claimInvitation( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + uri: JString, + identity_index: jint, + pubkeys_blob: JByteArray, + signer_handle: jlong, + now_unix: jlong, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + if identity_index < 0 { + throw_sdk_exception(env, 1, "identityIndex must be non-negative"); + return ptr::null_mut(); + } + if now_unix < 0 || now_unix > u32::MAX as jlong { + throw_sdk_exception(env, 1, "nowUnix must be in 0..=u32::MAX"); + return ptr::null_mut(); + } + if signer_handle == 0 { + throw_sdk_exception(env, 1, "signerHandle must be non-null"); + return ptr::null_mut(); + } + + let uri_str: String = match env.get_string(&uri) { + Ok(s) => s.into(), + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "uri string was null/invalid"); + return ptr::null_mut(); + } + }; + let c_uri = match CString::new(uri_str) { + Ok(c) => c, + Err(_) => { + throw_sdk_exception(env, 1, "uri contained an interior NUL"); + return ptr::null_mut(); + } + }; + + // Decode the invitee's own new-identity keys — same blob layout and + // canonical keyId→role stamping as `registerIdentityWithFunding` + // (`decode_registration_pubkeys_blob` enforces ≥1 key, no duplicate + // key IDs, keyId 0 = MASTER + AUTHENTICATION), then lower each row to + // its FFI form via the row's own `to_ffi()`. + let Some(decoded) = decode_registration_pubkeys_blob(env, &pubkeys_blob) else { + return ptr::null_mut(); + }; + let ffi_rows: Vec = decoded.iter().map(|row| row.to_ffi()).collect(); + + let mut out_id = [0u8; 32]; + let mut out_managed: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_claim_invitation( + wallet_handle as Handle, + c_uri.as_ptr(), + identity_index as u32, + ffi_rows.as_ptr(), + ffi_rows.len(), + signer_handle as *mut SignerHandle, + now_unix as u32, + &mut out_id as *mut [u8; 32], + &mut out_managed as *mut Handle, + ) + }; + // `c_uri` / `decoded` / `ffi_rows` own the buffers the pointers above + // referenced; they stay in scope through the FFI call. + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + + // The new identity is folded into Rust's IdentityManager and lands in + // Room via the persister changeset; the standalone managed handle would + // otherwise leak, so drop it. + if out_managed != 0 { + let mut destroy = unsafe { platform_wallet_ffi::managed_identity_destroy(out_managed) }; + unsafe { platform_wallet_ffi_result_free(&mut destroy) }; + } + + env.byte_array_from_slice(&out_id) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + // ── Registration (Platform-address funded) ──────────────────────────── /// Register a new identity funded by the wallet's already-committed diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 5c5e11cd808..7932e8619be 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -54,7 +54,8 @@ use platform_wallet_ffi::{ AccountAddressPoolFFI, AccountChangeSetFFI, AccountSpecFFI, AddressBalanceEntryFFI, AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, ContactRequestFFI, ContactRequestRemovalFFI, CoreAddressEntryFFI, IdentityEntryFFI, IdentityKeyEntryFFI, - IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, PaymentRestoreEntryFFI, + IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, InvitationEntryFFI, + PaymentRestoreEntryFFI, PersistenceCallbacks, PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, WalletChangeSetFFI, @@ -171,11 +172,15 @@ pub(crate) fn build_vtable(context: *mut c_void) -> PersistenceCallbacks { on_get_core_tx_record_fn: Some(tramp_get_core_tx_record), on_get_core_tx_record_free_fn: Some(tramp_get_core_tx_record_free), on_persist_asset_locks_fn: Some(tramp_persist_asset_locks), - // Android hasn't wired DIP-13 invitation persistence yet. Leaving this - // `None` keeps `FFIPersister::persists_durably()` fail-closed, so the - // invitation flow refuses to run on Android rather than create a - // non-durable voucher whose one-time key could be reused on restart. - on_persist_invitations_fn: None, + // DIP-13 invitation persistence. Wiring this trampoline is what makes + // `FFIPersister` report `PersistenceCapabilities::INVITATIONS`, so the + // durability gate in `create_invitation` (which requires the full + // `INVITATION_CREATION` mask) passes on Android. The Kotlin bridge must + // durably store each upsert (`onPersistInvitationUpsert`) and honour + // removals (`onPersistInvitationRemoval`); a bridge that no-ops these + // would report the capability without real durability and could + // re-export the same one-time voucher key after a restart. + on_persist_invitations_fn: Some(tramp_persist_invitations), release_fn: Some(release_persistence_ctx), } } @@ -1307,6 +1312,80 @@ unsafe extern "C" fn tramp_persist_asset_locks( }) } +// ── Invitations (DIP-13) ────────────────────────────────────────────── + +/// Forward a DIP-13 sent-invitation changeset (`InvitationChangeSet`) to the +/// Kotlin bridge — one `onPersistInvitationUpsert` per funded-voucher record +/// (keyed by outpoint) and one `onPersistInvitationRemoval` per tombstoned +/// outpoint. Every [`InvitationEntryFFI`] field is plain-old-data (no owned +/// tx / proof buffers), so this is a strict subset of +/// [`tramp_persist_asset_locks`] with no buffer lifetime to manage. +/// +/// Wiring this callback (vs leaving the slot `None`) is exactly what flips +/// `FFIPersister` to report `PersistenceCapabilities::INVITATIONS`, which +/// `create_invitation` requires before it moves any funds. The bridge must +/// therefore make these writes genuinely durable — a no-op implementation +/// would defeat the gate. +/// +/// Field marshaling: `walletId` and `outPoint` (`txid[32] || vout_le[4]`) go +/// out as `byte[]`; `fundingIndex` as an unsigned-in-`int` (small HD index, +/// mirroring `onPersistAssetLockUpsert`'s `accountIndex`); `amountDuffs`, +/// `expiryUnix`, and `createdAtSecs` as `long` (the two u32 unix timestamps +/// widen into `long` so they never wrap negative past 2038); `hasInviter` +/// (0/1) and `status` (0=Created,1=Claimed,2=Reclaimed) as `byte`. +unsafe extern "C" fn tramp_persist_invitations( + context: *mut c_void, + wallet_id: *const u8, + upserts_ptr: *const InvitationEntryFFI, + upserts_count: usize, + removed_ptr: *const [u8; 36], + removed_count: usize, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + for e in slice_or_empty(upserts_ptr, upserts_count) { + let code = env.with_local_frame(16, |env| { + let outpoint = env.byte_array_from_slice(&e.out_point)?; + env.call_method( + bridge, + "onPersistInvitationUpsert", + "([B[BIJJJBB)I", + &[ + (&wid).into(), + (&outpoint).into(), + JValue::Int(e.funding_index as i32), + JValue::Long(e.amount_duffs as i64), + JValue::Long(e.expiry_unix as i64), + JValue::Long(e.created_at_secs as i64), + JValue::Byte(e.has_inviter as i8), + JValue::Byte(e.status as i8), + ], + )? + .i() + })?; + if code != 0 { + return Ok(code); + } + } + for op in slice_or_empty(removed_ptr, removed_count) { + let code = env.with_local_frame(16, |env| { + let opb = env.byte_array_from_slice(op)?; + env.call_method( + bridge, + "onPersistInvitationRemoval", + "([B[B)I", + &[(&wid).into(), (&opb).into()], + )? + .i() + })?; + if code != 0 { + return Ok(code); + } + } + Ok(0) + }) +} + // ── Shielded persist ────────────────────────────────────────────────── #[cfg(feature = "shielded")] @@ -4141,7 +4220,10 @@ mod tests { let callbacks = build_vtable(ptr::null_mut()); assert!(callbacks.on_changeset_begin_fn.is_some()); assert!(callbacks.on_changeset_end_fn.is_some()); - assert!(callbacks.on_persist_invitations_fn.is_none()); + // DIP-13 invitation persistence is now wired; the slot being `Some` is + // what makes `FFIPersister` report the `INVITATIONS` capability so the + // `create_invitation` durability gate passes on Android. + assert!(callbacks.on_persist_invitations_fn.is_some()); } #[cfg(feature = "shielded")] From fd798461dac49a0619ae81b80943056b6eecfe85 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:58:12 -0400 Subject: [PATCH 02/10] =?UTF-8?q?feat(platform-wallet):=20stale-islock=20?= =?UTF-8?q?=E2=86=92=20ChainLock=20fallback=20for=20L1=20invite=20claim=20?= =?UTF-8?q?(#4240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A voucher islock is signed at creation, so by claim time (minutes-to-hours later, possibly across a testnet platform-4.1 quorum rotation) Drive may reject it with InvalidInstantAssetLockProofSignatureError ("try chain asset lock proof instead"). claim_invitation now recovers exactly as the register/top-up paths do: - reconstruct_asset_lock_proof / assemble_asset_lock_proof return a ReconstructedProof { primary, chain_fallback }. When the link carried an islock AND the funding tx is already chain-locked, a ChainLock proof over the SAME credit output is built as chain_fallback. - The primary proof is submitted through submit_with_cl_height_retry. If it is an IS proof rejected with is_instant_lock_proof_invalid(), the ChainLock fallback is resubmitted over the same outpoint. If no fallback exists (tx not yet chain-locked), AssetLockNotChainLocked surfaces a clear retry signal. Adds unit tests for the three assemble outcomes (chainlock-only, islock+chainlocked→fallback, islock+not-chainlocked→no-fallback). Co-Authored-By: Claude Opus 4.8 --- .../src/wallet/identity/network/invitation.rs | 221 +++++++++++++++--- 1 file changed, 193 insertions(+), 28 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index caad079e0e2..c2dd2f57048 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -36,7 +36,8 @@ use crate::changeset::{ use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::transition::put_settings::PutSettings; -use crate::error::PlatformWalletError; +use crate::error::{is_instant_lock_proof_invalid, PlatformWalletError}; +use crate::wallet::asset_lock::orchestration::submit_with_cl_height_retry; use crate::wallet::identity::crypto::{ encode_invitation_uri, voucher_output_index, wif_network_matches, }; @@ -390,8 +391,18 @@ impl IdentityWallet { /// 2. Fail-fast that the fetched tx is really the funding tx, and (if an /// islock is present) that the islock locks it. /// 3. Select the funded credit output by pk↔script match (not index 0). - /// 4. Build an `InstantAssetLockProof` when an islock is present, else a - /// `ChainAssetLockProof` once the funding tx is chain-locked. + /// 4. Build an `InstantAssetLockProof` when an islock is present (the fast + /// path), else a `ChainAssetLockProof` once the funding tx is chain-locked. + /// + /// The IS proof is the *fast* path only: a voucher islock is signed at + /// creation, so by the time the invitee claims (minutes-to-hours later, and on + /// testnet possibly across a platform-4.1 quorum rotation) Drive may reject the + /// islock as stale with `InvalidInstantAssetLockProofSignatureError` — its own + /// message asks to "try chain asset lock proof instead". When the funding tx is + /// already chain-locked (the usual case by claim time), this resubmits a + /// `ChainAssetLockProof` over the SAME credit output, mirroring the IS→CL + /// fallback the register/top-up paths use. If the tx is not yet chain-locked no + /// fallback is possible and the claim surfaces a retry signal. /// /// The invitee's own identity keys (`keys_map`, derived from the invitee's /// seed) are signed by `identity_signer`; the asset-lock's outer @@ -430,7 +441,16 @@ impl IdentityWallet { // enforces pk↔output, islock↔tx, and identity_id↔outpoint, so the local // guards below are for fast-fail + correct-index selection, not theft // prevention (a crafted link at worst yields a failed claim). - let asset_lock = self.reconstruct_asset_lock_proof(&invitation).await?; + // + // `primary` is submitted first: an InstantSend proof when the link carried + // an islock (the fast path), or a ChainLock proof when it did not. + // `chain_fallback` is a ChainLock proof over the SAME credit output, + // populated only when `primary` is InstantSend AND the funding tx is + // already chain-locked — the stale-islock recovery below resubmits it. + let ReconstructedProof { + primary, + chain_fallback, + } = self.reconstruct_asset_lock_proof(&invitation).await?; // The voucher key signs the asset lock's outer ST signature (ECDSA over // the credit-output pubkey hash). Convert to the SDK's `PrivateKey`, @@ -438,6 +458,8 @@ impl IdentityWallet { let network = self.sdk.network; let voucher_priv = WipingPrivateKey(PrivateKey::new(invitation.voucher_key, network)); + // Build the placeholder identity ONCE so both the primary attempt and the + // IS→CL fallback submit the same key set without a `keys_map` clone. let placeholder = Identity::V0(IdentityV0 { id: Identifier::default(), public_keys: keys_map, @@ -445,19 +467,62 @@ impl IdentityWallet { revision: 0, }); - // Submit directly. An InstantSend or ChainLock proof both prove finality; - // a proof that no longer applies (e.g. the invite was already claimed) is - // rejected by consensus and surfaced to the caller. - let identity = placeholder - .put_to_platform_and_wait_for_response_with_private_key( + // Submit the primary proof, wrapped in the shared CL-height-too-low retry + // (Platform's observed Core tip briefly behind the proof's chain-locked + // height — the same transient the register/top-up paths absorb; harmless + // for an IS proof, which never triggers it). + // + // If Platform rejects the primary IS proof with + // `InvalidInstantAssetLockProofSignatureError` (the islock is stale — its + // signing quorum rotated out, or it is no longer "recent"; Platform's own + // message asks us to "try chain asset lock proof instead"), resubmit the + // ChainLock proof over the SAME credit output. This mirrors + // `register_identity_with_funding`'s IS→CL fallback, except the CL proof is + // rebuilt from the refetched tx: the invitee tracks no asset lock of its + // own, so there is no `upgrade_to_chain_lock_proof` to call. + let identity = match submit_with_cl_height_retry(settings, |s| { + placeholder.put_to_platform_and_wait_for_response_with_private_key( &self.sdk, - asset_lock, + primary.clone(), &voucher_priv.0, identity_signer, - settings, + s, ) - .await - .map_err(PlatformWalletError::Sdk)?; + }) + .await + { + Ok(identity) => identity, + Err(e) if is_instant_lock_proof_invalid(&e) => { + let Some(chain_proof) = chain_fallback else { + // The islock was rejected but the funding tx is not yet + // chain-locked, so no ChainLock proof can be built. Surface a + // clear retry signal rather than the raw consensus error. + return Err(PlatformWalletError::AssetLockNotChainLocked( + "invitation islock proof was rejected by Platform (stale — quorum \ + rotated or no longer recent) and the funding transaction is not yet \ + chain-locked, so no ChainLock fallback is possible; retry once the \ + funding block is chain-locked" + .to_string(), + )); + }; + tracing::warn!( + "invitation IS-lock proof rejected by Platform on claim; retrying with a \ + ChainLock proof over the same funding outpoint" + ); + submit_with_cl_height_retry(settings, |s| { + placeholder.put_to_platform_and_wait_for_response_with_private_key( + &self.sdk, + chain_proof.clone(), + &voucher_priv.0, + identity_signer, + s, + ) + }) + .await + .map_err(PlatformWalletError::Sdk)? + } + Err(e) => return Err(PlatformWalletError::Sdk(e)), + }; // Best-effort local bookkeeping — Platform has already accepted the // registration, so a local failure must NOT propagate (mirrors @@ -530,10 +595,15 @@ impl IdentityWallet { /// tx, select the voucher's credit output, and assemble an InstantSend proof /// (when the link carried an islock) or a ChainLock proof (islock absent / /// `"null"` — a chainlock-confirmed invite). + /// + /// Returns a [`ReconstructedProof`]: the `primary` proof to submit plus an + /// optional `chain_fallback` ChainLock proof used by the caller's stale-islock + /// recovery (populated only when the primary is InstantSend and the funding tx + /// is already chain-locked). async fn reconstruct_asset_lock_proof( &self, invitation: &ParsedInvitation, - ) -> Result { + ) -> Result { let sdk = &self.sdk; let fetched = fetch_funding_tx_with_retry( &invitation.funding_txid, @@ -609,18 +679,41 @@ where Ok(None) } +/// The claim's reconstructed funding proof, plus an optional ChainLock fallback. +/// +/// `primary` is submitted first: an [`AssetLockProof::Instant`] when the link +/// carried an islock (the fast path), or an [`AssetLockProof::Chain`] when it did +/// not. `chain_fallback` is a ChainLock proof over the SAME credit output, +/// populated ONLY when `primary` is InstantSend AND the funding tx is already +/// chain-locked — [`IdentityWallet::claim_invitation`]'s stale-islock recovery +/// resubmits it if Platform rejects the primary IS proof with +/// `InvalidInstantAssetLockProofSignatureError`. It is `None` when the primary is +/// already a ChainLock proof (nothing to fall back to) or when the funding tx is +/// not yet chain-locked (no ChainLock proof can be built — the claim must be +/// retried once the block confirms). +#[derive(Debug)] +struct ReconstructedProof { + primary: AssetLockProof, + chain_fallback: Option, +} + /// Assemble the asset-lock proof from an already-fetched funding transaction — the /// pure, testable core of the claim reconstruction (the fetch/retry lives in /// `reconstruct_asset_lock_proof`). Validates the tx is the funding tx (either byte /// order), selects the voucher's credit output, and builds an InstantSend proof /// (link carried an islock) or a ChainLock proof (islock absent), requiring /// chain-lock finality for the latter. +/// +/// When an islock is present AND the funding tx is already chain-locked, the +/// returned [`ReconstructedProof`] also carries a `chain_fallback` ChainLock proof +/// over the same credit output, so the caller can recover from a stale islock that +/// Platform rejects without refetching the tx. fn assemble_asset_lock_proof( transaction: Transaction, is_chain_locked: bool, height: u32, invitation: &ParsedInvitation, -) -> Result { +) -> Result { // Fail-fast: the fetched tx must actually be the funding tx (either byte // order). DAPI returns whatever tx matches the id we asked for, so this // guards a backend that answers with an unrelated tx. @@ -638,6 +731,16 @@ fn assemble_asset_lock_proof( // — a legacy invite's credit output need not be first). let output_index = voucher_output_index(&transaction, &invitation.voucher_key)?; + // A ChainLock proof over the selected credit output. Buildable only once the + // funding block is chain-locked; `height` is the tx's mined height (the + // `ChainAssetLockProof`'s `core_chain_locked_height`). Reused both as the + // primary for an islock-less invite and as the stale-islock fallback. + let chain_lock_proof = |txid| -> AssetLockProof { + let out_point = OutPoint::new(txid, output_index); + let out_point_bytes: [u8; 36] = out_point.into(); + AssetLockProof::Chain(ChainAssetLockProof::new(height, out_point_bytes)) + }; + match &invitation.islock_hex { Some(islock_hex) => { let islock_bytes = hex::decode(islock_hex).map_err(|e| { @@ -659,17 +762,29 @@ fn assemble_asset_lock_proof( "invitation islock does not lock the funding transaction".to_string(), )); } - Ok(AssetLockProof::Instant(InstantAssetLockProof::new( + // Fast path: submit the InstantSend proof. If the islock is stale + // (quorum rotated / no longer "recent") Platform rejects it, and the + // claim falls back to `chain_fallback` — available only when the + // funding tx is already chain-locked (the usual case by claim time, + // since the voucher was funded minutes-to-hours earlier). Computed + // from `&transaction` BEFORE it is moved into the IS proof below. + let chain_fallback = is_chain_locked.then(|| chain_lock_proof(transaction.txid())); + let primary = AssetLockProof::Instant(InstantAssetLockProof::new( instant_lock, transaction, output_index, - ))) + )); + Ok(ReconstructedProof { + primary, + chain_fallback, + }) } None => { // ChainLock invite: the proof references the outpoint + a chain-locked // core height. Require the funding tx to be chain-locked so the proof // proves finality; the inviter/invitee retries once the block is - // chain-locked otherwise. + // chain-locked otherwise. There is no separate fallback — this IS the + // ChainLock proof. if !is_chain_locked { return Err(PlatformWalletError::InvalidIdentityData( "chainlock invitation funding transaction is not yet chain-locked; \ @@ -677,12 +792,10 @@ fn assemble_asset_lock_proof( .to_string(), )); } - let out_point = OutPoint::new(transaction.txid(), output_index); - let out_point_bytes: [u8; 36] = out_point.into(); - Ok(AssetLockProof::Chain(ChainAssetLockProof::new( - height, - out_point_bytes, - ))) + Ok(ReconstructedProof { + primary: chain_lock_proof(transaction.txid()), + chain_fallback: None, + }) } } } @@ -802,16 +915,68 @@ mod tests { assert!(format!("{err}").contains("not yet chain-locked")); } - /// A chain-locked ChainLock invite assembles a ChainLock proof at the tx's - /// voucher output. + /// A chain-locked ChainLock invite (no islock) assembles a ChainLock proof at + /// the tx's voucher output, with no separate fallback (the primary IS the CL + /// proof). #[test] fn assemble_chainlock_ok_when_locked() { let key = voucher_secret(); let tx = funding_tx(&key); let txid = tx.txid().to_string(); let inv = parsed(key, txid, None); - let proof = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); - assert!(matches!(proof, AssetLockProof::Chain(_))); + let reconstructed = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); + assert!(matches!(reconstructed.primary, AssetLockProof::Chain(_))); + assert!( + reconstructed.chain_fallback.is_none(), + "an islock-less chainlock invite has no separate fallback" + ); + } + + /// An islock that locks the funding tx, with the tx already chain-locked, + /// yields an InstantSend primary (fast path) PLUS a ChainLock fallback over the + /// same credit output — the stale-islock recovery the claim path submits if + /// Platform rejects the IS proof with `InvalidInstantAssetLockProofSignatureError`. + #[test] + fn assemble_islock_present_and_chainlocked_carries_chain_fallback() { + let key = voucher_secret(); + let tx = funding_tx(&key); + let txid = tx.txid().to_string(); + let mut islock = InstantLock::default(); + islock.txid = tx.txid(); + let mut islock_bytes = Vec::new(); + islock.consensus_encode(&mut islock_bytes).unwrap(); + let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); + let reconstructed = assemble_asset_lock_proof(tx, true, 100, &inv).unwrap(); + assert!( + matches!(reconstructed.primary, AssetLockProof::Instant(_)), + "the islock fast path must be tried first" + ); + assert!( + matches!(reconstructed.chain_fallback, Some(AssetLockProof::Chain(_))), + "a chain-locked funding tx must carry a ChainLock fallback for stale-islock recovery" + ); + } + + /// An islock that locks the funding tx, but the tx NOT yet chain-locked, yields + /// the IS primary with NO fallback: if that islock is later rejected there is + /// no ChainLock proof to fall back to, and the claim must be retried once the + /// block confirms. + #[test] + fn assemble_islock_present_not_chainlocked_has_no_fallback() { + let key = voucher_secret(); + let tx = funding_tx(&key); + let txid = tx.txid().to_string(); + let mut islock = InstantLock::default(); + islock.txid = tx.txid(); + let mut islock_bytes = Vec::new(); + islock.consensus_encode(&mut islock_bytes).unwrap(); + let inv = parsed(key, txid, Some(hex::encode(islock_bytes))); + let reconstructed = assemble_asset_lock_proof(tx, false, 100, &inv).unwrap(); + assert!(matches!(reconstructed.primary, AssetLockProof::Instant(_))); + assert!( + reconstructed.chain_fallback.is_none(), + "an un-chain-locked funding tx cannot produce a ChainLock fallback" + ); } /// An islock that locks a DIFFERENT tx than the funding tx is rejected (the From 096eeb1587b1953ea54bff84a4accf56776ccf7b Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:24:55 -0400 Subject: [PATCH 03/10] test(kotlin-sdk): update persistence-capabilities test for INVITATIONS The L1-invite change added the INVITATIONS capability (0x02) via onPersistInvitationUpsert/Removal, so persistenceCapabilitiesBits() is now 0xbf, not 0xbd. Update the stale assertions (the test still expected the pre-invitation bitmask and asserted INVITATIONS absent). Co-Authored-By: Claude Opus 4.8 --- .../PlatformWalletPersistenceHandlerTest.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 970950a907e..157499f3f90 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -65,10 +65,11 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0L, noOpBridge.persistenceCapabilitiesBits()) assertEquals(1, handler.persistenceCapabilitiesVersion()) - assertEquals(0xbdL, handler.persistenceCapabilitiesBits()) - // Android has no invitation or pending-contact-crypto callback, so it - // must not attest either semantic contract. - assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x02L) + assertEquals(0xbfL, handler.persistenceCapabilitiesBits()) + // Android now owns invitation persistence (INVITATIONS, 0x02, is set via + // onPersistInvitationUpsert/Removal). It still has no pending-contact-crypto + // callback, so it must not attest that contract (0x40). + assertEquals(0x02L, handler.persistenceCapabilitiesBits() and 0x02L) assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L) val diagnostic = PlatformWalletPersistenceCapabilities( @@ -76,7 +77,7 @@ class PlatformWalletPersistenceHandlerTest { handler.persistenceCapabilitiesBits(), ) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.ATOMIC_CHANGESETS)) - assertFalse(diagnostic.contains(PlatformWalletPersistenceCapabilities.INVITATIONS)) + assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.INVITATIONS)) } // ── Standalone (non-bracketed) writes ───────────────────────────── From 91ccef64c817601093d56c27ee74be279c5bd1ca Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:49:02 -0400 Subject: [PATCH 04/10] fix(kotlin-sdk): harden L1-invitation persistence + delivery guarantees Review fixes for the three blockers plus one functional minor: - PlatformWalletPersistenceHandler.onPersistAccountAddressPoolEntry: a missing IdentityInvitation (type tag 5) funding account now THROWS instead of silently skipping the address-pool write, mirroring Swift's persistAccountAddresses. That pool is the only restart-surviving record of a used voucher funding index; the throw fails the staged round so the Rust pre-broadcast durability gate aborts before funds move, preventing bearer-voucher key reuse. Other account types keep the tolerant skip. - DataManager: clear the FK-less `invitations` table in the WALLETS category so both the category clear and clearAll() actually remove sent-invitation metadata instead of letting it resurface on re-import. - IdentityRegistration.createInvitation: run the native call under withContext(NonCancellable) (after an explicit ensureActive() gate) so coroutine cancellation observed while withContext dispatches back can no longer discard the only bearer URI after the voucher was already funded. Cancellation contract documented in the KDoc. - createInvitation now rejects inviterUsername without inviterIdentityId (previously silently discarded by the native layer) and the KDoc says so. Verified: :sdk:compileDebugKotlin + :sdk:testDebugUnitTest (persistence handler / DataManager / IdentityRegistration tests) pass. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/identity/IdentityRegistration.kt | 65 +++++++++++++------ .../PlatformWalletPersistenceHandler.kt | 28 +++++++- .../dashsdk/services/DataManager.kt | 5 ++ 3 files changed, 77 insertions(+), 21 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt index b99ae82df3e..32d75e2c942 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt @@ -4,6 +4,9 @@ import org.dashfoundation.dashsdk.wallet.op import org.dashfoundation.dashsdk.wallet.opWithCleanupOnCancellation import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.credits.FundingInput import org.dashfoundation.dashsdk.errors.mapNativeErrors @@ -320,10 +323,21 @@ class IdentityRegistration internal constructor( * * @param amountDuffs voucher amount in duffs (must be positive). * @param fundingAccountIndex BIP-44 account the voucher is funded from. + * Cancellation contract: the caller's cancellation is honored BEFORE the + * native call starts, but once it begins the operation runs to completion + * under [NonCancellable] and the result is always delivered. The native op + * may have broadcast the asset lock and generated the bearer URI by the + * time cancellation is observed; JNI cannot see Kotlin cancellation, Room + * intentionally stores no URI or voucher key, and no regeneration API + * exists — so a discarded `withContext` result would lose the only + * shareable credential after funds have moved. + * * @param inviterIdentityId optional 32-byte inviter id enabling the * contact-bootstrap opt-in; `null` for a pure funding voucher. When * non-null, [inviterUsername] is required. - * @param inviterUsername inviter DPNS username carried in the link. + * @param inviterUsername inviter DPNS username carried in the link. Only + * used when [inviterIdentityId] is non-null; passing it alone is + * rejected rather than silently discarded. * @param nowUnix current unix time in seconds (must be > 0). * @param coreSignerHandle `MnemonicResolverHandle` for the funding-spend * signature (the SAME handle [registerWithWalletFunding] takes). @@ -337,7 +351,7 @@ class IdentityRegistration internal constructor( inviterUsername: String? = null, nowUnix: Long, coreSignerHandle: Long, - ): CreatedInvitation = gate.op { + ): CreatedInvitation { require(amountDuffs > 0) { "amountDuffs must be positive, got $amountDuffs" } require(fundingAccountIndex >= 0) { "fundingAccountIndex must be non-negative, got $fundingAccountIndex" @@ -349,26 +363,37 @@ class IdentityRegistration internal constructor( "inviterUsername is required when inviterIdentityId is provided" } } - val blob = mapNativeErrors { - IdentityNative.createInvitation( - walletHandle, - amountDuffs, - fundingAccountIndex, - inviterIdentityId, - inviterUsername, - nowUnix, - coreSignerHandle, - ) + require(inviterIdentityId != null || inviterUsername == null) { + "inviterIdentityId is required when inviterUsername is provided " + + "(the username is otherwise silently ignored by the native layer)" } - // Blob layout (fixed by the JNI): outpoint[36] (txid[32] || vout_le[4]) - // then the UTF-8 URI. Anything shorter is a contract violation. - require(blob.size >= 36) { - "createInvitation returned a ${blob.size}-byte blob; expected >= 36" + // Honor cancellation up to here; past this point the operation is + // non-cancellable (see the KDoc cancellation contract above). + currentCoroutineContext().ensureActive() + return withContext(NonCancellable) { + gate.op { + val blob = mapNativeErrors { + IdentityNative.createInvitation( + walletHandle, + amountDuffs, + fundingAccountIndex, + inviterIdentityId, + inviterUsername, + nowUnix, + coreSignerHandle, + ) + } + // Blob layout (fixed by the JNI): outpoint[36] (txid[32] || vout_le[4]) + // then the UTF-8 URI. Anything shorter is a contract violation. + require(blob.size >= 36) { + "createInvitation returned a ${blob.size}-byte blob; expected >= 36" + } + CreatedInvitation( + outPoint = blob.copyOfRange(0, 36), + uri = String(blob.copyOfRange(36, blob.size), Charsets.UTF_8), + ) + } } - CreatedInvitation( - outPoint = blob.copyOfRange(0, 36), - uri = String(blob.copyOfRange(36, blob.size), Charsets.UTF_8), - ) } /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 850e9dbabec..d99c00d8d12 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -589,7 +589,24 @@ class PlatformWalletPersistenceHandler( db, walletId, accountTypeTag.toInt() and 0xFF, accountIndex, accountStandardTag.toInt() and 0xFF, accountRegistrationIndex, accountKeyClass, accountUserIdentityId, accountFriendIdentityId, - ) ?: return@stage + ) ?: run { + // Mirror Swift `persistAccountAddresses`: a missing account is + // tolerated for every type EXCEPT `IdentityInvitation` (type + // tag 5). That account is registered at wallet setup and its + // address pool is the ONLY state restored after a restart that + // marks a voucher funding index as used (the invitation + // metadata row stores the index but never rebuilds the pool) — + // silently dropping the write would let the same bearer + // voucher key be selected again. Throwing fails the staged + // round, so `onChangesetEnd` (or the standalone `guarded` + // path) returns nonzero and the Rust pre-broadcast durability + // gate aborts invitation creation before funds move. + check((accountTypeTag.toInt() and 0xFF) != ACCOUNT_TYPE_IDENTITY_INVITATION) { + "IdentityInvitation account missing for wallet ${walletId.toHex()}; " + + "refusing to drop the voucher funding-index address-pool write" + } + return@stage + } if ((accountTypeTag.toInt() and 0xFF) == ACCOUNT_TYPE_PLATFORM_PAYMENT) { // DIP-17 PlatformPayment pool → PlatformAddressEntity // (mirror of Swift `persistPlatformPaymentAddresses`). Rust @@ -3059,6 +3076,15 @@ class PlatformWalletPersistenceHandler( /** DIP-17 PlatformPayment account type tag (`accountTypeName` 14). */ private const val ACCOUNT_TYPE_PLATFORM_PAYMENT = 14 + /** + * `AccountTypeTagFFI::IdentityInvitation` discriminant — the DIP-13 + * invitation funding account (mirrors Swift's + * `identityInvitationTypeTag`). A missing account of this type in + * [onPersistAccountAddressPoolEntry] is a hard failure, not a + * tolerated skip. + */ + private const val ACCOUNT_TYPE_IDENTITY_INVITATION = 5 + private val HEX = "0123456789abcdef".toCharArray() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/DataManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/DataManager.kt index 189b5db5f32..03fcab92826 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/DataManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/services/DataManager.kt @@ -47,6 +47,11 @@ class DataManager(private val db: DashDatabase) { when (category) { Category.WALLETS -> { // Children first; wallets cascade accounts but be explicit. + // `invitations` carries no FK to `wallets`, so it must be + // cleared explicitly or sent-invitation metadata survives + // both this category clear and clearAll(), resurfacing if + // the same wallet id is ever imported again. + db.invitationDao().deleteAll() db.accountDao().deleteAll() db.walletDao().deleteAll() db.walletManagerMetadataDao().deleteAll() From 722835dbbf32b59efc584292667c47f90babce16 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:38:51 -0400 Subject: [PATCH 05/10] test(kotlin-sdk): teach the gate-coverage lint to read block-bodied borrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createInvitation` is the SDK's only block-bodied handle-borrowing suspend fun, and it IS gated — the NonCancellable bearer-URI fix (638430bd53) moved it off an expression body so the lint's `= gate.op {` opener regex stopped matching, failing CI on a correctly-fenced method. Allowlisting it would have been wrong: it is not exempt, and an ALLOWLIST entry would blind the lint to a future refactor that drops the bracket. So the scan now understands both body shapes instead. Expression bodies keep the exact opener rule. A block body must open its bracket before the first JNI call, which still rejects every real defect shape — no bracket, a plain `withContext(Dispatchers.IO)` borrow, a bracket opened after the native call, and a native call leaked outside the bracket — while allowing the `require(...)` preamble and `withContext(NonCancellable)` delivery wrapper that the cancellation contract requires. Verified against the 51 handle-borrowing suspend funs in the source set plus synthetic cases for each violation shape. 186 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/wallet/GateCoverageLintTest.kt | 94 ++++++++++++++++--- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/GateCoverageLintTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/GateCoverageLintTest.kt index 4d293cd9fdc..46d061c1f8c 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/GateCoverageLintTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/wallet/GateCoverageLintTest.kt @@ -11,7 +11,9 @@ import org.junit.Test * `mnemonicResolverHandle` / `sdkHandle`) must open with a gate bracket * (`gate.op {` / `gate.opWithCleanupOnCancellation(...) {` / * `teardownGate.op {` / `queryGate.op {` / `sdk.queryGate.op {`) or visibly - * delegate to another (gated) method. + * delegate to another (gated) method. A block-bodied method may run argument + * validation and a `withContext(NonCancellable)` delivery wrapper first, but + * the bracket must still open before the first JNI call. * * This exact defect class — a new binding borrowing a handle under plain * `withContext(Dispatchers.IO)` — shipped three separate times during @@ -49,14 +51,7 @@ class GateCoverageLintTest { if (!HANDLE_PARAM.containsMatchIn(params)) continue if ("$name@${file.name}" in ALLOWLIST) continue - // The opener is whatever follows the (optional) return type. - val tail = src.substring(i, minOf(i + 260, src.length)) - val gated = GATED_OPENER.containsMatchIn(tail) - // A delegation body (`= someOtherFun(...)`) is allowed: the - // delegate is itself scanned. - val delegates = DELEGATION_OPENER.containsMatchIn(tail) && - !tail.contains("withContext(") - if (!gated && !delegates) { + if (!isGated(src, i)) { violations += "${file.relativeTo(srcRoot)}: suspend fun $name " + "borrows a raw handle without a gate bracket" } @@ -70,6 +65,68 @@ class GateCoverageLintTest { ) } + /** + * @param src the whole file. + * @param afterParams index just past the parameter list's close paren — + * i.e. the start of the optional return type. + */ + private fun isGated(src: String, afterParams: Int): Boolean { + // The opener is whatever follows the (optional) return type: `=` for an + // expression body, `{` for a block body. A declaration with no body at + // all (none exist today) finds no opener and is reported, so a future + // abstract handle-borrowing member fails closed into review. + val openerAt = (afterParams until minOf(afterParams + 260, src.length)) + .firstOrNull { src[it] == '=' || src[it] == '{' } + ?: return false + + if (src[openerAt] == '=') { + val tail = src.substring(afterParams, minOf(afterParams + 260, src.length)) + // A delegation body (`= someOtherFun(...)`) is allowed: the + // delegate is itself scanned. + val delegates = DELEGATION_OPENER.containsMatchIn(tail) && + !tail.contains("withContext(") + return GATED_OPENER.containsMatchIn(tail) || delegates + } + + // Block body. The gate bracket cannot be the first token here, because + // `require(...)` argument validation and a `withContext(NonCancellable)` + // result-delivery wrapper legitimately precede it — see + // `IdentityRegistration.createInvitation`, where letting `withContext` + // discard the gated result would lose the only copy of a bearer + // credential after the voucher has already been funded. What the + // teardown fence actually requires is that nothing touches the borrowed + // handle OUTSIDE the bracket, so demand that the gate opens before the + // first JNI call rather than at the first statement. A bracket that + // opens after a native call, or no bracket at all, is still a violation. + val body = src.substring(openerAt, endOfBlock(src, openerAt)) + val gateAt = GATE_BRACKET.find(body)?.range?.first ?: -1 + val nativeAt = NATIVE_CALL.find(body)?.range?.first ?: -1 + if (gateAt >= 0) return nativeAt < 0 || gateAt < nativeAt + // No gate and no JNI call: a plain delegation to another (scanned) + // method, allowed only if it does not dispatch on its own. + return nativeAt < 0 && !body.contains("withContext(") + } + + /** + * Index just past the `}` matching the `{` at [open]. String-interpolation + * `${...}` braces are balanced, so counting them is harmless. + */ + private fun endOfBlock(src: String, open: Int): Int { + var depth = 0 + var i = open + while (i < src.length) { + when (src[i]) { + '{' -> depth++ + '}' -> { + depth-- + if (depth == 0) return i + 1 + } + } + i++ + } + return src.length + } + private fun findSdkMainSources(): File { // Tests may run with the working dir at the module, project, or repo // level — walk up until the SDK main source set is found. @@ -92,12 +149,23 @@ class GateCoverageLintTest { val HANDLE_PARAM = Regex( """\b(signerHandle|coreSignerHandle|resolverHandle|mnemonicResolverHandle|sdkHandle)\s*:\s*Long\b""", ) - val GATED_OPENER = Regex( - """=\s*(\w+\.)?(gate|teardownGate|queryGate)\.""" + - """(?:op \{|opWithCleanupOnCancellation\()""", - ) + + /** The gate bracket itself, wherever in a body it appears. */ + const val GATE_BRACKET_PATTERN = + """(\w+\.)?(gate|teardownGate|queryGate)\.""" + + """(?:op \{|opWithCleanupOnCancellation\()""" + val GATE_BRACKET = Regex(GATE_BRACKET_PATTERN) + + /** The bracket in expression-body position, i.e. as the whole body. */ + val GATED_OPENER = Regex("""=\s*$GATE_BRACKET_PATTERN""") val DELEGATION_OPENER = Regex("""=\s*\w+\(""") + /** + * A JNI entry point — the call that actually consumes the borrowed + * handle, and so the thing that must sit inside the bracket. + */ + val NATIVE_CALL = Regex("""\b\w*Native\.\w+\(""") + /** `funName@FileName.kt` entries that are intentionally ungated. */ val ALLOWLIST = emptySet() } From a167afe84c0e326e7d323450927aa52ee6fe116c Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:47:04 -0400 Subject: [PATCH 06/10] docs(platform-wallet): record the test-coverage boundary of the IS->CL resubmission arm The unit tests pin proof assembly (IS primary + optional CL fallback) but not the resubmission arm itself, which needs an injectable submission seam to be unit-testable; note that at the arm so the gap raised in review is visible in the code. Co-Authored-By: Claude Opus 4.8 --- .../src/wallet/identity/network/invitation.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index c2dd2f57048..b63bfb234d6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -492,6 +492,10 @@ impl IdentityWallet { .await { Ok(identity) => identity, + // Coverage note (#4240 review): the unit tests pin proof assembly + // (IS primary + optional CL fallback) but not this resubmission arm + // itself — exercising it needs an injectable submission seam, so + // until one exists regressions here surface only in live claims. Err(e) if is_instant_lock_proof_invalid(&e) => { let Some(chain_proof) = chain_fallback else { // The islock was rejected but the funding tx is not yet From da36fd57bb4ee08f7581c21309704cb87c34b1e3 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:54:47 -0400 Subject: [PATCH 07/10] fix(jni): publish the invitation bearer URI without a post-funding allocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createInvitation` returned the funding outpoint + `dashpay://invite` link via `env.byte_array_from_slice(&blob)`. In jni 0.21 that is a `NewByteArray` call, so an allocation failure returned null while the copied URI was dropped — after the voucher had already been broadcast, persisted and proven. Nothing can recover the link at that point: the persisted invitation row keeps only the outpoint and funding metadata (no URI, no one-time key), there is no in-memory replay in v1, and no FFI entry point regenerates or re-exports an invitation. The caller could therefore lose the sole bearer credential after the funds had moved. Move the payload into caller-allocated storage, validated before the native operation, and publish with non-allocating region writes: - `MAX_INVITATION_URI_LEN` becomes `pub` in platform-wallet. It is already the hard cap `encode_invitation_uri` enforces on every emitted link, so it makes a fixed output buffer exact rather than heuristic. Documented as part of the cross-language contract. - platform-wallet-ffi re-exports it as `PLATFORM_WALLET_MAX_INVITATION_URI_LEN` and derives `PLATFORM_WALLET_INVITATION_BLOB_CAPACITY` (36-byte outpoint prefix + URI cap) from it — single source of truth, never a second hardcoded 8192. - The JNI `createInvitation` takes `outBlob: byte[]` and `outLen: int[1]`, validates both (non-null, capacity, length >= 1) with `exception_clear` + `throw_sdk_exception` BEFORE the call — so a bad buffer fails closed with no funds moved — and returns void, publishing through `set_byte_array_region` / `set_int_array_region`. This mirrors `create_wallet_from_mnemonic_impl`, which already applies the same discipline to the post-persistence wallet handle/id, defensive backstop included. - New `invitationBlobCapacity()` entry point exposes the capacity so Kotlin never hardcodes it and the two sides cannot drift. - Kotlin allocates both buffers inside the gate, slices the outpoint/URI from the reported length, and scrubs the buffer in a `finally`; the Rust staging copies are held in `Zeroizing`. The URI is a bearer credential, so it should not linger in a long-lived JVM array. The public `IdentityRegistration.createInvitation` signature is unchanged. This closes the JNI-allocation hole in the same delivery chain the earlier `NonCancellable` fix addressed for coroutine cancellation; both KDoc blocks are updated to describe the contract that now holds. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/ffi/IdentityNative.kt | 30 ++- .../dashsdk/identity/IdentityRegistration.kt | 66 +++++-- .../rs-platform-wallet-ffi/src/invitation.rs | 31 +++- .../src/wallet/identity/crypto/invitation.rs | 14 +- .../src/wallet/identity/crypto/mod.rs | 2 +- packages/rs-unified-sdk-jni/src/identity.rs | 172 +++++++++++++++--- 6 files changed, 262 insertions(+), 53 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt index 2735781aec6..54791a0d646 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/IdentityNative.kt @@ -172,11 +172,28 @@ internal object IdentityNative { coreSignerHandle: Long, ): ByteArray + /** + * Byte capacity a [createInvitation] `outBlob` must have: the 36-byte + * outpoint prefix plus the native hard cap on an emitted + * `dashpay://invite` link. Read from the native side rather than + * hard-coded here so the two cannot drift apart across a rebuild. + */ + external fun invitationBlobCapacity(): Int + /** * Create a DashPay invitation (DIP-13): fund a one-time asset-lock * voucher and return a shareable `dashpay://invite` link. No identity is * registered — this is pure voucher creation. * + * Both out-buffers are caller-allocated and validated natively BEFORE the + * voucher is funded, so the result is published with non-allocating region + * writes that cannot fail. That is required rather than stylistic: the call + * only succeeds once the asset lock has been broadcast and persisted, and + * the link it produces is the SOLE copy of a bearer credential (Room stores + * only the outpoint and funding metadata — no URI, no key — and there is no + * regeneration API), so a fallible allocation after the funding could lose + * the credential outright. + * * @param amountDuffs voucher amount in duffs (must be positive). * @param fundingAccountIndex BIP-44 account the voucher is funded from. * @param inviterIdentityId optional 32-byte inviter id enabling the @@ -188,9 +205,12 @@ internal object IdentityNative { * ~24h expiry is derived Rust-side. * @param coreSignerHandle `MnemonicResolverHandle` for the funding-spend * signature (the SAME handle [registerIdentityWithFunding] takes). - * @return a blob: `outpoint[36] (txid[32] || vout_le[4]) || utf8Uri`. The - * URI embeds the bearer voucher key — never log or persist it beyond - * the share sheet. + * @param outBlob a `ByteArray` of at least [invitationBlobCapacity] bytes, + * receiving `outpoint[36] (txid[32] || vout_le[4]) || utf8Uri`. The URI + * embeds the bearer voucher key — never log or persist it beyond the + * share sheet, and scrub this buffer once the string has been built. + * @param outLen an `IntArray(1)` receiving the number of bytes actually + * written to [outBlob] (`36 + uri.length`); the rest is untouched. */ external fun createInvitation( walletHandle: Long, @@ -200,7 +220,9 @@ internal object IdentityNative { inviterUsername: String?, nowUnix: Long, coreSignerHandle: Long, - ): ByteArray + outBlob: ByteArray, + outLen: IntArray, + ) /** * Claim a DashPay invitation (DIP-13): register a NEW identity for the diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt index 32d75e2c942..d0d3a87d76b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityRegistration.kt @@ -332,6 +332,13 @@ class IdentityRegistration internal constructor( * exists — so a discarded `withContext` result would lose the only * shareable credential after funds have moved. * + * Delivery contract (the same hazard, one layer down): the link is + * published into a caller-allocated buffer that the native side validates + * BEFORE it funds anything, and writes with non-allocating region writes + * afterwards. No fallible allocation sits between the broadcast voucher and + * the URI arriving here, so the credential cannot be dropped on the way + * out. The buffer is scrubbed once the string has been built. + * * @param inviterIdentityId optional 32-byte inviter id enabling the * contact-bootstrap opt-in; `null` for a pure funding voucher. When * non-null, [inviterUsername] is required. @@ -372,26 +379,49 @@ class IdentityRegistration internal constructor( currentCoroutineContext().ensureActive() return withContext(NonCancellable) { gate.op { - val blob = mapNativeErrors { - IdentityNative.createInvitation( - walletHandle, - amountDuffs, - fundingAccountIndex, - inviterIdentityId, - inviterUsername, - nowUnix, - coreSignerHandle, + // Caller-allocated out-buffers, validated natively BEFORE the + // voucher is funded — the native side then publishes with + // non-allocating region writes. The previous shape allocated + // the result array inside JNI *after* the asset lock had been + // broadcast and persisted, where an allocation failure would + // have destroyed the only copy of the bearer link (Room stores + // no URI or voucher key, and there is no regeneration API). + // The capacity comes from the native cap on an emitted link, so + // the buffer is always large enough and the two cannot drift. + val capacity = IdentityNative.invitationBlobCapacity() + val outBlob = ByteArray(capacity) + val outLen = IntArray(1) + try { + mapNativeErrors { + IdentityNative.createInvitation( + walletHandle, + amountDuffs, + fundingAccountIndex, + inviterIdentityId, + inviterUsername, + nowUnix, + coreSignerHandle, + outBlob, + outLen, + ) + } + // Blob layout (fixed by the JNI): outpoint[36] (txid[32] || + // vout_le[4]) then the UTF-8 URI, `len` bytes in total. + // Anything outside that window is a contract violation. + val len = outLen[0] + require(len in 36..capacity) { + "createInvitation reported a $len-byte blob; expected 36..$capacity" + } + CreatedInvitation( + outPoint = outBlob.copyOfRange(0, 36), + uri = String(outBlob, 36, len - 36, Charsets.UTF_8), ) + } finally { + // The buffer held the plaintext bearer voucher key; scrub it + // so the secret does not linger in a JVM array that outlives + // this call (same discipline as the key-preview blobs). + outBlob.fill(0) } - // Blob layout (fixed by the JNI): outpoint[36] (txid[32] || vout_le[4]) - // then the UTF-8 URI. Anything shorter is a contract violation. - require(blob.size >= 36) { - "createInvitation returned a ${blob.size}-byte blob; expected >= 36" - } - CreatedInvitation( - outPoint = blob.copyOfRange(0, 36), - uri = String(blob.copyOfRange(36, blob.size), Charsets.UTF_8), - ) } } } diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index ed01ce69013..72adf522bb8 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -29,7 +29,9 @@ use std::ffi::CStr; use std::os::raw::c_char; use dpp::identity::accessors::IdentityGettersV0; -use platform_wallet::wallet::identity::crypto::{parse_invitation_uri, InviterInfo}; +use platform_wallet::wallet::identity::crypto::{ + parse_invitation_uri, InviterInfo, MAX_INVITATION_URI_LEN, +}; use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; use platform_wallet::wallet::identity::network::MAX_INVITATION_TTL_SECS; @@ -42,6 +44,33 @@ use crate::identity_registration_with_signer::{decode_identity_pubkeys, Identity use crate::runtime::block_on_worker; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +/// Maximum length, in bytes, of a `dashpay://invite` link this library emits. +/// +/// Re-export of platform-wallet's `MAX_INVITATION_URI_LEN` — the single source +/// of truth, enforced by `encode_invitation_uri` on every link it produces, so +/// the value is *derived* here rather than re-typed. Exposed so a +/// caller-allocates-the-output binding (the Android JNI bridge) can size a +/// fixed buffer that is always large enough. +pub const PLATFORM_WALLET_MAX_INVITATION_URI_LEN: usize = MAX_INVITATION_URI_LEN; + +/// Length of the funding-outpoint prefix (`txid[32] || vout_le[4]`) framed +/// ahead of the URI by bindings that hand back one blob — the same 36-byte +/// encoding the persistence layer keys invitation rows by. +pub const PLATFORM_WALLET_INVITATION_OUTPOINT_LEN: usize = 32 + 4; + +/// Worst-case size of the `outpoint || utf8_uri` blob a create-invitation +/// caller must preallocate. +/// +/// Bounded precisely because the URI itself is capped, so a buffer of this size +/// can always receive the whole payload. That is what lets a binding validate +/// its output storage *before* entering the operation and then publish with a +/// non-allocating write: create only succeeds after the voucher has been +/// broadcast and persisted, and the returned link is the sole copy of a bearer +/// credential (nothing persists the URI or the one-time key, and there is no +/// regeneration entry point), so no fallible allocation may follow the funding. +pub const PLATFORM_WALLET_INVITATION_BLOB_CAPACITY: usize = + PLATFORM_WALLET_INVITATION_OUTPOINT_LEN + PLATFORM_WALLET_MAX_INVITATION_URI_LEN; + /// Create a DashPay invitation: fund a one-time asset-lock voucher at the /// DIP-13 invitation path and return a shareable `dashpay://invite` link. /// diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs index 6060013024a..91d5e6d0ebe 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/invitation.rs @@ -79,7 +79,19 @@ const IS_LOCK_NULL_SENTINEL: &str = "null"; /// username + txid (64) + WIF (~52) + islock hex (~400) + optional avatar url — /// is well under 2 KB; 8192 is comfortable headroom while bounding the /// allocation a hostile link can force. -const MAX_INVITATION_URI_LEN: usize = 8192; +/// +/// This cap is also part of the **cross-language output contract**: +/// [`encode_invitation_uri`] enforces it on every link it emits, so an emitted +/// invitation always fits in a fixed-size buffer. `platform-wallet-ffi` +/// re-exports it as `PLATFORM_WALLET_MAX_INVITATION_URI_LEN` (and derives +/// `PLATFORM_WALLET_INVITATION_BLOB_CAPACITY` from it), which is what lets the +/// JNI create-invitation bridge publish the bearer link into a pre-validated +/// caller-allocated buffer with a non-allocating region write — no fallible +/// allocation may sit between the funded voucher and the link reaching the +/// caller. Changing this value therefore changes that ABI-adjacent capacity; +/// the FFI/JNI/Kotlin callers all derive it from here, so they move together on +/// a rebuild rather than drifting. +pub const MAX_INVITATION_URI_LEN: usize = 8192; /// Max length (bytes) of a UTF-8 string field (username / display name / avatar /// url). DPNS labels are short; this only bounds a hostile link. diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index bd72b8fe402..3e10276a57f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -21,7 +21,7 @@ pub use dip14::{ }; pub use invitation::{ encode_invitation_uri, parse_invitation_uri, voucher_output_index, wif_network_matches, - InviterInfo, ParsedInvitation, + InviterInfo, ParsedInvitation, MAX_INVITATION_URI_LEN, }; pub use tx_metadata::{ derive_tx_metadata_key, derive_tx_metadata_key_from_master, open_tx_metadata, seal_tx_metadata, diff --git a/packages/rs-unified-sdk-jni/src/identity.rs b/packages/rs-unified-sdk-jni/src/identity.rs index 46c8b4e0919..43a59e9c8fc 100644 --- a/packages/rs-unified-sdk-jni/src/identity.rs +++ b/packages/rs-unified-sdk-jni/src/identity.rs @@ -35,7 +35,7 @@ use crate::pubkey_rows::decode_registration_pubkeys_blob; use crate::support::{ generic_asset_lock_recovery_allowed, guard, net_from_ord, take_pwffi_error, throw_sdk_exception, }; -use jni::objects::{JByteArray, JClass, JString, JValue}; +use jni::objects::{JByteArray, JClass, JIntArray, JString, JValue}; use jni::sys::{jboolean, jbyteArray, jint, jlong, jobject}; use jni::JNIEnv; use platform_wallet_ffi::core_wallet_types::OutPointFFI; @@ -45,6 +45,9 @@ use platform_wallet_ffi::identity_discovery::DiscoveredIdentityIdsFFI; use platform_wallet_ffi::identity_key_preview::{IdentityKeyPreviewFFI, IdentityKeyPreviewsFFI}; use platform_wallet_ffi::identity_registration::IdentityFundingInputFFI; use platform_wallet_ffi::identity_registration_with_signer::IdentityPubkeyFFI; +use platform_wallet_ffi::invitation::{ + PLATFORM_WALLET_INVITATION_BLOB_CAPACITY, PLATFORM_WALLET_INVITATION_OUTPOINT_LEN, +}; use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle}; use std::ffi::{CStr, CString}; use std::os::raw::c_char; @@ -723,13 +726,37 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_regist /// after a restart; the call fails closed BEFORE any funds move when the /// bridge doesn't implement invitation persistence. /// -/// ## Return +/// ## Output contract (caller-allocated, pre-validated) +/// +/// The result is published into buffers the CALLER allocates, not into a +/// freshly allocated return array: +/// +/// - `outBlob` — a `byte[]` at least +/// [`PLATFORM_WALLET_INVITATION_BLOB_CAPACITY`] bytes long (Kotlin reads the +/// number from +/// [`Java_org_dashfoundation_dashsdk_ffi_IdentityNative_invitationBlobCapacity`] +/// rather than hard-coding it). The first 36 bytes receive the funding +/// outpoint (`txid[32] || vout_le[4]` — the same 36-byte encoding the +/// persistence layer keys invitation rows by); the bytes after it receive the +/// UTF-8 `dashpay://invite` URI. +/// - `outLen` — an `int[1]` receiving the number of bytes actually written +/// (`36 + uri.len()`). The remainder of `outBlob` is left untouched. /// -/// A `byte[]` blob: the first 36 bytes are the funding outpoint -/// (`txid[32] || vout_le[4]` — the same 36-byte encoding the persistence -/// layer keys invitation rows by), and the remaining bytes are the UTF-8 -/// `dashpay://invite` URI. **The URI embeds the bearer voucher key — the -/// Kotlin caller MUST NOT log it or persist it anywhere but the share sheet.** +/// Both buffers are validated BEFORE the native call, so a malformed buffer +/// fails closed while no funds have moved; publishing afterwards is a pair of +/// non-allocating region writes that cannot fail. This shape is required, not +/// stylistic: `platform_wallet_create_invitation` returns success only once the +/// voucher has been broadcast, persisted and proven, and the URI it hands back +/// is the SOLE copy of a bearer credential — the persisted invitation row keeps +/// only the outpoint and funding metadata, and there is no regeneration or +/// re-export entry point. A fallible post-funding allocation here (the previous +/// `byte_array_from_slice` return) could therefore drop the only credential +/// after the money was spent. Mirrors the wallet-creation bridge's out-buffer +/// discipline in [`crate::wallet_manager`]. +/// +/// **The URI embeds the bearer voucher key — the Kotlin caller MUST NOT log it +/// or persist it anywhere but the share sheet, and should scrub `outBlob` once +/// the string has been built.** The Rust-side copies are scrubbed here. #[no_mangle] #[allow(clippy::too_many_arguments)] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_createInvitation( @@ -742,17 +769,46 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_create inviter_username: JString, now_unix: jlong, core_signer_handle: jlong, -) -> jbyteArray { - guard(&mut env, ptr::null_mut(), |env| { + out_blob: JByteArray, + out_len: JIntArray, +) { + guard(&mut env, (), |env| { + // Validate the caller-allocated out-buffers FIRST — before any + // argument marshaling and, crucially, before the native call. Create + // only returns success once the voucher has been funded, broadcast and + // persisted, and the bearer URI it produces cannot be regenerated, so + // the publish step that follows has to be infallible. Checking the + // bounds up front is what makes the region writes below allocation-free + // and in-range; a bad buffer fails closed here, with no funds moved. + // Same discipline as `create_wallet_from_mnemonic_impl`. + if out_blob.is_null() + || env.get_array_length(&out_blob).map_or(true, |len| { + (len as usize) < PLATFORM_WALLET_INVITATION_BLOB_CAPACITY + }) + { + let _ = env.exception_clear(); + throw_sdk_exception( + env, + 1, + "outBlob must be a non-null byte[] of at least invitationBlobCapacity() bytes", + ); + return; + } + if out_len.is_null() || env.get_array_length(&out_len).map_or(true, |len| len < 1) { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "outLen must be a non-null int[1]"); + return; + } + // Reject sign / range errors at the boundary before they bit-cast to // huge unsigned values across the FFI. if amount_duffs <= 0 { throw_sdk_exception(env, 1, "amountDuffs must be positive"); - return ptr::null_mut(); + return; } if funding_account_index < 0 { throw_sdk_exception(env, 1, "fundingAccountIndex must be non-negative"); - return ptr::null_mut(); + return; } if now_unix <= 0 || now_unix > u32::MAX as jlong { throw_sdk_exception( @@ -760,11 +816,11 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_create 1, "nowUnix must be a valid unix timestamp (1..=u32::MAX)", ); - return ptr::null_mut(); + return; } if core_signer_handle == 0 { throw_sdk_exception(env, 1, "coreSignerHandle must be non-null"); - return ptr::null_mut(); + return; } // Optional contact-bootstrap opt-in: a null `inviterIdentityId` ⇒ pure @@ -776,13 +832,13 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_create } else { match read_id32(env, &inviter_identity_id, "inviterIdentityId") { Some(id) => Some(id), - None => return ptr::null_mut(), // read_id32 already threw + None => return, // read_id32 already threw } }; let inviter_username_c = match read_optional_cstring(env, &inviter_username, "inviterUsername") { Ok(c) => c, - Err(()) => return ptr::null_mut(), // already threw + Err(()) => return, // already threw }; if inviter_id.is_some() && inviter_username_c.is_none() { throw_sdk_exception( @@ -790,7 +846,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_create 1, "inviterUsername is required when inviterIdentityId is provided", ); - return ptr::null_mut(); + return; } let mut out_uri: *mut c_char = ptr::null_mut(); @@ -816,29 +872,89 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_create // `inviter_id` / `inviter_username_c` own the buffers the pointers above // referenced; they stay in scope through the FFI call. if take_pwffi_error(env, result) { - return ptr::null_mut(); + return; } if out_uri.is_null() { throw_sdk_exception(env, 99, "createInvitation returned success but no URI"); - return ptr::null_mut(); + return; } - // Copy the (secret) URI out, then free the Rust string. It is copied - // straight into the return blob and never logged. - let uri_bytes = unsafe { CStr::from_ptr(out_uri) }.to_bytes().to_vec(); + // Copy the (secret) URI out, then free the Rust string. `Zeroizing` + // scrubs this copy on drop; it is never logged and goes straight into + // the caller's buffer. + let uri_bytes = + zeroize::Zeroizing::new(unsafe { CStr::from_ptr(out_uri) }.to_bytes().to_vec()); unsafe { platform_wallet_ffi::platform_wallet_string_free(out_uri) }; // Blob: outpoint (`txid[32] || vout_le[4]`) then the UTF-8 URI. The // outpoint uses the same 36-byte encoding the persistence layer keys // invitation rows by, so Kotlin has ONE outpoint shape everywhere. - let mut blob = Vec::with_capacity(36 + uri_bytes.len()); - blob.extend_from_slice(&out_outpoint.txid); - blob.extend_from_slice(&out_outpoint.vout.to_le_bytes()); - blob.extend_from_slice(&uri_bytes); + // Staged directly as `jbyte` so publishing is one region write with no + // second (unscrubbed) copy of the bearer key, and wrapped in + // `Zeroizing` so the staging buffer is scrubbed on drop. + let mut blob: zeroize::Zeroizing> = zeroize::Zeroizing::new( + Vec::with_capacity(PLATFORM_WALLET_INVITATION_OUTPOINT_LEN + uri_bytes.len()), + ); + blob.extend(out_outpoint.txid.iter().map(|b| *b as jni::sys::jbyte)); + blob.extend( + out_outpoint + .vout + .to_le_bytes() + .iter() + .map(|b| *b as jni::sys::jbyte), + ); + blob.extend(uri_bytes.iter().map(|b| *b as jni::sys::jbyte)); - env.byte_array_from_slice(&blob) - .map(|a| a.into_raw()) - .unwrap_or(ptr::null_mut()) + // Unreachable: `encode_invitation_uri` rejects any link longer than + // `MAX_INVITATION_URI_LEN`, which is exactly what the capacity above is + // derived from. Kept as a defensive backstop so an invariant break + // surfaces as a clear error instead of an out-of-range region write. + if blob.len() > PLATFORM_WALLET_INVITATION_BLOB_CAPACITY { + throw_sdk_exception( + env, + 99, + "createInvitation produced a blob larger than invitationBlobCapacity()", + ); + return; + } + + // Publish into the pre-validated caller buffers. Region writes on + // bounds-checked arrays allocate nothing, so nothing fallible sits + // between the funded voucher and the bearer link reaching Kotlin. + // Length last: a caller that sees `outLen[0] == 0` never reads a + // half-written blob. + let published = env + .set_byte_array_region(&out_blob, 0, &blob) + .and_then(|_| env.set_int_array_region(&out_len, 0, &[blob.len() as jint])); + if published.is_err() { + // Unreachable after the up-front bounds validation; defensive + // backstop only. NOTE the limit: the voucher IS funded, broadcast + // and persisted by now and the URI cannot be regenerated, so all + // this can do is report the loss loudly rather than silently + // returning a null array (the defect this contract removes). + let _ = env.exception_clear(); + throw_sdk_exception( + env, + 99, + "invitation was funded but publishing the link into the caller buffer failed", + ); + } + }) +} + +/// Byte capacity a [`Java_org_dashfoundation_dashsdk_ffi_IdentityNative_createInvitation`] +/// caller must preallocate for `outBlob`: the 36-byte outpoint prefix plus the +/// hard cap platform-wallet enforces on an emitted `dashpay://invite` link +/// (`MAX_INVITATION_URI_LEN`). Exposed as its own entry point so Kotlin never +/// hard-codes the number — both sides move together on a native rebuild and +/// cannot silently drift apart. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_invitationBlobCapacity( + mut env: JNIEnv, + _class: JClass, +) -> jint { + guard(&mut env, 0, |_env| { + PLATFORM_WALLET_INVITATION_BLOB_CAPACITY as jint }) } From 9349c07b371920786eedea9a0bafb136aadd4d80 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:05:44 -0400 Subject: [PATCH 08/10] fix(jni): reserve the invitation bearer buffer before the voucher is funded The previous shape removed the post-funding `NewByteArray`, but two Rust allocations still sat between the funded voucher and the bearer link reaching Kotlin: `CStr::to_vec()` and a `Vec::with_capacity` sized from the returned URI. Rust allocation failure aborts, so either one could destroy the only copy of a link that is already funded, broadcast and persisted, and that Room does not store. Reserve the staging buffer at the full advertised capacity BEFORE the FFI call, borrow the URI in place instead of copying it, and bounds-check the total against that capacity before staging so `extend` provably cannot reallocate. A debug assertion pins the no-realloc invariant against the capacity the allocator actually handed back. Co-Authored-By: Claude Opus 4.8 --- packages/rs-unified-sdk-jni/src/identity.rs | 74 ++++++++++++++------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/identity.rs b/packages/rs-unified-sdk-jni/src/identity.rs index 43a59e9c8fc..4082f761ca1 100644 --- a/packages/rs-unified-sdk-jni/src/identity.rs +++ b/packages/rs-unified-sdk-jni/src/identity.rs @@ -849,6 +849,26 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_create return; } + // Staging buffer for the bearer blob, allocated BEFORE the voucher is + // funded and at the FULL advertised capacity — never at a size derived + // from the returned URI. Rust allocation failure aborts the process, so + // an allocation performed *after* funding is an unrecoverable loss of + // the only copy of the bearer link. Reserving here moves that abort + // point ahead of the asset lock: everything between the funded voucher + // and the region write below is allocation-free, because the length is + // bounds-checked against this capacity before a single byte is pushed + // and `extend` therefore cannot reallocate. + // + // Wrapped in `Zeroizing` so the staging copy of the bearer key is + // scrubbed on drop; `jbyte` so publishing is one region write with no + // second, unscrubbed copy. + let mut blob: zeroize::Zeroizing> = + zeroize::Zeroizing::new(Vec::with_capacity(PLATFORM_WALLET_INVITATION_BLOB_CAPACITY)); + // The allocator may hand back more than requested; pin whatever it + // actually gave us so the post-funding assertion below detects a + // reallocation rather than comparing against the requested figure. + let reserved_capacity = blob.capacity(); + let mut out_uri: *mut c_char = ptr::null_mut(); let mut out_outpoint = OutPointFFI { txid: [0u8; 32], @@ -879,22 +899,33 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_create return; } - // Copy the (secret) URI out, then free the Rust string. `Zeroizing` - // scrubs this copy on drop; it is never logged and goes straight into - // the caller's buffer. - let uri_bytes = - zeroize::Zeroizing::new(unsafe { CStr::from_ptr(out_uri) }.to_bytes().to_vec()); - unsafe { platform_wallet_ffi::platform_wallet_string_free(out_uri) }; + // Borrow the (secret) URI in place — no owned copy is made, so no + // allocation happens on this side of the funding. The Rust string is + // freed once its bytes have been staged. + let uri_bytes: &[u8] = unsafe { CStr::from_ptr(out_uri) }.to_bytes(); // Blob: outpoint (`txid[32] || vout_le[4]`) then the UTF-8 URI. The // outpoint uses the same 36-byte encoding the persistence layer keys // invitation rows by, so Kotlin has ONE outpoint shape everywhere. - // Staged directly as `jbyte` so publishing is one region write with no - // second (unscrubbed) copy of the bearer key, and wrapped in - // `Zeroizing` so the staging buffer is scrubbed on drop. - let mut blob: zeroize::Zeroizing> = zeroize::Zeroizing::new( - Vec::with_capacity(PLATFORM_WALLET_INVITATION_OUTPOINT_LEN + uri_bytes.len()), - ); + // + // Bounds-check BEFORE staging: this is what guarantees the `extend` + // calls below stay inside the capacity reserved before funding and + // never reallocate. Unreachable in practice — `encode_invitation_uri` + // rejects any link longer than `MAX_INVITATION_URI_LEN`, which is what + // the capacity is derived from — but it is the invariant the + // allocation-free claim rests on, so it is checked rather than assumed. + if PLATFORM_WALLET_INVITATION_OUTPOINT_LEN + uri_bytes.len() + > PLATFORM_WALLET_INVITATION_BLOB_CAPACITY + { + unsafe { platform_wallet_ffi::platform_wallet_string_free(out_uri) }; + throw_sdk_exception( + env, + 99, + "createInvitation produced a blob larger than invitationBlobCapacity()", + ); + return; + } + blob.extend(out_outpoint.txid.iter().map(|b| *b as jni::sys::jbyte)); blob.extend( out_outpoint @@ -904,19 +935,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_create .map(|b| *b as jni::sys::jbyte), ); blob.extend(uri_bytes.iter().map(|b| *b as jni::sys::jbyte)); - - // Unreachable: `encode_invitation_uri` rejects any link longer than - // `MAX_INVITATION_URI_LEN`, which is exactly what the capacity above is - // derived from. Kept as a defensive backstop so an invariant break - // surfaces as a clear error instead of an out-of-range region write. - if blob.len() > PLATFORM_WALLET_INVITATION_BLOB_CAPACITY { - throw_sdk_exception( - env, - 99, - "createInvitation produced a blob larger than invitationBlobCapacity()", - ); - return; - } + debug_assert_eq!( + blob.capacity(), + reserved_capacity, + "staging the invitation blob reallocated after the voucher was funded" + ); + unsafe { platform_wallet_ffi::platform_wallet_string_free(out_uri) }; // Publish into the pre-validated caller buffers. Region writes on // bounds-checked arrays allocate nothing, so nothing fallible sits From 242c461eb4be1265eb20d6d1c7186fe6693cb065 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:05:44 -0400 Subject: [PATCH 09/10] style(jni): restore rustfmt on the persistence FFI import block `cargo fmt --check --all` fails at this branch head on an import block that predates this change, which fails the workspace formatting job. Co-Authored-By: Claude Opus 4.8 --- packages/rs-unified-sdk-jni/src/persistence.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 7932e8619be..1a7eaaa7ef1 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -55,11 +55,10 @@ use platform_wallet_ffi::{ AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, ContactRequestFFI, ContactRequestRemovalFFI, CoreAddressEntryFFI, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, InvitationEntryFFI, - PaymentRestoreEntryFFI, - PersistenceCallbacks, PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, - TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, - UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, WalletChangeSetFFI, - WalletRestoreEntryFFI, + PaymentRestoreEntryFFI, PersistenceCallbacks, PlatformAddressFFI, + ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, TokenBalanceRemovalFFI, + TokenBalanceUpsertFFI, TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, + UtxoRestoreEntryFFI, WalletChangeSetFFI, WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; From cebad144f396fa5e0f4bf40b71ed418b83af5deb Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:10:37 -0400 Subject: [PATCH 10/10] fix(kotlin-sdk): surface claim byte[] alloc failure; correct v9 cascade KDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CodeRabbit findings on the L1-invite PR (dashpay/platform#4240): - identity.rs claimInvitation bridge: byte_array_from_slice failure returned a bare null through a non-null Kotlin ByteArray return, silently indistinguishable from a valid empty id. Raise the same native exception the sibling identity byte[] allocation path uses (identity.rs:564) before returning null. Nothing to release here — the claimed identity is already folded into IdentityManager and persisted. - DashDatabase.kt: the Version 9 KDoc claimed invitation rows "die with their wallet via the deleteWalletData cascade". There is no foreign key on the invitations table (PRIMARY KEY(outPoint) only), so nothing cascades; deleteWalletDataLocked removes them explicitly via invitationDao().deleteByWallet(). Reworded so a maintainer does not assume ON DELETE CASCADE protects this durability-critical table. Co-Authored-By: Claude Opus 4.8 --- .../dashsdk/persistence/DashDatabase.kt | 5 +++-- packages/rs-unified-sdk-jni/src/identity.rs | 16 +++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) 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 e0d0397a104..2812978b42d 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 @@ -114,8 +114,9 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * row per funded one-time asset-lock voucher, keyed by its 36-byte funding * outpoint. Durable storage here is what lets the Rust `create_invitation` * durability gate mint a voucher (a non-durable store could re-export the - * same one-time key after a restart). Rows die with their wallet via the - * `deleteWalletData` cascade. + * same one-time key after a restart). The `invitations` table has no foreign + * key to `wallets`, so rows do not cascade on delete; `deleteWalletData` + * removes them explicitly instead. */ @Database( version = 9, diff --git a/packages/rs-unified-sdk-jni/src/identity.rs b/packages/rs-unified-sdk-jni/src/identity.rs index 4082f761ca1..d6c9146bd42 100644 --- a/packages/rs-unified-sdk-jni/src/identity.rs +++ b/packages/rs-unified-sdk-jni/src/identity.rs @@ -1085,9 +1085,19 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_IdentityNative_claimI unsafe { platform_wallet_ffi_result_free(&mut destroy) }; } - env.byte_array_from_slice(&out_id) - .map(|a| a.into_raw()) - .unwrap_or(ptr::null_mut()) + // The claimed identity is already folded into IdentityManager and + // persisted, so there is nothing to release here — but a bare + // `unwrap_or(null)` would hand Kotlin a silent null through a non-null + // `ByteArray` return type, indistinguishable from success with an empty + // id. Raise the same native exception the sibling identity byte[] + // allocation failure uses (line ~564) so the caller sees a real error. + match env.byte_array_from_slice(&out_id) { + Ok(array) => array.into_raw(), + Err(_) => { + throw_sdk_exception(env, 99, "claimed identity result byte[] allocation failed"); + ptr::null_mut() + } + } }) }