diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json new file mode 100644 index 00000000000..725ba983524 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json @@ -0,0 +1,4162 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "f124080579cecd914cdc8f96827ee79b", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `lastAppliedChainLockHeight` INTEGER, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "lastAppliedChainLockHeight", + "columnName": "lastAppliedChainLockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockPosition", + "columnName": "blockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockPosition", + "columnName": "hasBlockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "transaction_account_involvements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "transactionTxid", + "columnName": "transactionTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transactionTxid", + "accountId" + ] + }, + "indices": [ + { + "name": "index_transaction_account_involvements_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "transactionTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, `supersededByTxid` BLOB, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + }, + { + "fieldPath": "supersededByTxid", + "columnName": "supersededByTxid", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "invitations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `rawOutPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `reclaimInFlight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawOutPoint", + "columnName": "rawOutPoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingIndexRaw", + "columnName": "fundingIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryUnix", + "columnName": "expiryUnix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtSecs", + "columnName": "createdAtSecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviter", + "columnName": "hasInviter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reclaimInFlight", + "columnName": "reclaimInFlight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_invitations_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `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, `documentId` BLOB, `isOwned` INTEGER NOT NULL, `priceCredits` INTEGER, `saleStatusRaw` INTEGER NOT NULL, `counterpartyIdentityId` BLOB, `documentCreatedAtMs` INTEGER NOT NULL, `documentUpdatedAtMs` INTEGER NOT NULL, `documentTransferredAtMs` INTEGER NOT NULL, `marketplaceUpdatedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "BLOB" + }, + { + "fieldPath": "isOwned", + "columnName": "isOwned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priceCredits", + "columnName": "priceCredits", + "affinity": "INTEGER" + }, + { + "fieldPath": "saleStatusRaw", + "columnName": "saleStatusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB" + }, + { + "fieldPath": "documentCreatedAtMs", + "columnName": "documentCreatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentUpdatedAtMs", + "columnName": "documentUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTransferredAtMs", + "columnName": "documentTransferredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "marketplaceUpdatedAt", + "columnName": "marketplaceUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_dpns_names_documentId", + "unique": false, + "columnNames": [ + "documentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` ON `${TABLE_NAME}` (`documentId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `isSweptTombstone` INTEGER NOT NULL DEFAULT 0, `winnerMinedHeight` INTEGER, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSweptTombstone", + "columnName": "isSweptTombstone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "winnerMinedHeight", + "columnName": "winnerMinedHeight", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + }, + { + "name": "index_pending_inputs_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight", + "unique": false, + "columnNames": [ + "walletId", + "isSweptTombstone", + "winnerMinedHeight" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` ON `${TABLE_NAME}` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `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, 'f124080579cecd914cdc8f96827ee79b')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index e6ce11bee92..ef90b3804a1 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -394,13 +394,89 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v10 → v11 adds the four sweep-hold columns — `txos.supersededByTxid` + * (nullable), `pending_inputs.isSweptTombstone` (defaulted `false`), + * `pending_inputs.winnerMinedHeight` (nullable) and + * `wallets.lastAppliedChainLockHeight` (nullable) — plus the two + * `pending_inputs` indexes the sweep lookup and the collector use. All + * additive. Pre-existing rows in each table must survive and read back + * with the new columns at their defaults (an unstamped, non-tombstone + * row is never collected; a wallet with no chainlock height has no + * finality boundary), the nullable columns must accept an explicit + * value on write, and `runMigrationsAndValidate` pins the indexes + * against the exported 11.json. + */ + @Test + fun migrate10To11AddsSweepHoldColumnsAndIndexes() { + val legacy = helper.createDatabase(dbName, 10) + legacy.execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'01', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)", + ) + legacy.execSQL( + "INSERT INTO transactions (txid, transactionData, context, blockHeight, " + + "blockTimestamp, blockPosition, hasBlockPosition, direction, " + + "transactionType, transactionTypeKind, netAmount, label, firstSeen, " + + "createdAt, lastUpdated) " + + "VALUES (x'02', x'00', 0, 0, 0, 0, 0, 0, 'Standard', 0, 0, '', 0, 0, 0)", + ) + legacy.execSQL( + "INSERT INTO txos (outpoint, vout, amount, address, scriptPubKey, height, " + + "isCoinbase, isConfirmed, isInstantLocked, isLocked, isSpent, createdAt, " + + "lastUpdated, walletId, txid) " + + "VALUES (x'0201', 1, 1000, 'y', x'00', 0, 0, 0, 0, 0, 0, 0, 0, x'01', x'02')", + ) + legacy.execSQL( + "INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, walletId, " + + "createdAt) VALUES (x'0301', 0, x'02', x'01', 0)", + ) + legacy.close() + + val db = helper.runMigrationsAndValidate(dbName, 11, true, DashDatabase.MIGRATION_10_11) + db.query("SELECT supersededByTxid FROM txos WHERE outpoint = x'0201'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue(c.isNull(0)) + } + db.query( + "SELECT isSweptTombstone, winnerMinedHeight FROM pending_inputs WHERE outpoint = x'0301'", + ).use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, c.getInt(0)) + assertTrue("pre-migration rows read back unstamped", c.isNull(1)) + } + db.query("SELECT lastAppliedChainLockHeight FROM wallets WHERE walletId = x'01'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue("pre-migration wallets have no chainlock height on record", c.isNull(0)) + } + db.execSQL( + "INSERT INTO pending_inputs (outpoint, inputIndex, spendingTxid, " + + "walletId, createdAt, isSweptTombstone, winnerMinedHeight) " + + "VALUES (x'07', 0, x'05', x'01', 0, 1, 1234)", + ) + db.query( + "SELECT isSweptTombstone, winnerMinedHeight FROM pending_inputs WHERE outpoint = x'07'", + ).use { c -> + assertTrue(c.moveToFirst()) + assertEquals(1, c.getInt(0)) + assertEquals(1234, c.getInt(1)) + } + db.execSQL("UPDATE wallets SET lastAppliedChainLockHeight = 4321 WHERE walletId = x'01'") + db.query("SELECT lastAppliedChainLockHeight FROM wallets WHERE walletId = x'01'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(4321, c.getInt(0)) + } + db.close() + } + /** The requested contiguous path from the pre-u64 v4 schema to latest. */ @Test fun migrate4ToLatest() { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 10, + 11, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -408,16 +484,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_7_8, DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, + DashDatabase.MIGRATION_10_11, ).close() } - /** The full chain from v1 must also land on a valid v10 schema. */ + /** The full chain from v1 must also land on a valid v11 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 10, + 11, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -428,6 +505,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_7_8, DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, + DashDatabase.MIGRATION_10_11, ).close() } } 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 65c25e423d0..d199a07ee36 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 @@ -60,6 +60,17 @@ abstract class NativePersistenceBridge { open fun persistenceCapabilitiesBits(): Long = 0L + companion object { + /** + * `PersistenceCapabilities::CORE_SWEEP_REMOVAL` (bit 11, `0x800`). + * The one Kotlin home of this bit: `PlatformWalletPersistenceHandler` + * declares it through [persistenceCapabilitiesBits] and the public + * diagnostic mirror (`PlatformWalletPersistenceCapabilities`) aliases + * it, so the declaration and the mirror can never drift apart. + */ + const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = 0x800 + } + // ── Transactional bracketing ────────────────────────────────────── /** `on_changeset_begin_fn` — descriptor `([B)I`. */ @@ -294,6 +305,98 @@ abstract class NativePersistenceBridge { /** Close the current account bucket. Descriptor `([BI)I`. */ open fun onWalletChangesetAccountEnd(walletId: ByteArray, accountIndex: Int): Int = 0 + /** + * Transactions the wallet removed in one sweep batch: [txidCount] raw + * 32-byte txids packed back to back in [txids], the single transaction + * [supersededBy] that settled their inputs, and the + * [releasedOutpointCount] 36-byte outpoint keys (raw txid followed by a + * little-endian vout, the same packing as `onWalletChangesetTransaction`'s + * `inputOutpoints`) packed in [releasedOutpoints] that this batch + * actually freed. Descriptor `([B[BI[B[BIZI)I`. + * + * Order within a round, stated once here (`store()` in + * `rs-platform-wallet-ffi/src/persistence.rs`): native fires the + * changeset callback — the header, then every account slice + * (transactions, then `utxos_added`, then `utxos_spent` per account) — + * then the chainlock-height slot ([onWalletChangesetChainLockHeight]) + * when the round carries a chainlock, then this slot once PER BATCH in + * the round's emission order, and only when the round swept + * something. Batches are non-commutative — each release is true only + * of the wallet its own sweep saw, and a later batch can keep spent a + * coin an earlier one freed — so an implementation must apply every + * call's holds before its releases and must apply the calls in order. + * It may buffer them until the round's end (the handler does, so the + * co-swept set spans the round), but it must never reorder them. + * + * [hasWinnerMinedHeight] says whether [winnerMinedHeight] is the + * winner's own mined block height (a block-context sweep) or + * meaningless (an InstantSend-locked winner not yet mined). It keys the + * lifetime of the durable claim every non-released input retains: a + * stamped hold is collectible once the chainlock finality boundary + * reaches the stamp, while the unmined case leaves the SAME hold + * UNSTAMPED — an IS-locked winner has no mining deadline, so no + * boundary can prove the held input's funding delivered-or-never — and + * no collector may ever remove an unstamped hold: it resolves only + * through proof, when the funding TXO materializes it, a later + * block-context sweep re-stamps it, or a release deletes it. An + * implementation that drops the hold instead (either by skipping it + * for an unmined winner or by aging it out) deletes the only + * cross-restart carrier of a consumed coin's spend claim and later + * restores that coin as spendable. + * + * Each removed transaction was a recorded spend that its winner beat to + * one of its inputs, so it can never confirm. Every other slot on this + * bus is additive; this is the only removal, and an implementation that + * ignores it keeps dead rows that are handed back at the next load and + * re-create a balance the wallet has already corrected. + * + * [releasedOutpoints] is wallet-scoped, not attributed per removal: an + * implementation holds every input of every row it deletes, so it only + * needs to know which of them came free. Everything else it holds was + * taken by the transaction that won those inputs and must stay spent. + * The set cannot be inferred from [supersededBy] — that transaction may + * pay entirely to outside addresses and never be reported here at all. + * + * Native delivers these through the persistence extension's + * size-negotiated sweep callback (not the wallet-changeset struct, whose + * bare-pointer ABI cannot version itself). The JNI layer wires that + * slot only when the concrete bridge OVERRIDES this method + * (`rs-unified-sdk-jni/src/persistence.rs`, `bridge_overrides`), and + * Rust's own derivation — slot present AND + * [CAPABILITY_CORE_SWEEP_REMOVAL] declared through + * [persistenceCapabilitiesBits] — is the gate: a subclass that declares + * the bit without overriding never has the slot wired, so Rust strips + * the bit and the sync watermark with it rather than trusting a + * removal that would never be applied. This default is therefore the + * benign ignore, never reached in production for a wired slot. + */ + open fun onWalletChangesetTransactionsSwept( + walletId: ByteArray, + txids: ByteArray, + txidCount: Int, + supersededBy: ByteArray, + releasedOutpoints: ByteArray, + releasedOutpointCount: Int, + hasWinnerMinedHeight: Boolean, + winnerMinedHeight: Int, + ): Int = 0 + + /** + * The round's numeric chainlock height, fired on every round whose + * changeset carries a chainlock, after the changeset callback and + * before the sweep batches (see [onWalletChangesetTransactionsSwept] + * for the full order). Descriptor `([BI)I`. + * + * The bincode chainlock blob on the header call is opaque to Kotlin, + * and this scalar is the half of the swept-tombstone collection + * boundary `min(chainlockHeight, syncedHeight)` an implementation + * cannot otherwise know. Purely additive: a host that ignores it + * simply never collects tombstones, which is the safe direction — + * holding a tombstone forever is junk, collecting one early is a + * wrongly-freed claim. + */ + open fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = 0 + // ── Identities ──────────────────────────────────────────────────── /** 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 13e78e16471..822c08a242a 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 @@ -119,9 +119,32 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * document id, ownership/sale state, counterparty, document timestamps and * marketplace reconciliation watermark. Defaults keep every legacy label an * owned, unlisted row until the first native marketplace sync refreshes it. + * + * Version 11 (durable sweep holds): adds `txos.supersededByTxid`, + * `pending_inputs.isSweptTombstone`, `pending_inputs.winnerMinedHeight` and + * `wallets.lastAppliedChainLockHeight`, plus two `pending_inputs` indexes. + * A sweep's winner can beat a loser to an input whose funding TXO has not + * landed here yet, and until now the only record of that claim was the + * loser's own `pending_inputs` row, which cascades away with the loser it + * names — leaving the funding TXO's later arrival free to re-insert the + * outpoint as an ordinary unspent UTXO. `supersededByTxid` is the durable + * hold on a materialised coin (the SQLite store's `spent_in_txid`); + * `isSweptTombstone` marks the detached pending row that carries the same + * hold for a coin that has not materialised; `winnerMinedHeight` is the + * winner's own mined height stamped on that tombstone, the horizon the + * end-of-round collector compares against the chainlock finality boundary + * `min(chainlockHeight, syncedHeight)`; and `lastAppliedChainLockHeight` + * is the numeric chainlock height `onWalletChangesetChainLockHeight` + * delivers, the chainlock half of that boundary (the bincode chainlock + * blob is opaque here). The `spendingTxid` index serves the sweep's + * claimed-row lookup; the `(walletId, isSweptTombstone, winnerMinedHeight)` + * index covers the collector. All four columns are additive: every + * pre-migration row reads back as an ordinary, unstamped, non-tombstone + * entry, and a wallet with no recorded chainlock height has no boundary + * at all (nothing collects). */ @Database( - version = 10, + version = 11, exportSchema = true, entities = [ WalletEntity::class, @@ -556,6 +579,43 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v10 → v11: the four additive sweep-hold columns and the two + * `pending_inputs` indexes — see the version-11 class doc above. + * `isSweptTombstone` is defaulted so every existing row reads as + * "not a tombstone"; the other three are nullable and need no + * default (pre-migration tombstones read back unstamped and are + * never collected; the chainlock height starts NULL, so no + * boundary exists until `onWalletChangesetChainLockHeight` + * records one). Column order = entity field order, and the index + * SQL is the exported schema's verbatim so Room's validation of a + * migrated database passes. + */ + val MIGRATION_10_11: Migration = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `txos` ADD COLUMN `supersededByTxid` BLOB") + db.execSQL( + "ALTER TABLE `pending_inputs` ADD COLUMN `isSweptTombstone` " + + "INTEGER NOT NULL DEFAULT 0", + ) + db.execSQL( + "ALTER TABLE `pending_inputs` ADD COLUMN `winnerMinedHeight` INTEGER", + ) + db.execSQL( + "ALTER TABLE `wallets` ADD COLUMN `lastAppliedChainLockHeight` INTEGER", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` " + + "ON `pending_inputs` (`spendingTxid`)", + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS " + + "`index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` " + + "ON `pending_inputs` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -574,6 +634,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, + MIGRATION_10_11, ) .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 fa807e83c00..d68d1758f93 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 @@ -122,6 +122,15 @@ class PlatformWalletPersistenceHandler( * Null = unscoped (unit tests exercising raw persistence only). */ private val network: org.dashfoundation.dashsdk.Network? = null, + /** + * Where the sweep pass gets a stored loser's input outpoints (the + * outpoint-keyed hold, see `applySweptTransactions`). The production + * default decodes the stored consensus bytes through key-wallet-ffi's + * `transaction_decode`; tests inject a fake keyed by txid, since the + * native library is not loadable under Robolectric. + */ + private val storedTransactionInputs: StoredTransactionInputs = + NativeStoredTransactionInputs(network), ) : NativePersistenceBridge(), AutoCloseable { override fun persistenceCapabilitiesVersion(): Int = PERSISTENCE_CAPABILITIES_VERSION @@ -135,7 +144,8 @@ class PlatformWalletPersistenceHandler( CAPABILITY_UNSIGNED_TOKEN_STORAGE or CAPABILITY_WALLET_RESTORE or CAPABILITY_DPNS_NAME_STATES or - CAPABILITY_TRACKED_ASSET_LOCKS + CAPABILITY_TRACKED_ASSET_LOCKS or + CAPABILITY_CORE_SWEEP_REMOVAL /** * The single-thread executor created when no [dispatcher] is injected. @@ -195,6 +205,22 @@ class PlatformWalletPersistenceHandler( val pendingKeyDeltas: MutableList<(Map) -> Map> = mutableListOf() + + /** + * The round's sweep batches, in emission order. Buffered rather + * than staged as ops so [onChangesetEnd] can apply them as one + * pass after every account slice — the co-swept predicate must see + * the union of every batch's txids — and before the collector. + */ + val sweepBatches: MutableList = mutableListOf() + + /** + * Set when the round advanced the synced height (header slot) or + * the chainlock height (chainlock-height slot): the tombstone + * collection boundary may have moved, so [onChangesetEnd] runs + * [collectFinalizedSweptTombstones] once, last. + */ + var finalityAdvanced: Boolean = false } /** Open rounds keyed by walletId hex (a round is per-walletId). */ @@ -375,8 +401,7 @@ class PlatformWalletPersistenceHandler( * in its own transaction (the standalone-callback path). */ private fun stage(walletId: ByteArray, op: suspend (DashDatabase) -> Unit) { - val key = walletId.toHex() - val buffer = buffers[key] + val buffer = openRound(walletId) if (buffer != null) { buffer.ops.add(op) } else { @@ -386,6 +411,9 @@ class PlatformWalletPersistenceHandler( } } + /** The open round for [walletId], or null on the standalone-callback path. */ + private fun openRound(walletId: ByteArray): ChangesetBuffer? = buffers[walletId.toHex()] + // ── Bracketing ──────────────────────────────────────────────────── override fun onChangesetBegin(walletId: ByteArray): Int = guarded { @@ -421,6 +449,17 @@ class PlatformWalletPersistenceHandler( for (op in buffer.ops) { op(database) } + // Sweeps run after every account slice, so a winner + // arriving in this very round has its own rows written + // before the removal touches the coins it took; the + // collector runs last, after the sweeps, on the wallet + // row as this round left it. + if (buffer.sweepBatches.isNotEmpty()) { + applySweepRound(database, walletId, buffer.sweepBatches) + } + if (buffer.finalityAdvanced) { + collectFinalizedSweptTombstones(database, walletId) + } } } // Rows committed — the aliases are discoverable the normal way, @@ -693,6 +732,13 @@ class PlatformWalletPersistenceHandler( lockedDelta: Long, lastAppliedChainLockBytes: ByteArray, ): Int = guarded { + // The synced-height half of the tombstone collection boundary + // moved: flag the round so `onChangesetEnd` runs the collector once, + // after every slice and every sweep (see + // [collectFinalizedSweptTombstones]). Outside a round the write and + // the collection run together in the standalone transaction. + val round = openRound(walletId) + if (hasSyncedHeight) round?.finalityAdvanced = true stage(walletId) { db -> // Drop stale post-deletion callbacks (can't resurrect a wallet). val wallet = db.walletDao().getByWalletId(walletId) ?: return@stage @@ -704,10 +750,69 @@ class PlatformWalletPersistenceHandler( lastUpdated = now(), ), ) + if (hasSyncedHeight && round == null) collectFinalizedSweptTombstones(db, walletId) + } + 0 + } + + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `persistWalletChangesetChainLockHeight`: record the round's numeric + * chainlock height on the wallet row — monotonic max, through the + * narrow [org.dashfoundation.dashsdk.persistence.dao.WalletDao.advanceChainLockHeight] + * UPDATE so it cannot clobber what the header slot wrote moments + * earlier in the same round — and flag the round for the end-of-round + * collector. This slot is what turns the boundary on at all (no numeric + * height, no collection), so a chainlock-advancing round collects too, + * not only a header round. + */ + override fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = guarded { + val round = openRound(walletId) + round?.finalityAdvanced = true + stage(walletId) { db -> + // Drop stale post-deletion callbacks (can't resurrect a wallet). + val updated = db.walletDao().advanceChainLockHeight( + walletId, height, System.currentTimeMillis(), + ) + if (updated == 0) return@stage + if (round == null) collectFinalizedSweptTombstones(db, walletId) } 0 } + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `collectFinalizedSweptTombstones`, the Room mirror of the SQLite + * store's `collect_finalized_tombstones`. Delete this wallet's swept + * tombstones whose winner's mined height the chainlock finality + * boundary `min(chainlockHeight, syncedHeight)` has reached — + * key-wallet's `prune_finalized_observed_spends` condition verbatim. + * Both halves must be on record: without a numeric chainlock height + * nothing is provably final, and without filter coverage up to the + * winner's height the funding output could still be delivered by the + * unscanned range. + * + * Runs ONCE per round, at the end — from [onChangesetEnd], after every + * account slice and after every sweep batch, gated by + * [ChangesetBuffer.finalityAdvanced] — and reads both halves back from + * the wallet row as this round left it, so no caller assembles a + * fresh/stored pair. Running earlier (from the header, as it once did) + * was unsound: a round can fold a backward rescan that delivers a + * tombstone's funding output through `utxos_added` together with the + * synced height that finalizes it, and a header-time collection would + * delete the tombstone before the drain could move its hold onto the + * TXO, landing a provably consumed coin unspent. + */ + private suspend fun collectFinalizedSweptTombstones(db: DashDatabase, walletId: ByteArray) { + val wallet = db.walletDao().getByWalletId(walletId) ?: return + val chainLockHeight = wallet.lastAppliedChainLockHeight ?: return + if (wallet.syncedHeight <= 0) return + db.documentDao().collectFinalizedSweptTombstones( + walletId, + boundary = minOf(chainLockHeight, wallet.syncedHeight), + ) + } + override fun onWalletChangesetAccountBegin( walletId: ByteArray, accountIndex: Int, @@ -784,6 +889,14 @@ class PlatformWalletPersistenceHandler( ): Int = guarded { stage(walletId) { db -> val existing = db.transactionDao().getByTxid(txid) + // A record for a txid an earlier round swept is upstream's newer + // word — the wallet's sweep state is not monotonic (a chainlocked + // return beats the IS-locked conflict that swept it), and the + // sweep deleted the row outright, so this upsert simply + // re-creates it. Its outputs come back only if this round also + // carries a fresh `onWalletChangesetUtxoAdded` for them, the same + // way any transaction's outputs ordinarily arrive alongside its + // record; nothing here can reconstruct them. // firstSeen: adopt non-zero from FFI; else keep existing; // else stamp now (never leave a placeholder zero). val resolvedFirstSeen = when { @@ -836,9 +949,9 @@ class PlatformWalletPersistenceHandler( TransactionAccountInvolvementEntity(txid, account.id), ) } - // Reconcile every spent input outpoint against our TXOs — a 1:1 - // port of Swift resolveInputOutpoint - // (PlatformWalletPersistenceHandler.swift:688-785). `inputOutpoints` + // Reconcile every spent input outpoint against our TXOs — a port + // of Swift `resolveInputOutpoint` + // (PlatformWalletPersistenceHandler.swift). `inputOutpoints` // carries EVERY input of this spending tx (even ones whose funding // TXO isn't known yet — Rust builds it from tx.input directly, not // the classified utxos_spent slice), so a spend observed before its @@ -846,31 +959,47 @@ class PlatformWalletPersistenceHandler( // link the spend now; otherwise we stage a pending row that the // funding TXO's later upsert drains. Without this the UTXO-restore // path (CORE-06) would hand a consumed output back to Rust as - // spendable after relaunch. Replaces the old getUnspentBySpendingTxid - // flip pass, which had no Swift analog and could not see - // out-of-order / unclassified inputs. + // spendable after relaunch. for (i in 0 until inputOutpointCount) { val outpoint = inputOutpoints.copyOfRange(i * 36, i * 36 + 36) val txo = db.txoDao().getByOutpoint(outpoint) if (txo != null) { - // Found: link the spend. Monotonic — only a confirmed - // (in-block) context flips isSpent; a mempool re-emit never - // downgrades a flag that is already true (mirrors spendIsInBlock). - db.txoDao().upsert( - txo.copy( - isSpent = txo.isSpent || context >= CONTEXT_IN_BLOCK, - spendingTxid = txid, - spendingInputIndex = i, - lastUpdated = now(), - ), - ) - for (p in db.documentDao().getPendingInputsByOutpoint(outpoint)) { - db.documentDao().deletePendingInput(p) + // Found: link the spend through the one link writer + // ([linkSpender]) shared with the `utxos_spent` channel + // and the pending drain. The LINK is guarded, not + // last-writer-wins: a network-final spender keeps it + // against any lower-context arrival + // ([keepSettledSpenderLink]), because the sweep release + // pass reads the link as a veto and upstream cannot + // re-supply a claim it has pruned or lost across a + // restart. A stamped, unlinked row ADOPTS this spender's + // link — attribution matters for `walletFundedTransaction` + // — but keeps `isSpent` and its stamp: the hold is the + // stamp, not the link. + val keepExistingLink = keepSettledSpenderLink(db, txo, txid, context) + db.txoDao().upsert(linkSpender(txo, txid, i, context, keepExistingLink)) + // Pending rows on an outpoint whose TXO exists are stale. + // When this record's claim was refused, only its OWN rows + // are stale; another wallet's claim or tombstone on the + // outpoint is not this record's to erase. Otherwise this + // wallet's ordinary rows are stale (their spend is now + // linked or displaced); tombstones stay — they carry a + // hold the collector or a release owns. + val stale = db.documentDao().getPendingInputsByOutpoint(outpoint).filter { p -> + if (keepExistingLink) { + p.spendingTransactionTxid?.contentEquals(txid) == true + } else { + p.walletId.contentEquals(walletId) && !p.isSweptTombstone + } } - } else if (db.documentDao().getPendingInput(outpoint, txid) == null) { + for (p in stale) db.documentDao().deletePendingInput(p) + } else if (db.documentDao().getPendingInput(outpoint, txid, walletId) == null) { // Funding TXO unknown — defer via a pending row (dedup-guarded - // on outpoint+spendingTxid). FK parent = the tx row upserted - // just above, so the CASCADE relationship holds. + // on outpoint + spendingTxid + walletId: a second wallet + // recording the same transaction gets its own row, because + // sweep holds and releases are decided per wallet). FK + // parent = the tx row upserted just above, so the CASCADE + // relationship holds. db.documentDao().upsertPendingInput( PendingInputEntity( outpoint = outpoint, @@ -910,6 +1039,22 @@ class PlatformWalletPersistenceHandler( } val existing = db.txoDao().getByOutpoint(outpoint) val coreAddressId = if (address.isNotEmpty()) address else null + // A materialised coin the wallet re-delivers unspent follows the + // wallet — the mirror of the SQLite store's upsert valve, which + // holds only never-materialised placeholders. The wallet knows + // this coin, and any network-final spender of a coin it knows is + // wallet-relevant by BIP158 prevout matching, so its own scan + // re-discovers the spend; refusing the re-delivery would lock a + // real coin out forever after a reorg of the winner, and on this + // side of the FFI a row at `isSpent = true` is never restored to + // Rust again. So an UNLINKED row — a sweep hold with its stamp, or + // a legacy flag with nothing behind it — is cleared, stamp + // included. A LINKED row keeps its flag and stamp: the link is + // this store's recorded spend attribution, the pending drain + // below and the sweep pass own that transition, and a spender + // that reached a block is confirmed evidence a re-delivery never + // displaces. + val linked = existing?.spendingTxid != null val row = TxoEntity( outpoint = outpoint, vout = vout, @@ -921,7 +1066,7 @@ class PlatformWalletPersistenceHandler( isConfirmed = isConfirmed, isInstantLocked = isInstantLocked, isLocked = isLocked, - isSpent = existing?.isSpent ?: false, + isSpent = linked && existing!!.isSpent, walletId = walletId, txid = txid, spendingTxid = existing?.spendingTxid, @@ -930,29 +1075,61 @@ class PlatformWalletPersistenceHandler( coreAddressId = existing?.coreAddressId ?: coreAddressIdIfPresent(db, coreAddressId), createdAt = existing?.createdAt ?: java.util.Date(), lastUpdated = now(), + supersededByTxid = if (linked) existing!!.supersededByTxid else null, ) db.txoDao().upsert(row) // Drain any pending-input rows staged before this funding TXO - // existed — a 1:1 port of the Swift upsertUtxo drain - // (PlatformWalletPersistenceHandler.swift:895-953). A spend that - // arrived first was deferred (see onWalletChangesetTransaction); - // now that the funding output is here, link the newest pending - // spend (reorg/double-spend: newest wins) and clear the rows so - // the UTXO-restore path won't hand this consumed output back to + // existed — a port of the Swift `upsertUtxo` drain + // (PlatformWalletPersistenceHandler.swift). A spend that arrived + // first was deferred (see onWalletChangesetTransaction); now that + // the funding output is here, resolve the claim and clear the rows + // so the UTXO-restore path won't hand this consumed output back to // Rust as spendable. val pending = db.documentDao().getPendingInputsByOutpoint(outpoint) if (pending.isNotEmpty()) { - val chosen = pending.maxByOrNull { it.createdAt }!! - val spending = db.transactionDao().getByTxid(chosen.spendingTxid) - val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK - db.txoDao().upsert( - row.copy( - isSpent = row.isSpent || spentInBlock, - spendingTxid = chosen.spendingTxid, - spendingInputIndex = chosen.inputIndex, - lastUpdated = now(), - ), - ) + // A tombstone outranks every ordinary row regardless of age: + // ordinary rows are competing *observations*, a tombstone is + // the sweep's settled verdict that its winner consumed this + // coin. Prefer the tombstone tagged with the delivering + // wallet; failing that any tombstone on the outpoint still + // holds — the stamp is a txid fact, not a per-wallet one. + val tombstones = pending.filter { it.isSweptTombstone } + val tombstone = tombstones.filter { it.walletId.contentEquals(walletId) } + .maxByOrNull { it.createdAt } + ?: tombstones.maxByOrNull { it.createdAt } + if (tombstone != null) { + // A drained tombstone STAMPS, it never mints a spender + // link: the winner need not have its own `transactions` + // row, and a link would make the coin non-releasable + // (the release pass frees stamped, unlinked rows) when a + // later sweep proves the winner never took it. The + // existing link, if any, is carried as it was. + db.txoDao().upsert( + row.copy( + isSpent = true, + supersededByTxid = tombstone.spendingTxid, + lastUpdated = now(), + ), + ) + } else { + // Competing ordinary observations: a network-final spender + // outranks a newer mempool one (its row is the settled + // claim the link guard protects); among equals the newest + // wins, as before (reorg / double-spend: newest wins). + val ranked = pending.map { p -> p to db.transactionDao().getByTxid(p.spendingTxid) } + val (chosen, spending) = ranked.maxWithOrNull( + compareBy>( + { it.second?.context ?: 0 }, + { it.first.createdAt }, + ), + )!! + val spendingContext = spending?.context ?: 0 + val keepExistingLink = + keepSettledSpenderLink(db, row, chosen.spendingTxid, spendingContext) + db.txoDao().upsert( + linkSpender(row, chosen.spendingTxid, chosen.inputIndex, spendingContext, keepExistingLink), + ) + } for (p in pending) db.documentDao().deletePendingInput(p) } } @@ -968,15 +1145,32 @@ class PlatformWalletPersistenceHandler( stage(walletId) { db -> val outpoint = makeOutpoint(txid, vout) val txo = db.txoDao().getByOutpoint(outpoint) ?: return@stage - // Only mark spent when the spending tx exists in-block (never - // flap false on an unresolved spend), mirroring markUtxoSpent. + // Port of Swift `markUtxoSpent` + // (PlatformWalletPersistenceHandler.swift), through the same link + // writer as `onWalletChangesetTransaction` ([linkSpender]): the + // link is guarded by [keepSettledSpenderLink] and `isSpent` is + // monotonic — an + // arrival that is not in-block never lowers a flag a block, a + // sweep stamp, or a healed asset-lock spend already set. This + // channel IS reachable with a conflicting spender: + // `buildUtxoRestoreData` deliberately restores rows whose + // spender is IS-locked (context 1), so a later conflicting + // `utxos_spent` for that outpoint arrives here, and the guard is + // the only thing stopping an in-block usurper from stealing the + // settled link. A spender with no row yet cannot be linked (the + // FK forbids it) — the record channel links it when its record + // lands. val spending = db.transactionDao().getByTxid(spendingTxid) - val spentInBlock = spending != null && spending.context >= CONTEXT_IN_BLOCK + val keepExistingLink = + spending == null || keepSettledSpenderLink(db, txo, spendingTxid, spending.context) db.txoDao().upsert( - txo.copy( - spendingTxid = if (spending != null) spendingTxid else txo.spendingTxid, - isSpent = if (spending != null) spentInBlock else txo.isSpent, - lastUpdated = now(), + linkSpender( + txo, + spendingTxid, + // This channel carries no vin index; a link it moves starts unindexed. + inputIndex = null, + spenderContext = spending?.context ?: 0, + keepExistingLink = keepExistingLink, ), ) } @@ -985,6 +1179,430 @@ class PlatformWalletPersistenceHandler( override fun onWalletChangesetAccountEnd(walletId: ByteArray, accountIndex: Int): Int = 0 + /** + * The one place a spender link and `isSpent` are decided, shared by the + * record channel, the `utxos_spent` channel and the ordinary pending + * drain. `isSpent` is monotonic on every channel: + * `existing || spender in-block || stamped` — a block flips it, a sweep + * stamp keeps it, and no lower-context arrival lowers it. In-block + * evidence counts even when the link is refused ([keepExistingLink]): + * the coin is provably consumed whichever spender is attributed. The + * link itself moves to [spender] unless the existing one is kept; a + * stamped, unlinked row adopts the new link and keeps its stamp. + */ + private fun linkSpender( + txo: TxoEntity, + spender: ByteArray, + inputIndex: Int?, + spenderContext: Int, + keepExistingLink: Boolean, + ): TxoEntity = + txo.copy( + isSpent = txo.isSpent || spenderContext >= CONTEXT_IN_BLOCK || txo.supersededByTxid != null, + spendingTxid = if (keepExistingLink) txo.spendingTxid else spender, + spendingInputIndex = if (keepExistingLink) txo.spendingInputIndex else inputIndex, + lastUpdated = now(), + ) + + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `settledSpenderLinkIsKept`: whether [txo]'s existing `spendingTxid` + * link must survive an arriving spender ([newSpendingTxid], at + * [newContext]) that also claims the outpoint. A network-final spender's + * link is load-bearing — the sweep release pass reads it as a veto, and + * upstream cannot re-supply it for a spender it has pruned or lost + * across a restart. + * + * Kept when the existing spender's row still exists and is + * network-final: IS-locked, in-block, or chainlocked + * (context >= [CONTEXT_INSTANT_SEND]). Two mempool spenders keep + * last-writer-wins — neither claim outranks the other and a final + * winner sorts them out. The single sanctioned takeover mirrors DIP-10 + * precedence: a chainlocked arrival (context == [CONTEXT_CHAIN_LOCKED]) + * may take the coin from a spender that was only IS-locked — a plain + * in-block arrival may not, exactly as upstream's sweep gate refuses a + * plain block against a signed lock. A re-emit of the same spender is + * never a takeover. An unlinked row (stamped or not) keeps nothing — + * the stamp is not a link, and adoption is what attributes the coin. + */ + private suspend fun keepSettledSpenderLink( + db: DashDatabase, + txo: TxoEntity, + newSpendingTxid: ByteArray, + newContext: Int, + ): Boolean { + val existingTxid = txo.spendingTxid ?: return false + if (existingTxid.contentEquals(newSpendingTxid)) return false + val existing = db.transactionDao().getByTxid(existingTxid) ?: return false + if (existing.context < CONTEXT_INSTANT_SEND) return false + val chainlockOverIsLock = + newContext >= CONTEXT_CHAIN_LOCKED && existing.context == CONTEXT_INSTANT_SEND + return !chainlockOverIsLock + } + + // ── Sweeps ──────────────────────────────────────────────────────── + + /** One sweep batch, unpacked from the JNI trampoline's flat arrays at the callback. */ + private class SweepBatch( + val txids: List, + val supersededBy: ByteArray, + val releasedOutpoints: List, + /** The winner's own mined height for a block-context sweep; null for an IS-locked, unmined winner. */ + val winnerMinedHeight: Int?, + ) + + /** + * One input a swept loser claimed: the outpoint and, when the loser's + * bytes named it, its vin index (informational on a minted tombstone). + */ + private class LoserInput(val outpoint: ByteArray, val inputIndex: Int?) + + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `persistWalletChangesetSweeps`: buffer one sweep batch into the open + * round. Batches are applied in order by [applySweepRound] from + * [onChangesetEnd] — after every account slice, before the collector — + * so the co-swept predicate sees the union of every batch's txids in + * the round, exactly as the SQLite store's `swept_txids` spans + * `cs.sweeps`. Outside a round (no `onChangesetBegin`) the batch is a + * round of its own, applied in its own transaction. + */ + override fun onWalletChangesetTransactionsSwept( + walletId: ByteArray, + txids: ByteArray, + txidCount: Int, + supersededBy: ByteArray, + releasedOutpoints: ByteArray, + releasedOutpointCount: Int, + hasWinnerMinedHeight: Boolean, + winnerMinedHeight: Int, + ): Int = guarded { + require(supersededBy.size == 32) { "sweep winner must be a 32-byte txid" } + val batch = SweepBatch( + txids = unpackFixed(txids, txidCount, 32, "sweep txids"), + supersededBy = supersededBy.copyOf(), + releasedOutpoints = unpackFixed(releasedOutpoints, releasedOutpointCount, 36, "released outpoints"), + winnerMinedHeight = winnerMinedHeight.takeIf { hasWinnerMinedHeight }, + ) + val round = openRound(walletId) + if (round != null) { + round.sweepBatches += batch + } else { + runBlockingCatching { + database.withTransaction { applySweepRound(database, walletId, listOf(batch)) } + } + } + 0 + } + + /** + * Apply a round's sweep batches, in order. The co-swept set — an input + * whose funding txid is itself swept this round is a dead parent's + * output, deleted rather than held — spans every batch of the round; + * everything else is per batch, because each release is true only of + * the wallet its own sweep saw and a later batch has to be able to keep + * spent a coin an earlier one freed. + */ + private suspend fun applySweepRound(db: DashDatabase, walletId: ByteArray, batches: List) { + // Drop stale post-deletion callbacks (can't resurrect a wallet). + if (db.walletDao().getByWalletId(walletId) == null) return + val sweptTxidKeys = batches.flatMapTo(HashSet()) { batch -> batch.txids.map { it.toHex() } } + for (batch in batches) applySweptTransactions(db, walletId, batch, sweptTxidKeys) + } + + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `applySweptTransaction`, for one batch of losers at once — the Room + * mirror of the SQLite store's `apply_sweep` plus its by-outpoint + * release pass. + * + * Each loser was a recorded spend that its winner beat to one of its + * inputs, so it can never confirm and Rust has already dropped it. + * Keeping the row would hand it back at the next load and re-create a + * balance the wallet has already corrected; its own outputs are dead + * coins for every wallet. + * + * THE HOLD IS KEYED BY OUTPOINT, NOT BY LINK. A loser's inputs are + * decoded from its stored bytes ([storedTransactionInputs]); a link can + * move between the record and the sweep — a winner recorded in the + * same round takes it first, at `isSpent = 0` while it is only + * IS-locked — and a hold keyed by `spendingTxid = loser` would miss + * exactly the coin the winner consumed. For every input NOT in this + * wallet's released set: a `txos` row is stamped + * (`isSpent = 1`, `supersededByTxid = winner`) whatever it is linked to + * — only a link that points at a swept loser is detached; a link to the + * winner or to any other surviving record is kept; every wallet's + * pending row claimed by the loser becomes a tombstone; and where + * nothing carries the claim for this wallet a tombstone is minted, so + * the funding output's later arrival drains into a stamp instead of + * landing the coin unspent. The loser's rows are the record-lost + * fallback: rows still linked to it and pending rows still claimed by + * it are unioned into the input set, so a loser whose bytes are gone + * (or a stub row `utxos_added` wrote) still holds by link. + * + * THE HOLD IS GLOBAL, THE RELEASE IS PER WALLET. `supersededBy` is a + * txid fact, so the first callback that sees the sweep holds every + * wallet's rows for the loser's inputs and then deletes the loser's row + * unconditionally (hold before delete, so the FK `SET NULL` / cascade + * only clears links, never the hold). Each wallet's own callback + * applies ITS released set to ITS rows: its pending rows on a released + * input are deleted outright (never a freed tombstone), and its `txos` + * row is freed by [applyReleases] — by outpoint, after the losers, so a + * later callback for the same loser from another wallet, finding no + * row, still applies its releases. A callback that never arrives + * leaves a coin conservatively held, not restorable. + * + * A released input is REFUSED when a stored network-final spender + * still claims it ([releaseVetoed]) — the mirror of the SQLite store's + * `surviving_stored_input_claims` — and a released outpoint whose + * funding transaction is itself swept this round is deleted, not freed. + * + * Every statement is a chunked bulk form (`IN (:chunk)`, + * [SWEEP_BIND_CHUNK]) so the arity never crosses API 29's 999-variable + * ceiling; the pending-row writes are rowid-keyed. + */ + private suspend fun applySweptTransactions( + db: DashDatabase, + walletId: ByteArray, + batch: SweepBatch, + sweptTxidKeys: Set, + ) { + val losers = batch.txids + val releasedKeys = batch.releasedOutpoints.mapTo(HashSet()) { it.toHex() } + val loserRows = chunkedFlatMap(losers) { db.transactionDao().getByTxids(it) } + .associateBy { it.txid.toHex() } + // Dead coins first: the losers' own outputs, for every wallet. + chunked(losers) { db.txoDao().deleteByTxids(it) } + + // The losers' inputs, by outpoint: decoded bytes first, then the + // link-keyed and claim-keyed fallbacks. + val linkedRows = chunkedFlatMap(losers) { db.txoDao().getBySpendingTxids(it) } + val claimedRows = chunkedFlatMap(losers) { db.documentDao().getPendingInputsBySpendingTxids(it) } + val inputs = LinkedHashMap() + for (loser in losers) { + val row = loserRows[loser.toHex()] ?: continue + if (row.transactionData.isEmpty()) continue + storedTransactionInputs.inputOutpoints(loser, row.transactionData).forEachIndexed { i, outpoint -> + inputs.putIfAbsent(outpoint.toHex(), LoserInput(outpoint, i)) + } + } + for (txo in linkedRows) { + inputs.putIfAbsent(txo.outpoint.toHex(), LoserInput(txo.outpoint, txo.spendingInputIndex)) + } + for (claim in claimedRows) { + inputs.putIfAbsent(claim.outpoint.toHex(), LoserInput(claim.outpoint, claim.inputIndex)) + } + val claimsByOutpoint = claimedRows.groupBy { it.outpoint.toHex() } + + val coSwept = ArrayList() + val held = ArrayList() + val released = ArrayList() + for ((key, input) in inputs) { + when { + sweptTxidKeys.contains(outpointTxid(input.outpoint).toHex()) -> coSwept += input.outpoint + releasedKeys.contains(key) -> released += input.outpoint + else -> held += input + } + } + + // Co-swept: a dead parent's output — nobody's coin, not something + // the winner took. Upstream's descendant closure always sweeps + // parent and child together and excludes exactly these outpoints + // from the released set, so the claim is neither released nor + // legitimate to hold; holding it would wedge the parent's + // chainlocked reinstatement (the re-delivered output would drain + // into the tombstone). Deleted outright, every wallet's claim. + chunked(coSwept) { db.txoDao().deleteByOutpoints(it) } + chunked(coSwept.flatMap { claimsByOutpoint[it.toHex()].orEmpty() }.map { it.id }) { + db.documentDao().deletePendingInputsByIds(it) + } + + // Held, globally: stamp the rows that exist, tombstone every + // wallet's claim, mint this wallet's tombstone where nothing + // carries the claim. A block-context winner stamps its mined + // height; an IS-locked, unmined winner leaves a new tombstone + // unstamped and an existing stamp untouched. + val heldOutpoints = held.map { it.outpoint } + val heldTxoKeys = chunkedFlatMap(heldOutpoints) { db.txoDao().getByOutpoints(it) } + .mapTo(HashSet()) { it.outpoint.toHex() } + chunked(heldOutpoints) { db.txoDao().holdByOutpoints(it, batch.supersededBy) } + val heldClaimIds = held.flatMap { claimsByOutpoint[it.outpoint.toHex()].orEmpty() }.map { it.id } + chunked(heldClaimIds) { ids -> + db.documentDao().tombstonePendingInputs( + ids, batch.supersededBy, + hasWinnerMinedHeight = batch.winnerMinedHeight != null, + winnerMinedHeight = batch.winnerMinedHeight ?: 0, + ) + } + val minted = held.filter { input -> + val key = input.outpoint.toHex() + !heldTxoKeys.contains(key) && + claimsByOutpoint[key].orEmpty().none { it.walletId.contentEquals(walletId) } + }.map { input -> + PendingInputEntity( + outpoint = input.outpoint, + inputIndex = input.inputIndex ?: 0, + spendingTxid = batch.supersededBy, + spendingTransactionTxid = null, + walletId = walletId, + isSweptTombstone = true, + winnerMinedHeight = batch.winnerMinedHeight, + ) + } + if (minted.isNotEmpty()) db.documentDao().insertPendingInputs(minted) + + // Released, per wallet: this wallet's claims on a released input are + // deleted outright; another wallet's claims are held — its own + // callback carries its own verdict. The `txos` rows are decided by + // the by-outpoint pass below. + val (ownReleasedClaims, foreignReleasedClaims) = + released.flatMap { claimsByOutpoint[it.toHex()].orEmpty() } + .partition { it.walletId.contentEquals(walletId) } + chunked(ownReleasedClaims.map { it.id }) { db.documentDao().deletePendingInputsByIds(it) } + chunked(foreignReleasedClaims.map { it.id }) { ids -> + db.documentDao().tombstonePendingInputs( + ids, batch.supersededBy, + hasWinnerMinedHeight = batch.winnerMinedHeight != null, + winnerMinedHeight = batch.winnerMinedHeight ?: 0, + ) + } + + // Only now the dead links and the rows themselves: every hold above + // is already in place, so the cascade can only take claims that were + // released or attached to nothing. + chunked(losers) { db.txoDao().detachSpenders(it) } + chunked(losers) { db.transactionDao().deleteByTxids(it) } + + applyReleases(db, walletId, batch, sweptTxidKeys) + } + + /** + * The by-outpoint release pass for one batch, this wallet's rows only. + * Releases are outpoint-keyed facts applied after the losers rather + * than only through each loser's decoded inputs: the loser freeing a + * coin need not have a row here any more (another wallet's callback + * deleted it, or a fatal flush wiped the round that carried it), and + * dropping the release with it would leave the hold in place forever. + * + * A released outpoint whose funding transaction is swept in this round + * is deleted whatever its shape — a coin created by a dead transaction + * cannot be unspent, only gone — together with every wallet's tombstone + * on it. Every other released row of this wallet is freed unless a + * stored network-final spender vetoes it ([releaseVetoed]); this + * wallet's tombstones on released outpoints are deleted (a released + * placeholder is never left as a freed tombstone). Another wallet's row + * on an outpoint this wallet released is not this wallet's to decide. + */ + private suspend fun applyReleases( + db: DashDatabase, + walletId: ByteArray, + batch: SweepBatch, + sweptTxidKeys: Set, + ) { + if (batch.releasedOutpoints.isEmpty()) return + val (deadOutputs, candidates) = batch.releasedOutpoints.partition { + sweptTxidKeys.contains(outpointTxid(it).toHex()) + } + chunked(deadOutputs) { + db.txoDao().deleteByOutpoints(it) + db.documentDao().deleteSweptTombstonesByOutpoints(it) + } + val finalClaims = HashMap() + val freed = ArrayList() + for (row in chunkedFlatMap(candidates) { db.txoDao().getByOutpoints(it) }) { + if (!row.walletId.contentEquals(walletId)) continue + if (releaseVetoed(db, row, sweptTxidKeys, finalClaims)) continue + freed += row.outpoint + } + chunked(freed) { db.txoDao().releaseByOutpoints(it, walletId) } + chunked(candidates) { db.documentDao().deleteWalletSweptTombstonesByOutpoints(walletId, it) } + } + + /** + * A stored network-final claimant of a released coin, memoised per + * txid within one release pass. [inputs] is the set of outpoint keys + * its stored bytes spend; null when those bytes could not be decoded, + * in which case the claim vetoes every outpoint it is asked about — + * failing CLOSED, as the SQLite store's claim scan does, rather than + * silently dropping a veto. + */ + private class FinalClaim(val inputs: Set?) + + /** + * The release veto — the mirror of the SQLite store's + * `surviving_stored_input_claims`: a release of [row]'s outpoint is + * refused when the row is linked to a stored transaction with context + * >= InstantSend-locked that is not swept in this round, or when its + * stamp names such a transaction AND that transaction's stored bytes + * actually spend the outpoint (a hold stamps the winner on every + * non-released input of a loser, including one a different surviving + * record claimed, so the stamp alone is not proof the winner took the + * coin — the reference vetoes by the claimant's inputs, and so does + * this). A stamp whose transaction has no stored row (a chained sweep + * already deleted it, or it never paid this wallet) does not veto. + * Bare mempool claimants never veto: a mempool row is the one context + * that can go stale forever, and letting it veto an authoritative + * release would strand the coin. + */ + private suspend fun releaseVetoed( + db: DashDatabase, + row: TxoEntity, + sweptTxidKeys: Set, + finalClaims: HashMap, + ): Boolean { + val link = row.spendingTxid + if (link != null && !sweptTxidKeys.contains(link.toHex())) { + val spender = db.transactionDao().getByTxid(link) + if (spender != null && spender.context >= CONTEXT_INSTANT_SEND) return true + } + val stamp = row.supersededByTxid ?: return false + val stampKey = stamp.toHex() + if (sweptTxidKeys.contains(stampKey)) return false + val claim = finalClaims.getOrPut(stampKey) { + val winner = db.transactionDao().getByTxid(stamp) + if (winner == null || winner.context < CONTEXT_INSTANT_SEND || winner.transactionData.isEmpty()) { + null + } else { + val inputs = try { + storedTransactionInputs.inputOutpoints(stamp, winner.transactionData) + .mapTo(HashSet()) { it.toHex() } + } catch (t: Throwable) { + Log.w(TAG, "sweep release: stored winner bytes undecodable; vetoing its stamped coins", t) + null + } + FinalClaim(inputs) + } + } ?: return false + val inputs = claim.inputs ?: return true + return inputs.contains(row.outpoint.toHex()) + } + + /** Run [op] over [items] in [SWEEP_BIND_CHUNK]-sized slices (no-op on an empty list). */ + private suspend fun chunked(items: List, op: suspend (List) -> Unit) { + for (slice in items.chunked(SWEEP_BIND_CHUNK)) op(slice) + } + + /** [chunked] for reads: the concatenation of every slice's rows. */ + private suspend fun chunkedFlatMap(items: List, op: suspend (List) -> List): List { + if (items.isEmpty()) return emptyList() + val out = ArrayList() + for (slice in items.chunked(SWEEP_BIND_CHUNK)) out += op(slice) + return out + } + + /** + * Split a flat `count × width` byte array (the JNI trampoline's packing + * for txids and outpoints) into its elements, refusing a length that + * disagrees with the count — a descriptor or packing drift must fail + * the round, not silently truncate a sweep. + */ + private fun unpackFixed(packed: ByteArray, count: Int, width: Int, what: String): List { + require(count >= 0 && packed.size == count * width) { + "$what: expected $count × $width bytes, got ${packed.size}" + } + return List(count) { i -> packed.copyOfRange(i * width, (i + 1) * width) } + } + // ── Identities ──────────────────────────────────────────────────── override fun onPersistIdentityUpsert( @@ -1679,7 +2297,7 @@ class PlatformWalletPersistenceHandler( // flip the linked TXOs here. Monotonic, and keyed strictly to // TXOs already linked to THIS lock's funding txid. if (incomingStatus >= ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED) { - val fundingTxid = outPoint.copyOfRange(0, 32) + val fundingTxid = outpointTxid(outPoint) db.txoDao().markSpentBySpendingTxid(fundingTxid, now()) } } @@ -1688,18 +2306,13 @@ class PlatformWalletPersistenceHandler( override fun onPersistAssetLockRemoval(walletId: ByteArray, outPoint: ByteArray): Int = guarded { stage(walletId) { db -> - val outPointHex = encodeOutPointHex(outPoint) - // Same terminal rule as the upsert guard above: a Consumed (4) - // row is deliberately retained for historical lookup and the - // only removal emitter (`untrack_asset_lock`) targets rejected - // Built rows — a removal reaching a consumed row is by - // construction a stale write. Mirrors Swift `persistAssetLocks` - // (PlatformWalletPersistenceHandler.swift:310). - val existing = db.assetLockDao().getByOutPointHex(outPointHex) - if (existing != null && existing.statusRaw == ASSET_LOCK_STATUS_CONSUMED) { - return@stage - } - db.assetLockDao().deleteByOutPointHex(outPointHex) + // The terminal rule — a Consumed row is retained for historical + // lookup, so a removal reaching one is a stale write — lives in + // the DAO's `statusRaw != 4` clause (see + // `AssetLockDao.deleteByOutPointHex`), the same predicate the + // SQLite store's DELETE carries; mirrors Swift `persistAssetLocks` + // (PlatformWalletPersistenceHandler.swift). + db.assetLockDao().deleteByOutPointHex(encodeOutPointHex(outPoint)) } 0 } @@ -2735,7 +3348,7 @@ class PlatformWalletPersistenceHandler( val out = ArrayList(locks.size) for (lock in locks) { val outPoint = decodeOutPointHex(lock.outPointHex) ?: continue - val txid = outPoint.copyOfRange(0, 32) + val txid = outpointTxid(outPoint) val tx = database.transactionDao().getByTxid(txid) ?: continue if (tx.transactionData.isEmpty()) continue out.add( @@ -3357,9 +3970,21 @@ class PlatformWalletPersistenceHandler( internal const val CAPABILITY_WALLET_RESTORE: Long = 0x80 internal const val CAPABILITY_DPNS_NAME_STATES: Long = 0x100 internal const val CAPABILITY_TRACKED_ASSET_LOCKS: Long = 0x200 + internal const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = + NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL private const val TAG = "DashPersistence" + /** + * Slice size for the sweep pass's `IN (:chunk)` statements: well + * under the 999-variable ceiling API 29's framework SQLite still + * carries, with room for the statement's fixed binds. + */ + private const val SWEEP_BIND_CHUNK = 500 + + /** `TransactionContext::InstantSend` — network-final under DIP-10. */ + private const val CONTEXT_INSTANT_SEND = 1 + /** `TransactionContext::InBlock` — spends only count once in-block. */ private const val CONTEXT_IN_BLOCK = 2 @@ -3374,6 +3999,8 @@ class PlatformWalletPersistenceHandler( */ private const val ASSET_LOCK_STATUS_INSTANT_SEND_LOCKED = 2 + /** `TransactionContext::InChainLockedBlock` — outranks an IS lock. */ + private const val CONTEXT_CHAIN_LOCKED = 3 /** `Network.testnet` rawValue — the Swift fallback network. */ private const val NETWORK_TESTNET = 1 @@ -3471,6 +4098,49 @@ interface PrivateKeyDeriver { */ data class DerivedKeyStoreResult(val identifier: String, val wasNewlyCreated: Boolean) +/** + * Names a stored transaction's input outpoints for the sweep pass, which + * keys its hold by OUTPOINT: a swept loser's inputs are read from its own + * stored bytes, never inferred from which rows happen to link to it. The + * txid is passed alongside the bytes so an implementation can refuse a + * key/record disagreement — the typed key is what named the row a swept + * loser, and processing some other record's inputs under it would hold or + * free the wrong coins. + * + * Must throw on bytes it cannot decode: the round then fails and rolls + * back (fail closed, as the SQLite store's `apply_sweep` does on a bad + * blob) rather than sweeping a loser whose inputs are unknown. + */ +fun interface StoredTransactionInputs { + /** 36-byte outpoints ([makeOutpoint] layout) of every input of [txid], in vin order. */ + fun inputOutpoints(txid: ByteArray, txData: ByteArray): List +} + +/** + * Production [StoredTransactionInputs]: key-wallet-ffi's + * `transaction_decode` through [org.dashfoundation.dashsdk.keywallet.TransactionDecoder]. + * The decoder is a stateless marshaler — it takes no wallet-manager lock — + * so calling it from inside a persistence callback (which runs while Rust + * holds that lock) cannot deadlock; the "no native calls under + * `callbackExclusion`" rule guards the manager lock, not this. [network] + * only shapes the decoder's address rendering, which this caller discards, + * so an unscoped handler decodes on the default network. + */ +class NativeStoredTransactionInputs( + private val network: org.dashfoundation.dashsdk.Network?, +) : StoredTransactionInputs { + override fun inputOutpoints(txid: ByteArray, txData: ByteArray): List { + val decoded = org.dashfoundation.dashsdk.keywallet.TransactionDecoder.decode( + txData, + network ?: org.dashfoundation.dashsdk.Network.DEFAULT, + ) + check(decoded.txid.contentEquals(txid)) { + "stored transaction bytes disagree with their txid key" + } + return decoded.inputs.map { makeOutpoint(it.prevTxid, it.prevVout) } + } +} + // ── Free functions (unit-testable, no `this`) ───────────────────────── /** Lowercase hex of a byte array (used as the changeset-buffer key). */ @@ -3574,7 +4244,7 @@ internal fun base58Encode(input: ByteArray): String { */ internal fun encodeOutPointHex(outPoint: ByteArray): String { require(outPoint.size == 36) { "outpoint must be 36 bytes, got ${outPoint.size}" } - val txidWire = outPoint.copyOfRange(0, 32) + val txidWire = outpointTxid(outPoint) val displayTxid = txidWire.reversedArray() val vout = (outPoint[32].toInt() and 0xFF) or ((outPoint[33].toInt() and 0xFF) shl 8) or @@ -3618,6 +4288,9 @@ internal fun decodeOutPointHex(hex: String): ByteArray? { return out } +/** The 32-byte wire-order txid half of a 36-byte outpoint built by [makeOutpoint]. */ +internal fun outpointTxid(outpoint: ByteArray): ByteArray = outpoint.copyOfRange(0, 32) + /** Build a 36-byte outpoint from a wire-order txid + vout (matches `makeOutpoint`). */ internal fun makeOutpoint(txid: ByteArray, vout: Int): ByteArray { val out = ByteArray(36) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt index 92924b2ebc0..6e3fcea24f2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/AssetLockDao.kt @@ -172,8 +172,18 @@ interface AssetLockDao { @Delete suspend fun delete(assetLock: AssetLockEntity) - /** Consumed-lock removal path (`$0.outPointHex == hex`). */ - @Query("DELETE FROM asset_locks WHERE outPointHex = :outPointHex") + /** + * Asset-lock removal path (`onPersistAssetLockRemoval`). The + * `statusRaw != 4` guard is the same terminal rule SQLite's DELETE + * (`status != 'consumed'`) and Swift's `statusRaw == 4` skip apply: a + * Consumed row is deliberately retained for historical lookup, and + * neither removal producer can legitimately name one — a Built row + * rejected at broadcast never got that far, and a sweep of the funding + * transaction only tombstones entries still tracked, which a consumed + * lock no longer is — so a removal reaching a consumed row is by + * construction a stale write. + */ + @Query("DELETE FROM asset_locks WHERE outPointHex = :outPointHex AND statusRaw != 4") suspend fun deleteByOutPointHex(outPointHex: String) /** Wallet teardown mirror of `deleteWalletData`'s asset-lock pass. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt index 3f343d8d267..649df5271c4 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/DocumentDao.kt @@ -2,6 +2,7 @@ package org.dashfoundation.dashsdk.persistence.dao import androidx.room.Dao import androidx.room.Delete +import androidx.room.Insert import androidx.room.Query import androidx.room.Upsert import kotlinx.coroutines.flow.Flow @@ -163,17 +164,132 @@ interface DocumentDao { @Query("SELECT * FROM pending_inputs WHERE outpoint = :outpoint") suspend fun getPendingInputsByOutpoint(outpoint: ByteArray): List - /** Duplicate guard used before inserting a pending row. */ + /** + * Duplicate guard used before inserting a pending row. Keyed by + * `(outpoint, spendingTxid, walletId)`: a sweep's hold and release + * verdicts are per wallet, so a second wallet recording the same + * transaction must get its own row — otherwise the first wallet's + * collector or release could erase the only hold the second was + * entitled to keep. + */ @Query( "SELECT * FROM pending_inputs WHERE outpoint = :outpoint " + - "AND spendingTxid = :spendingTxid" + "AND spendingTxid = :spendingTxid AND walletId = :walletId LIMIT 1" ) - suspend fun getPendingInput(outpoint: ByteArray, spendingTxid: ByteArray): PendingInputEntity? + suspend fun getPendingInput( + outpoint: ByteArray, + spendingTxid: ByteArray, + walletId: ByteArray, + ): PendingInputEntity? /** Per-wallet pending-input scan (cleanup / diagnostics). */ @Query("SELECT * FROM pending_inputs WHERE walletId = :walletId") fun observePendingInputsByWallet(walletId: ByteArray): Flow> + /** + * Every wallet's rows claimed by one of [spendingTxids] — the ordinary + * rows a loser staged (`spendingTxid == spendingTransactionTxid`) and + * the tombstones an earlier sweep re-pointed at it (`spendingTxid` + * alone, the FK already detached). One bulk read per sweep batch, all + * wallets, because the hold is global: the first callback that sees a + * sweep tombstones every wallet's claim on the loser's inputs. Chunked + * by the caller (`SWEEP_BIND_CHUNK`) so the arity stays under the + * 999-variable ceiling API 29's framework SQLite still carries. + */ + @Query("SELECT * FROM pending_inputs WHERE spendingTxid IN (:spendingTxids)") + suspend fun getPendingInputsBySpendingTxids(spendingTxids: List): List + + /** + * Turn the rows with these ids into swept tombstones held by [winner]: + * detach the FK (the loser's row is about to be deleted and must not + * cascade the claim away), re-point the scalar at the winner, flag the + * row, and stamp the winner's mined height when this sweep has one — + * an IS-locked, unmined winner (`hasWinnerMinedHeight = false`) keeps + * whatever stamp the row already carries, because upstream's + * observed-spend entry is never retracted by an unconfirmed conflict + * and collection at the old height stays sound. Rowid-keyed and + * chunked by the caller. + */ + @Query( + "UPDATE pending_inputs SET spendingTransactionTxid = NULL, spendingTxid = :winner, " + + "isSweptTombstone = 1, " + + "winnerMinedHeight = CASE WHEN :hasWinnerMinedHeight THEN :winnerMinedHeight " + + "ELSE winnerMinedHeight END " + + "WHERE id IN (:ids)", + ) + suspend fun tombstonePendingInputs( + ids: List, + winner: ByteArray, + hasWinnerMinedHeight: Boolean, + winnerMinedHeight: Int, + ) + + /** Rowid-keyed bulk delete; chunked by the caller. */ + @Query("DELETE FROM pending_inputs WHERE id IN (:ids)") + suspend fun deletePendingInputsByIds(ids: List) + + /** + * Delete every wallet's tombstones on [outpoints] — outputs of a + * transaction swept in this round, dead coins nobody may hold a claim + * on (holding one would wedge the parent's chainlocked reinstatement). + * Chunked by the caller. + */ + @Query( + "DELETE FROM pending_inputs WHERE isSweptTombstone = 1 AND outpoint IN (:outpoints)", + ) + suspend fun deleteSweptTombstonesByOutpoints(outpoints: List) + + /** + * Delete [walletId]'s tombstones on [outpoints] — this wallet's + * release of those coins. A released placeholder is deleted outright, + * never left as a freed tombstone: no row is the correct end state, and + * the funding output's own later upsert creates the real row freshly + * unspent. Ordinary rows on the same outpoints are NOT touched — they + * are some surviving spender's spend-before-funding claim, not the + * swept loser's. Chunked by the caller. + */ + @Query( + "DELETE FROM pending_inputs WHERE walletId = :walletId AND isSweptTombstone = 1 " + + "AND outpoint IN (:outpoints)", + ) + suspend fun deleteWalletSweptTombstonesByOutpoints(walletId: ByteArray, outpoints: List) + + /** Bulk insert of freshly minted tombstones; Room binds one row at a time. */ + @Insert + suspend fun insertPendingInputs(rows: List) + + /** + * Bounded tombstone lifetime: delete this wallet's swept tombstones + * whose winner's mined height the chainlock finality boundary has + * reached (`:boundary` = `min(chainlockHeight, syncedHeight)`, read + * by the caller from the wallet row at the end of the round) — + * key-wallet's `prune_finalized_observed_spends` condition verbatim, + * no observation-age margin: the stamp IS the winner's height, so at + * the boundary the funding transaction (mined at or below it) has been + * filter-scanned with no false negatives. A tombstone still + * collectible here never drained — its funding TXO never arrived — so + * the junk case (a foreign input of a swept incoming payment) is + * exactly what this removes; a genuine claim's row was already + * deleted by the drain that moved the hold onto the TXO. Selects + * tombstones only, served by the + * `(walletId, isSweptTombstone, winnerMinedHeight)` index; ordinary + * pending rows are never materialised here. Unstamped rows are never + * collected — and they are a CURRENT, deliberate shape, not legacy + * data: a mempool-context sweep (IS-locked, unmined winner) writes its + * tombstone with a null stamp, because such a winner has no mining + * deadline and no boundary can prove the held funding + * delivered-or-never. An unstamped hold resolves only through proof — + * the funding TXO drains it, a later block-context sweep re-stamps it + * into this collector's reach, or a release deletes it — and holding + * an unresolved one forever is the contract, not a safe fallback. + */ + @Query( + "DELETE FROM pending_inputs " + + "WHERE walletId = :walletId AND isSweptTombstone = 1 " + + "AND winnerMinedHeight IS NOT NULL AND winnerMinedHeight <= :boundary", + ) + suspend fun collectFinalizedSweptTombstones(walletId: ByteArray, boundary: Int) + @Upsert suspend fun upsertPendingInput(pendingInput: PendingInputEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt index 322750d27a4..c72a5569832 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TransactionDao.kt @@ -69,6 +69,7 @@ interface TransactionDao { /** * Provider kinds 2…5 scoped through explicit account membership. The * ordering preserves Core's same-block transaction order when present. + */ @Query( "SELECT DISTINCT transactions.* FROM transactions " + @@ -93,6 +94,23 @@ interface TransactionDao { @Query("DELETE FROM transactions WHERE txid = :txid") suspend fun deleteByTxid(txid: ByteArray) + /** + * Point lookups for one sweep batch's losers in one statement; chunked + * by the caller (`SWEEP_BIND_CHUNK`) to stay under the 999-variable + * ceiling API 29's framework SQLite still carries. + */ + @Query("SELECT * FROM transactions WHERE txid IN (:txids)") + suspend fun getByTxids(txids: List): List + + /** + * Delete a sweep batch's losers in one statement, after every hold on + * their inputs is in place: the FK cascade takes any still-attached + * pending row and any remaining own output with it, and `SET NULL` + * clears any link still pointing at a loser. Chunked by the caller. + */ + @Query("DELETE FROM transactions WHERE txid IN (:txids)") + suspend fun deleteByTxids(txids: List) + /** * Orphan sweep run after a wallet wipe (Swift `deleteWalletData`'s * post-delete pass): drop transactions no longer referenced by any diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt index 361c8e37b98..ac484917836 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/TxoDao.kt @@ -68,6 +68,114 @@ interface TxoDao { "WHERE outpoint = :outpoint AND isSpent = 0", ) suspend fun markSpentByOutpoint(outpoint: ByteArray, now: Date): Int + /** + * Rows keyed by outpoint — the sweep pass's bulk read of a loser's + * decoded inputs and of a batch's released outpoints. Callers chunk + * the list (`SWEEP_BIND_CHUNK`) so the statement arity stays under + * the 999-variable ceiling API 29's framework SQLite still carries. + */ + @Query("SELECT * FROM txos WHERE outpoint IN (:outpoints)") + suspend fun getByOutpoints(outpoints: List): List + + /** + * Every row still linked to one of [spendingTxids] — the sweep pass's + * link-keyed fallback for a loser whose stored bytes cannot name its + * inputs (record lost, or a stub row written by `utxos_added` before + * the record arrived). Chunked by the caller. + */ + @Query("SELECT * FROM txos WHERE spendingTxid IN (:spendingTxids)") + suspend fun getBySpendingTxids(spendingTxids: List): List + + /** + * Hold the coins at [outpoints] out of the restore set, attributed to + * [supersededBy] — the same stamp the SQLite store writes as + * `spent_in_txid`, and the same one the pending-input drain writes when + * the claim had no TXO row yet. The hold is keyed by OUTPOINT, computed + * from the swept loser's own decoded inputs, never by this row's link: + * a link can move between the record and the sweep (a winner recorded + * in the same round takes it first), and a hold keyed by link would + * miss exactly the coin the winner consumed. The link is left alone + * here — [detachSpenders] drops only links that point at a swept loser; + * a link to the winner or to any other surviving record is kept, and + * the stamp holds the coin regardless. + * + * Global, not wallet-scoped: `supersededBy` is a txid fact, and the + * first callback that sees the sweep holds every wallet's rows for the + * loser's inputs; only the RELEASE is per wallet ([releaseByOutpoints]). + * A stamped hold only ever comes free through a release or through the + * wallet re-delivering the unlinked coin unspent. Chunked by the caller. + */ + @Query( + "UPDATE txos SET isSpent = 1, supersededByTxid = :supersededBy " + + "WHERE outpoint IN (:outpoints)", + ) + suspend fun holdByOutpoints(outpoints: List, supersededBy: ByteArray) + + /** + * Drop every link that points at one of [spendingTxids] — the swept + * losers of one batch. The foreign key would null these on the losers' + * delete anyway; doing it explicitly, before the delete, keeps the + * order the sweep pass documents (hold by outpoint, detach the dead + * link, delete the row) independent of FK enforcement. Chunked by the + * caller. + */ + @Query( + "UPDATE txos SET spendingTxid = NULL, spendingInputIndex = NULL " + + "WHERE spendingTxid IN (:spendingTxids)", + ) + suspend fun detachSpenders(spendingTxids: List) + + /** + * Mark [walletId]'s own coins at [outpoints] unspent again — coins a + * sweep released, meaning no surviving transaction spent them *at the + * time the sweep was computed*. Keyed by outpoint because that is how + * upstream reports it: the transaction that took the other inputs may + * never be recorded here at all, so the released set is the only + * authority on which coins came free. + * + * Per wallet, unlike [holdByOutpoints]: a released set is only ever + * true of the wallet that computed it, so it never touches another + * wallet's row. The caller has already excluded every vetoed outpoint + * (a row linked to, or stamped with, a stored network-final spender + * that this round did not sweep) and every outpoint whose funding + * transaction is itself swept this round (deleted instead). The link + * is not touched: a link to a swept loser was detached by + * [detachSpenders], and a link to a surviving mempool spender is kept + * as attribution at `isSpent = 0`, exactly what such a link means on + * the record channel. + * + * `supersededByTxid` clears in the same statement, the way the SQLite + * store's release UPDATE clears `spent_in_txid`: a released coin + * keeping its dead winner's marker would read as a durable claim to + * every later hold on this outpoint. Chunked by the caller. + */ + @Query( + "UPDATE txos SET isSpent = 0, supersededByTxid = NULL " + + "WHERE outpoint IN (:outpoints) AND walletId = :walletId", + ) + suspend fun releaseByOutpoints(outpoints: List, walletId: ByteArray) + + /** + * Delete every TXO created by one of [txids] — the swept losers' own + * outputs, dead coins for every wallet. The FK from `txos.txid` to + * `transactions.txid` (CASCADE) does this on the losers' delete too; + * the explicit form runs first so the sweep pass never depends on FK + * enforcement for the one removal that is a funds fact. Chunked by the + * caller. + */ + @Query("DELETE FROM txos WHERE txid IN (:txids)") + suspend fun deleteByTxids(txids: List) + + /** + * Delete the rows at [outpoints] outright — outputs of a transaction + * swept in this round that some loser claimed or some release named. + * A coin created by a dead transaction cannot be unspent, only gone: + * a chainlocked reinstatement of the parent re-delivers it through the + * ordinary `utxos_added` upsert with nothing left standing in its way. + * Chunked by the caller. + */ + @Query("DELETE FROM txos WHERE outpoint IN (:outpoints)") + suspend fun deleteByOutpoints(outpoints: List) @Upsert suspend fun upsert(txo: TxoEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt index a58721b0abd..31bf8e81ba5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/WalletDao.kt @@ -61,6 +61,23 @@ interface WalletDao { ) suspend fun updateName(walletId: ByteArray, name: String?, nowMillis: Long): Int + /** + * Record the round's numeric chainlock height, monotonic max — a + * stale round's chainlock never lowers the finality boundary, matching + * the SQLite store's `upsert_sync_state`. A narrow column write rather + * than a full-row [upsert], so it cannot clobber a sibling column the + * header slot wrote moments earlier in the same round. + * + * @param nowMillis epoch millis for the `lastUpdated` stamp. + * @return number of rows updated (0 when the wallet row is gone). + */ + @Query( + "UPDATE wallets SET lastAppliedChainLockHeight = " + + "MAX(COALESCE(lastAppliedChainLockHeight, -1), :height), " + + "lastUpdated = :nowMillis WHERE walletId = :walletId" + ) + suspend fun advanceChainLockHeight(walletId: ByteArray, height: Int, nowMillis: Long): Int + @Delete suspend fun delete(wallet: WalletEntity) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt index ef9b4c5c019..2725ba1b667 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PendingInputEntity.kt @@ -1,5 +1,6 @@ package org.dashfoundation.dashsdk.persistence.entities +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index @@ -10,12 +11,17 @@ import java.util.Date * Port of `PersistentPendingInput.swift` — side-table row tracking a * transaction input whose previous-output TXO hasn't landed yet. * - * Deliberately NO unique constraint on [outpoint] (per the Swift doc: a - * re-org / double-spend could produce two pending rows for one outpoint - * and both should resolve naturally) — hence a surrogate rowid PK, - * mirroring SwiftData's hidden `persistentModelID`. + * Deliberately NO unique constraint on [outpoint] (per the Swift doc): the + * dedup at record time is `(outpoint, spendingTxid, walletId)`, so a + * re-org / double-spend produces one row per conflicting spender and a + * second wallet recording the same transaction gets its own row — hence a + * surrogate rowid PK, mirroring SwiftData's hidden `persistentModelID`. * - * Swift `#Index([\.outpoint], [\.walletId])` → the two indices below. + * Swift `#Index([\.outpoint], [\.walletId])` → the first two indices below. + * The `spendingTxid` index serves the sweep's claimed-row lookup (a + * tombstone is findable only by that scalar once detached from the FK), + * and the `(walletId, isSweptTombstone, winnerMinedHeight)` index covers + * the per-round tombstone collector exactly. * * [spendingTransactionTxid] materializes the optional * `spendingTransaction` relationship (CASCADE per @@ -29,6 +35,8 @@ import java.util.Date Index(value = ["outpoint"]), Index(value = ["walletId"]), Index(value = ["spendingTransactionTxid"]), + Index(value = ["spendingTxid"]), + Index(value = ["walletId", "isSweptTombstone", "winnerMinedHeight"]), ], foreignKeys = [ ForeignKey( @@ -46,11 +54,62 @@ data class PendingInputEntity( val outpoint: ByteArray, /** Position of this input in the spending tx. Swift `UInt32` → [Int]. */ val inputIndex: Int, - /** 32-byte txid of the spending transaction (denorm, always set). */ + /** + * 32-byte txid of the transaction that claims this input (denorm, + * always set). For an ordinary row that is the spender that staged it; + * for a tombstone it is the sweep WINNER the hold is attributed to. + */ val spendingTxid: ByteArray, /** FK materialization of the Swift `spendingTransaction` relationship. */ val spendingTransactionTxid: ByteArray? = null, - /** Wallet id denorm for cleanup / per-wallet diagnostics. */ + /** Wallet id denorm — the per-wallet release scope and the dedup key's third half. */ val walletId: ByteArray, val createdAt: Date = Date(), + /** + * Port of Swift `PersistentPendingInput.isSweptTombstone`. Set by the + * sweep pass (`PlatformWalletPersistenceHandler.applySweptTransaction`, + * the port of `PlatformWalletPersistenceHandler.swift`'s + * `applySweptTransaction`) for a held input of a swept loser that has + * no `txos` row: [spendingTransactionTxid] is cleared (detaching the FK + * so the row survives the loser's cascade-delete) and [spendingTxid] is + * overwritten with the winner's txid. When the funding TXO later + * arrives, `onWalletChangesetUtxoAdded` drains the tombstone into a + * STAMP — `TxoEntity.isSpent = true`, `TxoEntity.supersededByTxid` = + * this row's [spendingTxid] — and never into a spender link: the winner + * need not have its own `transactions` row, and the hold is the stamp, + * not the link. Defaulted `false` so pre-migration rows read as + * ordinary pending entries. + * + * Declares its default so the exported schema agrees with what + * `MIGRATION_10_11` writes: SQLite requires one on a NOT NULL + * `ADD COLUMN`, and Room compares defaults when validating a migrated + * database against the entity — a mismatch fails the upgrade outright. + */ + @ColumnInfo(defaultValue = "0") + val isSweptTombstone: Boolean = false, + /** + * The mined block height of the WINNER that swept this tombstone's + * loser — the winner's own height, carried on the sweep event itself, + * not any observation watermark. This stamp is the row's whole + * lifetime rule: the end-of-round collector deletes the tombstone once + * the chainlock finality boundary `min(chainlockHeight, syncedHeight)` + * reaches it — key-wallet's `prune_finalized_observed_spends` + * condition verbatim, no observation-age margin — because at that + * boundary the funding transaction (necessarily mined at or below + * the winner's height) has been filter-scanned with no false + * negatives, so an undrained row is provably not the wallet's coin. + * A genuine claim drains into its TXO on funding arrival and leaves + * the collectible set with the row. + * + * NULL is never collected. A mempool/IS-context sweep (unmined + * winner) writes its tombstone unstamped on purpose: under DIP-10 + * the IS lock alone settles the input, but the winner has no mining + * deadline, so no boundary can ever prove its funding output + * delivered-or-never — the hold lasts until the funding TXO drains + * it, a later block-context sweep stamps it, or a release deletes + * it. An IS-locked re-point likewise keeps the existing stamp. + * Nullable, so the ADD COLUMN migration needs no default and + * pre-migration rows read as unstamped. + */ + val winnerMinedHeight: Int? = null, ) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TxoEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TxoEntity.kt index 9f3255848b4..9ee5f4749ac 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TxoEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/TxoEntity.kt @@ -77,7 +77,15 @@ data class TxoEntity( val isConfirmed: Boolean = false, val isInstantLocked: Boolean = false, val isLocked: Boolean = false, - /** Denormalized `spendingTxid != null`; kept explicit (hot filter path). */ + /** + * Whether this coin is out of the restore set; kept explicit (hot + * filter path). Monotonic on the record and `utxos_spent` channels + * (`isSpent = existing || spender in-block || stamped`), so it is + * true for a spender that reached a block, for every stamped hold + * ([supersededByTxid] set), and for a healed asset-lock spend — and + * false for a coin whose only claim is a mempool/IS-locked link. Only + * a sweep release or an unlinked re-delivery of the coin lowers it. + */ val isSpent: Boolean = false, val createdAt: Date = Date(), val lastUpdated: Date = Date(), @@ -100,6 +108,34 @@ data class TxoEntity( * navigation pointer. */ val coreAddressId: String? = null, + /** + * Port of Swift `PersistentTxo.supersededByTxid` — the winner a sweep + * attributed this coin's consumption to, mirroring the SQLite store's + * `spent_in_txid`. Two writers set it: the sweep pass + * (`PlatformWalletPersistenceHandler.applySweptTransaction`), for + * every held input of a swept loser that has a row, keyed by the + * loser's decoded input outpoints rather than by this row's link; and + * `onWalletChangesetUtxoAdded` draining a `pending_inputs` tombstone — + * the funding output arrived only after the loser that spent it was + * swept and deleted. Deliberately NOT an FK: the winner named here + * need not have its own `transactions` row (it can be + * wallet-irrelevant), so this column has to hold a bare txid that + * `transactions(txid)` may never contain. + * + * The hold is the stamp, not the link. A stamped row keeps + * `isSpent = true` whatever later happens to [spendingTxid] — a new + * spender may adopt the link (attribution for `walletFundedTransaction`) + * without lowering the flag — and a stamped row that is UNLINKED is + * what the sweep release pass frees. Cleared by exactly two events: + * a sweep release of this outpoint (a later sweep proved the coin came + * free after all, and no stored network-final spender vetoes it), and + * the wallet re-delivering the coin unspent while the row is unlinked + * (`onWalletChangesetUtxoAdded`: the wallet knows the coin, so any + * network-final spender of it is re-discovered by its own scan; holding + * the row would lock a real coin out forever after a reorg of the + * winner). + */ + val supersededByTxid: ByteArray? = null, ) { override fun equals(other: Any?): Boolean = other is TxoEntity && outpoint.contentEquals(other.outpoint) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/WalletEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/WalletEntity.kt index fcf5d678420..200edbe1947 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/WalletEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/WalletEntity.kt @@ -55,6 +55,19 @@ data class WalletEntity( * Opaque passthrough — decoded only by Rust; never re-encoded here. */ val lastAppliedChainLockBytes: ByteArray? = null, + /** + * The numeric block height of the last applied chainlock, delivered + * separately by `onWalletChangesetChainLockHeight` (the bincode blob + * above is opaque on this side of the FFI) and written through the + * narrow `WalletDao.advanceChainLockHeight` UPDATE. Monotonic max — a + * stale round never lowers it. This is the chainlock half of the + * swept-tombstone collection boundary `min(chainlockHeight, + * syncedHeight)` the end-of-round collector reads back from this row; + * while NULL no finality boundary exists and the collector never + * runs, mirroring the SQLite store's "no-op until a chainlock height + * has been persisted". + */ + val lastAppliedChainLockHeight: Int? = null, val isImported: Boolean = false, val createdAt: Date = Date(), val lastUpdated: Date = Date(), diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index d8f0cf26b6f..aba0c4ceb2d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -25,6 +25,7 @@ import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.DashpayNative import org.dashfoundation.dashsdk.ffi.DpnsMarketplaceNative import org.dashfoundation.dashsdk.ffi.FundingNative +import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge import org.dashfoundation.dashsdk.ffi.NativeWalletEventBridge import org.dashfoundation.dashsdk.ffi.WalletManagerNative import org.dashfoundation.dashsdk.funding.ShieldedProver @@ -61,6 +62,21 @@ data class PlatformWalletPersistenceCapabilities( const val WALLET_RESTORE: Long = 1L shl 7 const val DPNS_NAME_STATES: Long = 1L shl 8 const val TRACKED_ASSET_LOCKS: Long = 1L shl 9 + /** + * A stored core changeset's non-empty sweeps are durably applied + * in order: each swept transaction and its outputs are deleted, + * each released outpoint of the wallet's own is freed unless a + * stored network-final spender still claims it, and each + * non-released input RETAINS a durable spend claim — a stamp on + * the materialised coin, or a tombstone where the funding TXO has + * not materialised yet — that outlives the loser's deletion, or a + * post-restart funding delivery would credit a coin the network + * already consumed. Mirrors `PersistenceCapabilities::CORE_SWEEP_REMOVAL`; + * aliased to the bridge's declaration so the mirror cannot drift + * from the bit the handler attests (bit 10, `TRACKED_MASTERNODES`, + * is deliberately absent: Android never attests it). + */ + const val CORE_SWEEP_REMOVAL: Long = NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt index 523474cc0b8..524f6d057e8 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt @@ -232,6 +232,35 @@ class DashDatabaseTest { assertEquals(1L, db.walletDao().count().first()) } + @Test + fun schemaIsAtVersion11WithTheSweepHoldIndexes() = runTest { + // The sweep-hold columns land in ONE migration (10 → 11), with the + // two `pending_inputs` indexes the sweep's claimed-row lookup + // (`spendingTxid`) and the end-of-round collector + // (`walletId, isSweptTombstone, winnerMinedHeight`) rely on. + assertEquals(11, db.openHelper.readableDatabase.version) + val indexes = mutableSetOf() + db.openHelper.readableDatabase.query("PRAGMA index_list('pending_inputs')").use { c -> + val nameColumn = c.getColumnIndexOrThrow("name") + while (c.moveToNext()) indexes += c.getString(nameColumn) + } + assertTrue(indexes.contains("index_pending_inputs_spendingTxid")) + assertTrue(indexes.contains("index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight")) + } + + @Test + fun advanceChainLockHeightIsANarrowMonotonicMaxWrite() = runTest { + db.walletDao().upsert(WalletEntity(walletId = walletId, networkRaw = 1, name = "w", syncedHeight = 7)) + assertEquals(1, db.walletDao().advanceChainLockHeight(walletId, 500, 1L)) + assertEquals(500, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) + assertEquals(1, db.walletDao().advanceChainLockHeight(walletId, 400, 2L)) + assertEquals("a stale height never lowers it", 500, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) + val row = db.walletDao().getByWalletId(walletId)!! + assertEquals("sibling columns are untouched", 7, row.syncedHeight) + assertEquals("w", row.name) + assertEquals(0, db.walletDao().advanceChainLockHeight(ByteArray(32) { 9 }, 1, 3L)) + } + @Test fun storageCountsCoverEveryTable() = runTest { val counts = db.storageCountsDao() 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 5008ade780b..e9f941bcfd1 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 @@ -22,6 +22,7 @@ import org.dashfoundation.dashsdk.persistence.entities.CoreAddressEntity import org.dashfoundation.dashsdk.persistence.entities.TransactionEntity import org.dashfoundation.dashsdk.persistence.entities.TxoEntity import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity +import org.dashfoundation.dashsdk.persistence.entities.PendingInputEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressEntity import org.dashfoundation.dashsdk.persistence.entities.WalletEntity import org.junit.After @@ -59,10 +60,27 @@ class PlatformWalletPersistenceHandlerTest { private val groupId = ByteArray(32) { 2 } private val testnet = 1 + /** + * What the sweep pass "decodes" from a stored record's bytes, keyed by + * txid. Every fixture records transactions with dummy bytes + * (`ByteArray(10) { 5 }`) that key-wallet-ffi could never decode, and + * the native decoder is not loadable under Robolectric anyway, so + * [recordTransaction] registers each record's `inputOutpoints` here and + * [storedInputs] hands them back. A txid never registered throws, as + * the production decoder would on bytes it cannot parse — a fixture + * that seeds a loser row directly must register its inputs. + */ + private val recordedInputs = HashMap>() + + private val storedInputs = StoredTransactionInputs { txid, _ -> + recordedInputs[txid.toHex()] + ?: error("test decoder: no inputs registered for ${txid.toHex()}") + } + @Before fun setUp() { db = DashDatabase.createInMemory(ApplicationProvider.getApplicationContext()) - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + handler = newHandler() } @After @@ -70,6 +88,130 @@ class PlatformWalletPersistenceHandlerTest { db.close() } + /** A handler over [db] wired to the test decoder — also the suite's "restart" idiom. */ + private fun newHandler(deriver: PrivateKeyDeriver? = null): PlatformWalletPersistenceHandler = + PlatformWalletPersistenceHandler( + db, + Dispatchers.Unconfined, + deriver, + storedTransactionInputs = storedInputs, + ) + + /** + * `onWalletChangesetTransaction` through the test decoder: registers + * the record's [inputOutpointCount] input outpoints under [txid] so a + * later sweep of it can key its hold by outpoint, then forwards the + * call unchanged. + */ + private fun recordTransaction( + h: PlatformWalletPersistenceHandler, + walletId: ByteArray, + txid: ByteArray, + txData: ByteArray, + context: Int, + blockHeight: Int, + blockHash: ByteArray, + blockTimestamp: Int, + direction: Int, + transactionType: String, + transactionTypeKind: Int, + netAmount: Long, + fee: Long, + hasFee: Boolean, + label: String, + firstSeen: Long, + inputOutpoints: ByteArray, + inputOutpointCount: Int, + accountTypeTag: Byte = (-1).toByte(), + accountStandardTag: Byte = 0, + accountIndex: Int = -1, + accountRegistrationIndex: Int = 0, + accountKeyClass: Int = 0, + accountUserIdentityId: ByteArray = ByteArray(0), + accountFriendIdentityId: ByteArray = ByteArray(0), + blockPosition: Int = 0, + hasBlockPosition: Boolean = false, + ): Int { + registerInputs(txid, List(inputOutpointCount) { i -> inputOutpoints.copyOfRange(i * 36, i * 36 + 36) }) + return h.onWalletChangesetTransaction( + walletId, txid, txData, context, blockHeight, blockHash, blockTimestamp, direction, + transactionType, transactionTypeKind, netAmount, fee, hasFee, label, firstSeen, + inputOutpoints, inputOutpointCount, accountTypeTag, accountStandardTag, accountIndex, + accountRegistrationIndex, accountKeyClass, accountUserIdentityId, + accountFriendIdentityId, blockPosition, hasBlockPosition, + ) + } + + /** Register what the test decoder returns for [txid] (for rows seeded directly). */ + private fun registerInputs(txid: ByteArray, inputs: List) { + recordedInputs[txid.toHex()] = inputs + } + + /** + * The sweep slot as the JNI trampoline packs it: [losers] as one flat + * 32·N array plus count, [released] as one flat 36·M array plus count, + * and the winner's mined height as the `(has, height)` pair — -1 here + * means an IS-locked, unmined winner (`has = false`). + */ + private fun sweep( + h: PlatformWalletPersistenceHandler, + wallet: ByteArray, + losers: List, + winner: ByteArray, + released: List, + winnerMinedHeight: Int, + ): Int = h.onWalletChangesetTransactionsSwept( + wallet, + losers.fold(ByteArray(0)) { acc, txid -> acc + txid }, + losers.size, + winner, + released.fold(ByteArray(0)) { acc, outpoint -> acc + outpoint }, + released.size, + winnerMinedHeight >= 0, + if (winnerMinedHeight >= 0) winnerMinedHeight else 0, + ) + + /** One committed round carrying a single sweep batch. */ + private fun sweepRound( + wallet: ByteArray, + losers: List, + winner: ByteArray, + released: List = emptyList(), + winnerMinedHeight: Int = 400, + h: PlatformWalletPersistenceHandler = handler, + ) { + h.onChangesetBegin(wallet) + assertEquals(0, sweep(h, wallet, losers, winner, released, winnerMinedHeight)) + assertEquals(0, h.onChangesetEnd(wallet, success = true)) + } + + /** + * The wallet + BIP44 account + one `CoreAddressEntity` prologue every + * restore-facing fixture needs: a TXO on [address] routes to the + * account through `core_addresses` (Android txos carry no accountId + * FK), which is what `onLoadWalletList` needs to hand it back. + */ + private suspend fun seedWalletWithAddress( + wallet: ByteArray, + address: String, + xpubFill: Byte = 30, + ) { + handler.onPersistWalletMetadata(wallet, testnet, groupId, 0) + handler.onPersistAccountRegistration( + wallet, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { xpubFill }, + ) + val account = db.accountDao().observeByWallet(wallet).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = address, + poolTypeTag = 0, + addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", + accountId = account.id, + ), + ) + } + @Test fun persistenceCapabilitiesAreExplicitAndFailClosedByDefault() { val noOpBridge = object : NativePersistenceBridge() {} @@ -77,7 +219,7 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0L, noOpBridge.persistenceCapabilitiesBits()) assertEquals(1, handler.persistenceCapabilitiesVersion()) - assertEquals(0x3bfL, handler.persistenceCapabilitiesBits()) + assertEquals(0xbbfL, handler.persistenceCapabilitiesBits()) // Android has no pending-contact-crypto callback, so it must not // attest that semantic contract. assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L) @@ -90,6 +232,40 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.INVITATIONS)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.DPNS_NAME_STATES)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.TRACKED_ASSET_LOCKS)) + assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.CORE_SWEEP_REMOVAL)) + } + + @Test + fun sweepSlotDefaultIsTheBenignIgnoreWhateverTheDeclaredBitsSay() { + // The gate against "declared the bit, never overrode the slot" is + // not in Kotlin any more: the JNI layer wires the sweep slot only + // for a bridge whose class overrides the method + // (`bridge_overrides` in rs-unified-sdk-jni), and Rust derives the + // effective capability from "slot present AND bit declared" — a + // declaring-but-not-overriding subclass never gets the slot, so + // Rust strips the bit and the watermark with it. The inherited + // body is therefore the benign ignore for every subclass; a runtime + // bit inspection here would gate one bit out of eleven that all + // share the declared-but-not-overridden hazard. + val declaringButNotOverriding = object : NativePersistenceBridge() { + override fun persistenceCapabilitiesBits(): Long = + NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL + } + val nonAttesting = object : NativePersistenceBridge() {} + val walletId = ByteArray(32) { 1 } + for (bridge in listOf(declaringButNotOverriding, nonAttesting)) { + assertEquals( + 0, + bridge.onWalletChangesetTransactionsSwept( + walletId, ByteArray(32) { 2 }, 1, ByteArray(32) { 3 }, ByteArray(0), 0, true, 400, + ), + ) + } + assertEquals( + "the diagnostic mirror aliases the bridge's declaration, so the two cannot drift", + NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL, + PlatformWalletPersistenceCapabilities.CORE_SWEEP_REMOVAL, + ) } // ── Standalone (non-bracketed) writes ───────────────────────────── @@ -357,7 +533,8 @@ class PlatformWalletPersistenceHandlerTest { val txid = ByteArray(32) { (marker ?: 0).toByte() } assertEquals( 0, - handler.onWalletChangesetTransaction( + recordTransaction( + handler, walletId = id, txid = txid, txData = marker?.let { byteArrayOf(it.toByte()) } ?: ByteArray(0), @@ -518,7 +695,8 @@ class PlatformWalletPersistenceHandlerTest { internalHighestUsed = -1, hasInternalHighestUsed = false, ) - handler.onWalletChangesetTransaction( + recordTransaction( + handler, walletId = walletId, txid = txid, txData = ByteArray(10) { 4 }, @@ -652,7 +830,7 @@ class PlatformWalletPersistenceHandlerTest { // A fresh handler models process restart. Its restore payload must // carry the canonical tuple, not the conflicting callback tuple. - val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + val restarted = newHandler() val restored = restarted.onLoadWalletList().single().platformAddressBalances.single() assertEquals(2, restored.accountIndex) assertEquals(7, restored.addressIndex) @@ -946,7 +1124,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun identityKeyUpsertDerivesAndRecordsPrivateKeyIdentifier() = runTest { val deriver = FakeDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 12 } seedIdentity(identityId) @@ -1012,7 +1190,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun identityKeyUpsertSkipsDeriveForWatchOnlyKey() = runTest { val deriver = FakeDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 14 } seedIdentity(identityId) @@ -1034,7 +1212,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun rolledBackRoundScrubsDeriverWrittenAliases() = runTest { val deriver = FakeDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 15 } seedIdentity(identityId) @@ -1062,7 +1240,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun rolledBackRoundDoesNotScrubPreExistingAliases() = runTest { val deriver = FakeDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 17 } seedIdentity(identityId) @@ -1088,7 +1266,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun failedAliasDeletionRetainsCleanupStateUntilRetrySucceeds() = runTest { val deriver = FakeDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 18 } seedIdentity(identityId) @@ -1120,7 +1298,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun committedRoundKeepsDeriverWrittenAliases() = runTest { val deriver = FakeDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 16 } seedIdentity(identityId) @@ -1177,7 +1355,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun derivationFailureIsRecordedAsAPendingIdentityKey() = runTest { - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 15 } seedIdentity(identityId) @@ -1260,7 +1438,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun markIdentityKeyRepairedClearsThePendingEntry() = runTest { // A derive failure records the key as pending… - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 18 } seedIdentity(identityId) val pubkey = ByteArray(33) { 13 } @@ -1286,7 +1464,7 @@ class PlatformWalletPersistenceHandlerTest { // removing that key (onPersistIdentityKeyRemoval) must drop the now- // phantom entry — a repair could never re-derive a key into an identity // that no longer carries it. - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 20 } seedIdentity(identityId) val pubkey = ByteArray(33) { 15 } @@ -1307,7 +1485,7 @@ class PlatformWalletPersistenceHandlerTest { // The removal's pending-clear is staged with the round (mirroring the // upsert path): an aborted round discards both the row deletion and the // pending-clear, so the pre-round pending entry survives untouched. - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 21 } seedIdentity(identityId) val pubkey = ByteArray(33) { 16 } @@ -1329,7 +1507,7 @@ class PlatformWalletPersistenceHandlerTest { // identity is a phantom afterwards — a repair could never re-derive a // key into an identity that no longer exists. All of them must clear // (not just one keyId, as onPersistIdentityKeyRemoval handles). - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 22 } seedIdentity(identityId) // Two watch-only keys under the same identity, different keyIds. @@ -1353,7 +1531,7 @@ class PlatformWalletPersistenceHandlerTest { // The identity-removal pending-clear is staged with the round: an // aborted round discards both the identity deletion and the clear, so // the pre-round pending entry survives untouched. - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 23 } seedIdentity(identityId) val pubkey = ByteArray(33) { 19 } @@ -1374,7 +1552,7 @@ class PlatformWalletPersistenceHandlerTest { // scoped to that wallet is a phantom afterwards. deleteWalletData must // prune them (Room's cascade cannot mutate the process-local // StateFlow). - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 24 } seedIdentity(identityId) val pubkey = ByteArray(33) { 20 } @@ -1395,7 +1573,7 @@ class PlatformWalletPersistenceHandlerTest { */ @Test fun abortedRoundLeavesNoPhantomPendingKeyState() = runTest { - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 19 } seedIdentity(identityId) @@ -1491,7 +1669,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun reconstructionSeedsPendingFromBreadcrumbRowsWithNullIdentifier() = runTest { - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 21 } seedIdentity(identityId) val pubkey = ByteArray(33) { 12 } @@ -1499,7 +1677,7 @@ class PlatformWalletPersistenceHandlerTest { // Model a process restart: a fresh handler starts with an empty // in-memory map, then rebuilds it from the durable rows. - val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + val restarted = newHandler() assertTrue(restarted.pendingIdentityKeys.value.isEmpty()) restarted.reconstructPendingIdentityKeysFromPersistence( isPrivateKeyDecryptable = { false }, @@ -1522,14 +1700,14 @@ class PlatformWalletPersistenceHandlerTest { // The derive SUCCEEDED at persist time (identifier recorded), but the // stored blob no longer passes the cheap capability check — e.g. the // Keystore keypair was replaced. The repair slot must resurface. - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, FakeDeriver()) + handler = newHandler(FakeDeriver()) val identityId = ByteArray(32) { 22 } seedIdentity(identityId) val pubkey = ByteArray(33) { 13 } upsertIdentityKey(pubkey, identityId) assertTrue(handler.pendingIdentityKeys.value.isEmpty()) // healthy at persist time - val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + val restarted = newHandler() restarted.reconstructPendingIdentityKeysFromPersistence( isPrivateKeyDecryptable = { false }, // blob stranded ) @@ -1540,13 +1718,13 @@ class PlatformWalletPersistenceHandlerTest { fun reconstructionSkipsHealthyRows() = runTest { // Identifier recorded AND the blob still decrypts: nothing to repair, // so a restart must not fabricate pending state. - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, FakeDeriver()) + handler = newHandler(FakeDeriver()) val identityId = ByteArray(32) { 23 } seedIdentity(identityId) val pubkey = ByteArray(33) { 14 } upsertIdentityKey(pubkey, identityId) - val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + val restarted = newHandler() restarted.reconstructPendingIdentityKeysFromPersistence( isPrivateKeyDecryptable = { true }, ) @@ -1558,7 +1736,7 @@ class PlatformWalletPersistenceHandlerTest { // A failed derive leaves a pending row; the repair path later records // the identifier on the Room row (and the blob decrypts). The next // restart's reconstruction must NOT resurrect the repaired key. - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 24 } seedIdentity(identityId) val pubkey = ByteArray(33) { 16 } @@ -1570,7 +1748,7 @@ class PlatformWalletPersistenceHandlerTest { row.copy(privateKeyKeychainIdentifier = "privkey." + pubkey.toHex()), ) - val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + val restarted = newHandler() restarted.reconstructPendingIdentityKeysFromPersistence( isPrivateKeyDecryptable = { true }, ) @@ -1589,7 +1767,7 @@ class PlatformWalletPersistenceHandlerTest { */ @Test fun signingKeyInvalidationSeedsPendingDespiteAUsableCheapCheck() = runTest { - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, FakeDeriver()) + handler = newHandler(FakeDeriver()) val identityId = ByteArray(32) { 26 } seedIdentity(identityId) val pubkey = ByteArray(33) { 18 } @@ -1611,7 +1789,7 @@ class PlatformWalletPersistenceHandlerTest { // And the SAME durable path re-seeds after a restart, still despite // the cheap check claiming usable. - val restarted = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + val restarted = newHandler() restarted.reconstructPendingIdentityKeysFromPersistence( isPrivateKeyDecryptable = { true }, ) @@ -1620,7 +1798,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun reconstructionNeverOverwritesALiveEntry() = runTest { - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, ThrowingDeriver()) + handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 25 } seedIdentity(identityId) val pubkey = ByteArray(33) { 17 } @@ -1688,7 +1866,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun repairWithCorrectBreadcrumbsDerivesVerifiesAndClearsPending() = runTest { val deriver = VerifyingRepairDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 30 } seedIdentity(identityId) @@ -1720,7 +1898,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun repairWithMismatchedBreadcrumbsIsRejectedAndLeavesPending() = runTest { val deriver = VerifyingRepairDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 31 } seedIdentity(identityId) @@ -1761,7 +1939,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun repairWithoutPersistedBreadcrumbsFailsAndLeavesPending() = runTest { val deriver = VerifyingRepairDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 32 } seedIdentity(identityId) @@ -1795,7 +1973,7 @@ class PlatformWalletPersistenceHandlerTest { @Test fun repairWithFailedDurableWriteLeavesPendingIntact() = runTest { val deriver = VerifyingRepairDeriver() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined, deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 33 } seedIdentity(identityId) @@ -1961,32 +2139,68 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(380_987L, restored.asOfHeight) } + /** + * The bridge emits a round's `transactions` before its `utxos_added` + * (`rs-unified-sdk-jni/src/persistence.rs`, `persist_changeset_account`; + * same order as Swift's `applyAccountChangeset`). A spend whose funding + * output arrives in the SAME round therefore stages a pending row first + * and must drain it when the TXO lands a few ops later: the coin ends + * the round linked to its spender, spent per the spender's context, with + * no pending row left behind. Separate-round drains are covered + * elsewhere; this pins the one-round fold. + */ + @Test + fun aFundingOutputAndItsSpenderInOneRoundLeaveTheCoinLinkedAndSpent() = runTest { + seedWalletWithAddress(walletId, "ySameRoundAddr") + + val fundingTxid = ByteArray(32) { 61 } + val spendingTxid = ByteArray(32) { 62 } + val outpoint = makeOutpoint(fundingTxid, 0) + + handler.onChangesetBegin(walletId) + // The spender first — its input has no TXO yet, so this stages a + // pending row keyed by the outpoint. + recordTransaction( + handler, + walletId, spendingTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, + 1_700_000_200, 1, "Standard", 0, -60_000, 0, false, "", 1_700_000_100, + outpoint, 1, + ) + // Then the funding output, in the same round. + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 60_000, "ySameRoundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val txo = db.txoDao().getByOutpoint(outpoint) + assertNotNull("the funding output materialised", txo) + assertTrue("the in-block spender's claim drained onto the TXO", spendingTxid.contentEquals(txo!!.spendingTxid)) + assertEquals("vin index carried from the staged claim", 0, txo.spendingInputIndex) + assertTrue("spent per the spender's in-block context", txo.isSpent) + assertTrue( + "the staged claim is consumed by the drain, not left behind", + db.documentDao().getPendingInputsByOutpoint(outpoint).isEmpty(), + ) + assertTrue( + "and the coin is not handed back as spendable", + handler.onLoadWalletList().single().utxos.none { it.prevTxid.contentEquals(fundingTxid) && it.vout == 0 }, + ) + } + @Test fun loadWalletListRestoresUnspentUtxosAndExcludesConfirmedSpends() = runTest { // CORE-06 regression: persisted unspent TXOs must come back on // the restore row (routed to their owning account through // core_addresses — Android txos carry no accountId FK), and a // TXO whose spend has confirmed must NOT rehydrate as spendable. - handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) - val xpub = ByteArray(78) { 30 } - handler.onPersistAccountRegistration( - walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, - ) - val account = db.accountDao().observeByWallet(walletId).first().single() - db.coreAddressDao().upsert( - CoreAddressEntity( - address = "yUtxoAddr", - poolTypeTag = 0, - addressIndex = 0, - derivationPath = "m/44'/1'/0'/0/0", - accountId = account.id, - ), - ) + seedWalletWithAddress(walletId, "yUtxoAddr") val fundingTxid = ByteArray(32) { 21 } val spendingTxid = ByteArray(32) { 22 } handler.onChangesetBegin(walletId) - handler.onWalletChangesetTransaction( + recordTransaction( + handler, walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, ByteArray(0), 0, // funding tx: no inputs of ours @@ -2006,7 +2220,8 @@ class PlatformWalletPersistenceHandlerTest { // and the row stays in the restore set (iOS semantics — the // post-restart classifier needs the TXO back). handler.onChangesetBegin(walletId) - handler.onWalletChangesetTransaction( + recordTransaction( + handler, walletId, spendingTxid, ByteArray(10) { 5 }, 1, 0, ByteArray(32), 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_100, makeOutpoint(fundingTxid, 1), 1, // spends fundingTxid:1 @@ -2022,7 +2237,8 @@ class PlatformWalletPersistenceHandlerTest { // must flip `isSpent` (the flag would otherwise never converge // — the CORE-06 over-count hazard)… handler.onChangesetBegin(walletId) - handler.onWalletChangesetTransaction( + recordTransaction( + handler, walletId, spendingTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, 1_700_000_200, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_100, makeOutpoint(fundingTxid, 1), 1, // spends fundingTxid:1 @@ -2046,145 +2262,2208 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun spendBeforeFundingReconcilesViaPendingInputAndExcludesFromRestore() = runTest { - // CORE-06, out-of-order arrival: an in-block spending tx is persisted - // BEFORE its funding TXO is known (Rust's utxos_spent slice is empty - // because the previous output wasn't classified yet). The spend must - // not be lost — `inputOutpoints` stages a pending-input row that the - // funding TXO's later upsert drains, so the consumed output is excluded - // from the restore set instead of being handed back to Rust as - // spendable. 1:1 mirror of Swift resolveInputOutpoint + upsertUtxo drain. - handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) - val xpub = ByteArray(78) { 30 } - handler.onPersistAccountRegistration( - walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + fun sweptTransactionIsDeletedAndFreesOnlyItsOwnInputs() = runTest { + // A recorded spend that a later, final transaction beat to an input + // can never confirm; Rust drops it and names it here. The mirror has + // to drop it too — otherwise the row comes back on the next load and + // re-creates a balance the wallet already corrected. + // + // Shape: the loser (unconfirmed, as every swept loser is) spends A + // and B; the winner is wallet-relevant, in-block, and takes only A. + // A must stay out of the restore set, B must return to it. + seedWalletWithAddress(walletId, "yUtxoAddr") + + val fundingTxid = ByteArray(32) { 41 } + val sweptTxid = ByteArray(32) { 42 } + val winnerTxid = ByteArray(32) { 44 } + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 140_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, ) - val account = db.accountDao().observeByWallet(walletId).first().single() - db.coreAddressDao().upsert( - CoreAddressEntity( - address = "yFundAddr", - poolTypeTag = 0, - addressIndex = 0, - derivationPath = "m/44'/1'/0'/0/0", - accountId = account.id, - ), + // A (vout 0) and B (vout 1). + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 1, 40_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, ) + handler.onChangesetEnd(walletId, success = true) - val fundingTxid = ByteArray(32) { 41 } - val spendingTxid = ByteArray(32) { 42 } + // The doomed transaction: mempool context — upstream only ever + // sweeps unconfirmed records, so its inputs are linked to it without + // `isSpent` ever flipping. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, sweptTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0) + makeOutpoint(fundingTxid, 1), 2, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, sweptTxid) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 1, sweptTxid) + handler.onWalletChangesetUtxoAdded( + walletId, sweptTxid, 0, 60_000, "yUtxoAddr", ByteArray(25) { 6 }, + 0, false, false, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + assertFalse( + "a pre-block spender links but must not flip isSpent", + db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent, + ) - // Changeset 1: the in-block spending tx arrives first. Its funding TXO - // is unknown, so a pending-input row is staged (no utxos_spent fires). + // The winner confirms, taking A, then the sweep runs — the ordering + // the persist path guarantees inside one round. handler.onChangesetBegin(walletId) - handler.onWalletChangesetTransaction( - walletId, spendingTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, - 1_700_000_200, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_100, - makeOutpoint(fundingTxid, 0), 1, // spends fundingTxid:0 (TXO unknown) + recordTransaction( + handler, + walletId, winnerTxid, ByteArray(10) { 6 }, 2, 102, ByteArray(32) { 9 }, + 1_700_000_200, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_150, + makeOutpoint(fundingTxid, 0), 1, ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winnerTxid) + sweep(handler, walletId, listOf(sweptTxid), winnerTxid, listOf(makeOutpoint(fundingTxid, 1)), 400) handler.onChangesetEnd(walletId, success = true) - val staged = db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)) - assertEquals(1, staged.size) - assertTrue(spendingTxid.contentEquals(staged.single().spendingTxid)) - // Funding TXO absent → nothing to restore yet. - assertEquals(0, handler.onLoadWalletList().single().utxos.size) + assertNull("the swept transaction row is gone", db.transactionDao().getByTxid(sweptTxid)) + assertNull( + "the change it created is gone with it", + db.txoDao().getByOutpoint(makeOutpoint(sweptTxid, 0)), + ) + assertNotNull("the funding transaction is untouched", db.transactionDao().getByTxid(fundingTxid)) + + val winnerTaken = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!! + assertTrue("the coin the winner took stays spent", winnerTaken.isSpent) + assertTrue(winnerTxid.contentEquals(winnerTaken.spendingTxid)) + + // B was only ever claimed by the loser, so it is spendable again. + val released = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 1))!! + assertFalse("the loser's own input is free again", released.isSpent) + assertNull(released.spendingTxid) + val restored = handler.onLoadWalletList().single().utxos.single() + assertEquals(1, restored.vout) + } + + @Test + fun anAbsentWinnerStillKeepsItsOwnInputSpent() = runTest { + // The winner can spend our coin and pay only outside addresses. It + // sweeps the loser all the same, but no record for it ever reaches + // the persister — so nothing in this store could work out that the + // coin is gone. Upstream can, and reports it by leaving the coin out + // of the released set. A swept loser is unconfirmed, so its input is + // linked at `isSpent = 0`; deleting the loser and stopping there + // would return a coin the chain has already spent as spendable. + seedWalletWithAddress(walletId, "yUtxoAddr") + + val fundingTxid = ByteArray(32) { 45 } + val sweptTxid = ByteArray(32) { 46 } + val irrelevantWinner = ByteArray(32) { 47 } - // Changeset 2: the funding TXO finally lands. The drain links the spend - // (in-block → isSpent) and clears the pending row. handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) handler.onWalletChangesetUtxoAdded( - walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, 100, false, true, false, false, ) handler.onChangesetEnd(walletId, success = true) - val txo = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)) - assertNotNull(txo) - assertTrue(txo!!.isSpent) - assertTrue(spendingTxid.contentEquals(txo.spendingTxid!!)) + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, sweptTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, sweptTxid) + handler.onChangesetEnd(walletId, success = true) + assertFalse(db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!!.isSpent) + + handler.onChangesetBegin(walletId) + // Upstream knows the winner took this coin even though it never + // reports the winner itself, so nothing is released. + sweep(handler, walletId, listOf(sweptTxid), irrelevantWinner, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + assertNull(db.transactionDao().getByTxid(sweptTxid)) + val held = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0))!! + assertTrue("the coin the unrecorded winner may have taken is held", held.isSpent) + assertNull("with no spender invented for it", held.spendingTxid) assertTrue( - db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)).isEmpty(), + "but with the winner stamped, the same attribution SQLite " + + "records as spent_in_txid", + irrelevantWinner.contentEquals(held.supersededByTxid), ) - // The consumed output must NOT be handed back to Rust as spendable. - assertEquals(0, handler.onLoadWalletList().single().utxos.size) + assertTrue( + "and it stays out of the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + } @Test - fun loadWalletListRestoresCoreAddressPoolsBeyondGapWindow() = runTest { - // prior-2 regression: the persisted Core address pools must come - // back on the restore row so every restored address maps to its - // derivation path — including addresses PAST the gap-limit window - // (`DEFAULT_GAP_LIMIT` = 20) that `ManagedWalletInfo::from_wallet` - // pre-derives. Without this, a restored UTXO on an out-of-window - // address has no derivation-path mapping and the wallet cannot - // sign a core-to-core spend after a cold restart. Mirror of the - // Swift `buildCoreAddressPoolBuffer` round-trip. - handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) - val xpub = ByteArray(78) { 30 } - handler.onPersistAccountRegistration( - walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, - ) - val account = db.accountDao().observeByWallet(walletId).first().single() + fun aStampedUnlinkedCoinTheWalletRedeliversUnspentFollowsTheWallet() = runTest { + // The rule this test USED to pin was the opposite — "a re-delivery + // cannot outrank the sweep's verdict, only a release frees a + // stamped hold". That rule locked a real coin out forever: a + // materialised coin is one the wallet knows, any network-final + // spender of a coin it knows is wallet-relevant by BIP158 prevout + // matching, so the wallet's own scan re-discovers the spend — and + // if it instead re-delivers the coin UNSPENT, the winner was reorged + // out (or was never mined) and there is nothing to hold it against. + // On this side of the FFI a row at `isSpent = true` is never + // restored to Rust again, so refusing meant the coin was gone for + // good. Same answer as the SQLite store's upsert valve, which now + // holds only never-materialised placeholders: a stamped, UNLINKED + // row the wallet hands back as a UTXO is cleared, stamp included. + // A row still LINKED to a spender keeps its flag — the link is the + // store's recorded spend attribution and the sweep pass owns it. + seedWalletWithAddress(walletId, "yUtxoAddr") + val fundingTxid = ByteArray(32) { 48 } + val coin = makeOutpoint(fundingTxid, 0) + val loserTxid = ByteArray(32) { 49 } + val irrelevantWinner = ByteArray(32) { 54 } - // An external (pool tag 0) address well beyond the gap window, - // used and carrying a balance + a full derivation path + pubkey. - val pubkey = ByteArray(33) { 4 } - db.coreAddressDao().upsert( - CoreAddressEntity( - address = "yFarAddr", - publicKey = pubkey, - poolTypeTag = 0, - addressIndex = 100, - derivationPath = "m/44'/1'/0'/0/100", - isUsed = true, - balance = 12_345, - accountId = account.id, - ), + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, ) - // A second, unused internal (pool tag 1) address — proves grouping - // by pool type emits a distinct pool for the change chain. - db.coreAddressDao().upsert( - CoreAddressEntity( - address = "yChangeAddr", - publicKey = ByteArray(0), - poolTypeTag = 1, - addressIndex = 3, - derivationPath = "m/44'/1'/0'/1/3", - isUsed = false, - accountId = account.id, - ), + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, ) + recordTransaction( + handler, + walletId, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_050, + coin, 1, + ) + handler.onChangesetEnd(walletId, success = true) + sweepRound(walletId, listOf(loserTxid), irrelevantWinner) + val held = db.txoDao().getByOutpoint(coin)!! + assertTrue("sanity: held by the stamp, unlinked", held.isSpent && held.spendingTxid == null) + assertTrue(irrelevantWinner.contentEquals(held.supersededByTxid)) - val list = handler.onLoadWalletList() - assertEquals(1, list.size) - val pools = list[0].coreAddressPools - // One pool per (account, poolType) group, ascending tag order. - assertEquals(2, pools.size) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) - val external = pools[0] - assertEquals(0.toByte(), external.poolTypeTag) - // The pool routes via the account tuple (xpub omitted — the loader - // ignores it on this path). - assertEquals(0.toByte(), external.account.typeTag) - assertEquals(0, external.account.index) - assertEquals(0, external.account.accountXpubBytes.size) - assertEquals(1, external.addresses.size) - val far = external.addresses[0] - assertEquals("yFarAddr", far.addressBase58) - // The out-of-window address keeps its derivation path — the whole - // point of the fix. - assertEquals("m/44'/1'/0'/0/100", far.derivationPath) - assertEquals(100, far.addressIndex) - assertTrue(far.isUsed) - assertEquals(12_345L, far.balance) - assertTrue(pubkey.contentEquals(far.publicKey)) - assertEquals(0.toByte(), far.poolTypeTag) + val redelivered = db.txoDao().getByOutpoint(coin)!! + assertFalse("the wallet re-delivering the coin unspent lifts the hold", redelivered.isSpent) + assertNull("stamp included", redelivered.supersededByTxid) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } - val internal = pools[1] - assertEquals(1.toByte(), internal.poolTypeTag) - assertEquals(1, internal.addresses.size) - val change = internal.addresses[0] - assertEquals("yChangeAddr", change.addressBase58) + @Test + fun aWinnersLateSpentEmitDoesNotDowngradeAStampedHold() = runTest { + // The winner's own record can reach this store only after the sweep + // and the funding TXO already did — IS-locked, not yet in a block. + // Its record pass is monotonic and merely links the spender, but + // the utxos_spent emit that rides with it resolved the in-block + // gate to false and wrote it, flipping a durable stamped hold back + // into the restore set until the winner confirmed — contradicting + // the verdict the sweep already recorded. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 56 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val loserTxid = ByteArray(32) { 57 } + val winnerTxid = ByteArray(32) { 58 } + + // The doomed spend, before its funding output. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // The sweep holds the claim; the funding TXO then materializes it + // as a stamped hold. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(loserTxid), winnerTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + assertTrue(db.txoDao().getByOutpoint(pOutpoint)!!.isSpent) + + // The winner's own record finally arrives, IS-locked (context 1 < + // in-block), with the spent emit riding along the way a real round + // delivers both. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, winnerTxid, ByteArray(10) { 6 }, 1, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_060, + pOutpoint, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winnerTxid) + handler.onChangesetEnd(walletId, success = true) + + val held = db.txoDao().getByOutpoint(pOutpoint)!! + assertTrue( + "the winner's own unconfirmed arrival must not downgrade the stamped hold", + held.isSpent, + ) + assertTrue(winnerTxid.contentEquals(held.supersededByTxid)) + assertTrue( + "the spender is linked all the same", + winnerTxid.contentEquals(held.spendingTxid), + ) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) + } + + @Test + fun aReleaseNamingACoinASettledSpenderStillClaimsIsRefused() = runTest { + // The pruned-finalized-release defect, on this store's terms: a + // chainlocked spender F is pruned upstream to a bare txid, so a + // later loser L that pays this wallet while reusing F's input (plus + // an attacker-owned one) sweeps with F's coin wrongly named in + // `releasedOutpoints`. F's row and its `spendingTxid` link survive + // HERE, and the link guard keeps L's record pass from stealing the + // attribution — so the release pass finds F's coin linked to a + // stored network-final spender and refuses it, while the coin only + // L claimed still comes free in the same batch. The restore surface is the + // restart: what `onLoadWalletList` hands back is what a relaunch + // spends from. + seedWalletWithAddress(walletId, "yUtxoAddr") + + val fundingTxid = ByteArray(32) { 60 } + val settledCoin = makeOutpoint(fundingTxid, 0) + val losersOwnCoin = makeOutpoint(fundingTxid, 1) + val attackerInput = makeOutpoint(ByteArray(32) { 61 }, 0) + val finalizedTxid = ByteArray(32) { 62 } + val loserTxid = ByteArray(32) { 63 } + val winnerTxid = ByteArray(32) { 64 } + + // Fund both coins. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 200_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 1, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + // F: the chainlocked spender of `settledCoin` — upstream keeps only + // its txid from here on; this store keeps the row and the link. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, finalizedTxid, ByteArray(10) { 5 }, 3, 120, ByteArray(32) { 8 }, + 1_700_000_100, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_050, + settledCoin, 1, + ) + handler.onChangesetEnd(walletId, success = true) + val linked = db.txoDao().getByOutpoint(settledCoin)!! + assertTrue("sanity: F's spend marked", linked.isSpent) + assertTrue("sanity: F holds the link", finalizedTxid.contentEquals(linked.spendingTxid)) + + // L: arrives after F's pruning — pays this wallet, reuses F's input + // alongside the attacker's and one coin of its own. Its record pass + // must NOT steal F's link. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loserTxid, ByteArray(10) { 6 }, 0, 0, ByteArray(32), + 0, 0, "Standard", 0, 50_000, 0, false, "", 1_700_000_200, + settledCoin + attackerInput + losersOwnCoin, 3, + ) + handler.onChangesetEnd(walletId, success = true) + val guarded = db.txoDao().getByOutpoint(settledCoin)!! + assertTrue( + "a settled spender's link is not stolen by a conflicting record", + finalizedTxid.contentEquals(guarded.spendingTxid), + ) + assertTrue( + "the loser's own coin links normally", + loserTxid.contentEquals(db.txoDao().getByOutpoint(losersOwnCoin)!!.spendingTxid), + ) + + // W (final) beats L on the attacker input alone. Upstream's release + // set — computed from live records that no longer include F — wrongly + // names F's coin alongside the loser's own. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(loserTxid), winnerTxid, listOf(settledCoin, losersOwnCoin), 400) + handler.onChangesetEnd(walletId, success = true) + + val settled = db.txoDao().getByOutpoint(settledCoin)!! + assertTrue( + "a released coin a settled stored spender still claims must stay spent", + settled.isSpent, + ) + assertTrue(finalizedTxid.contentEquals(settled.spendingTxid)) + val freed = db.txoDao().getByOutpoint(losersOwnCoin)!! + assertFalse("a coin only the swept loser claimed must come free", freed.isSpent) + assertNull(freed.spendingTxid) + assertEquals( + "the restore surface hands back exactly the freed coin", + 1, + handler.onLoadWalletList().single().utxos.size, + ) + } + + @Test + fun aPreStampHoldStillFreesOnRedelivery() = runTest { + // The same rule as + // aStampedUnlinkedCoinTheWalletRedeliversUnspentFollowsTheWallet, + // for the shape no current writer produces: a coin held spent with + // neither a spender nor a `supersededByTxid` stamp. An UNLINKED row + // follows the wallet whatever it carries, so the wallet + // re-delivering it as a UTXO lifts the mark; only a link is spend + // evidence a re-delivery leaves alone. + seedWalletWithAddress(walletId, "yUtxoAddr") + + val fundingTxid = ByteArray(32) { 55 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + db.transactionDao().upsert( + TransactionEntity(txid = fundingTxid, transactionData = ByteArray(0)), + ) + db.txoDao().upsert( + TxoEntity( + outpoint = pOutpoint, + vout = 0, + amount = 100_000, + address = "yUtxoAddr", + isSpent = true, + walletId = walletId, + txid = fundingTxid, + ), + ) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + assertFalse( + "a hold with nothing durable behind it frees on re-delivery", + db.txoDao().getByOutpoint(pOutpoint)!!.isSpent, + ) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun aReleasedCoinAlreadyReclaimedInTheSameRoundKeepsItsNewSpender() = runTest { + // A round can carry both a release and a later transaction that + // legitimately spends the freed coin: merging folds several events + // together, and every record is written before sweeps are processed. + // By the time the release runs the coin is claimed again, and freeing + // it would hand a spent coin back to the restore set. + seedWalletWithAddress(walletId, "yUtxoAddr") + + val fundingTxid = ByteArray(32) { 50 } + val sweptTxid = ByteArray(32) { 51 } + val winnerTxid = ByteArray(32) { 52 } + val reclaimerTxid = ByteArray(32) { 53 } + val freedCoin = makeOutpoint(fundingTxid, 1) + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 140_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 1, 40_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + // The doomed transaction claims both coins, unconfirmed as every + // swept loser is. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, sweptTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0) + freedCoin, 2, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, sweptTxid) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 1, sweptTxid) + handler.onChangesetEnd(walletId, success = true) + + // One round now carries the winner, the sweep releasing the coin the + // winner did not take, and a later transaction that already spent + // that freed coin. Records are applied first, sweeps last. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, winnerTxid, ByteArray(10) { 6 }, 2, 101, ByteArray(32) { 8 }, + 1_700_000_100, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_090, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, winnerTxid) + recordTransaction( + handler, + walletId, reclaimerTxid, ByteArray(10) { 7 }, 2, 102, ByteArray(32) { 9 }, + 1_700_000_200, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_150, + freedCoin, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 1, reclaimerTxid) + sweep(handler, walletId, listOf(sweptTxid), winnerTxid, listOf(freedCoin), 400) + handler.onChangesetEnd(walletId, success = true) + + assertNull("the swept transaction row is still gone", db.transactionDao().getByTxid(sweptTxid)) + + val reclaimed = db.txoDao().getByOutpoint(freedCoin)!! + assertTrue( + "the later spender keeps its claim", + reclaimerTxid.contentEquals(reclaimed.spendingTxid), + ) + assertTrue("so the coin stays spent", reclaimed.isSpent) + assertTrue( + "and never returns to the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + } + + @Test + fun aLaterSweepKeepingACoinSpentOverridesAnEarlierRelease() = runTest { + // JNI delivers one call per sweep batch, in order. The first frees a + // coin, a second transaction spends it, and the second sweep removes + // that spender while freeing nothing — its own winner took the coin. + // The later answer has to win, which is what applying the calls in + // sequence gives: each one holds its losers' inputs before releasing. + seedWalletWithAddress(walletId, "yUtxoAddr") + + val fundingTxid = ByteArray(32) { 70 } + val firstLoser = ByteArray(32) { 71 } + val secondLoser = ByteArray(32) { 72 } + val contested = makeOutpoint(fundingTxid, 0) + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + // Both losers claim the coin; each is unconfirmed, as swept losers are. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, firstLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_050, + contested, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, firstLoser) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, secondLoser, ByteArray(10) { 6 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -100_000, 0, false, "", 1_700_000_100, + contested, 1, + ) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, secondLoser) + handler.onChangesetEnd(walletId, success = true) + + // One round, two batches, in order. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(firstLoser), ByteArray(32) { 73 }, listOf(contested), 400) + sweep(handler, walletId, listOf(secondLoser), ByteArray(32) { 74 }, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + val row = db.txoDao().getByOutpoint(contested)!! + assertTrue("the later sweep kept the coin spent", row.isSpent) + assertTrue( + "so it stays out of the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + } + + /** + * Seed the review finding's exact shape: one loser transaction shared by + * two wallets, spending one coin from each. Upstream computes each + * wallet's released set independently + * (`per_wallet_released_outpoints`), and neither wallet's own winner row + * is ever created here — matching the "the winner can pay only outside + * addresses" case the released set exists to handle. Both coins live in + * the same funding transaction purely for setup convenience; what makes + * the loser shared is that it spends a TXO owned by each wallet. + * + * Returns the funding txid and the loser txid so callers can build the + * outpoints and drive the sweep. + */ + private suspend fun seedSharedLoserAcrossTwoWallets(walletA: ByteArray, walletB: ByteArray): Pair { + handler.onPersistWalletMetadata(walletA, testnet, groupId, 0) + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + // Distinct xpubs — `accountExtendedPubKeyBytes` carries a unique + // index, so two accounts sharing one would silently fail the second + // registration (`guarded` swallows the constraint violation). + handler.onPersistAccountRegistration( + walletA, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { 30 }, + ) + handler.onPersistAccountRegistration( + walletB, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { 31 }, + ) + val accountA = db.accountDao().observeByWallet(walletA).first().single() + val accountB = db.accountDao().observeByWallet(walletB).first().single() + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yWalletA", poolTypeTag = 0, addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", accountId = accountA.id, + ), + ) + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yWalletB", poolTypeTag = 0, addressIndex = 0, + derivationPath = "m/44'/1'/0'/0/0", accountId = accountB.id, + ), + ) + + val fundingTxid = ByteArray(32) { 80 } + val loserTxid = ByteArray(32) { 81 } + + // P (vout 0) — wallet A's coin. + handler.onChangesetBegin(walletA) + recordTransaction( + handler, + walletA, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 140_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletA, fundingTxid, 0, 100_000, "yWalletA", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletA, success = true) + + // Q (vout 1) — wallet B's coin, same funding transaction. + handler.onChangesetBegin(walletB) + handler.onWalletChangesetUtxoAdded( + walletB, fundingTxid, 1, 40_000, "yWalletB", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletB, success = true) + + // The shared loser: unconfirmed, spends both P and Q. + handler.onChangesetBegin(walletA) + recordTransaction( + handler, + walletA, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0) + makeOutpoint(fundingTxid, 1), 2, + ) + handler.onWalletChangesetUtxoSpent(walletA, fundingTxid, 0, loserTxid) + handler.onWalletChangesetUtxoSpent(walletA, fundingTxid, 1, loserTxid) + handler.onChangesetEnd(walletA, success = true) + + return fundingTxid to loserTxid + } + + @Test + fun sharedLoserAppliesEachWalletsOwnReleaseSetRegardlessOfOrder_walletBThenWalletA() = runTest { + // The hold is global, the release is per wallet. The FIRST callback + // that sees the sweep holds EVERY wallet's rows for the loser's + // inputs (stamped with the winner, links to the loser detached) and + // deletes the loser's row outright; each wallet's own callback then + // applies ITS released set to ITS rows, by outpoint — so a later + // callback for the same loser, finding no row, still frees what it + // was entitled to. Wallet B (which releases nothing) runs first: it + // holds A's coin too — conservatively, until A's own verdict lands. + val walletB = ByteArray(32) { 9 } + val (fundingTxid, loserTxid) = seedSharedLoserAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 82 } + val p = makeOutpoint(fundingTxid, 0) + val q = makeOutpoint(fundingTxid, 1) + + sweepRound(walletB, listOf(loserTxid), winnerTxid) + + assertNull( + "the first callback deletes the shared row — the hold outlives it", + db.transactionDao().getByTxid(loserTxid), + ) + val heldP = db.txoDao().getByOutpoint(p)!! + assertTrue("wallet A's coin is held until A's own release names it", heldP.isSpent) + assertNull("the link to the dead loser is detached", heldP.spendingTxid) + assertTrue(winnerTxid.contentEquals(heldP.supersededByTxid)) + + // Wallet A second: the loser's row is gone, and its release of P + // still lands by outpoint. + sweepRound(walletId, listOf(loserTxid), winnerTxid, released = listOf(p)) + + val freedP = db.txoDao().getByOutpoint(p)!! + assertFalse("wallet A's own release must free its own coin", freedP.isSpent) + assertNull(freedP.spendingTxid) + assertNull("the stamp goes with the hold", freedP.supersededByTxid) + + val heldQ = db.txoDao().getByOutpoint(q)!! + assertTrue("wallet B's own decision to hold Q survives wallet A's callback", heldQ.isSpent) + assertNull(heldQ.spendingTxid) + assertTrue(winnerTxid.contentEquals(heldQ.supersededByTxid)) + } + + @Test + fun sharedLoserAppliesEachWalletsOwnReleaseSetRegardlessOfOrder_walletAThenWalletB() = runTest { + // Mirror of the ordering above: wallet A (which releases P) runs + // first and holds B's coin; B's callback releases nothing. The end + // state must be the same. + val walletB = ByteArray(32) { 9 } + val (fundingTxid, loserTxid) = seedSharedLoserAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 92 } + val p = makeOutpoint(fundingTxid, 0) + val q = makeOutpoint(fundingTxid, 1) + + sweepRound(walletId, listOf(loserTxid), winnerTxid, released = listOf(p)) + + assertNull("the first callback deletes the shared row", db.transactionDao().getByTxid(loserTxid)) + val heldQ = db.txoDao().getByOutpoint(q)!! + assertTrue("wallet B's coin is held by A's callback until B's own verdict", heldQ.isSpent) + assertNull(heldQ.spendingTxid) + assertTrue(winnerTxid.contentEquals(heldQ.supersededByTxid)) + assertFalse("wallet A's own coin came free at once", db.txoDao().getByOutpoint(p)!!.isSpent) + + sweepRound(walletB, listOf(loserTxid), winnerTxid) + + val freedP = db.txoDao().getByOutpoint(p)!! + assertFalse("wallet A's earlier release must survive wallet B's callback", freedP.isSpent) + assertNull(freedP.spendingTxid) + + val stillHeldQ = db.txoDao().getByOutpoint(q)!! + assertTrue("wallet B's own decision to hold its coin must stick", stillHeldQ.isSpent) + assertNull(stillHeldQ.spendingTxid) + } + + /** + * [seedSharedLoserAcrossTwoWallets] plus an output of the loser's own — + * phantom money, since a transaction that never confirms funded + * nothing. Driven through the ordinary [onWalletChangesetUtxoAdded] + * write path, the same as every other row in this fixture, rather than + * reaching into the DB directly. + */ + private suspend fun seedSharedLoserWithOwnOutputAcrossTwoWallets( + walletA: ByteArray, + walletB: ByteArray, + ): Pair { + val (fundingTxid, loserTxid) = seedSharedLoserAcrossTwoWallets(walletA, walletB) + handler.onChangesetBegin(walletA) + handler.onWalletChangesetUtxoAdded( + walletA, loserTxid, 2, 60_000, "yLoserChange", ByteArray(25) { 6 }, + 0, false, false, false, false, + ) + handler.onChangesetEnd(walletA, success = true) + return fundingTxid to loserTxid + } + + @Test + fun sharedLoserOutputAndCoreTxRecordAreExcludedAfterOnlyOneWalletsCallbackCommits() = runTest { + // `commit_batch` calls `store()` once per wallet and each commits + // independently, so wallet A's callback may never arrive at all — a + // crash, a rejection, or simply never coming. One committed callback + // must already be the whole removal: the row and its phantom output + // gone, `onGetCoreTxRecord` blind to it, and A's coin HELD rather + // than restorable — a missing callback leaves a coin conservatively + // held, never a wrongly-spent or resurrectable one. + val walletB = ByteArray(32) { 9 } + val (fundingTxid, loserTxid) = seedSharedLoserWithOwnOutputAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 82 } + val p = makeOutpoint(fundingTxid, 0) + val phantomOutput = makeOutpoint(loserTxid, 2) + + // Only wallet B's callback ever runs, and it releases nothing. + sweepRound(walletB, listOf(loserTxid), winnerTxid) + + assertNull("one committed callback deletes the row", db.transactionDao().getByTxid(loserTxid)) + assertNull("and the loser's own output with it", db.txoDao().getByOutpoint(phantomOutput)) + val heldP = db.txoDao().getByOutpoint(p)!! + assertTrue("wallet A's coin is held, not returned, while A's verdict is missing", heldP.isSpent) + assertTrue(winnerTxid.contentEquals(heldP.supersededByTxid)) + + // "Restart": a fresh handler bound to the same underlying store. + // Wallet A's own callback never happens. + val restarted = newHandler() + + assertNull("the phantom output must not resurrect across a restart", db.txoDao().getByOutpoint(phantomOutput)) + assertNull( + "wallet A must not read the swept loser back as a live transaction", + restarted.onGetCoreTxRecord(walletId, loserTxid), + ) + val utxosA = restarted.onLoadWalletList().first { it.walletId.contentEquals(walletId) }.utxos + assertTrue( + "neither the phantom output nor the held coin is handed back as restorable", + utxosA.isEmpty(), + ) + } + + @Test + fun twoWalletsEachReleaseTheirOwnPendingClaimOnASharedLoser() = runTest { + // A shared loser holds one unresolved pending claim per wallet. + // Upstream computes each wallet's released set from that wallet's + // own records (every input of the loser that the winner did not + // take and no surviving record of that wallet still claims), so + // both wallets name both coins. The first callback (A) deletes its + // own released claim, tombstones B's — B's verdict is not in yet, + // and a callback that never arrives must leave a coin held — and + // deletes the row; B's callback, finding no row, still applies its + // release by outpoint and deletes its tombstone. No row and no + // claim survives, and never a freed tombstone. + val walletB = ByteArray(32) { 8 } + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 65 } + val pA = makeOutpoint(fundingTxid, 8) + val pB = makeOutpoint(fundingTxid, 9) + val loserTxid = ByteArray(32) { 66 } + val winnerTxid = ByteArray(32) { 67 } + + // The loser's row plus one still-unfunded pending claim per wallet + // — what each wallet's own record pass would have staged. + db.transactionDao().upsert( + TransactionEntity(txid = loserTxid, transactionData = ByteArray(10) { 5 }), + ) + registerInputs(loserTxid, listOf(pA, pB)) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = pA, inputIndex = 0, spendingTxid = loserTxid, + spendingTransactionTxid = loserTxid, walletId = walletId, + ), + ) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = pB, inputIndex = 1, spendingTxid = loserTxid, + spendingTransactionTxid = loserTxid, walletId = walletB, + ), + ) + + sweepRound(walletId, listOf(loserTxid), winnerTxid, released = listOf(pA, pB)) + assertNull("the first callback deletes the row", db.transactionDao().getByTxid(loserTxid)) + assertTrue("A's released claim is deleted outright", db.documentDao().getPendingInputsByOutpoint(pA).isEmpty()) + val heldB = db.documentDao().getPendingInputsByOutpoint(pB).single() + assertTrue("B's claim is held until B's own verdict", heldB.isSweptTombstone) + assertTrue(walletB.contentEquals(heldB.walletId)) + assertTrue(winnerTxid.contentEquals(heldB.spendingTxid)) + + sweepRound(walletB, listOf(loserTxid), winnerTxid, released = listOf(pA, pB)) + assertTrue( + "B's release reaches its tombstone with the row already gone", + db.documentDao().getPendingInputsByOutpoint(pB).isEmpty(), + ) + assertTrue(db.documentDao().getPendingInputsByOutpoint(pA).isEmpty()) + } + + @Test + fun aReinstatingRecordInALaterRoundRevivesASweptTransactionAndItsOutputs() = runTest { + // Cross-round reinstatement: the sweep and its reinstating record + // land in two SEPARATE callback rounds. Upstream's sweep state is + // not monotonic — per CoreChangeSet::merge's documented + // IS-lock-precedence sequence, a transaction swept by an IS-locked + // conflict can return chainlocked and sweep that conflict in turn — + // and the sweep deleted the row outright, so the later record is + // simply an ordinary record of a txid this store no longer holds: + // nothing marks it as "the reinstating one", nothing can refuse it, + // and its output rides along in the same round. + val walletB = ByteArray(32) { 9 } + val (fundingTxid, loserTxid) = seedSharedLoserWithOwnOutputAcrossTwoWallets(walletId, walletB) + val winnerTxid = ByteArray(32) { 82 } + val p = makeOutpoint(fundingTxid, 0) + val phantomOutput = makeOutpoint(loserTxid, 2) + + // Round 1: only wallet B's own sweep callback runs, releasing + // nothing — the row, the phantom output and A's coin's link are gone; + // A's coin is held by the stamp. + sweepRound(walletB, listOf(loserTxid), winnerTxid) + assertNull("sanity: the row is gone after round 1", db.transactionDao().getByTxid(loserTxid)) + assertNull("sanity: the loser's own output is gone after round 1", db.txoDao().getByOutpoint(phantomOutput)) + assertTrue("sanity: A's coin is held", db.txoDao().getByOutpoint(p)!!.isSpent) + + // Round 2, a SEPARATE callback: the wallet returns chainlocked, with + // its own output riding along — transaction before utxo per the + // JNI bridge's account ordering. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loserTxid, ByteArray(10) { 5 }, 3, 200, ByteArray(32) { 8 }, + 1_700_000_200, 1, "Standard", 0, -140_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onWalletChangesetUtxoAdded( + walletId, loserTxid, 2, 60_000, "yLoserChange", ByteArray(25) { 6 }, + 200, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val reinstated = db.transactionDao().getByTxid(loserTxid)!! + assertEquals(200, reinstated.blockHeight) + + val revivedOutput = db.txoDao().getByOutpoint(phantomOutput) + assertNotNull("the reinstated transaction's own output must come back", revivedOutput) + assertEquals(60_000L, revivedOutput!!.amount) + + val reclaimedP = db.txoDao().getByOutpoint(p)!! + assertTrue("wallet A's coin stays spent — now by its own live record", reclaimedP.isSpent) + assertTrue( + "the stamped, unlinked row adopts the reinstated spender's link", + loserTxid.contentEquals(reclaimedP.spendingTxid), + ) + + assertNotNull( + "wallet A must be able to read the reinstated transaction as live again", + handler.onGetCoreTxRecord(walletId, loserTxid), + ) + + // "Restart": the reinstatement has to be durable. + val restarted = newHandler() + assertNotNull("the reinstatement must survive a restart", db.transactionDao().getByTxid(loserTxid)) + assertNotNull("the revived output must survive a restart", db.txoDao().getByOutpoint(phantomOutput)) + assertTrue("the reclaimed input must survive a restart", db.txoDao().getByOutpoint(p)!!.isSpent) + assertNotNull( + "the reinstated transaction must still be readable as live after a restart", + restarted.onGetCoreTxRecord(walletId, loserTxid), + ) + } + + @Test + fun aSweepReleasingMoreOutpointsThanSqliteCanBindStillCommits() = runTest { + // The released set's size follows the input count of a transaction a + // remote sender chooses, so it is not bounded by anything this wallet + // controls. Binding it one variable per outpoint crosses the + // 999-variable ceiling API 29's framework SQLite still carries: the + // statement throws, the whole atomic round fails, and the watermark + // freezes on a loser that would be re-swept into the same failure + // after every restart. + // + // The count is far past 999 because this suite runs on the host's + // SQLite, whose own ceiling is much higher — at 1200 the pre-fix code + // passed here while still being broken on API 29. What this pins is + // therefore the property that matters, that the query arity does not + // grow with the set at all, rather than one platform's exact limit. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val xpub = ByteArray(78) { 30 } + handler.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + ) + + val loser = ByteArray(32) { 80 } + // Comfortably past the limit, and past the 1000-variable default of + // newer SQLite too. + val released = (0 until 40000).map { i -> + makeOutpoint(ByteArray(32) { 81 }, i) + } + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -1_000, 0, false, "", 1_700_000_000, + ByteArray(0), 0, + ) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + val code = sweep(handler, walletId, listOf(loser), ByteArray(32) { 82 }, released, 400) + val committed = handler.onChangesetEnd(walletId, success = true) + + assertEquals("the sweep callback must not fail on a large release set", 0, code) + assertEquals(0, committed) + assertNull("and the round must actually commit", db.transactionDao().getByTxid(loser)) + } + + @Test + fun sweptTransactionRollsBackWithItsRound() = runTest { + // The deletion is staged in the same buffered transaction as every + // other write in the round, so a round that fails must not take the + // rows with it. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val txid = ByteArray(32) { 43 } + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, txid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(txid), ByteArray(32) { 44 }, emptyList(), 400) + handler.onChangesetEnd(walletId, success = false) + + assertNotNull(db.transactionDao().getByTxid(txid)) + } + + @Test + fun spendBeforeFundingReconcilesViaPendingInputAndExcludesFromRestore() = runTest { + // CORE-06, out-of-order arrival: an in-block spending tx is persisted + // BEFORE its funding TXO is known (Rust's utxos_spent slice is empty + // because the previous output wasn't classified yet). The spend must + // not be lost — `inputOutpoints` stages a pending-input row that the + // funding TXO's later upsert drains, so the consumed output is excluded + // from the restore set instead of being handed back to Rust as + // spendable. 1:1 mirror of Swift resolveInputOutpoint + upsertUtxo drain. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 41 } + val spendingTxid = ByteArray(32) { 42 } + + // Changeset 1: the in-block spending tx arrives first. Its funding TXO + // is unknown, so a pending-input row is staged (no utxos_spent fires). + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, spendingTxid, ByteArray(10) { 5 }, 2, 101, ByteArray(32) { 8 }, + 1_700_000_200, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_100, + makeOutpoint(fundingTxid, 0), 1, // spends fundingTxid:0 (TXO unknown) + ) + handler.onChangesetEnd(walletId, success = true) + + val staged = db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)) + assertEquals(1, staged.size) + assertTrue(spendingTxid.contentEquals(staged.single().spendingTxid)) + // Funding TXO absent → nothing to restore yet. + assertEquals(0, handler.onLoadWalletList().single().utxos.size) + + // Changeset 2: the funding TXO finally lands. The drain links the spend + // (in-block → isSpent) and clears the pending row. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val txo = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)) + assertNotNull(txo) + assertTrue(txo!!.isSpent) + assertTrue(spendingTxid.contentEquals(txo.spendingTxid!!)) + assertTrue( + db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)).isEmpty(), + ) + // The consumed output must NOT be handed back to Rust as spendable. + assertEquals(0, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun sweptSpendBeforeFundingSurvivesRestartAndStaysSpentWhenFunded() = runTest { + // The loser can be persisted before its own funding output ever is + // (see spendBeforeFundingReconcilesViaPendingInputAndExcludesFromRestore + // above) — the spend arrives as a `pending_inputs` row rather than a + // `TxoEntity` update. When the sweep holds that input (it's not in + // `releasedOutpoints`), there is no TXO row to mark — the only record + // of the claim is the pending row, which cascades away with the loser + // it names (`spendingTransactionTxid`'s FK) unless + // `onWalletChangesetTransactionsSwept` rescues it first. This is the + // regression the review finding described: seed the pending spend, + // sweep it, restart the store, and only then let the funding UTXO + // arrive. The coin must come back spent, attributed to the winner, + // not as a fresh unspent row. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 61 } + val sweptTxid = ByteArray(32) { 62 } + val winnerTxid = ByteArray(32) { 64 } + + // Changeset 1: the doomed spend arrives with no prior + // `onWalletChangesetUtxoAdded` for `fundingTxid:0` — the funding side + // of that outpoint has not been observed yet. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, sweptTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + makeOutpoint(fundingTxid, 0), 1, + ) + handler.onChangesetEnd(walletId, success = true) + + assertNull( + "sanity: the funding TXO has not arrived yet", + db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)), + ) + assertEquals( + 1, + db.documentDao().getPendingInputsByOutpoint(makeOutpoint(fundingTxid, 0)).size, + ) + + // Changeset 2: the sweep holds the input (not in `releasedOutpoints`), + // with nothing on hand to update. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(sweptTxid), winnerTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + assertNull("the loser is gone", db.transactionDao().getByTxid(sweptTxid)) + + // Restart: a fresh persister loading the same on-disk store — same + // Room database, new handler, matching this suite's own restart + // idiom (e.g. addressBalanceConflictPreservesDerivationIndicesAcrossRestart above). + val restarted = newHandler() + + // The funding transaction finally arrives and hands the outpoint + // back as a UTXO — the ordinary path a rescan or late block takes. + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + restarted.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(makeOutpoint(fundingTxid, 0)) + assertNotNull("the funding UTXO's own upsert must still create the row", coin) + assertTrue( + "the winner's claim must survive the loser's deletion, a restart, " + + "and the funding UTXO's own arrival", + coin!!.isSpent, + ) + assertTrue(winnerTxid.contentEquals(coin.supersededByTxid)) + assertEquals(0, restarted.onLoadWalletList().single().utxos.size) + } + + @Test + fun aWinnersOwnPendingRowDoesNotEvaporateTheSweepTombstone() = runTest { + // Records precede sweeps within a round, so a wallet-relevant winner + // whose own funding side is ALSO unobserved stages an ordinary + // pending row for the same outpoint moments before the sweep + // repoints the loser's row into a tombstone. The tombstone keeps the + // loser's original, older `createdAt`, so the drain's newest-wins + // pick would select the winner's ordinary row, take the gated + // branch (`isSpent` stays false until the winner confirms — never, + // for an IS-locked unconfirmed winner), skip the `supersededByTxid` + // stamp, and delete every pending row including the tombstone: the + // durable hold evaporates and the consumed coin re-enters the + // restore set. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 91 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val loserTxid = ByteArray(32) { 92 } + val winnerTxid = ByteArray(32) { 93 } + + // Changeset 1: the doomed spend arrives before its funding output. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // The loser's pending row must be strictly older than the winner's, + // as it always is in reality — `createdAt` has millisecond + // resolution and both rows land in the same test-run instant + // otherwise. + Thread.sleep(5) + + // Changeset 2: the winner's record (IS-locked, still unconfirmed) + // and the sweep it caused, records first — the order the persist + // path guarantees inside one round. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, winnerTxid, ByteArray(10) { 6 }, 1, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_060, + pOutpoint, 1, + ) + sweep(handler, walletId, listOf(loserTxid), winnerTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + // Sanity: the coexisting pair this regression is about — the + // winner's ordinary row plus the repointed tombstone. + val rows = db.documentDao().getPendingInputsByOutpoint(pOutpoint) + assertEquals(2, rows.size) + assertEquals(1, rows.count { it.isSweptTombstone }) + + // The funding TXO finally arrives and drains both rows. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(pOutpoint)!! + assertTrue( + "the sweep's hold must survive the winner's own coexisting pending row", + coin.isSpent, + ) + assertTrue(winnerTxid.contentEquals(coin.supersededByTxid)) + assertTrue( + "the consumed coin must stay out of the restore set", + handler.onLoadWalletList().single().utxos.isEmpty(), + ) + } + + @Test + fun aBatchSweepingParentAndChildDeletesTheChildsClaimOnTheParentsOutput() = runTest { + // The multi-loser batch shape upstream's descendant closure always + // produces — parent P and child C removed together — which no + // fixture here ever exercised: C spends P:0, still unfunded, so the + // claim lives as a pending row. Upstream never releases a + // loser-funded outpoint, so without a co-swept check the sweep + // tombstones the claim to the winner — and P's chainlocked + // reinstatement then re-delivers P:0 straight into the + // tombstone-outranks drain: isSpent = true, supersededByTxid = + // winner, a hold on a coin the winner never took. A dead parent's + // output is nobody's coin; the claim must be deleted with the + // batch. + seedWalletWithAddress(walletId, "yFundAddr") + + val parentTxid = ByteArray(32) { 101 } // P — record never persisted + val pOutpoint = makeOutpoint(parentTxid, 0) + val childTxid = ByteArray(32) { 102 } // C + val winnerTxid = ByteArray(32) { 103 } // W + + // C arrives spending the still-unfunded P:0 — parked as a pending + // claim. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, childTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_100, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + assertEquals(1, db.documentDao().getPendingInputsByOutpoint(pOutpoint).size) + + // One batch removes both; upstream excludes P:0 from the released + // set because its funder is itself a loser. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(parentTxid, childTxid), winnerTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + assertTrue( + "a claim on a co-swept parent's output must be deleted, not tombstoned", + db.documentDao().getPendingInputsByOutpoint(pOutpoint).isEmpty(), + ) + + // The chainlocked return: P reinstated with its output re-delivered + // must land spendable — nothing the batch left behind may hold it. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, parentTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(pOutpoint)!! + assertFalse( + "the reinstated parent's output must not be wedged by its dead child's claim", + coin.isSpent, + ) + assertNull(coin.supersededByTxid) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun chainedSweepBeforeFundingReleasesAnEarlierTombstoneOnASecondSweep() = runTest { + // Regression for the review finding on + // sweptSpendBeforeFundingSurvivesRestartAndStaysSpentWhenFunded above: + // that fix repoints a held-but-unfunded pending input at its sweep's + // winner and detaches it from `spendingTransactionTxid` so it + // survives the loser's cascade-delete. But a SECOND sweep of that + // winner — the sweep's staged-row fetch matches + // `spendingTransactionTxid = :txid`, which the first tombstoning + // already cleared to null — cannot find the row that way anymore. + // L spends P; W spends P and Q and sweeps L, holding the still- + // unfunded P; X spends Q and sweeps W, this time releasing P. P's + // funding TXO finally arrives and must come back spendable. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 71 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val qOutpoint = makeOutpoint(ByteArray(32) { 72 }, 0) + val firstLoserTxid = ByteArray(32) { 73 } // L + val secondLoserTxid = ByteArray(32) { 74 } // W + val finalWinnerTxid = ByteArray(32) { 75 } // X + + // L spends only P, and P's funding side has never been observed. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, firstLoserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_070, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // First sweep: W beats L, holding P (still unfunded). + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(firstLoserTxid), secondLoserTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + val tombstone = db.documentDao().getPendingInputsByOutpoint(pOutpoint).single() + assertTrue("the first sweep must tombstone the pending row", tombstone.isSweptTombstone) + assertTrue(secondLoserTxid.contentEquals(tombstone.spendingTxid)) + assertNull( + "the tombstone must have detached from the doomed loser's FK", + tombstone.spendingTransactionTxid, + ) + + // W's own record — spends P and Q — must be on hand for the second + // sweep to find, the same requirement any sweep of a wallet-relevant + // loser has. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, secondLoserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_071, + pOutpoint + qOutpoint, 2, + ) + handler.onChangesetEnd(walletId, success = true) + + // Second sweep: X beats W, releasing P this time. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(secondLoserTxid), finalWinnerTxid, listOf(pOutpoint), 400) + handler.onChangesetEnd(walletId, success = true) + + assertTrue( + "a released outpoint's tombstone must not survive a chained sweep", + db.documentDao().getPendingInputsByOutpoint(pOutpoint).isEmpty(), + ) + + // P's funding TXO finally arrives. + val restarted = newHandler() + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + restarted.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(pOutpoint) + assertNotNull(coin) + assertFalse( + "the final sweep released this coin, so it must come back spendable " + + "even though an earlier sweep in the chain had tombstoned it", + coin!!.isSpent, + ) + } + + @Test + fun aReleasedCoinDropsItsDeadWinnersMarker() = runTest { + // The funding-BEFORE-release ordering of the chained scenario above: + // the funding TXO arrives between the sweep that held the coin and + // the sweep that frees it, so the tombstone drains into + // `TxoEntity.supersededByTxid` and the pending row is gone by the + // time the release runs. The release must clear that column with + // the hold: W has no stored row, so its stamp cannot veto, and a + // released coin keeping its dead winner's marker would read as a + // durable claim to every later hold on this outpoint. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 96 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val loserTxid = ByteArray(32) { 97 } // L + val intermediateWinner = ByteArray(32) { 98 } // W — never recorded here + val finalWinner = ByteArray(32) { 99 } // X + + // L spends the still-unfunded P. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_090, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // First sweep: W beats L, holding P. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(loserTxid), intermediateWinner, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + // P's funding TXO arrives NOW — the drain consumes the tombstone + // and stamps the claim onto the row itself. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val stamped = db.txoDao().getByOutpoint(pOutpoint)!! + assertTrue("sanity: the drained claim holds the coin", stamped.isSpent) + assertTrue(intermediateWinner.contentEquals(stamped.supersededByTxid)) + + // Second sweep: X beats W, and this time upstream frees P. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(intermediateWinner), finalWinner, listOf(pOutpoint), 400) + handler.onChangesetEnd(walletId, success = true) + + val freed = db.txoDao().getByOutpoint(pOutpoint)!! + assertFalse("the released coin is spendable again", freed.isSpent) + assertNull( + "and its dead winner's marker goes with the hold it carried", + freed.supersededByTxid, + ) + assertEquals(1, handler.onLoadWalletList().single().utxos.size) + } + + @Test + fun chainedSweepBeforeFundingRepointsAnEarlierTombstoneToTheNewWinner() = runTest { + // The held (not released) half of the chained scenario above: the + // second sweep keeps P spent instead of releasing it, and the + // tombstone must end up attributed to the NEW winner rather than the + // intermediate one that no longer has a row. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 81 } + val pOutpoint = makeOutpoint(fundingTxid, 0) + val firstLoserTxid = ByteArray(32) { 83 } // L + val secondLoserTxid = ByteArray(32) { 84 } // W + val finalWinnerTxid = ByteArray(32) { 85 } // X + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, firstLoserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_080, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // First sweep: W beats L, holding P. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(firstLoserTxid), secondLoserTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + // W's own record, needed by the second sweep below. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, secondLoserTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_081, + pOutpoint, 1, + ) + handler.onChangesetEnd(walletId, success = true) + + // Second sweep: X beats W, still holding the same input. + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(secondLoserTxid), finalWinnerTxid, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + val tombstone = db.documentDao().getPendingInputsByOutpoint(pOutpoint).single() + assertTrue(tombstone.isSweptTombstone) + assertTrue( + "the tombstone must be repointed at the FINAL winner, not the " + + "intermediate one the second sweep already removed", + finalWinnerTxid.contentEquals(tombstone.spendingTxid), + ) + + val restarted = newHandler() + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + restarted.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(pOutpoint) + assertNotNull(coin) + assertTrue( + "the final winner's claim must survive both sweeps and the " + + "funding UTXO's own arrival", + coin!!.isSpent, + ) + assertTrue(finalWinnerTxid.contentEquals(coin.supersededByTxid)) + } + + @Test + fun sharedWinnerDeletedByAnotherWalletsCallbackStillAppliesThisWalletsReleaseToItsOwnTombstones() = runTest { + // Multi-wallet continuation of the chained-before-funding scenarios + // above. The hold is global and the row goes with the FIRST + // callback, so wallet B's callback for the shared winner W arrives + // after W's row is gone: it must still apply B's own release by + // outpoint to B's own tombstones (deleting a released one, never + // leaving a freed tombstone), while the held tombstones — every + // wallet's — were already re-pointed at X by A's callback. + val walletB = ByteArray(32) { 9 } + seedWalletWithAddress(walletId, "yWalletA", xpubFill = 30) + seedWalletWithAddress(walletB, "yWalletB", xpubFill = 31) + + val fundingTxid = ByteArray(32) { 101 } + val pA = makeOutpoint(fundingTxid, 0) + val pB = makeOutpoint(fundingTxid, 1) + val rB = makeOutpoint(fundingTxid, 2) + val sharedLoser = ByteArray(32) { 103 } // L + val sharedWinner = ByteArray(32) { 104 } // W + val finalWinner = ByteArray(32) { 105 } // X + + // The shared loser L claims one still-unfunded coin of wallet A's + // and two of wallet B's. Its record arrives through wallet A's + // round; a pending row carries the wallet of the round that wrote + // it, so wallet B's two claims are seeded directly in the exact + // shape B's own round would have written them. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, sharedLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_090, + pA + pB + rB, 3, + ) + handler.onChangesetEnd(walletId, success = true) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = pB, inputIndex = 1, spendingTxid = sharedLoser, + spendingTransactionTxid = sharedLoser, walletId = walletB, + ), + ) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = rB, inputIndex = 2, spendingTxid = sharedLoser, + spendingTransactionTxid = sharedLoser, walletId = walletB, + ), + ) + + // First sweep: W beats L, holding everything (nothing funded, + // nothing released). A's callback tombstones every wallet's claim + // and deletes L; B's callback finds nothing left to do. + sweepRound(walletId, listOf(sharedLoser), sharedWinner) + assertNull("L is gone with the first callback", db.transactionDao().getByTxid(sharedLoser)) + sweepRound(walletB, listOf(sharedLoser), sharedWinner) + for (outpoint in listOf(pA, pB, rB)) { + val rows = db.documentDao().getPendingInputsByOutpoint(outpoint) + assertTrue("every claim on ${outpoint.toHex()} is a tombstone held by W", rows.all { it.isSweptTombstone && sharedWinner.contentEquals(it.spendingTxid) }) + } + + // W's own record arrives through A's round, claiming all three + // outpoints. A's `(pA, W)` tombstone occupies the duplicate-guard + // key; B's tombstones are B's, so A stages its own ordinary claims + // on pB and rB. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, sharedWinner, ByteArray(10) { 6 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_091, + pA + pB + rB, 3, + ) + handler.onChangesetEnd(walletId, success = true) + + // Second sweep: X beats W. Wallet A's callback runs first, releasing + // pA and rB (X took only pB) — its own claims on those are deleted, + // B's claims on them are held until B speaks — and deletes W's row. + sweepRound(walletId, listOf(sharedWinner), finalWinner, released = listOf(pA, rB)) + assertNull( + "sanity: wallet A's callback deleted the shared winner row — the premise " + + "wallet B's callback below has to survive", + db.transactionDao().getByTxid(sharedWinner), + ) + assertTrue("A's released claim on pA is gone", db.documentDao().getPendingInputsByOutpoint(pA).isEmpty()) + val heldForB = db.documentDao().getPendingInputsByOutpoint(rB).single() + assertTrue("B's claim on rB is held by A's callback, re-pointed at X", heldForB.isSweptTombstone) + assertTrue(walletB.contentEquals(heldForB.walletId)) + assertTrue(finalWinner.contentEquals(heldForB.spendingTxid)) + + // Wallet B's callback arrives after the row is gone, releasing rB + // and holding pB. + sweepRound(walletB, listOf(sharedWinner), finalWinner, released = listOf(rB)) + + val heldTombstones = db.documentDao().getPendingInputsByOutpoint(pB) + assertTrue(heldTombstones.isNotEmpty()) + for (tombstone in heldTombstones) { + assertTrue(tombstone.isSweptTombstone) + assertTrue( + "the held tombstones follow the chain to X even though W's row was " + + "already deleted by wallet A's callback", + finalWinner.contentEquals(tombstone.spendingTxid), + ) + } + assertTrue( + "wallet B's release reaches its tombstone even though W's row was " + + "already deleted by wallet A's callback", + db.documentDao().getPendingInputsByOutpoint(rB).isEmpty(), + ) + + // The funding TXOs finally arrive, one round per owning wallet. + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yWalletA", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletB) + handler.onWalletChangesetUtxoAdded( + walletB, fundingTxid, 1, 40_000, "yWalletB", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onWalletChangesetUtxoAdded( + walletB, fundingTxid, 2, 20_000, "yWalletB", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletB, success = true) + + assertFalse("wallet A's released coin comes back spendable", db.txoDao().getByOutpoint(pA)!!.isSpent) + val heldCoin = db.txoDao().getByOutpoint(pB)!! + assertTrue("wallet B's held coin stays spent", heldCoin.isSpent) + assertTrue( + "the held coin must be attributed to the final winner, not the deleted W", + finalWinner.contentEquals(heldCoin.supersededByTxid), + ) + val releasedCoin = db.txoDao().getByOutpoint(rB)!! + assertFalse( + "wallet B's released coin must not resurrect spent under the obsolete winner", + releasedCoin.isSpent, + ) + assertNull(releasedCoin.supersededByTxid) + } + + @Test + fun anotherWalletsTombstoneStillHoldsACoinAtDrainWhenTheOwnerHasNone() = runTest { + // The per-wallet half of the drain preference: the delivering + // wallet's own tombstone is preferred, but when it has none, any + // tombstone on the outpoint still holds — the stamp is a txid fact, + // not a per-wallet one, and the owner's callback may simply never + // have arrived. Wallet A recorded a loser spending B's still-unfunded + // coin; only A's sweep callback ever ran. + val walletB = ByteArray(32) { 9 } + seedWalletWithAddress(walletId, "yWalletA", xpubFill = 30) + seedWalletWithAddress(walletB, "yWalletB", xpubFill = 31) + val fundingTxid = ByteArray(32) { 0x61 } + val coinOfB = makeOutpoint(fundingTxid, 0) + val loser = ByteArray(32) { 0x62 } + val winner = ByteArray(32) { 0x63 } + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, loser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_090, + coinOfB, 1, + ) + handler.onChangesetEnd(walletId, success = true) + sweepRound(walletId, listOf(loser), winner) + val tombstone = db.documentDao().getPendingInputsByOutpoint(coinOfB).single() + assertTrue(tombstone.isSweptTombstone && walletId.contentEquals(tombstone.walletId)) + + handler.onChangesetBegin(walletB) + handler.onWalletChangesetUtxoAdded( + walletB, fundingTxid, 0, 40_000, "yWalletB", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletB, success = true) + + val coin = db.txoDao().getByOutpoint(coinOfB)!! + assertTrue("A's tombstone holds B's coin at drain", coin.isSpent) + assertTrue(winner.contentEquals(coin.supersededByTxid)) + assertTrue("and the drained rows are gone", db.documentDao().getPendingInputsByOutpoint(coinOfB).isEmpty()) + } + + // ── Outpoint-keyed holds, settled claims, round-scoped passes ───── + + /** + * Wallet, address, and one funded coin at `fundingTxid:0` (in-block, + * recorded + delivered in one round). Returns the coin's outpoint. + */ + private suspend fun seedFundedCoin(fundingTxid: ByteArray, address: String = "yUtxoAddr"): ByteArray { + seedWalletWithAddress(walletId, address) + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 100_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, address, ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + return makeOutpoint(fundingTxid, 0) + } + + /** One committed round recording a mempool spend of [inputs] by [txid]. */ + private fun recordMempoolSpend(txid: ByteArray, vararg inputs: ByteArray, context: Int = 0, h: PlatformWalletPersistenceHandler = handler) { + h.onChangesetBegin(walletId) + recordTransaction( + h, + walletId, txid, ByteArray(10) { 5 }, context, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + inputs.fold(ByteArray(0)) { acc, op -> acc + op }, inputs.size, + ) + h.onChangesetEnd(walletId, success = true) + } + + @Test + fun aWinnerRecordedInTheSameRoundDoesNotHideTheLosersInputFromTheHold() = runTest { + // The hold is keyed by OUTPOINT, decoded from the loser's stored + // bytes, not by which rows still link to the loser. Own coin O is + // linked to mempool loser L. One round carries the winner's record + // (IS-locked, spends O) and the sweep of L. Records precede sweeps, + // so W takes the link first — at `isSpent = 0`, since only a block + // flips the flag on the record channel — and a hold keyed by + // `spendingTxid = L` then finds nothing: after a restart the store + // hands O back as spendable while the winner sits unmined. With the + // hold keyed by L's decoded inputs, O is stamped whatever it links + // to, and the link to W is kept. + val fundingTxid = ByteArray(32) { 0x30 } + val coin = seedFundedCoin(fundingTxid) + val loser = ByteArray(32) { 0x31 } + val winner = ByteArray(32) { 0x32 } + recordMempoolSpend(loser, coin) + assertTrue(loser.contentEquals(db.txoDao().getByOutpoint(coin)!!.spendingTxid)) + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, winner, ByteArray(10) { 6 }, 1, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_060, + coin, 1, + ) + sweep(handler, walletId, listOf(loser), winner, emptyList(), -1) + handler.onChangesetEnd(walletId, success = true) + + val held = db.txoDao().getByOutpoint(coin)!! + assertTrue("the coin the winner took is held although its link moved before the sweep", held.isSpent) + assertTrue(winner.contentEquals(held.supersededByTxid)) + assertTrue("the link to the winner is kept — only a link to the loser is detached", winner.contentEquals(held.spendingTxid)) + assertNull(db.transactionDao().getByTxid(loser)) + assertTrue("and it stays out of the restore set", newHandler().onLoadWalletList().single().utxos.isEmpty()) + } + + @Test + fun aLoserWithNoStoredBytesStillHoldsTheCoinsLinkedToIt() = runTest { + // The record-lost fallback: a loser whose row carries no bytes (a + // stub `utxos_added` wrote, or a record whose data never arrived) + // cannot name its inputs, so the rows still linked to it and the + // pending rows still claimed by it are the input set. The coin is + // held all the same. + val fundingTxid = ByteArray(32) { 0x33 } + val coin = seedFundedCoin(fundingTxid) + val loser = ByteArray(32) { 0x34 } + val winner = ByteArray(32) { 0x35 } + db.transactionDao().upsert(TransactionEntity(txid = loser, transactionData = ByteArray(0))) + db.txoDao().upsert(db.txoDao().getByOutpoint(coin)!!.copy(spendingTxid = loser, spendingInputIndex = 0)) + + sweepRound(walletId, listOf(loser), winner) + + val held = db.txoDao().getByOutpoint(coin)!! + assertTrue(held.isSpent) + assertNull(held.spendingTxid) + assertTrue(winner.contentEquals(held.supersededByTxid)) + assertNull(db.transactionDao().getByTxid(loser)) + } + + @Test + fun aLoserWhoseStoredBytesCannotBeDecodedFailsTheRoundClosed() = runTest { + // A stored record the decoder rejects fails the round rather than + // sweeping a loser whose inputs are unknown: the typed key named the + // row a swept loser, and processing it blind could free the wrong + // coins. Same verdict as the SQLite store's `apply_sweep` on a bad + // blob. The round rolls back, so nothing — not even the delete — + // lands. + val fundingTxid = ByteArray(32) { 0x36 } + val coin = seedFundedCoin(fundingTxid) + val loser = ByteArray(32) { 0x37 } + val winner = ByteArray(32) { 0x38 } + recordMempoolSpend(loser, coin) + recordedInputs.remove(loser.toHex()) + + handler.onChangesetBegin(walletId) + assertEquals(0, sweep(handler, walletId, listOf(loser), winner, emptyList(), 400)) + assertEquals("the round is refused", 1, handler.onChangesetEnd(walletId, success = true)) + + assertNotNull("nothing landed: the loser's row survives", db.transactionDao().getByTxid(loser)) + val untouched = db.txoDao().getByOutpoint(coin)!! + assertFalse(untouched.isSpent) + assertTrue(loser.contentEquals(untouched.spendingTxid)) + } + + @Test + fun aReleaseOfACoinItsStoredFinalWinnerSpendsIsRefusedByTheStamp() = runTest { + // The settled-claim veto by STAMP, on a row with no settled link to + // veto through. W (IS-locked, stored, spends O) was recorded before + // O's funding arrived, so its claim was a pending row; the sweep of + // L tombstoned L's claim to W, and O's arrival drained the + // tombstone into a stamp — unlinked, because a drain never mints a + // link, and W's own ordinary claim went with the drain. A later + // conflicting mempool L2 adopts the link; L3 IS-locks L2's other + // input and sweeps L2 with O in its released set — upstream's live + // view has no record claiming O. The stored W is a network-final + // claim on O, so the release is refused; without the stamp veto the + // hold pass would detach L2 and the release would flip a provably + // consumed coin unspent. + seedWalletWithAddress(walletId, "yUtxoAddr") + val fundingTxid = ByteArray(32) { 0x39 } + val coin = makeOutpoint(fundingTxid, 0) + val other = makeOutpoint(ByteArray(32) { 0x3A }, 0) + val loser = ByteArray(32) { 0x3B } + val winner = ByteArray(32) { 0x3C } + val laterLoser = ByteArray(32) { 0x3D } + val finalWinner = ByteArray(32) { 0x3E } + recordMempoolSpend(loser, coin) + recordMempoolSpend(winner, coin, context = 1) + sweepRound(walletId, listOf(loser), winner, winnerMinedHeight = -1) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 100_000, "yUtxoAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + val held = db.txoDao().getByOutpoint(coin)!! + assertTrue("sanity: held by the stamp, unlinked", held.isSpent && held.spendingTxid == null) + assertTrue(winner.contentEquals(held.supersededByTxid)) + + // A conflicting mempool spend adopts the link; the hold is the stamp. + recordMempoolSpend(laterLoser, coin, other) + val adopted = db.txoDao().getByOutpoint(coin)!! + assertTrue(laterLoser.contentEquals(adopted.spendingTxid)) + assertTrue("adoption keeps the flag and the stamp", adopted.isSpent) + assertTrue(winner.contentEquals(adopted.supersededByTxid)) + + sweepRound(walletId, listOf(laterLoser), finalWinner, released = listOf(coin), winnerMinedHeight = -1) + + val stillHeld = db.txoDao().getByOutpoint(coin)!! + assertTrue("a release of a coin a stored final winner spends is refused", stillHeld.isSpent) + assertTrue(winner.contentEquals(stillHeld.supersededByTxid)) + assertNull("the dead link is detached all the same", stillHeld.spendingTxid) + assertTrue(newHandler().onLoadWalletList().single().utxos.isEmpty()) + } + + @Test + fun aStampNamingAFinalTransactionThatDoesNotSpendTheCoinDoesNotVeto() = runTest { + // The stamp alone is not proof the winner took the coin: a hold + // stamps the winner on EVERY non-released input of a loser, and an + // input can be unreleased because a different surviving record + // claims it. So the veto reads the stamped winner's stored bytes — + // as the SQLite store's claim scan reads every claimant's inputs — + // and vetoes only when they spend the coin. Here W (IS-locked, + // stored) spends only P; O was held under W's stamp because own + // record R also claimed it; when R is swept with O released, W's + // stamp must not strand O. + val fundingTxid = ByteArray(32) { 0x40 } + val coin = seedFundedCoin(fundingTxid) + val p = makeOutpoint(ByteArray(32) { 0x41 }, 0) + val loser = ByteArray(32) { 0x42 } + val rival = ByteArray(32) { 0x43 } + val winner = ByteArray(32) { 0x44 } + val laterWinner = ByteArray(32) { 0x45 } + recordMempoolSpend(loser, coin, p) + recordMempoolSpend(rival, coin) + recordMempoolSpend(winner, p, context = 1) + // W beats L on P; O is not released because R still claims it. + sweepRound(walletId, listOf(loser), winner, winnerMinedHeight = -1) + val held = db.txoDao().getByOutpoint(coin)!! + assertTrue("sanity: held under W's stamp, linked to R", held.isSpent) + assertTrue(winner.contentEquals(held.supersededByTxid)) + assertTrue(rival.contentEquals(held.spendingTxid)) + + // R is beaten in turn and O comes free. + sweepRound(walletId, listOf(rival), laterWinner, released = listOf(coin), winnerMinedHeight = -1) + + val freed = db.txoDao().getByOutpoint(coin)!! + assertFalse("W never spent O, so its stamp does not veto the release", freed.isSpent) + assertNull(freed.supersededByTxid) + assertNull(freed.spendingTxid) + } + + @Test + fun aConflictingMempoolSpentEmitDoesNotLowerAHealedSpendFlag() = runTest { + // `isSpent` is monotonic on the `utxos_spent` channel. O is linked + // to asset-lock funding tx F stuck at mempool context and was + // healed to `isSpent = 1` (the SPV-miss case). A conflicting mempool + // spend N arrives via `utxos_spent`: F is not settled, so N takes + // the link — but the flag must not be re-answered from N's context. + // Before the fix it was, O re-entered the restore set, and the + // asset-lock heal was no longer consulted because the link was N's. + val fundingTxid = ByteArray(32) { 0x46 } + val coin = seedFundedCoin(fundingTxid) + val lockTx = ByteArray(32) { 0x47 } + val conflicting = ByteArray(32) { 0x48 } + recordMempoolSpend(lockTx, coin) + db.txoDao().markSpentBySpendingTxid(lockTx, java.util.Date()) + assertTrue("sanity: healed", db.txoDao().getByOutpoint(coin)!!.isSpent) + db.transactionDao().upsert(TransactionEntity(txid = conflicting, transactionData = ByteArray(10) { 9 })) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoSpent(walletId, fundingTxid, 0, conflicting) + handler.onChangesetEnd(walletId, success = true) + + val row = db.txoDao().getByOutpoint(coin)!! + assertTrue("a mempool usurper never lowers the flag", row.isSpent) + assertTrue("though it takes the link from a mempool spender", conflicting.contentEquals(row.spendingTxid)) + } + + @Test + fun aTombstoneWhoseFundingArrivesInTheFinalizingRoundDrainsBeforeTheCollector() = runTest { + // The collector runs once per round, at the END — after every + // account slice and every sweep. A tombstone T (O → W, mined 400) + // survives from an earlier round; the chainlock already covers 400. + // A later round folds a backward rescan delivering O together with + // the synced height that completes the boundary. Collecting at the + // header would delete T before the drain could move its hold onto + // O, and O would land unspent although W provably consumed it. + seedWalletWithAddress(walletId, "yFundAddr") + chainLockHeightRound(handler, 10_000) + val fundingTxid = ByteArray(32) { 0x49 } + val coin = makeOutpoint(fundingTxid, 0) + val loser = ByteArray(32) { 0x4A } + val winner = ByteArray(32) { 0x4B } + seedSweptTombstone(coin, loser, winner, winnerMinedHeight = 400) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetHeader( + walletId = walletId, hasSyncedHeight = true, syncedHeight = 400, hasBalance = false, + confirmedDelta = 0, unconfirmedDelta = 0, immatureDelta = 0, lockedDelta = 0, + lastAppliedChainLockBytes = ByteArray(84) { 9 }, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + val drained = db.txoDao().getByOutpoint(coin)!! + assertTrue("the funding delivery drained the tombstone before anything collected it", drained.isSpent) + assertTrue(winner.contentEquals(drained.supersededByTxid)) + assertTrue(db.documentDao().getPendingInputsByOutpoint(coin).isEmpty()) + } + + @Test + fun theCoSweptSetSpansEveryBatchOfTheRound() = runTest { + // One round carries two batches: {P by W1} then {C by W2}, where + // child C's pending row names P:0. Evaluated per batch, the second + // batch does not know P is swept and tombstones the claim to W2 — + // a hold on a dead parent's output that wedges P's chainlocked + // reinstatement. Evaluated against the union of the round's txids, + // the claim is deleted. + seedWalletWithAddress(walletId, "yFundAddr") + val parent = ByteArray(32) { 0x4C } + val child = ByteArray(32) { 0x4D } + val w1 = ByteArray(32) { 0x4E } + val w2 = ByteArray(32) { 0x4F } + val parentOutput = makeOutpoint(parent, 0) + recordMempoolSpend(parent, makeOutpoint(ByteArray(32) { 0x50 }, 0)) + recordMempoolSpend(child, parentOutput) + assertEquals(1, db.documentDao().getPendingInputsByOutpoint(parentOutput).size) + + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(parent), w1, emptyList(), 400) + sweep(handler, walletId, listOf(child), w2, emptyList(), 400) + handler.onChangesetEnd(walletId, success = true) + + assertTrue( + "the child's claim on the co-swept parent's output is deleted, not tombstoned", + db.documentDao().getPendingInputsByOutpoint(parentOutput).isEmpty(), + ) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, parent, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + assertFalse("the reinstated parent's output lands spendable", db.txoDao().getByOutpoint(parentOutput)!!.isSpent) + } + + @Test + fun aReleaseNamingAnOutputOfACoSweptParentDeletesItRatherThanFreeingIt() = runTest { + // A released outpoint whose funding transaction is swept in this + // round is deleted whatever its shape: a coin created by a dead + // transaction cannot be unspent, only gone. P's output materialised + // (P's own record never did — a stub row carries it); C spends it; + // the round sweeps both and a release names P:0. + seedWalletWithAddress(walletId, "yFundAddr") + val parent = ByteArray(32) { 0x51 } + val child = ByteArray(32) { 0x52 } + val winner = ByteArray(32) { 0x53 } + val parentOutput = makeOutpoint(parent, 0) + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, parent, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 0, false, false, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + recordMempoolSpend(child, parentOutput) + assertTrue(child.contentEquals(db.txoDao().getByOutpoint(parentOutput)!!.spendingTxid)) + + sweepRound(walletId, listOf(parent, child), winner, released = listOf(parentOutput)) + + assertNull("a dead parent's output is deleted, never freed", db.txoDao().getByOutpoint(parentOutput)) + assertNull(db.transactionDao().getByTxid(parent)) + assertNull(db.transactionDao().getByTxid(child)) + } + + @Test + fun aDrainedTombstoneStampsWithoutLinkingSoALaterReleaseCanFreeTheCoin() = runTest { + // A drained tombstone STAMPS, it never mints a spender link — even + // when the winner's own row exists. An input can be unreleased + // because another live record claims it, not because the winner + // took it; a link to W would make the coin non-releasable when that + // record is swept in turn with O released. Own L (spends O + P) and + // own R (spends O + Q); W (spends P only) sweeps L; O is not + // released (R claims it). O's funding arrives: the tombstone drains + // into a stamp, unlinked. W2 sweeps R with O released: O comes free. + seedWalletWithAddress(walletId, "yFundAddr") + val fundingTxid = ByteArray(32) { 0x54 } + val coin = makeOutpoint(fundingTxid, 0) + val p = makeOutpoint(ByteArray(32) { 0x55 }, 0) + val q = makeOutpoint(ByteArray(32) { 0x56 }, 0) + val loser = ByteArray(32) { 0x57 } + val rival = ByteArray(32) { 0x58 } + val winner = ByteArray(32) { 0x59 } + val laterWinner = ByteArray(32) { 0x5A } + recordMempoolSpend(loser, coin, p) + recordMempoolSpend(rival, coin, q) + recordMempoolSpend(winner, p, context = 1) + sweepRound(walletId, listOf(loser), winner, winnerMinedHeight = -1) + assertTrue(db.documentDao().getPendingInputsByOutpoint(coin).any { it.isSweptTombstone }) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + val drained = db.txoDao().getByOutpoint(coin)!! + assertTrue(drained.isSpent) + assertTrue(winner.contentEquals(drained.supersededByTxid)) + assertNull("the drain stamps; it does not link the winner", drained.spendingTxid) + assertNull(drained.spendingInputIndex) + + sweepRound(walletId, listOf(rival), laterWinner, released = listOf(coin), winnerMinedHeight = -1) + val freed = db.txoDao().getByOutpoint(coin)!! + assertFalse("a stamped, unlinked coin is exactly what a release can free", freed.isSpent) + assertNull(freed.supersededByTxid) + } + + @Test + fun aSecondWalletRecordingTheSameSpendGetsItsOwnPendingRow() = runTest { + // Pending rows are per (outpoint, spendingTxid, walletId). Sweep + // holds and releases are decided per wallet, so a second wallet + // recording the same spend of a not-yet-materialised coin must get + // its own row — with one shared row, the first wallet's release or + // collector could erase the only hold the second was entitled to + // keep. + val walletB = ByteArray(32) { 9 } + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + val coin = makeOutpoint(ByteArray(32) { 0x5B }, 0) + val spender = ByteArray(32) { 0x5C } + for (wallet in listOf(walletId, walletB)) { + handler.onChangesetBegin(wallet) + recordTransaction( + handler, + wallet, spender, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + coin, 1, + ) + handler.onChangesetEnd(wallet, success = true) + } + val rows = db.documentDao().getPendingInputsByOutpoint(coin) + assertEquals(2, rows.size) + assertEquals( + setOf(walletId.toHex(), walletB.toHex()), + rows.map { it.walletId.toHex() }.toSet(), + ) + // And a re-emit for the same wallet is still deduplicated. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, spender, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -50_000, 0, false, "", 1_700_000_050, + coin, 1, + ) + handler.onChangesetEnd(walletId, success = true) + assertEquals(2, db.documentDao().getPendingInputsByOutpoint(coin).size) + } + + @Test + fun aRefusedClaimDoesNotEraseAnotherWalletsTombstoneOnTheOutpoint() = runTest { + // The found-TXO branch of the record channel prunes pending rows on + // the outpoint. When the arriving record's claim is REFUSED (a + // settled spender keeps the link), only that record's own rows are + // stale; another wallet's tombstone on the outpoint is that + // wallet's hold and not this record's to erase. When the claim is + // accepted, only this wallet's ordinary rows go. + val walletB = ByteArray(32) { 9 } + val fundingTxid = ByteArray(32) { 0x5D } + val coin = seedFundedCoin(fundingTxid) + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + val settled = ByteArray(32) { 0x5E } + val usurper = ByteArray(32) { 0x5F } + val someWinner = ByteArray(32) { 0x60 } + recordMempoolSpend(settled, coin, context = 1) + db.documentDao().upsertPendingInput( + PendingInputEntity( + outpoint = coin, inputIndex = 0, spendingTxid = someWinner, + spendingTransactionTxid = null, walletId = walletB, isSweptTombstone = true, + ), + ) + + recordMempoolSpend(usurper, coin) + + val row = db.txoDao().getByOutpoint(coin)!! + assertTrue("sanity: the settled spender kept its link", settled.contentEquals(row.spendingTxid)) + val survivor = db.documentDao().getPendingInputsByOutpoint(coin).single() + assertTrue("wallet B's tombstone survives a refused claim", survivor.isSweptTombstone) + assertTrue(walletB.contentEquals(survivor.walletId)) + } + + @Test + fun aBatchSweepingMoreLosersThanSqliteCanBindStillCommits() = runTest { + // The loser side of the arity discipline: every per-batch statement + // is a chunked `IN (:chunk)` form, so a batch of more losers than + // SQLite can bind in one statement still commits. The count is past + // the host's own ceiling (32766) for the same reason + // aSweepReleasingMoreOutpointsThanSqliteCanBindStillCommits gives: + // what is pinned is that the arity does not grow with the batch. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val count = 33_000 + val losers = (0 until count).map { i -> + ByteArray(32).also { it[0] = (i and 0xFF).toByte(); it[1] = (i shr 8).toByte(); it[2] = 0x7E } + } + // Stub rows (no bytes — nothing to decode), seeded in one SQL + // transaction; every one is a loser this batch names. + val raw = db.openHelper.writableDatabase + raw.beginTransaction() + try { + val insert = raw.compileStatement( + "INSERT INTO transactions (txid, transactionData, context, blockHeight, " + + "blockTimestamp, blockPosition, hasBlockPosition, direction, transactionType, " + + "transactionTypeKind, netAmount, label, firstSeen, createdAt, lastUpdated) " + + "VALUES (?, x'', 0, 0, 0, 0, 0, 0, 'Standard', 0, 0, '', 0, 0, 0)", + ) + for (loser in losers) { + insert.bindBlob(1, loser) + insert.executeInsert() + } + raw.setTransactionSuccessful() + } finally { + raw.endTransaction() + } + assertEquals(count.toLong(), db.transactionDao().count().first()) + val winner = ByteArray(32) { 0x7C } + + sweepRound(walletId, losers, winner) + + assertEquals("every loser's row is gone", 0L, db.transactionDao().count().first()) + } + + @Test + fun aSweepBatchWhosePackedLengthDisagreesWithItsCountFailsTheRound() = runTest { + // The trampoline ships txids and released outpoints as flat arrays + // plus counts; a descriptor or packing drift must fail the round, + // never silently truncate a sweep. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + handler.onChangesetBegin(walletId) + val code = handler.onWalletChangesetTransactionsSwept( + walletId, ByteArray(31), 1, ByteArray(32) { 3 }, ByteArray(0), 0, true, 400, + ) + assertTrue("a malformed batch is refused at the callback", code != 0) + handler.onChangesetEnd(walletId, success = false) + } + + @Test + fun loadWalletListRestoresCoreAddressPoolsBeyondGapWindow() = runTest { + // prior-2 regression: the persisted Core address pools must come + // back on the restore row so every restored address maps to its + // derivation path — including addresses PAST the gap-limit window + // (`DEFAULT_GAP_LIMIT` = 20) that `ManagedWalletInfo::from_wallet` + // pre-derives. Without this, a restored UTXO on an out-of-window + // address has no derivation-path mapping and the wallet cannot + // sign a core-to-core spend after a cold restart. Mirror of the + // Swift `buildCoreAddressPoolBuffer` round-trip. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val xpub = ByteArray(78) { 30 } + handler.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + ) + val account = db.accountDao().observeByWallet(walletId).first().single() + + // An external (pool tag 0) address well beyond the gap window, + // used and carrying a balance + a full derivation path + pubkey. + val pubkey = ByteArray(33) { 4 } + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yFarAddr", + publicKey = pubkey, + poolTypeTag = 0, + addressIndex = 100, + derivationPath = "m/44'/1'/0'/0/100", + isUsed = true, + balance = 12_345, + accountId = account.id, + ), + ) + // A second, unused internal (pool tag 1) address — proves grouping + // by pool type emits a distinct pool for the change chain. + db.coreAddressDao().upsert( + CoreAddressEntity( + address = "yChangeAddr", + publicKey = ByteArray(0), + poolTypeTag = 1, + addressIndex = 3, + derivationPath = "m/44'/1'/0'/1/3", + isUsed = false, + accountId = account.id, + ), + ) + + val list = handler.onLoadWalletList() + assertEquals(1, list.size) + val pools = list[0].coreAddressPools + // One pool per (account, poolType) group, ascending tag order. + assertEquals(2, pools.size) + + val external = pools[0] + assertEquals(0.toByte(), external.poolTypeTag) + // The pool routes via the account tuple (xpub omitted — the loader + // ignores it on this path). + assertEquals(0.toByte(), external.account.typeTag) + assertEquals(0, external.account.index) + assertEquals(0, external.account.accountXpubBytes.size) + assertEquals(1, external.addresses.size) + val far = external.addresses[0] + assertEquals("yFarAddr", far.addressBase58) + // The out-of-window address keeps its derivation path — the whole + // point of the fix. + assertEquals("m/44'/1'/0'/0/100", far.derivationPath) + assertEquals(100, far.addressIndex) + assertTrue(far.isUsed) + assertEquals(12_345L, far.balance) + assertTrue(pubkey.contentEquals(far.publicKey)) + assertEquals(0.toByte(), far.poolTypeTag) + + val internal = pools[1] + assertEquals(1.toByte(), internal.poolTypeTag) + assertEquals(1, internal.addresses.size) + val change = internal.addresses[0] + assertEquals("yChangeAddr", change.addressBase58) assertEquals("m/44'/1'/0'/1/3", change.derivationPath) assertEquals(3, change.addressIndex) assertFalse(change.isUsed) @@ -2638,7 +4917,8 @@ class PlatformWalletPersistenceHandlerTest { val fundingOutpoint = makeOutpoint(fundingTxid, 0) val fundingTxData = ByteArray(24) { 52 } handler.onChangesetBegin(walletId) - handler.onWalletChangesetTransaction( + recordTransaction( + handler, walletId, fundingTxid, fundingTxData, 2, 200, ByteArray(32) { 60 }, 1_700_000_000, 0, "Standard", 0, 90_000, 0, false, "", 1_699_999_000, ByteArray(0), 0, // funding tx: no inputs of ours @@ -2718,27 +4998,119 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun assetLockPersistRoundTrips() = runTest { - val outpoint = makeOutpoint(ByteArray(32) { 40 }, 1) + fun assetLockPersistRoundTrips() = runTest { + val outpoint = makeOutpoint(ByteArray(32) { 40 }, 1) + handler.onChangesetBegin(walletId) + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = outpoint, + transactionBytes = ByteArray(20) { 41 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 0, + amountDuffs = 100_000, + status = 1, // Broadcast + proofBytes = null, + ) + handler.onChangesetEnd(walletId, success = true) + + val row = db.assetLockDao().getByOutPointHex(encodeOutPointHex(outpoint)) + assertNotNull(row) + assertEquals(100_000L, row!!.amountDuffs) + assertEquals(1, row.statusRaw) + assertFalse(row.proofBytes != null) + } + + @Test + fun assetLockUpsertNeverRegressesAConsumedRow() = runTest { + // The upsert-side twin of the delete guard below, matching Swift's + // skip and SQLite's WHERE clause: Consumed is the terminal state, + // and a stale reconstruction/enrichment snapshot folded after the + // live consumption write must not regress it. + val outpoint = makeOutpoint(ByteArray(32) { 48 }, 0) handler.onChangesetBegin(walletId) handler.onPersistAssetLockUpsert( walletId = walletId, outPoint = outpoint, - transactionBytes = ByteArray(20) { 41 }, + transactionBytes = ByteArray(20) { 49 }, accountIndex = 0, fundingType = 0, identityIndex = 0, - amountDuffs = 100_000, - status = 1, // Broadcast + amountDuffs = 70_000, + status = 4, // Consumed — terminal + proofBytes = ByteArray(8) { 50 }, + ) + // The stale snapshot arrives after the consumption write. + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = outpoint, + transactionBytes = ByteArray(20) { 49 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 0, + amountDuffs = 70_000, + status = 1, // Broadcast — a stale pre-consumption view proofBytes = null, ) handler.onChangesetEnd(walletId, success = true) val row = db.assetLockDao().getByOutPointHex(encodeOutPointHex(outpoint)) assertNotNull(row) - assertEquals(100_000L, row!!.amountDuffs) - assertEquals(1, row.statusRaw) - assertFalse(row.proofBytes != null) + assertEquals( + "a stale non-Consumed snapshot must not regress the terminal", + 4, + row!!.statusRaw, + ) + } + + @Test + fun assetLockRemovalNeverDeletesAConsumedRow() = runTest { + // Parity with SQLite (`status != 'consumed'`) and Swift + // (`statusRaw == 4` skip): a Consumed row is deliberately retained + // for historical lookup, and neither removal producer — a + // rejected-at-broadcast Built row, or the sweep cascade for a swept + // funding tx — can legitimately name one, so a removal reaching a + // consumed row is by construction a stale write. Kotlin deleted + // unconditionally. + val liveOutpoint = makeOutpoint(ByteArray(32) { 43 }, 0) + val consumedOutpoint = makeOutpoint(ByteArray(32) { 44 }, 1) + handler.onChangesetBegin(walletId) + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = liveOutpoint, + transactionBytes = ByteArray(20) { 45 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 0, + amountDuffs = 100_000, + status = 1, // Broadcast — a removal may take this one + proofBytes = null, + ) + handler.onPersistAssetLockUpsert( + walletId = walletId, + outPoint = consumedOutpoint, + transactionBytes = ByteArray(20) { 46 }, + accountIndex = 0, + fundingType = 0, + identityIndex = 1, + amountDuffs = 55_000, + status = 4, // Consumed — terminal, retained for history + proofBytes = ByteArray(8) { 47 }, + ) + handler.onChangesetEnd(walletId, success = true) + + handler.onChangesetBegin(walletId) + handler.onPersistAssetLockRemoval(walletId, liveOutpoint) + handler.onPersistAssetLockRemoval(walletId, consumedOutpoint) + handler.onChangesetEnd(walletId, success = true) + + assertNull( + "a live row is removable", + db.assetLockDao().getByOutPointHex(encodeOutPointHex(liveOutpoint)), + ) + val consumed = db.assetLockDao().getByOutPointHex(encodeOutPointHex(consumedOutpoint)) + assertNotNull("a stale removal must never take the Consumed terminal", consumed) + assertEquals(4, consumed!!.statusRaw) } // ── Invitations (DIP-13) ────────────────────────────────────────── @@ -2979,20 +5351,7 @@ class PlatformWalletPersistenceHandlerTest { txid: ByteArray, xpubFill: Byte, ) { - handler.onPersistWalletMetadata(wallet, testnet, groupId, 0) - handler.onPersistAccountRegistration( - wallet, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), ByteArray(78) { xpubFill }, - ) - val account = db.accountDao().observeByWallet(wallet).first().single() - db.coreAddressDao().upsert( - CoreAddressEntity( - address = address, - poolTypeTag = 0, - addressIndex = 0, - derivationPath = "m/44'/1'/0'/0/0", - accountId = account.id, - ), - ) + seedWalletWithAddress(wallet, address, xpubFill) handler.onChangesetBegin(wallet) handler.onWalletChangesetUtxoAdded( @@ -3019,7 +5378,8 @@ class PlatformWalletPersistenceHandlerTest { seedRestorableWallet(wallet, address, fundingTxid, xpubFill) handler.onChangesetBegin(wallet) - handler.onWalletChangesetTransaction( + recordTransaction( + handler, wallet, lockTxid, ByteArray(10) { 5 }, 0, 0, ByteArray(32), 0, 1, "AssetLock", 0, -999_545, 0, false, "", 1_700_000_100, makeOutpoint(fundingTxid, 0), 1, @@ -3170,7 +5530,7 @@ class PlatformWalletPersistenceHandlerTest { .allowMainThreadQueries() .openHelperFactory(faults) .build() - handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + handler = newHandler() val fundingTxid = ByteArray(32) { 71 } val lockTxid = ByteArray(32) { 72 } @@ -3442,6 +5802,271 @@ class PlatformWalletPersistenceHandlerTest { ) } + // ── Bounded tombstone lifetime ──────────────────────────────────── + + /** One committed round: synced height + (optionally) chainlock bytes. */ + private fun headerRound( + h: PlatformWalletPersistenceHandler, + synced: Int, + chainLockBytes: ByteArray = ByteArray(84) { 9 }, + ) { + h.onChangesetBegin(walletId) + h.onWalletChangesetHeader( + walletId = walletId, + hasSyncedHeight = true, + syncedHeight = synced, + hasBalance = false, + confirmedDelta = 0, + unconfirmedDelta = 0, + immatureDelta = 0, + lockedDelta = 0, + lastAppliedChainLockBytes = chainLockBytes, + ) + h.onChangesetEnd(walletId, success = true) + } + + /** + * One committed round delivering the numeric chainlock height, the way + * the JNI bridge does — its own slot, after the header's. + */ + private fun chainLockHeightRound(h: PlatformWalletPersistenceHandler, height: Int) { + h.onChangesetBegin(walletId) + h.onWalletChangesetChainLockHeight(walletId, height) + h.onChangesetEnd(walletId, success = true) + } + + /** + * Record a loser spending [outpoint] (funding unknown), then sweep it + * in the given winner context — a mined height (default 400) leaves + * the block-context tombstone the collection tests reason about, -1 + * (an IS-locked, unmined winner) leaves the same tombstone unstamped, + * which the collector never touches. + */ + private fun seedSweptTombstone( + outpoint: ByteArray, + loser: ByteArray, + winner: ByteArray, + winnerMinedHeight: Int = 400, + ) { + recordMempoolSpend(loser, outpoint) + sweepRound(walletId, listOf(loser), winner, winnerMinedHeight = winnerMinedHeight) + } + + @Test + fun aSweptTombstoneIsCollectedAtFinalityAndNotBefore() = runTest { + // The attacker-shaped row: a swept incoming payment's foreign input + // leaves a pending tombstone that never drains — no funding TXO + // ever arrives — and before the collector existed it was permanent, + // growable one row per input by repeatedly double-spending payments + // at this wallet. The collector deletes it exactly when the + // chainlock finality boundary min(chainlockHeight, syncedHeight) + // reaches the WINNER'S mined height — no observation-age margin: + // the stamp is the winner's own height, carried on the sweep event + // itself, so nothing here guesses when the winner mined. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 71 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 72 }, ByteArray(32) { 73 }, winnerMinedHeight = 400) + + val tombstone = db.documentDao().getPendingInputsByOutpoint(p).single() + assertTrue("sanity: the sweep flagged the row", tombstone.isSweptTombstone) + assertEquals( + "the tombstone is stamped with the winner's own mined height, " + + "not any observation watermark", + 400, tombstone.winnerMinedHeight, + ) + + // Chainlocks race far ahead; the filter scan is one block short of + // the winner — the boundary has not reached the spend, so the + // funding output could still be delivered by the unscanned range. + chainLockHeightRound(handler, 10_000) + headerRound(handler, 399) + assertEquals( + "boundary min(10000, 399) = 399 is below the winner's height 400 — the hold stays", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + headerRound(handler, 400) + assertTrue( + "the boundary reaching the winner's height collects the row — no margin", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + } + + @Test + fun aSweptTombstoneOutlivesAnySyncProgressWithoutAChainLockHeight() = runTest { + // Synced height alone is not finality — and neither is the mere + // PRESENCE of chainlock bytes on the wallet row: the bincode blob + // is opaque here, so "bytes exist" proves nothing about WHICH + // block is final (the unsound gate the review flagged). Every + // round below carries chainlock bytes; only the numeric height + // delivered by onWalletChangesetChainLockHeight supplies a + // boundary, and the moment one lands the finalized stamp collects. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + headerRound(handler, 100) + + val fundingTxid = ByteArray(32) { 74 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 75 }, ByteArray(32) { 76 }, winnerMinedHeight = 400) + + headerRound(handler, 100_000) + assertEquals( + "chainlock bytes are on record but no numeric height is — the " + + "hold outlasts any amount of synced-height progress", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + chainLockHeightRound(handler, 100_000) + assertTrue( + "the first numeric chainlock height supplies the boundary and " + + "the finalized stamp collects", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + } + + @Test + fun aDrainedClaimIsImmuneToTheCollector() = runTest { + // The genuine claim the tombstone exists for: its funding TXO + // arrives, the drain moves the hold onto the TXO row + // (supersededByTxid) and deletes the pending rows — so no amount of + // later sync progress may touch the materialised hold. + seedWalletWithAddress(walletId, "yFundAddr") + headerRound(handler, 100) + + val fundingTxid = ByteArray(32) { 77 } + val p = makeOutpoint(fundingTxid, 0) + val winner = ByteArray(32) { 79 } + seedSweptTombstone(p, ByteArray(32) { 78 }, winner, winnerMinedHeight = 400) + assertEquals( + "sanity: held, undrained, stamped with the winner's height", + 400, db.documentDao().getPendingInputsByOutpoint(p).single().winnerMinedHeight, + ) + + handler.onChangesetBegin(walletId) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + headerRound(handler, 10_000) + chainLockHeightRound(handler, 10_000) + + val coin = db.txoDao().getByOutpoint(p) + assertNotNull("the materialised claim's row survives collection", coin) + assertTrue("still held spent by the winner's claim", coin!!.isSpent) + assertTrue(winner.contentEquals(coin.supersededByTxid)) + } + + @Test + fun aTombstoneWithoutAWinnerHeightIsNeverCollected() = runTest { + // A tombstone with a NULL stamp is never collected. The + // mempool-context sweep path writes exactly this shape — an + // IS-locked, unmined winner has no finality horizon to stamp — + // and legacy rows (the v10 → v11 migration leaves pre-existing + // tombstones NULL) read identically. With no proof of finality + // the safe reading is to hold it forever rather than guess it + // collectible. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 80 } + val p = makeOutpoint(fundingTxid, 0) + // The real writer: an IS-context sweep of a loser whose funding + // TXO never arrived. + seedSweptTombstone(p, ByteArray(32) { 81 }, ByteArray(32) { 82 }, winnerMinedHeight = -1) + + // Two rounds, not one: a back-filling collector (the rejected + // design) would stamp the row on the first round and collect it + // on the second. + chainLockHeightRound(handler, 1_000_000) + headerRound(handler, 1_000_000) + headerRound(handler, 1_000_010) + val row = db.documentDao().getPendingInputsByOutpoint(p).single() + assertNull( + "no winner height, no proof of finality — the hold outlasts any boundary", + row.winnerMinedHeight, + ) + assertTrue(row.isSweptTombstone) + } + + @Test + fun aRepointedTombstoneIsRestampedToTheLaterSweep() = runTest { + // A chained sweep that re-points a still-unfunded claim to a new + // BLOCK-CONTEXT winner also re-stamps it with THAT winner's mined + // height: the claim now belongs to a spend anchored at a later + // block, and its collection horizon moves with it. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 86 } + val p = makeOutpoint(fundingTxid, 0) + val firstLoser = ByteArray(32) { 87 } + val secondLoser = ByteArray(32) { 88 } + val finalWinner = ByteArray(32) { 89 } + seedSweptTombstone(p, firstLoser, secondLoser, winnerMinedHeight = 400) + assertEquals( + "sanity: stamped with the first winner's mined height", + 400, db.documentDao().getPendingInputsByOutpoint(p).single().winnerMinedHeight, + ) + + // The first winner's own record, then its sweep — mined 50 blocks + // later — the carry-forward path that re-points the earlier + // tombstone. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, secondLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_091, + p, 1, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(secondLoser), finalWinner, emptyList(), 450) + handler.onChangesetEnd(walletId, success = true) + + val rows = db.documentDao().getPendingInputsByOutpoint(p) + assertTrue("sanity: the claim survives the chained sweep", rows.isNotEmpty()) + for (row in rows) { + assertTrue(row.isSweptTombstone) + assertTrue(finalWinner.contentEquals(row.spendingTxid)) + assertEquals( + "re-pointed ⇒ re-stamped to the later WINNER'S mined height", + 450, row.winnerMinedHeight, + ) + } + } + + @Test + fun aBlockContextTombstoneOutlivesUnrelatedAdvancementBelowItsWinnersHeight() = runTest { + // The reviewer's unrelated-advancement scenario: the chainlock can + // run arbitrarily far ahead, but while the synced height sits + // below the winner's mined height the boundary has not reached the + // spend and the hold must survive — the funding output could still + // be delivered by the unscanned range. It collects the moment the + // scan catches up. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 111 } + val p = makeOutpoint(fundingTxid, 0) + seedSweptTombstone(p, ByteArray(32) { 112 }, ByteArray(32) { 113 }, winnerMinedHeight = 400) + + // Chainlocks race ahead by thousands of blocks; the filter scan + // has only reached one block short of the winner. + chainLockHeightRound(handler, 10_400) + headerRound(handler, 399) + assertEquals( + "min(chainlock, synced) = 399 is below the winner's height 400 — any " + + "amount of unrelated chainlock progress must not collect the hold", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + headerRound(handler, 400) + assertTrue( + "the scan reaching the winner's height completes the boundary and collects", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + } + @Test fun shouldNotClobberMarketplaceColumnsWhenAnIdentitySnapshotStillCarriesTheLabel() = runTest { handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) @@ -3579,6 +6204,296 @@ class PlatformWalletPersistenceHandlerTest { // Promote-only and idempotent: a second pass matches nothing. assertEquals(0, db.identityDao().healIsLocalFlags()) } + + @Test + fun aMempoolContextSweepPreservesAnUnstampedTombstone() = runTest { + // A mempool-context sweep — an InstantSend-locked winner that has + // not mined — preserves an UNSTAMPED tombstone for every + // held-but-unfunded input. Under DIP-10 the IS lock alone settles + // those inputs: upstream deletes the loser and retains them in the + // account's `spent_outpoints`, a hold with no height that no + // record survives to rebuild (the winner need not be + // wallet-relevant). The tombstone is that hold's only durable + // carrier — CORE_SWEEP_REMOVAL requires every non-released input + // to keep a durable spend claim before its funding TXO + // materializes — and it is unstamped because an IS-locked winner + // has no mining deadline, so no boundary may ever collect it. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + for (i in 0 until 3) { + val p = makeOutpoint(ByteArray(32) { (114 + i).toByte() }, 0) + seedSweptTombstone( + p, + ByteArray(32) { (117 + i).toByte() }, + ByteArray(32) { (120 + i).toByte() }, + winnerMinedHeight = -1, + ) + val row = db.documentDao().getPendingInputsByOutpoint(p).single() + assertTrue( + "an unmined IS-locked winner must leave a held tombstone for input #$i", + row.isSweptTombstone, + ) + assertNull("and it carries no finality stamp", row.winnerMinedHeight) + } + // Arbitrary chainlock/height advancement never collects an + // unstamped hold — two rounds, so a back-filling collector would + // be caught too. + chainLockHeightRound(handler, 1_000_000) + headerRound(handler, 1_000_000) + headerRound(handler, 1_000_010) + assertEquals( + "every unstamped hold outlasts any boundary — only funding " + + "materialization, a block-context re-stamp, or a release resolves one", + 3L, db.documentDao().countPendingInputs().first(), + ) + } + + @Test + fun aMempoolContextSweepStillSpendMarksAMaterialisedCoin() = runTest { + // The mempool-context sweep still spend-marks a coin that HAS + // materialised: the row carries real funding data, so holding it + // costs nothing an attacker controls, and the winner's eventual + // block delivery is the durable evidence. Only the never-funded + // tombstone is what the mempool path refuses to create. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 123 } + val p = makeOutpoint(fundingTxid, 0) + val loser = ByteArray(32) { 124 } + val winner = ByteArray(32) { 125 } + + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, fundingTxid, ByteArray(10) { 4 }, 2, 100, ByteArray(32) { 7 }, + 1_700_000_000, 0, "Standard", 0, 50_000, 0, false, "", 1_699_999_000, + ByteArray(0), 0, + ) + handler.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + handler.onChangesetEnd(walletId, success = true) + + seedSweptTombstone(p, loser, winner, winnerMinedHeight = -1) + + val coin = db.txoDao().getByOutpoint(p)!! + assertTrue( + "a materialised coin is spend-marked by the IS-locked winner", + coin.isSpent, + ) + assertTrue(winner.contentEquals(coin.supersededByTxid)) + assertTrue( + "and no pending tombstone rides alongside the real row", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + assertTrue(handler.onLoadWalletList().single().utxos.isEmpty()) + } + + @Test + fun aFundingOutputArrivingAfterAMempoolSweepAndRestartLandsSpent() = runTest { + // The reviewer's named regression: an IS-locked winner sweeps on + // the mempool path and never mines, the app restarts, chainlocks + // and heights advance arbitrarily, and only then is the funding + // output delivered. Under DIP-10 the IS lock already settled that + // input — upstream deleted the loser and retained the hold in the + // account's `spent_outpoints`, a set rebuilt from records on load + // that no surviving record can reconstruct. The unstamped + // tombstone is the claim's only durable carrier, so the funding + // delivery must drain INTO it and land spent: crediting the coin + // would hand coin selection an outpoint the network has provably + // consumed. + seedWalletWithAddress(walletId, "yFundAddr") + + val fundingTxid = ByteArray(32) { 126 } + val p = makeOutpoint(fundingTxid, 0) + val winner = ByteArray(32) { 0x7F } + seedSweptTombstone(p, ByteArray(32) { 127 }, winner, winnerMinedHeight = -1) + val tombstone = db.documentDao().getPendingInputsByOutpoint(p).single() + assertTrue("sanity: the mempool-context sweep left a tombstone", tombstone.isSweptTombstone) + assertNull("unstamped — no finality horizon exists", tombstone.winnerMinedHeight) + + // Restart: a fresh handler bound to the same underlying store — + // this suite's restart idiom (see + // sweptSpendBeforeFundingSurvivesRestartAndStaysSpentWhenFunded). + val restarted = newHandler() + + // Arbitrary chainlock/height advancement while the winner stays + // unmined — none of it may collect the unstamped hold. + headerRound(restarted, 25_000) + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetChainLockHeight(walletId, 25_000) + restarted.onChangesetEnd(walletId, success = true) + assertEquals( + "the unstamped hold survives the restart and every boundary", + 1, db.documentDao().getPendingInputsByOutpoint(p).size, + ) + + // The funding output is finally delivered and classified: it must + // drain into the tombstone and stay spent. + restarted.onChangesetBegin(walletId) + restarted.onWalletChangesetUtxoAdded( + walletId, fundingTxid, 0, 50_000, "yFundAddr", ByteArray(25) { 6 }, + 100, false, true, false, false, + ) + restarted.onChangesetEnd(walletId, success = true) + + val coin = db.txoDao().getByOutpoint(p) + assertNotNull(coin) + assertTrue( + "an input the IS-locked winner consumed must never come back " + + "spendable — the sweep's claim outlives the restart", + coin!!.isSpent, + ) + assertTrue( + "held by the winner the sweep named", + winner.contentEquals(coin.supersededByTxid), + ) + assertTrue( + "the claim drained into the TXO row", + db.documentDao().getPendingInputsByOutpoint(p).isEmpty(), + ) + assertTrue( + "a spent coin never reaches the restored UTXO set", + restarted.onLoadWalletList().single().utxos.isEmpty(), + ) + } + + @Test + fun aMempoolRepointedTombstoneKeepsItsBlockContextStamp() = runTest { + // The IS-locked half of the chained case: an unmined winner + // re-points the claim but must NOT disturb the earlier + // block-context stamp — upstream's observed-spend entry is never + // retracted by an unconfirmed conflict. Collection at the retained + // height stays sound (the funding output is mined at or below the + // FIRST spender's height regardless of who claims the coin now), + // so the row still collects at that boundary. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 106 } + val p = makeOutpoint(fundingTxid, 0) + val firstLoser = ByteArray(32) { 107 } + val secondLoser = ByteArray(32) { 108 } + val finalWinner = ByteArray(32) { 109 } + seedSweptTombstone(p, firstLoser, secondLoser, winnerMinedHeight = 400) + + // The first winner is evicted by an IS-locked, unmined conflict + // that also claims the unfunded input. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, secondLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_092, + p, 1, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(secondLoser), finalWinner, emptyList(), -1) + handler.onChangesetEnd(walletId, success = true) + + val rows = db.documentDao().getPendingInputsByOutpoint(p) + .filter { it.isSweptTombstone } + assertTrue("sanity: the tombstone survives the chained sweep", rows.isNotEmpty()) + for (row in rows) { + assertTrue( + "an unmined winner re-points the claim", + finalWinner.contentEquals(row.spendingTxid), + ) + assertEquals( + "without touching the earlier block-context stamp", + 400, row.winnerMinedHeight, + ) + } + + chainLockHeightRound(handler, 10_000) + headerRound(handler, 400) + assertTrue( + "the retained stamp still bounds the row: the funding output sits at " + + "or below the first spender's height, so the boundary reaching it " + + "proves delivery-or-never", + db.documentDao().getPendingInputsByOutpoint(p) + .none { it.isSweptTombstone }, + ) + } + + @Test + fun anUnstampedTombstoneRestampedByABlockContextSweepBecomesCollectible() = runTest { + // The other direction of the chained case: an UNSTAMPED hold + // (IS-context sweep) re-pointed by a later BLOCK-context sweep + // gains that winner's stamp — the claim now belongs to a spend + // anchored in a real block, so it enters the collectible set and + // the boundary reaching the new winner's height collects it. One + // of the three resolution channels that bound the unstamped + // population. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + + val fundingTxid = ByteArray(32) { 115 } + val p = makeOutpoint(fundingTxid, 0) + val firstLoser = ByteArray(32) { 116 } + val secondLoser = ByteArray(32) { 118 } + val finalWinner = ByteArray(32) { 119 } + seedSweptTombstone(p, firstLoser, secondLoser, winnerMinedHeight = -1) + assertNull( + "sanity: held and unstamped", + db.documentDao().getPendingInputsByOutpoint(p).single().winnerMinedHeight, + ) + + // The IS-locked first winner is itself beaten by a mined conflict + // still claiming the unfunded input. + handler.onChangesetBegin(walletId) + recordTransaction( + handler, + walletId, secondLoser, ByteArray(10) { 5 }, 0, 0, ByteArray(32), + 0, 1, "Standard", 0, -40_000, 0, false, "", 1_700_000_093, + p, 1, + ) + handler.onChangesetEnd(walletId, success = true) + handler.onChangesetBegin(walletId) + sweep(handler, walletId, listOf(secondLoser), finalWinner, emptyList(), 450) + handler.onChangesetEnd(walletId, success = true) + + val rows = db.documentDao().getPendingInputsByOutpoint(p) + .filter { it.isSweptTombstone } + assertTrue("sanity: the claim survives the chained sweep", rows.isNotEmpty()) + for (row in rows) { + assertEquals( + "the block-context re-point stamps the previously unstamped hold", + 450, row.winnerMinedHeight, + ) + } + + chainLockHeightRound(handler, 10_000) + headerRound(handler, 450) + assertTrue( + "once stamped, the ordinary finality boundary collects the row", + db.documentDao().getPendingInputsByOutpoint(p).none { it.isSweptTombstone }, + ) + } + + @Test + fun onWalletChangesetChainLockHeightStoresMonotonicMaxOnTheWalletRow() = runTest { + // The numeric chainlock height is the finality half of the + // collection boundary, so a stale round's chainlock must never + // lower it — monotonic max, matching the SQLite store's + // `upsert_sync_state`. + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + assertNull( + "no height on record until the slot fires", + db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight, + ) + + chainLockHeightRound(handler, 500) + assertEquals(500, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) + + chainLockHeightRound(handler, 400) + assertEquals( + "a stale round must not lower the stored height", + 500, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight, + ) + + chainLockHeightRound(handler, 600) + assertEquals(600, db.walletDao().getByWalletId(walletId)!!.lastAppliedChainLockHeight) + } } /** diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 917d26094df..2d136151719 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -48,7 +48,7 @@ #![allow(clippy::missing_safety_doc)] use crate::support::{guard, net_from_ord, JVM}; -use jni::objects::{GlobalRef, JByteArray, JClass, JObject, JString, JValue}; +use jni::objects::{GlobalRef, JByteArray, JClass, JObject, JObjectArray, JString, JValue}; use jni::sys::jstring; use jni::JNIEnv; use platform_wallet_ffi::{ @@ -56,11 +56,11 @@ use platform_wallet_ffi::{ AssetLockEntryFFI, ContactIgnoredSenderFFI, ContactProfileRestoreEntryFFI, ContactRequestFFI, ContactRequestRemovalFFI, CoreAddressEntryFFI, DpnsNameStateFFI, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, - InvitationEntryFFI, PaymentRestoreEntryFFI, PersistenceCallbacks, + InvitationEntryFFI, OutPointFFI, PaymentRestoreEntryFFI, PersistenceCallbacks, PersistenceCallbacksExtension, PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, - SpentOutPointFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, - UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, WalletChangeSetFFI, - WalletRestoreEntryFFI, + SpentOutPointFFI, SweepBatchFFI, TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, + TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, + WalletChangeSetFFI, WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; @@ -195,13 +195,82 @@ pub(crate) fn build_vtable(context: *mut c_void) -> PersistenceCallbacks { /// Assemble the additive, size/version-tagged persistence callbacks. It shares /// the legacy vtable's context and release hook; this value is copied by the /// native manager during creation and owns nothing itself. -pub(crate) fn build_extension() -> PersistenceCallbacksExtension { +/// +/// The sweep slot is wired only when the concrete `bridge` OVERRIDES +/// `onWalletChangesetTransactionsSwept` (see [`bridge_overrides`]). Rust +/// derives the effective `CORE_SWEEP_REMOVAL` capability from "slot present +/// AND bit declared", so a subclass that declares the bit without +/// overriding the method — a promise of removals its inherited no-op body +/// would silently swallow — never gets the slot, Rust strips the bit and the +/// sync watermark with it, and the round is refused one layer up instead of +/// advancing past a removal that never happened. Wiring the slot for every +/// subclass would make "slot present" prove nothing. +pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> PersistenceCallbacksExtension { + let sweeps_overridden = bridge_overrides(env, bridge, "onWalletChangesetTransactionsSwept"); PersistenceCallbacksExtension { on_persist_dpns_name_states_fn: Some(tramp_persist_dpns_name_states), + on_persist_wallet_changeset_sweeps_fn: if sweeps_overridden { + Some(tramp_persist_wallet_changeset_sweeps) + } else { + None + }, + on_persist_wallet_changeset_chain_lock_height_fn: Some( + tramp_persist_wallet_changeset_chain_lock_height, + ), ..Default::default() } } +/// Whether `bridge`'s concrete class — or any superclass strictly below +/// `NativePersistenceBridge` — declares a method named `name`. A Kotlin +/// `override fun` is a declared method of the overriding class, so walking +/// `getDeclaredMethods()` up the hierarchy until the abstract bridge answers +/// "did a subclass supply its own body". Any JNI failure counts as "not +/// overridden" (the pending exception is cleared): the consequence is a +/// slot left unwired, which Rust turns into a stripped capability — the +/// safe direction, never a silently swallowed removal. +fn bridge_overrides(env: &mut JNIEnv, bridge: &JObject, name: &str) -> bool { + fn probe(env: &mut JNIEnv, bridge: &JObject, name: &str) -> Result { + let base = env.find_class("org/dashfoundation/dashsdk/ffi/NativePersistenceBridge")?; + let mut class = env.get_object_class(bridge)?; + loop { + if env.is_same_object(&class, &base)? { + return Ok(false); + } + let methods: JObjectArray = env + .call_method(&class, "getDeclaredMethods", "()[Ljava/lang/reflect/Method;", &[])? + .l()? + .into(); + let count = env.get_array_length(&methods)?; + for i in 0..count { + let method = env.get_object_array_element(&methods, i)?; + let method_name: JString = env + .call_method(&method, "getName", "()Ljava/lang/String;", &[])? + .l()? + .into(); + let matches = env.get_string(&method_name)?.to_str().map(|s| s == name).unwrap_or(false); + if matches { + return Ok(true); + } + } + let superclass = env + .call_method(&class, "getSuperclass", "()Ljava/lang/Class;", &[])? + .l()?; + if superclass.is_null() { + return Ok(false); + } + class = superclass.into(); + } + } + match probe(env, bridge, name) { + Ok(overridden) => overridden, + Err(_) => { + let _ = env.exception_clear(); + false + } + } +} + /// `release_fn` for the persistence vtable: frees the boxed /// [`KotlinPersistenceCtx`] when the native manager's last persister /// reference drops. The FFI guarantees exactly one call, which may land @@ -616,7 +685,7 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( &[ (&wid).into(), JValue::Bool(has_synced as u8), - JValue::Int(synced_height as i32), + JValue::Int(if has_synced { jint_height(synced_height)? } else { 0 }), JValue::Bool(cs.has_balance as u8), JValue::Long(cs.balance.confirmed_delta), JValue::Long(cs.balance.unconfirmed_delta), @@ -637,10 +706,137 @@ unsafe extern "C" fn tramp_persist_wallet_changeset( return Ok(code); } } + Ok(0) }) } +/// Extension-callback trampoline for the round's sweep batches. These used +/// to ride at the tail of [`WalletChangeSetFFI`]; they now arrive through +/// `PersistenceCallbacksExtension`'s size-negotiated sweep slot (the bare +/// changeset pointer cannot prove to a consumer that its producer allocated +/// a tail field — see the layout note on that struct). Round order, as +/// `store()` in `rs-platform-wallet-ffi` fires it: the changeset callback +/// (`tramp_persist_wallet_changeset`: header, then every account slice), +/// then the chainlock-height slot, then this one — so the Kotlin bridge sees +/// records, then the finality boundary, then removals. +/// +/// One bridge call per batch, in order: a later sweep can keep a coin spent +/// that an earlier one freed, and only replaying them in sequence preserves +/// that. The Kotlin handler buffers the calls and applies them in the same +/// order at the round's end, so the ordering holds there too. The batch +/// count is not bounded by this ABI, so — as with the account loop in the +/// changeset trampoline — each batch's marshalling and call runs inside its +/// own local frame; without it the per-batch arrays would pile up in the +/// trampoline's own frame across every batch, and a large enough round can +/// exhaust ART's local-reference table before the callback ever returns. +unsafe extern "C" fn tramp_persist_wallet_changeset_sweeps( + context: *mut c_void, + wallet_id: *const u8, + sweeps: *const SweepBatchFFI, + sweeps_count: usize, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + for batch in slice_or_empty(sweeps, sweeps_count) { + let code = env.with_local_frame(8, |env| { + persist_changeset_sweep_batch(env, bridge, &wid, batch) + })?; + if code != 0 { + return Ok(code); + } + } + Ok(0) + }) +} + +/// Descriptor of `NativePersistenceBridge.onWalletChangesetTransactionsSwept`: +/// `(walletId, txids, txidCount, supersededBy, releasedOutpoints, +/// releasedOutpointCount, hasWinnerMinedHeight, winnerMinedHeight)`. Txids +/// and released outpoints are shipped as ONE flat `byte[]` each (32·N and +/// 36·N bytes) plus a count — the same packing `persist_changeset_transaction` +/// uses for `inputOutpoints`, sliced with `copyOfRange` on the Kotlin side +/// — rather than a `byte[][]` with one JVM allocation per element; the +/// loser count is network-influenced and this projection runs synchronously +/// inside the atomic persistence callback. The single winner rides as one +/// 32-byte array, and the winner's mined height as a `(Z, I)` pair like the +/// header's `(hasSyncedHeight, syncedHeight)`, not a sentinel. +const WALLET_CHANGESET_SWEEPS_DESCRIPTOR: &str = "([B[BI[B[BIZI)I"; + +unsafe fn persist_changeset_sweep_batch( + env: &mut JNIEnv, + bridge: &JObject, + wid: &JByteArray, + batch: &SweepBatchFFI, +) -> Result { + let txids = slice_or_empty(batch.txids, batch.txids_count); + let mut packed_txids = Vec::with_capacity(txids.len() * 32); + for txid in txids { + packed_txids.extend_from_slice(txid); + } + let txids_arr = env.byte_array_from_slice(&packed_txids)?; + let winner = env.byte_array_from_slice(&batch.superseded_by)?; + // Released outpoints ride as 36-byte keys (raw txid + a little-endian + // vout, `pack_outpoint_key`), the shape the handler stores them in. + let released = slice_or_empty(batch.released_outpoints, batch.released_outpoints_count); + let mut packed_released = Vec::with_capacity(released.len() * 36); + for outpoint in released { + packed_released.extend_from_slice(&pack_outpoint_key(outpoint)); + } + let released_arr = env.byte_array_from_slice(&packed_released)?; + // The winner's finality context: its mined height for a block-context + // sweep, absent for an InstantSend-locked winner still waiting to be + // mined. The handler keys a pending-input tombstone's LIFETIME on it, + // never its existence: every non-released input keeps a durable claim + // in either context, stamped and collectible at the chainlock finality + // boundary when the winner mined, unstamped and held until resolved by + // proof (funding arrival, a later block-context re-stamp, or a release) + // when it did not. + env.call_method( + bridge, + "onWalletChangesetTransactionsSwept", + WALLET_CHANGESET_SWEEPS_DESCRIPTOR, + &[ + wid.into(), + (&txids_arr).into(), + JValue::Int(txids.len() as i32), + (&winner).into(), + (&released_arr).into(), + JValue::Int(released.len() as i32), + JValue::Bool(batch.has_winner_mined_height as u8), + JValue::Int(if batch.has_winner_mined_height { + jint_height(batch.winner_mined_height)? + } else { + 0 + }), + ], + )? + .i() +} + +/// Deliver the round's numeric chainlock height (see +/// `PersistWalletChangesetChainLockHeightFn`), between the changeset callback +/// and the sweep batches. One scalar, one call — the bincode chainlock blob +/// on the header call is opaque to Kotlin, and this is the half of the +/// tombstone-collection boundary `min(chainlockHeight, syncedHeight)` the +/// handler cannot otherwise know. +unsafe extern "C" fn tramp_persist_wallet_changeset_chain_lock_height( + context: *mut c_void, + wallet_id: *const u8, + chain_lock_height: u32, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + env.call_method( + bridge, + "onWalletChangesetChainLockHeight", + "([BI)I", + &[(&wid).into(), JValue::Int(jint_height(chain_lock_height)?)], + )? + .i() + }) +} + unsafe fn persist_changeset_account( env: &mut JNIEnv, bridge: &JObject, @@ -674,6 +870,20 @@ unsafe fn persist_changeset_account( return Ok(code); } + // Transactions before their UTXOs — matches the Swift bridge's + // `applyAccountChangeset` order (transactions, then utxos_added, then + // utxos_spent). Parity, not a guard: the handler tolerates either order + // (`onWalletChangesetUtxoAdded` writes a stub parent row when no record + // exists yet, and the record's later upsert overwrites it), so nothing + // on the Kotlin side depends on this sequence. + for t in slice_or_empty(acc.transactions, acc.transactions_count) { + let code = env.with_local_frame(40, |env| { + persist_changeset_transaction(env, bridge, wid, acc, t) + })?; + if code != 0 { + return Ok(code); + } + } for u in slice_or_empty(acc.utxos_added, acc.utxos_added_count) { let code = env.with_local_frame(24, |env| persist_changeset_utxo_added(env, bridge, wid, u))?; @@ -688,14 +898,6 @@ unsafe fn persist_changeset_account( return Ok(code); } } - for t in slice_or_empty(acc.transactions, acc.transactions_count) { - let code = env.with_local_frame(40, |env| { - persist_changeset_transaction(env, bridge, wid, acc, t) - })?; - if code != 0 { - return Ok(code); - } - } env.call_method( bridge, @@ -774,15 +976,14 @@ unsafe fn persist_changeset_transaction( let tx_type = cstr(env, t.transaction_type)?; let label = cstr(env, t.label)?; // Input outpoints (one per tx input, in vin order; empty for coinbase). - // Flatten to txid[32] || vout(u32 LE) = 36 bytes each — byte-identical to - // Kotlin/Swift makeOutpoint, so the pending-input join key matches with no - // per-element conversion on the Kotlin side. Dropping these is what left a - // spend-before-funding output restorable as spendable (CORE-06). + // Flattened 36-byte keys (see `pack_outpoint_key`), so the pending-input + // join key matches with no per-element conversion on the Kotlin side. + // Dropping these is what left a spend-before-funding output restorable + // as spendable (CORE-06). let ops = slice_or_empty(t.input_outpoints, t.input_outpoints_count); let mut packed = Vec::with_capacity(ops.len() * 36); for op in ops { - packed.extend_from_slice(&op.txid); - packed.extend_from_slice(&op.vout.to_le_bytes()); + packed.extend_from_slice(&pack_outpoint_key(op)); } let input_outpoints = env.byte_array_from_slice(&packed)?; let input_outpoint_count = ops.len() as i32; @@ -3951,6 +4152,29 @@ unsafe fn slice_or_empty<'a, T>(ptr: *const T, count: usize) -> &'a [T] { } } +/// Pack an [`OutPointFFI`] into the 36-byte key (raw txid ‖ little-endian +/// vout) the Kotlin handler stores outpoints under — byte-identical to +/// Kotlin's `makeOutpoint` (and Swift's). This is the join key sweep +/// releases use to find additive-path rows, so every packing site routes +/// through here rather than re-inlining the layout. +/// A block height for a JNI `I` slot. Heights are `u32` on the Rust side +/// and `Int` on the Kotlin side; a value past `i32::MAX` would wrap +/// negative and be read as "absent" (or as a bogus boundary) by a handler +/// that has no way to tell. Unreachable for any real chain height, so it +/// is refused rather than reinterpreted: the round fails closed +/// (`with_bridge` maps the error to `ERR_JNI`). +fn jint_height(height: u32) -> Result { + i32::try_from(height) + .map_err(|_| jni::errors::Error::JniCall(jni::errors::JniError::InvalidArguments)) +} + +fn pack_outpoint_key(outpoint: &OutPointFFI) -> [u8; 36] { + let mut key = [0u8; 36]; + key[..32].copy_from_slice(&outpoint.txid); + key[32..].copy_from_slice(&outpoint.vout.to_le_bytes()); + key +} + /// `Vec` → `(*const T, len)`; empty vec yields `(null, 0)`. A non-null /// pointer is a leaked `Box<[T]>` the matching load-free trampoline /// reconstructs and drops — mint it only once the whole load succeeded. @@ -4270,6 +4494,21 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ "onWalletChangesetTransaction", WALLET_CHANGESET_TRANSACTION_DESCRIPTOR, ), + // Missing from this table let a sweep-round-only descriptor drift pass + // the smoke check and surface only when a live sweep first called it — + // right where a failed round freezes the wallet's watermark. The same + // constant is bound at the `call_method` site in + // `persist_changeset_sweep_batch`, so the two cannot drift. + ( + "onWalletChangesetTransactionsSwept", + WALLET_CHANGESET_SWEEPS_DESCRIPTOR, + ), + // Same drift risk as the sweeps descriptor above: this slot fires on + // chainlock-advancing rounds only, so a stale descriptor would surface + // exactly when the first real chainlock crossed. Must track the + // literal at the `call_method` site in + // `tramp_persist_wallet_changeset_chain_lock_height`. + ("onWalletChangesetChainLockHeight", "([BI)I"), ( "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ @@ -4441,6 +4680,25 @@ mod tests { ); } + /// A height past `i32::MAX` must refuse the call, never wrap into a + /// negative `Int` the handler would read as absent or as a bogus + /// collection boundary. + #[test] + fn a_height_past_i32_max_is_refused_rather_than_wrapped() { + assert_eq!(jint_height(0).unwrap(), 0); + assert_eq!(jint_height(i32::MAX as u32).unwrap(), i32::MAX); + assert!(jint_height(i32::MAX as u32 + 1).is_err()); + assert!(jint_height(u32::MAX).is_err()); + } + + #[test] + fn sweeps_callback_descriptor_ships_flat_arrays_and_an_explicit_height_pair() { + // walletId, packed txids + count, winner, packed released outpoints + // + count, (hasWinnerMinedHeight, winnerMinedHeight) — must match + // `NativePersistenceBridge.onWalletChangesetTransactionsSwept`. + assert_eq!(WALLET_CHANGESET_SWEEPS_DESCRIPTOR, "([B[BI[B[BIZI)I"); + } + #[test] fn vtable_layout_remains_independent_of_capability_declaration() { let callbacks = build_vtable(ptr::null_mut()); diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 1342df0ed37..c1ac4b1d871 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -155,7 +155,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_n let persistence_ctx = Box::into_raw(Box::new(KotlinPersistenceCtx::new(persistence_global))); let persistence: PersistenceCallbacks = build_vtable(persistence_ctx as *mut c_void); - let persistence_extension = build_extension(); + let persistence_extension = build_extension(env, &persistence_bridge); let persistence_capabilities = PersistenceCapabilitiesFFI { version: declared_capabilities_version, reserved: 0,