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..732534d25f0 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,91 @@ 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 +694,11 @@ 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 +719,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 +883,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 +911,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 +989,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 +4165,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 +4507,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 +4693,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, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 0126c3d65be..eaa0ff44317 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -51,12 +51,61 @@ public enum DashModelContainer { ] } + + /// The V1/V2/V3 model set: frozen copies for every model in the + /// relationship component (see `DashSchemaFrozenModels.swift`), live + /// types for the eleven models outside it, and `assetLock` for the one + /// model whose shape differs between V2 and V3. + /// + /// Positionally identical to `allModelTypes` — a released version's + /// list must describe exactly the entities that version shipped. + private static func componentFrozenModelTypes( + assetLock: any PersistentModel.Type + ) -> [any PersistentModel.Type] { + [ + DashSchemaV1.PersistentIdentity.self, + DashSchemaV1.PersistentDPNSName.self, + DashSchemaV1.PersistentDashpayProfile.self, + DashSchemaV1.PersistentDashpayContactProfile.self, + DashSchemaV1.PersistentDashpayContactRequest.self, + DashSchemaV1.PersistentDashpayPayment.self, + DashSchemaV1.PersistentDashpayIgnoredSender.self, + DashSchemaV1.PersistentDocument.self, + DashSchemaV1.PersistentDataContract.self, + DashSchemaV1.PersistentPublicKey.self, + DashSchemaV1.PersistentTokenBalance.self, + DashSchemaV1.PersistentKeyword.self, + DashSchemaV1.PersistentToken.self, + DashSchemaV1.PersistentDocumentType.self, + DashSchemaV1.PersistentIndex.self, + DashSchemaV1.PersistentProperty.self, + DashSchemaV1.PersistentTokenHistoryEvent.self, + DashSchemaV1.PersistentPlatformAddress.self, + PersistentPlatformAddressesSyncState.self, + DashSchemaV1.PersistentWallet.self, + DashSchemaV1.PersistentAccount.self, + DashSchemaV1.PersistentCoreAddress.self, + DashSchemaV1.PersistentTransaction.self, + DashSchemaV1.PersistentTxo.self, + DashSchemaV1.PersistentPendingInput.self, + PersistentWalletManagerMetadata.self, + PersistentShieldedNote.self, + PersistentShieldedOutgoingNote.self, + PersistentShieldedSyncState.self, + PersistentShieldedActivity.self, + PersistentShieldedViewingKey.self, + assetLock, + PersistentInvitation.self, + PersistentMasternode.self + ] + } + /// The exact model set registered as schema V1. Keep frozen: staged /// migration identifies an existing store by this schema's checksum, so /// this list may only reference models whose shape is frozen (see /// `DashSchemaFrozenModels.swift`). fileprivate static var v1ModelTypes: [any PersistentModel.Type] { - allModelTypes(assetLock: DashSchemaV1.PersistentAssetLock.self) + componentFrozenModelTypes(assetLock: DashSchemaV1.PersistentAssetLock.self) } /// The exact model set registered as schema V2 — V1 plus @@ -66,17 +115,25 @@ public enum DashModelContainer { v1ModelTypes + [PersistentTrackedMasternode.self] } - /// All persistent model types in the current Dash SDK schema (V3). - /// Unlike `v1ModelTypes` / `v2ModelTypes` this list tracks the LIVE - /// models, so it moves whenever a model gains a property — which is - /// exactly why the released versions above must not. + /// The exact model set registered as schema V3 — V2's frozen component + /// with the LIVE `PersistentAssetLock`, which is the only model V3 + /// changed. Frozen for the same reason as `v1ModelTypes`. + fileprivate static var v3ModelTypes: [any PersistentModel.Type] { + componentFrozenModelTypes(assetLock: PersistentAssetLock.self) + + [PersistentTrackedMasternode.self] + } + + /// All persistent model types in the current Dash SDK schema (V4). + /// Unlike the lists above this one tracks the LIVE models, so it moves + /// whenever a model gains a property — which is exactly why the + /// released versions must not. public static var modelTypes: [any PersistentModel.Type] { allModelTypes(assetLock: PersistentAssetLock.self) + [PersistentTrackedMasternode.self] } /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV3.self) + Schema(versionedSchema: DashSchemaV4.self) } /// Create a persistent model container for storing data @@ -124,13 +181,14 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self] + [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] } public static var stages: [MigrationStage] { [ .lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self), - .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self) + .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) ] } } @@ -296,6 +354,42 @@ public enum DashSchemaV3: VersionedSchema { Schema.Version(3, 0, 0) } + public static var models: [any PersistentModel.Type] { + DashModelContainer.v3ModelTypes + } +} + +/// Version 4 adds the sweep columns, on the same entity set as V3: +/// - `PersistentTxo.supersededByTxid` (optional) and +/// `PersistentPendingInput.isSweptTombstone` (defaulted `false`). +/// Together they let a sweep's claim on an input whose funding TXO +/// hasn't arrived yet survive the loser transaction's deletion — +/// previously that claim lived only on the doomed row's +/// `PersistentPendingInput`, which cascades away with it. Existing +/// rows migrate as ordinary (non-tombstone, non-superseded) entries. +/// - `PersistentPendingInput.winnerMinedHeight` (optional — a +/// block-context sweep tombstone's finality stamp, the winner's own +/// mined height) and `PersistentWallet.lastAppliedChainLockHeight` +/// (optional — the numeric chainlock watermark delivered by +/// `on_persist_wallet_changeset_chain_lock_height_fn`, stored +/// monotonic-max). Together they drive the bounded tombstone lifetime: +/// a tombstone is collected exactly when +/// `min(chainlockHeight, syncedHeight)` reaches its stamp. +/// Pre-existing rows read as unstamped (held forever) over a wallet +/// with no boundary yet. +/// - The `(walletId, isSweptTombstone)` index on +/// `PersistentPendingInput`, serving the collector's tombstone-only +/// scan. +/// Every column is additive with a default or optional and the index is +/// additive, so a lightweight migration preserves each existing row. +/// +/// Registering it required freezing the whole relationship component those +/// three models sit in — see `DashSchemaFrozenModels.swift`. +public enum DashSchemaV4: VersionedSchema { + public static var versionIdentifier: Schema.Version { + Schema.Version(4, 0, 0) + } + public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift index 72df796e36c..1b535ec001d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift @@ -25,21 +25,38 @@ import SwiftData // // ## Scope of the freeze // -// Only `PersistentAssetLock` is frozen today, because it is the only model -// this file's callers have changed since V2 shipped. Every other model in -// `DashSchemaV1` / `DashSchemaV2` is still referenced live and therefore -// still carries the same latent defect. Freezing them is a mechanical but -// wide change (34 models) and is deliberately left out of the change that -// introduced this file; when the next model gains a property, freeze that -// one here too and add the matching stage. +// Twenty-five of the 35 models are frozen here, in two groups: +// +// - `PersistentAssetLock`, frozen at its V2 shape (everything the live +// model has EXCEPT `recipientIsExternal`, which V3 added). Referenced by +// `DashSchemaV1.models` and `DashSchemaV2.models`; V3 and V4 reference +// the live type. +// - The 24 models of the relationship component that contains +// `PersistentTransaction`, `PersistentTxo`, `PersistentPendingInput` and +// `PersistentWallet`, frozen at their V3 shape (everything the live +// models had before V4's sweep columns). Referenced by V1, V2 and V3; +// V4 references the live types. The component travels as a whole +// because a frozen model must declare its relationships against frozen +// counterparts (an `inverse:` key path is typed on the destination +// model), and following those relationships in both directions closes +// over all 24 — while registering a frozen copy beside a live one for +// the SAME entity name is what a schema cannot express. +// +// The ten models outside the component (shielded storage, invitations, +// masternodes, the tracked-masternode registry, wallet-manager metadata, +// the platform-addresses sync state) are still referenced live by every +// version and still carry the latent defect described above. When the +// next change touches one of them, freeze it here too — and if it sits in +// a relationship component, freeze that component with it — then add a +// version and a stage. // // V1's own checksum has already drifted from what actually shipped as V1 // (see the `DashSchemaV1` doc comment: several models were changed in place // while V1 was the only registered version, and dev stores at V1 are -// knowingly expected to fail open and be rebuilt). The frozen copy below is -// therefore the shape as of the V2 release, shared by V1 and V2 — which is -// what makes V1 -> V2 continue to be "add `PersistentTrackedMasternode`" -// and nothing else, exactly as before. +// knowingly expected to fail open and be rebuilt). The asset-lock copy +// below is therefore the shape as of the V2 release, shared by V1 and V2 — +// which is what makes V1 -> V2 continue to be "add +// `PersistentTrackedMasternode`" and nothing else, exactly as before. extension DashSchemaV1 { /// `PersistentAssetLock` frozen at the shape it had when schema V2 @@ -100,3 +117,3268 @@ extension DashSchemaV1 { } } } + +// MARK: - The rest of the relationship component, frozen at the V3 shape +// +// The three models the sweep persistence changes — `PersistentTxo`, +// `PersistentPendingInput` and `PersistentWallet` — each gain a property +// (and `PersistentPendingInput` an index), so each needs a frozen copy for +// the same reason `PersistentAssetLock` did; `PersistentTransaction` is +// unchanged but sits in the same component. See the scope note at the top +// of this file for why the whole component travels together. +// +// These copies are the shape as of V3 — i.e. everything the live models had +// before the sweep columns — and are shared by V1, V2 and V3, none of which +// changed any model in this component. +// +// Do not edit these copies to match the live models. Every attribute, its +// optionality, its default, each `@Attribute` marker, each `#Index` and +// each relationship is an input to the V1/V2/V3 checksums, and changing one +// re-breaks the stores these types exist to keep openable. + +extension DashSchemaV1 { + @Model + final class PersistentAccount { + /// Compound uniqueness on the full account-identity tuple: + /// `(wallet, accountType, accountIndex, standardTag, + /// registrationIndex, keyClass, userIdentityId, + /// friendIdentityId)`. Mirrors the persister's match logic + /// exactly — the variant disambiguators (`standardTag` for + /// BIP44 vs BIP32, `registrationIndex` for top-ups, `keyClass` + /// for PlatformPayment) are part of the key so legitimate + /// sibling accounts can coexist (e.g. BIP44 #0 and BIP32 #0, + /// or multiple top-up accounts on the same identity). + #Unique([ + \.wallet, + \.accountType, + \.accountIndex, + \.standardTag, + \.registrationIndex, + \.keyClass, + \.userIdentityId, + \.friendIdentityId, + ]) + + /// Account type identifier — matches the `AccountTypeTagFFI` + /// discriminant from the Rust side (0 = Standard, 1 = CoinJoin, + /// … 14 = PlatformPayment, 15 = IdentityAuthenticationEcdsa, + /// 16 = IdentityAuthenticationBls). Stable across releases. + var accountType: UInt32 + /// Account index within the type (for indexed account types). For + /// `PlatformPayment` this is the `account` field; for + /// `DashpayReceivingFunds` / `DashpayExternalAccount` it's the + /// account-level selector; for + /// `IdentityAuthentication{Ecdsa,Bls}` it's the identity index. + var accountIndex: UInt32 + /// Human-readable account type name. + var accountTypeName: String + /// Per-account confirmed balance in duffs. + var balanceConfirmed: UInt64 + /// Per-account unconfirmed balance in duffs. + var balanceUnconfirmed: UInt64 + /// External address pool: highest used index (-1 = none). + var externalHighestUsed: Int32 + /// Internal (change) address pool: highest used index. + var internalHighestUsed: Int32 + /// `StandardAccountTypeTagFFI` value. Meaningful only when + /// `accountType == 0` (Standard): 0 = BIP44, 1 = BIP32. + var standardTag: UInt8 + /// `IdentityTopUp.registration_index`. Zero for other variants. + var registrationIndex: UInt32 + /// `PlatformPayment.key_class`. Zero for other variants. + var keyClass: UInt32 + /// `Dashpay*`.user_identity_id (32 bytes). Empty `Data` for other + /// variants. + var userIdentityId: Data + /// `Dashpay*`.friend_identity_id (32 bytes). Empty `Data` for + /// other variants. + var friendIdentityId: Data + /// Bincode-encoded extended public key for this account. For ECDSA + /// accounts it's an `ExtendedPubKey`; for the two provider + /// key-material accounts (`accountType == 10` operator = BLS, + /// `accountType == 11` platform node = Ed25519) it's the extended + /// BLS / Ed25519 public key instead. Populated by + /// `on_persist_account_registrations_fn`, consumed by + /// `on_load_wallet_list_fn` to reconstruct a watch-only account + /// (`Account::from_xpub` for ECDSA, `BLSAccount`/`EdDSAAccount` for + /// the provider accounts). `nil` means "not yet persisted" — + /// account cannot be restored silently. Unique because two + /// accounts can't legitimately share an xpub (would imply a key + /// reuse / derivation collision); SQL UNIQUE allows multiple + /// `nil` values, so freshly-inserted unhydrated rows don't + /// conflict. + @Attribute(.unique) var accountExtendedPubKeyBytes: Data? + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Parent wallet. Every account currently belongs to a wallet. If + /// standalone non-wallet accounts are introduced later, this + /// becomes optional again. + /// + /// Kept non-optional. SwiftData would otherwise fatal during + /// the `save()` phase of a wallet delete + /// (`Cannot remove PersistentWallet from relationship wallet on + /// PersistentAccount because an appropriate default value is + /// not configured`); the workaround is in + /// `PlatformWalletPersistenceHandler.deleteWalletData`, which + /// deletes all of the wallet's accounts in a separate + /// `save()` BEFORE deleting the wallet itself. By the time the + /// wallet row is deleted, its `accounts` collection is empty + /// and SwiftData has no inverse to null out. This costs + /// atomicity (two saves instead of one) — acceptable for a + /// user-initiated wipe. + var wallet: PersistentWallet + + /// Addresses from this account's address pools (external + + /// internal, or a single Absent pool for degenerate types). Holds + /// Core-chain (base58check) addresses only — PlatformPayment + /// accounts keep their addresses in `platformAddresses`. + /// Per-account TXOs flow through this collection + /// (`coreAddresses.flatMap(\.txos)`). + @Relationship(deleteRule: .cascade, inverse: \PersistentCoreAddress.account) + var coreAddresses: [PersistentCoreAddress] + + /// DIP-17 Platform Payment addresses for this account, keyed on + /// DIP-0018 bech32m encoding. Populated only when + /// `accountType == 14` (PlatformPayment). + @Relationship(deleteRule: .cascade, inverse: \PersistentPlatformAddress.account) + var platformAddresses: [PersistentPlatformAddress] + + /// Transactions this account participates in that the TXO graph + /// cannot recover — the payload-only involvement described in the + /// type doc above. Populated by the persistence handler, which + /// appends this account whenever it upserts a tx record the + /// changeset bucketed under this account, even when the record + /// produced no TXO here (special-tx payloads matching provider + /// owner / voting key addresses). + /// + /// A superset that overlaps the TXO-derived set for ordinary funded + /// txs (the handler appends there too), so consumers computing a + /// per-account transaction list must **union** this with the + /// TXO-derived txids and de-dup — see `AccountDetailView`. + /// + /// The `inverse:` for this many-to-many lives on + /// `PersistentTransaction.involvedAccounts`; this side carries the + /// plain declaration. Default `.nullify` delete rule — deleting + /// this account detaches it from each tx without removing the + /// (shared) tx rows. That matters for the wallet-wipe path + /// (`deleteWalletData`), which deletes accounts before the wallet: + /// `.nullify` on a to-many inverse has no "default value" fatal + /// (unlike the non-optional `wallet` back-reference), so no extra + /// pre-delete pass is needed. + var involvedTransactions: [PersistentTransaction] = [] + + init( + wallet: PersistentWallet, + accountType: UInt32, + accountIndex: UInt32, + accountTypeName: String + ) { + self.wallet = wallet + self.accountType = accountType + self.accountIndex = accountIndex + self.accountTypeName = accountTypeName + self.balanceConfirmed = 0 + self.balanceUnconfirmed = 0 + self.externalHighestUsed = -1 + self.internalHighestUsed = -1 + self.standardTag = 0 + self.registrationIndex = 0 + self.keyClass = 0 + self.userIdentityId = Data() + self.friendIdentityId = Data() + self.accountExtendedPubKeyBytes = nil + self.createdAt = Date() + self.lastUpdated = Date() + self.coreAddresses = [] + self.platformAddresses = [] + self.involvedTransactions = [] + } + } + + @Model + final class PersistentCoreAddress { + /// Base58check-encoded address. Unique across the SwiftData store + /// because the same address can't validly exist under two accounts + /// (collision would imply a wallet-id hash collision). + @Attribute(.unique) var address: String + /// Typed public key bytes, or empty Data when the Rust side couldn't + /// produce one (e.g. a pool entry that stored only a script). The + /// curve is given by `keyType`: 33-byte compressed secp256k1 (ECDSA), + /// 48-byte BLS operator key, or 32-byte Ed25519 platform-node key. + var publicKey: Data + /// `KeyTypeTagFFI` raw value identifying the curve of `publicKey`: + /// 0 ECDSA / 1 BLS / 2 EdDSA. Meaningful only when `publicKey` is + /// non-empty. The stored default (NOT just the init-parameter + /// default, which SwiftData migration never consults) keeps + /// pre-column stores openable: without it, lightweight migration + /// fails with "missing attribute values on mandatory destination + /// attribute" and the container refuses to load — a launch crash on + /// every device that has existing rows. Defaulted legacy rows read + /// as ECDSA with an empty `publicKey` until the next Rust + /// address-pool persist pulse (pool extension / address-used / + /// registration — NOT plain load, which only reads the snapshot) + /// re-emits them with typed keys. On load, Rust's + /// `restore_address_pool` keeps the pre-derived typed key when a + /// legacy row arrives key-less, so in-memory BLS operator matching + /// is unaffected; legacy Ed25519 platform-node keys are hardened-only + /// and re-derivable only via delete+re-import (pre-release + /// convention). + var keyType: UInt8 = 0 + /// `AddressPoolTypeTagFFI` raw value — 0 External, 1 Internal, + /// 2 Absent, 3 AbsentHardened. + var poolTypeTag: UInt8 + /// Derivation index within this pool. + var addressIndex: UInt32 + /// BIP32 derivation path (e.g. `"m/44'/1'/0'/0/3"`). + var derivationPath: String + /// Marked used by the Rust address pool (first-seen tx or explicit + /// `mark_used`). + var isUsed: Bool + /// SPV height where this address first appeared in a transaction. + /// Zero until the address is seen on-chain. + var firstSeenHeight: UInt32 + /// SPV height of the most recent transaction touching this address. + var lastSeenHeight: UInt32 + /// Cached balance in duffs from `AddressInfo.balance`. Updated by + /// subsequent `on_persist_account_address_pools_fn` pulses. + var balance: UInt64 + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Parent account. + var account: PersistentAccount? + + /// TXOs paid to this address. Cascade-delete: dropping the + /// address row takes its TXOs with it. The address is the + /// canonical owning record — no meaningful render path for an + /// address-less TXO. Pool rebuilds therefore need to reuse + /// existing rows (the persister upserts by Base58Check string, + /// which it already does) rather than wholesale-replace, or + /// the historical TXO chain gets wiped. + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.coreAddress) + var txos: [PersistentTxo] = [] + + init( + address: String, + publicKey: Data = Data(), + keyType: UInt8 = 0, + poolTypeTag: UInt8, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0 + ) { + self.address = address + self.publicKey = publicKey + self.keyType = keyType + self.poolTypeTag = poolTypeTag + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.balance = balance + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDPNSName { + /// Compound uniqueness on `(networkRaw, normalizedParentDomainName, + /// normalizedLabel)`. Mirrors the DPNS contract's `domain` + /// document index `parentNameAndLabel` + /// (`normalizedParentDomainName + normalizedLabel`, `unique: true`) + /// and adds the network scope so two networks don't collide in a + /// shared local store. A label is only unique within a domain + /// on a given chain. + #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Stays in sync with `identity.networkRaw` + /// via the init; identities don't migrate between networks. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts — matches + /// `PersistentIdentity.network`. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Display label — the original case-and-letters form the user + /// registered, e.g. "Alice". Maps to the DPNS document's + /// `label` property. + var label: String + + /// Homograph-safe lowercase form of `label` used for lookups + /// (e.g. "Alice" → "a11ce"; `o`/`O`→`0`, `i`/`I`→`1`, + /// `l`/`L`→`1`, everything else lowercased). Maps to the DPNS + /// document's `normalizedLabel` property and participates in the + /// per-domain uniqueness above. Computed once on insert from + /// `label` via `Self.normalize(_:)`. + var normalizedLabel: String + + /// Display parent domain — e.g. "dash". Maps to the DPNS + /// document's `parentDomainName` property. DPNS today only + /// supports the single top-level domain "dash", so the persister + /// stamps that as the default; the field exists so subdomain + /// support (when/if DPNS gains it) lands without a schema bump. + var parentDomainName: String + + /// Homograph-safe form of `parentDomainName` used for lookups. + /// Maps to the DPNS document's `normalizedParentDomainName` + /// property and participates in the per-domain uniqueness above. + var normalizedParentDomainName: String + + /// Unix-millis timestamp when the wallet first observed this + /// label belonging to the identity. Mirrors + /// `DpnsNameInfo.acquired_at`. `0` when unknown. + var acquiredAt: UInt64 + + /// Whether the latest canonical identity snapshot still includes this + /// name. Marketplace callbacks never overwrite this value. A name that + /// leaves the wallet keeps its row on the departed identity with `false`; + /// a same-wallet transfer rebinds the unique row to the current identity + /// with `true`. + var isOwned: Bool = true + + // MARK: - Username marketplace + // + // Fed by the `on_persist_dpns_name_states_fn` persister callback + // (`DpnsNameStateFFI`), NOT by the identity label snapshot that + // populates the fields above. All of them are optional or defaulted + // so an existing store migrates in place (SwiftData lightweight + // migration). + // + // READ CONTRACT: every field in this section is meaningful only + // while `documentIdBase58` is non-nil. A nil document id means the + // wallet is not tracking this name's marketplace state — it does NOT + // mean the name is owned and unlisted. Gate any marketplace UI on + // `documentIdBase58 != nil` before reading `saleStatus` or + // `priceCredits`. + + /// Base58 id of the DPNS `domain` document behind this label — the + /// handle every trade transition needs, stable across transfers and + /// purchases. `nil` while no marketplace state has been mirrored (or + /// after the row was dropped from marketplace tracking). + var documentIdBase58: String? + + /// Listed sale price in **credits** (1 duff = 1000 credits), stored + /// as `Int64(bitPattern:)` like `PersistentIdentity.balance` because + /// SwiftData has no unsigned 64-bit column. `nil` = the name is not + /// listed for sale, which is distinct from a 0-credit listing. + var priceCredits: Int64? + + /// Raw ``DpnsNameSaleStatus`` discriminant: 0 = owned, 1 = sold, + /// 2 = transferred. Defaults to 0 so existing rows migrate, so read + /// it through ``saleStatus`` rather than directly. + var saleStatusRaw: Int16 = 0 + + /// Base58 id of the counterparty a departed name went to — the buyer + /// when `saleStatusRaw == 1`, the recipient when it is 2. `nil` while + /// the name is still owned (or the counterparty is unknown). + var counterpartyIdBase58: String? + + /// Domain document `$createdAt` in Unix milliseconds. `nil` when + /// Platform did not carry the timestamp. + var documentCreatedAtMs: UInt64? + + /// Domain document `$updatedAt` in Unix milliseconds. `nil` when + /// Platform did not carry the timestamp. + var documentUpdatedAtMs: UInt64? + + /// Domain document `$transferredAt` in Unix milliseconds. `nil` when + /// Platform did not carry the timestamp. + var documentTransferredAtMs: UInt64? + + /// Unix-millis timestamp of the sync pass / confirmed transition + /// that last wrote the marketplace fields. `0` = never written. + var marketplaceUpdatedAt: UInt64 = 0 + + // MARK: - Relationships + + /// Owning identity. Cascade-deleted from the parent — losing the + /// identity row should drop its label cache too. The `inverse` + /// declaration on `PersistentIdentity.dpnsNames` is the source of + /// truth for this association. + /// + /// Non-optional: every DPNS-label row exists *because* of an + /// identity. The persister wires it at construction time + /// (before insert) so SwiftData's non-optional relationship + /// contract is honored. + var identity: PersistentIdentity + + // MARK: - Timestamps + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + identity: PersistentIdentity, + label: String, + parentDomainName: String = "dash", + acquiredAt: UInt64 = 0, + isOwned: Bool = true + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.label = label + self.normalizedLabel = label.lowercased() + self.parentDomainName = parentDomainName + self.normalizedParentDomainName = parentDomainName.lowercased() + self.acquiredAt = acquiredAt + self.isOwned = isOwned + // A freshly inserted row carries no marketplace state until the + // marketplace persister callback writes it — hence a nil document + // id, which is the "not tracked" signal the read contract above + // documents. + self.documentIdBase58 = nil + self.priceCredits = nil + self.saleStatusRaw = 0 + self.counterpartyIdBase58 = nil + self.documentCreatedAtMs = nil + self.documentUpdatedAtMs = nil + self.documentTransferredAtMs = nil + self.marketplaceUpdatedAt = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayContactProfile { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, + /// contactIdentityId)`. Mirrors the per-owner, per-contact keying of + /// the Rust `contact_profiles` map. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Kept in sync with `owner.networkRaw` by the + /// init. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id, denormalized so + /// `#Predicate` filters can match without a relationship traversal + /// through the `owner` join. Always equal to `owner.identityId` — + /// kept in sync by the persister. + var ownerIdentityId: Data + + /// The contact's 32-byte identity id — the `contact_profiles` map + /// key. Part of the compound unique key above. + var contactIdentityId: Data + + // MARK: - Profile fields + // + // All optional — every `dashpay.profile` document field is optional + // in the contract schema except the implicit `$ownerId`. We mirror + // that so partial profiles (only an `avatarUrl` set, only a + // `displayName` set, etc.) round-trip without forcing placeholders. + + /// `displayName` field on the contact's DashPay `profile` document. + var displayName: String? + + /// `publicMessage` field on the contact's `profile` document. + var publicMessage: String? + + /// `bio` field. Carried for forwards-compat with future contract + /// revisions; reserved here so adding it later doesn't trigger a + /// destructive schema change. + var bio: String? + + /// `avatarUrl` field — URL the consumer fetches + caches locally. + /// The binary asset itself is never persisted. Treated as untrusted + /// (attacker-controlled public data): the Rust side caches and + /// restores it only when it is a bounded `https://` URL. + var avatarUrl: String? + + /// `avatarHash` field — 32-byte hash of the avatar binary, so + /// consumers can verify a fetched asset matches what the contact + /// published. `nil` when the underlying `avatar_hash` was absent. + var avatarHash: Data? + + /// `avatarFingerprint` field — 8-byte perceptual hash for quick + /// equality checks on cached avatars. `nil` when absent. + var avatarFingerprint: Data? + + /// Wall-clock ms of the last fetch attempt on the Rust side + /// (`ContactProfileEntry.checked_at_ms`) — drives the self-heal + /// backoff. Round-tripped verbatim so the restored cache keeps the + /// same re-query schedule it had before relaunch. Stored as the + /// scalar so the predicate engine compares it directly. + var checkedAtMs: UInt64 + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity whose cached + /// contact profiles this row belongs to. Non-optional: every contact + /// profile exists *because of* an owner identity. Cascade-deleted + /// from `PersistentIdentity.contactProfiles`. + var owner: PersistentIdentity + + // MARK: - Timestamps (local row bookkeeping) + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + checkedAtMs: UInt64, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.checkedAtMs = checkedAtMs + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayContactRequest { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, + /// contactIdentityId, isOutgoing)`. Mirrors the per-direction + /// keying the Rust changeset uses on + /// `ContactChangeSet::sent_requests` / + /// `incoming_requests`, scoped by network so two networks don't + /// collide in a shared local store. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId, \.isOutgoing + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Kept in sync with `owner.networkRaw` by the + /// init. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id, denormalized so + /// `#Predicate` filters can match without a relationship traversal + /// through the optional `owner` join. Always equal to + /// `owner.identityId` — kept in sync by the persister. + var ownerIdentityId: Data + + /// Other party's 32-byte identity id. For outgoing rows this is + /// the recipient (`ContactRequest::recipient_id`); for incoming + /// rows this is the sender (`ContactRequest::sender_id`). The + /// `isOutgoing` bit disambiguates which direction this row + /// represents. + var contactIdentityId: Data + + /// Direction bit. `true` ⇒ owner sent this request to contact; + /// `false` ⇒ contact sent this request to owner. Same shape as + /// the Rust `ContactRequestFFI::is_outgoing` field. + var isOutgoing: Bool + + // MARK: - Payload — round-trips `ContactRequest` verbatim + + /// `ContactRequest::sender_key_index` — index of the sender's + /// identity public key used for the ECDH that encrypted the + /// payload. + var senderKeyIndex: UInt32 + + /// `ContactRequest::recipient_key_index`. + var recipientKeyIndex: UInt32 + + /// `ContactRequest::account_reference` — DashPay account derivation + /// hint the sender encoded in the request. + var accountReference: UInt32 + + /// `ContactRequest::encrypted_public_key` bytes. Always non-empty + /// — every contact-request document carries an encrypted key. + var encryptedPublicKey: Data + + /// `ContactRequest::encrypted_account_label` bytes, when present. + /// `nil` mirrors the source `Option` being `None`. + var encryptedAccountLabel: Data? + + /// `ContactRequest::auto_accept_proof` bytes, when present. `nil` + /// mirrors the source `Option` being `None`. + var autoAcceptProof: Data? + + /// `ContactRequest::core_height_created_at` — the Core block + /// height at which the request landed on Platform. + var coreHeightCreatedAt: UInt32 + + /// `ContactRequest::created_at` — Unix-millis timestamp the + /// request document was created. + var createdAtMillis: UInt64 + + /// Whether the established relationship this row belongs to has a + /// **permanently broken** payment channel. Mirrors + /// `ContactRequestFFI::payment_channel_broken`: only meaningful + /// for rows projected from the `established` map — both + /// directions of an established pair carry the same flag (it's a + /// property of the relationship, not of one direction). Always + /// `false` for pending rows. The UI reads it to disable "Send + /// Dash" and surface "payment channel broken — ask the contact to + /// send a new request". + /// + /// Defaulted so existing rows ride SwiftData's lightweight + /// migration (additive column, non-destructive). + var paymentChannelBroken: Bool = false + + /// Owner-private alias for the contact — `contactInfo`-backed, + /// synced across devices via Platform. Mirrors + /// `ContactRequestFFI::alias`; established rows only, replicated + /// onto both directions like `paymentChannelBroken`. Optional so + /// existing rows ride the lightweight migration. + var contactAlias: String? + + /// Owner-private note — same conventions as `contactAlias`. + var contactNote: String? + + /// `contactInfo.displayHidden` — whether the owner hid this + /// contact from the list. Defaulted for lightweight migration. + var contactHidden: Bool = false + + /// The contact's decrypted DIP-15 `encryptedAccountLabel` — the label + /// the contact chose for the account they shared (a payment-routing + /// hint, e.g. "Main wallet"). **System-derived and read-only**, unlike + /// the owner-private `contactAlias`/`contactNote`: it is decrypted in + /// Rust from the contact's incoming request, so it is populated only on + /// the incoming-direction row (the outgoing row carries a label *we* + /// sent, which is not surfaced). Optional so existing rows ride the + /// lightweight migration. + var contactAccountLabel: String? + + /// `EstablishedContact::accepted_accounts` — the DIP-15 + /// rotated-account acceptances for this relationship. Mirrors + /// `ContactRequestFFI::accepted_accounts`: a property of the + /// relationship (not one direction), so it is replicated onto + /// both directions like `paymentChannelBroken`; always empty for + /// pending rows. Defaulted to an empty array so existing rows + /// ride SwiftData's lightweight migration. + var contactAcceptedAccounts: [UInt32] = [] + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity this row's + /// `ownerIdentityId` denormalizes. Non-optional: every + /// contact-request row exists *because of* an owner identity. + /// Cascade-deleted from `PersistentIdentity.contactRequests`. + var owner: PersistentIdentity + + // MARK: - Timestamps + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + isOutgoing: Bool, + senderKeyIndex: UInt32, + recipientKeyIndex: UInt32, + accountReference: UInt32, + encryptedPublicKey: Data, + encryptedAccountLabel: Data? = nil, + autoAcceptProof: Data? = nil, + coreHeightCreatedAt: UInt32, + createdAtMillis: UInt64, + paymentChannelBroken: Bool = false + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.isOutgoing = isOutgoing + self.senderKeyIndex = senderKeyIndex + self.recipientKeyIndex = recipientKeyIndex + self.accountReference = accountReference + self.encryptedPublicKey = encryptedPublicKey + self.encryptedAccountLabel = encryptedAccountLabel + self.autoAcceptProof = autoAcceptProof + self.coreHeightCreatedAt = coreHeightCreatedAt + self.createdAtMillis = createdAtMillis + self.paymentChannelBroken = paymentChannelBroken + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayIgnoredSender { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, + /// ignoredSenderId)` — the Rust per-sender suppression key, scoped by + /// network so two networks don't collide in a shared store. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.ignoredSenderId + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue`, kept + /// in sync with `owner.networkRaw` by the init. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` if + /// the stored raw value drifts. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id — the recipient that + /// ignored the sender. Denormalized so `#Predicate` filters match + /// without a relationship traversal. Always equal to + /// `owner.identityId`. + var ownerIdentityId: Data + + /// The 32-byte id of the ignored sender. The per-sender suppression + /// key — no `accountReference`, so ALL of this sender's requests are + /// suppressed. + var ignoredSenderId: Data + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity that ignored the + /// sender. Non-optional: an ignore exists *because of* an owner + /// identity. Cascade-deleted from + /// `PersistentIdentity.dashpayIgnoredSenders`. + var owner: PersistentIdentity + + // MARK: - Timestamps (local row bookkeeping) + + var ignoredAt: Date + + // MARK: - Initialization + + init( + owner: PersistentIdentity, + ignoredSenderId: Data + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.ignoredSenderId = ignoredSenderId + self.ignoredAt = Date() + } + } + + @Model + final class PersistentDashpayPayment { + /// Compound uniqueness on `(networkRaw, ownerIdentityId, txid)`. + /// Mirrors the per-identity txid keying of the Rust + /// `dashpay_payments` map. + #Unique([ + \.networkRaw, \.ownerIdentityId, \.txid + ]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Kept in sync with `owner.networkRaw` by the + /// init. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + /// Owning (wallet-managed) identity's 32-byte id, denormalized so + /// `#Predicate` filters can match without a relationship traversal + /// through the `owner` join. Always equal to `owner.identityId` — + /// kept in sync by the refresh path. + var ownerIdentityId: Data + + /// The other identity in this payment + /// (`DashpayPaymentFFI::counterparty_id`). Whether they are the + /// sender or the receiver is encoded in `directionRaw`. + var counterpartyIdentityId: Data + + /// Amount in duffs. Always positive; `directionRaw` carries the + /// sign. + var amountDuffs: UInt64 + + /// Raw `DashPayPaymentDirection` value. Stored as the scalar so + /// the predicate engine compares it directly. + var directionRaw: UInt8 + + /// Type-safe accessor over `directionRaw`. Falls back to `.sent` + /// if the stored raw value drifts. + var direction: DashPayPaymentDirection { + get { DashPayPaymentDirection(rawValue: directionRaw) ?? .sent } + set { directionRaw = newValue.rawValue } + } + + /// Raw `DashPayPaymentStatus` value. + var statusRaw: UInt8 + + /// Type-safe accessor over `statusRaw`. Falls back to `.pending` + /// if the stored raw value drifts. + var status: DashPayPaymentStatus { + get { DashPayPaymentStatus(rawValue: statusRaw) ?? .pending } + set { statusRaw = newValue.rawValue } + } + + /// Transaction id (hex), the Rust `dashpay_payments` map key. + /// Part of the compound unique key above. + var txid: String + + /// Sender memo, when present. `nil` mirrors the source `Option` + /// being `None`. + var memo: String? + + // MARK: - Relationships + + /// Owning identity — the wallet-managed identity whose payment + /// history this row belongs to. Non-optional: every payment row + /// exists *because of* an owner identity. Cascade-deleted from + /// `PersistentIdentity.dashpayPayments`. + var owner: PersistentIdentity + + // MARK: - Timestamps (local row bookkeeping, not payment dates) + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + owner: PersistentIdentity, + counterpartyIdentityId: Data, + amountDuffs: UInt64, + direction: DashPayPaymentDirection, + status: DashPayPaymentStatus, + txid: String, + memo: String? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.counterpartyIdentityId = counterpartyIdentityId + self.amountDuffs = amountDuffs + self.directionRaw = direction.rawValue + self.statusRaw = status.rawValue + self.txid = txid + self.memo = memo + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDashpayProfile { + /// Compound uniqueness on `(networkRaw, identity)`. Mirrors the + /// DashPay contract's per-`ownerId` uniqueness on the `profile` + /// document, scoped by network so two networks don't collide in a + /// shared local store. + #Unique([\.networkRaw, \.identity]) + + /// Network discriminant. `UInt32` mirror of `Network.rawValue` — + /// Foundation's predicate engine compares it directly without a + /// custom converter. Stays in sync with `identity.networkRaw` + /// (set by the init); identities don't migrate between networks. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Falls back to `.testnet` + /// if the stored raw value drifts — matches + /// `PersistentIdentity.network`. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // MARK: - Profile fields + // + // All optional — every `dashpay.profile` document field is + // optional in the contract schema except the implicit + // `$ownerId`. We mirror that on the row so partial profiles + // (only an `avatarUrl` set, only a `displayName` set, etc.) + // round-trip without forcing placeholder values. + + /// `displayName` field on the DashPay `profile` document. Up to + /// 25 chars per the contract schema. + var displayName: String? + + /// `publicMessage` field on the DashPay `profile` document. Up to + /// 140 chars per the contract schema. + var publicMessage: String? + + /// `bio` field. Not part of the v3 DashPay contract today; the + /// FFI carries the slot for forwards-compat with future contract + /// revisions and the column is reserved here so adding it doesn't + /// trigger a destructive schema change. + var bio: String? + + /// `avatarUrl` field. URL string the consumer is expected to + /// fetch + cache locally; the binary asset itself is never + /// persisted on this row. + var avatarUrl: String? + + /// `avatarHash` field — 32-byte hash of the avatar binary, + /// stored alongside the URL so consumers can verify the fetched + /// asset matches what the profile author published. `nil` when + /// the underlying `avatar_hash` was `None`. + var avatarHash: Data? + + /// `avatarFingerprint` field — 8-byte perceptual hash for + /// quick equality checks on cached avatars without rehashing the + /// full asset. `nil` when the underlying `avatar_fingerprint` + /// was `None`. + var avatarFingerprint: Data? + + // MARK: - Relationships + + /// Owning identity. Non-optional — a profile only exists in the + /// context of an identity. Cascade-deleted from the parent's + /// `dashpayProfile` relationship; the persister wires this up at + /// construction time. + var identity: PersistentIdentity + + // MARK: - Timestamps + + var createdAt: Date + var lastUpdated: Date + + // MARK: - Initialization + + init( + identity: PersistentIdentity, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentDataContract { + /// Index `networkRaw` so the static `predicate(networkRaw:)` and + /// `tokensPredicate(networkRaw:)` helpers — plus every per-network + /// list view — can index-scan instead of table-scan. + #Index([\.networkRaw]) + + @Attribute(.unique) var id: Data + var name: String + var serializedContract: Data + var createdAt: Date + var lastAccessedAt: Date + + // Binary serialization (CBOR format) + var binarySerialization: Data? + + // Version info + var version: Int? + var ownerId: Data? + + // Keywords and description + @Relationship(deleteRule: .cascade, inverse: \PersistentKeyword.dataContract) + var keywordRelations: [PersistentKeyword] + var contractDescription: String? + + // Schema and document types storage + var schemaData: Data + var documentTypesData: Data + + // Groups + var groupsData: Data? + + // Network + /// Stored as the `Network.rawValue` `UInt32` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Setter writes through. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // Timestamps + var lastUpdated: Date + var lastSyncedAt: Date? + + // Contract configuration + var canBeDeleted: Bool + var readonly: Bool + var keepsHistory: Bool + var schemaDefs: Int? + + // Document defaults + var documentsKeepHistoryContractDefault: Bool + var documentsMutableContractDefault: Bool + var documentsCanBeDeletedContractDefault: Bool + + // Relationships with cascade delete + @Relationship(deleteRule: .cascade, inverse: \PersistentToken.dataContract) + var tokens: [PersistentToken]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocumentType.dataContract) + var documentTypes: [PersistentDocumentType]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.dataContract) + var documents: [PersistentDocument] + + // Owner identity — populated when the owner happens to also live in + // the local store. May be nil even when `ownerId` is set, because + // most contracts in the local cache will be owned by identities the + // user doesn't hold. Back-filled lazily by + // `ContractIdentityLinker.linkContractToOwner` when either side is + // inserted. + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.ownedDataContracts) + var ownerIdentity: PersistentIdentity? + + // Token support tracking + var hasTokens: Bool + var tokensData: Data? + + // Computed properties + var idBase58: String { + id.toBase58String() + } + + var ownerIdBase58: String? { + ownerId?.toBase58String() + } + + var parsedContract: [String: Any]? { + try? JSONSerialization.jsonObject(with: serializedContract, options: []) as? [String: Any] + } + + var binarySerializationHex: String? { + binarySerialization?.toHexString() + } + + var keywords: [String] { + keywordRelations.map { $0.keyword } + } + + var schema: [String: Any] { + get { + guard let json = try? JSONSerialization.jsonObject(with: schemaData), + let dict = json as? [String: Any] else { + return [:] + } + return dict + } + set { + schemaData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var documentTypesList: [String] { + get { + guard let json = try? JSONSerialization.jsonObject(with: documentTypesData), + let array = json as? [String] else { + return [] + } + return array + } + set { + documentTypesData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var tokenConfigurations: [String: Any]? { + get { + guard let data = tokensData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + tokensData = try? JSONSerialization.data(withJSONObject: newValue) + hasTokens = true + } else { + tokensData = nil + hasTokens = false + } + lastUpdated = Date() + } + } + + var groups: [String: Any]? { + get { + guard let data = groupsData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + groupsData = try? JSONSerialization.data(withJSONObject: newValue) + } else { + groupsData = nil + } + lastUpdated = Date() + } + } + + init( + id: Data, + name: String, + serializedContract: Data, + version: Int? = 1, + ownerId: Data? = nil, + schema: [String: Any] = [:], + documentTypesList: [String] = [], + keywords: [String] = [], + description: String? = nil, + hasTokens: Bool = false, + network: Network + ) { + self.id = id + self.name = name + self.serializedContract = serializedContract + self.createdAt = Date() + self.lastAccessedAt = Date() + self.version = version + self.ownerId = ownerId + + // Schema and document types + self.schemaData = (try? JSONSerialization.data(withJSONObject: schema)) ?? Data() + self.documentTypesData = (try? JSONSerialization.data(withJSONObject: documentTypesList)) ?? Data() + + // Keywords + self.keywordRelations = keywords.map { PersistentKeyword(keyword: $0, contractId: id.toBase58String()) } + self.contractDescription = description + + // Tokens + self.hasTokens = hasTokens + self.tokensData = nil + + // Groups + self.groupsData = nil + + // Documents + self.documents = [] + + // Owner identity link is back-filled later by + // `ContractIdentityLinker`. Initialise explicitly because + // SwiftData's auto-init of optional relationships has + // historically been flaky enough in this codebase to be + // worth the line. + self.ownerIdentity = nil + + // Network and timestamps + self.networkRaw = network.rawValue + self.lastUpdated = Date() + self.lastSyncedAt = nil + + // Default values for contract configuration + self.canBeDeleted = false + self.readonly = false + self.keepsHistory = false + self.documentsKeepHistoryContractDefault = false + self.documentsMutableContractDefault = true + self.documentsCanBeDeletedContractDefault = true + } + + func updateLastAccessed() { + self.lastAccessedAt = Date() + } + + func updateVersion(_ newVersion: Int) { + self.version = newVersion + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func addDocument(_ document: PersistentDocument) { + documents.append(document) + lastUpdated = Date() + } + + func removeDocument(withId documentId: String) { + if let docIdData = Data.identifier(fromBase58: documentId) { + documents.removeAll { $0.id == docIdData } + } + lastUpdated = Date() + } + } + + @Model + final class PersistentDocument { + /// Index `networkRaw` to keep per-network document scans + /// index-served. The static `predicate(contractId:network:)` helper + /// and every UI list view filter by the active network. + #Index([\.networkRaw]) + + // Primary key + @Attribute(.unique) var documentId: String + + // Core document properties + var documentType: String + var revision: Int32 + var data: Data + + // References (stored as strings for queries) + var contractId: String + var ownerId: String + + // Binary data for efficient operations + var contractIdData: Data + var ownerIdData: Data + + // Timestamps + var createdAt: Date + var updatedAt: Date + var transferredAt: Date? + + // Block heights + var createdAtBlockHeight: Int64? + var updatedAtBlockHeight: Int64? + var transferredAtBlockHeight: Int64? + + // Core block heights + var createdAtCoreBlockHeight: Int64? + var updatedAtCoreBlockHeight: Int64? + var transferredAtCoreBlockHeight: Int64? + + // Network + /// Stored as the `Network.rawValue` `UInt32` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Setter writes through. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // Deletion flag + var isDeleted: Bool = false + + // Local tracking + var localCreatedAt: Date + var localUpdatedAt: Date + + // Relationships + var documentType_relation: PersistentDocumentType? + var dataContract: PersistentDataContract? + + // Optional reference to local identity (if owner is local) + var ownerIdentity: PersistentIdentity? + + // Computed properties + var id: Data { + Data.identifier(fromBase58: documentId) ?? Data() + } + + var idBase58: String { + documentId + } + + var ownerIdBase58: String { + ownerId + } + + var contractIdBase58: String { + contractId + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var displayTitle: String { + guard let props = properties else { return "Document" } + + if let title = props["title"] as? String { return title } + if let name = props["name"] as? String { return name } + if let label = props["label"] as? String { return label } + if let normalizedLabel = props["normalizedLabel"] as? String { return normalizedLabel } + + return documentType + } + + var summary: String { + var parts: [String] = [] + + parts.append("Type: \(documentType)") + parts.append("Rev: \(revision)") + + // Pin to Gregorian so the `createdAt` year stays CE even + // when the device is configured for a non-Gregorian + // calendar (e.g. Thai region → Buddhist era). The SDK + // doesn't depend on the app's `AppDate` helper, so we + // configure the formatter inline. + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateStyle = .short + parts.append("Created: \(formatter.string(from: createdAt))") + + return parts.joined(separator: " • ") + } + + init( + documentId: String, + documentType: String, + revision: Int32, + data: Data, + contractId: String, + ownerId: String, + network: Network + ) { + self.documentId = documentId + self.documentType = documentType + self.revision = revision + self.data = data + self.contractId = contractId + self.ownerId = ownerId + self.contractIdData = Data.identifier(fromBase58: contractId) ?? Data() + self.ownerIdData = Data.identifier(fromBase58: ownerId) ?? Data() + self.networkRaw = network.rawValue + self.createdAt = Date() + self.updatedAt = Date() + self.localCreatedAt = Date() + self.localUpdatedAt = Date() + } + + // MARK: - Methods + func updateProperties(_ newData: Data) { + self.data = newData + self.updatedAt = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = Int32(newRevision) + self.updatedAt = Date() + } + + func markAsDeleted() { + self.isDeleted = true + self.updatedAt = Date() + } + + // MARK: - Static Methods + static func predicate(documentId: String) -> Predicate { + #Predicate { doc in + doc.documentId == documentId && doc.isDeleted == false + } + } + + static func predicate(contractId: String, network: Network) -> Predicate { + // See `PersistentIdentity.predicate(network:)` — Foundation's + // predicate engine can't capture `Network`, so we filter on + // the UInt32-backed `networkRaw` shadow field. + let target = network.rawValue + return #Predicate { doc in + doc.contractId == contractId && doc.networkRaw == target && doc.isDeleted == false + } + } + + static func predicate(ownerId: Data) -> Predicate { + let ownerIdString = ownerId.toBase58String() + return #Predicate { doc in + doc.ownerId == ownerIdString && doc.isDeleted == false + } + } + + // MARK: - Identity Linking + func linkToLocalIdentityIfNeeded(in modelContext: ModelContext) { + guard ownerIdentity == nil else { return } + + let ownerIdToMatch = self.ownerIdData + let identityPredicate = #Predicate { identity in + identity.identityId == ownerIdToMatch && identity.isLocal == true + } + + let descriptor = FetchDescriptor(predicate: identityPredicate) + + do { + if let localIdentity = try modelContext.fetch(descriptor).first { + self.ownerIdentity = localIdentity + self.localUpdatedAt = Date() + } + } catch { + print("Failed to link document to local identity: \(error)") + } + } + } + + @Model + final class PersistentDocumentType { + @Attribute(.unique) var id: Data + var contractId: Data + var name: String + + // Schema stored as JSON + var schemaJSON: Data + var propertiesJSON: Data + + // Document behavior settings + var documentsKeepHistory: Bool + var documentsMutable: Bool + var documentsCanBeDeleted: Bool + var documentsTransferable: Bool + + // indexOnly storage mode (meta-schema v3, protocol version 14): no + // stored rows — the index entries ARE the documents + var indexOnly: Bool = false + + // Required fields + var requiredFieldsJSON: Data? + + // Security + var securityLevel: Int + + // Trade and creation restrictions + var tradeMode: Int + var creationRestrictionMode: Int + + // Identity encryption keys + var requiresIdentityEncryptionBoundedKey: Bool + var requiresIdentityDecryptionBoundedKey: Bool + + // Timestamps + var createdAt: Date + var lastAccessedAt: Date + + // Relationship to data contract + var dataContract: PersistentDataContract? + + // Relationship to documents + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.documentType_relation) + var documents: [PersistentDocument]? + + // Relationship to indices + @Relationship(deleteRule: .cascade, inverse: \PersistentIndex.documentType) + var indices: [PersistentIndex]? + + // Relationship to properties + @Relationship(deleteRule: .cascade, inverse: \PersistentProperty.documentType) + var propertiesList: [PersistentProperty]? + + init(contractId: Data, name: String, schemaJSON: Data, propertiesJSON: Data) { + // Create unique ID by combining contract ID and name + var idData = contractId + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.name = name + self.schemaJSON = schemaJSON + self.propertiesJSON = propertiesJSON + self.documentsKeepHistory = false + self.documentsMutable = true + self.documentsCanBeDeleted = true + self.documentsTransferable = false + self.securityLevel = 0 + self.tradeMode = 0 + self.creationRestrictionMode = 0 + self.requiresIdentityEncryptionBoundedKey = false + self.requiresIdentityDecryptionBoundedKey = false + self.createdAt = Date() + self.lastAccessedAt = Date() + } + } + + @Model + final class PersistentIdentity { + /// Index `networkRaw` so per-network scans (`#Predicate { $0.networkRaw == raw }`) + /// don't degrade to a table scan. Every UI surface that lists + /// identities filters by the active network. + #Index([\.networkRaw]) + + // MARK: - Core Properties + @Attribute(.unique) var identityId: Data + var balance: Int64 + var revision: Int64 + /// `true` iff this identity is YOURS or deliberately tracked on + /// this device, two ways in: + /// - wallet-derived: identities of a wallet on this device are + /// ALWAYS local — the persister promotes the flag when it + /// attaches the `wallet` relationship, and the startup heal + /// repairs rows persisted before that rule existed; + /// - manually added: the user loaded/watched the identity via a + /// UI flow (LoadIdentityView by id/name), which marks its own + /// row (the initializer default `true` matches — a directly + /// constructed row is a manual add). + /// + /// `false` only for incidental rows — observed foreign + /// identities materialized by sync that nobody asked to track. + /// The flag is PROMOTE-ONLY: no sync path ever writes `false` + /// over a `true` (a manual mark must survive Platform data + /// flowing over the row, and losing a wallet link doesn't + /// un-track an identity). + /// + /// It makes no claim about signing capability — compute that + /// live where needed; wallet-owned filtering has + /// `walletOwnedIdentitiesPredicate`. + var isLocal: Bool + var alias: String? + /// User's chosen primary display label (the one rendered on + /// list rows and avatars). Populated only when the user selects a + /// main name from `mainDpnsName` selection or as the fallback set + /// during initial registration. The full label collection lives on + /// the `dpnsNames` relationship below; this scalar is just the + /// "show this one in the cell" hint. + var dpnsName: String? + var mainDpnsName: String? + var identityType: String + + // MARK: - Special Key Storage (stored in keychain) + var votingPrivateKeyIdentifier: String? + var ownerPrivateKeyIdentifier: String? + var payoutPrivateKeyIdentifier: String? + + // MARK: - Public Keys + @Relationship(deleteRule: .cascade) var publicKeys: [PersistentPublicKey] + + // MARK: - Timestamps + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + // MARK: - Network + /// Stored as the `Network.rawValue` `UInt32` so SwiftData + /// `#Predicate` expressions can evaluate it directly. Foundation's + /// predicate engine rejects captured non-primitive types — even + /// Codable raw-value enums crash at evaluation with + /// "Unsupported Predicate: Captured/constant values of type + /// 'Network' are not supported". The `network` computed + /// accessor below keeps the public API type-safe; only predicates + /// that need to filter by network reach for `networkRaw`. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Reads fall back to + /// `.testnet` if the stored raw value ever drifts out of the + /// `Network` range (shouldn't happen — writers only go through + /// this setter which uses `Network.rawValue`). + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // MARK: - Wallet Association + // + // Cardinality: an identity belongs to 0 or 1 wallet. A wallet + // holds N identities (see `PersistentWallet.identities`). When + // the wallet is deleted, `wallet` nulls out (deleteRule: + // `.nullify`) and the identity row survives orphaned. + // + // The `wallet` reference is the single source of truth — there + // is no denormalized scalar `walletId`. Callers that want the + // 32-byte wallet id read `identity.wallet?.walletId`; + // predicates filter with `$0.wallet?.walletId == target`. + // `@Relationship` is declared on the `PersistentWallet` side + // (`identities`, with `inverse: \PersistentIdentity.wallet`), + // so this is a plain stored property. + var wallet: PersistentWallet? + /// DIP-9 identity index within the owning wallet. Mirrors the + /// `identity_index` carried on `IdentityEntryFFI` from Rust. + /// Only meaningful when `wallet != nil`; defaults to 0 + /// otherwise. Used to stable-sort identities within a wallet + /// (e.g. when grouping public keys by identity). + var identityIndex: UInt32 = 0 + + // MARK: - Relationships + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.ownerIdentity) var documents: [PersistentDocument] + @Relationship(deleteRule: .nullify) var tokenBalances: [PersistentTokenBalance] + + /// Confirmed DPNS labels observed for this identity. Cascade-deleted from + /// the parent — losing the identity row drops the label cache and retained + /// marketplace history too. A name that leaves this wallet remains related + /// to its departed identity for history with + /// `PersistentDPNSName.isOwned == false`. A transfer to another identity in + /// the same wallet instead rebinds the schema's single unique-name row to + /// the current owner. Owned-name surfaces use + /// `PersistentDPNSName.predicate(identityId:)`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDPNSName.identity) + var dpnsNames: [PersistentDPNSName] = [] + + /// DashPay profile cache for this identity — at most one row per + /// (network, identity) per the contract's per-`ownerId` + /// uniqueness on the `profile` document. Cascade-deleted from the + /// parent. Optional because not every identity has published a + /// profile (and the FFI changeset's `dashpay_profile: None` + /// semantics mean "no update", not "delete" — the persister never + /// nils this out from a flush). Inserted / refreshed by + /// `PlatformWalletPersistenceHandler.upsertDashpayProfile(...)`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayProfile.identity) + var dashpayProfile: PersistentDashpayProfile? + + /// DashPay contact-request rows owned by this identity (both + /// outgoing and incoming). Cascade-deleted from the parent. Same + /// query-by-denormalized-id pattern as `dpnsNames`: filters use + /// `PersistentDashpayContactRequest.predicate(ownerIdentityId:)` + /// rather than walking this collection from a SwiftUI view. + /// Append / overwrite / delete on the write path: the persister + /// callback applies upserts (per `(owner, contact, isOutgoing)`) + /// and tombstones (`removed_sent` / `removed_incoming`) directly. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactRequest.owner) + var contactRequests: [PersistentDashpayContactRequest] = [] + + /// DashPay payment-history rows owned by this identity. + /// Cascade-deleted from the parent. Same + /// query-by-denormalized-id pattern as `contactRequests`: filters + /// use `PersistentDashpayPayment.predicate(ownerIdentityId:)` + /// rather than walking this collection from a SwiftUI view. + /// Populated by `PlatformWalletManager.refreshDashPayPayments` + /// (FFI getter → upsert), not by the persister callback. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayPayment.owner) + var dashpayPayments: [PersistentDashpayPayment] = [] + + /// DashPay ignored senders (per-sender mute, = block, reversible, + /// local-only) owned by this identity. Cascade-deleted from the parent. + /// Persisted from the `ignored` changeset array by `persistContacts` + /// and read back at load to rebuild the Rust `ignored_senders` set — + /// without them an ignored sender resurfaces on relaunch. Filters use + /// `PersistentDashpayIgnoredSender.predicate(ownerIdentityId:)`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayIgnoredSender.owner) + var dashpayIgnoredSenders: [PersistentDashpayIgnoredSender] = [] + + /// Cached DashPay **contact** profiles owned by this identity (one + /// per contact whose public profile has been fetched). Cascade-deleted + /// from the parent. Same query-by-denormalized-id pattern as + /// `contactRequests`: filters use + /// `PersistentDashpayContactProfile.predicate(ownerIdentityId:)` rather + /// than walking this collection from a SwiftUI view. Populated by the + /// persister callback (`IdentityEntryFFI.contact_profiles` rows) and + /// read back at load to rebuild the Rust `contact_profiles` map. + /// Distinct from the owner's own `dashpayProfile`. + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactProfile.owner) + var contactProfiles: [PersistentDashpayContactProfile] = [] + + // Contracts in the local store that name this identity as their + // owner. `.nullify` so deleting the identity leaves the contract + // rows alive (with `ownerIdentity` nulled) — matches the user's + // intent that contracts persist independently of whether the owner + // identity happens to be loaded. + // The `@Relationship` macro is declared on the contract side + // (`PersistentDataContract.ownerIdentity`) so this is a plain + // stored property — see `wallet` above for the same pattern. + var ownedDataContracts: [PersistentDataContract] + + // MARK: - Initialization + init( + identityId: Data, + balance: Int64 = 0, + revision: Int64 = 0, + isLocal: Bool = true, + alias: String? = nil, + dpnsName: String? = nil, + mainDpnsName: String? = nil, + identityType: IdentityType = .user, + votingPrivateKeyIdentifier: String? = nil, + ownerPrivateKeyIdentifier: String? = nil, + payoutPrivateKeyIdentifier: String? = nil, + network: Network, + identityIndex: UInt32 = 0 + ) { + self.identityId = identityId + self.balance = balance + self.revision = revision + self.isLocal = isLocal + self.alias = alias + self.dpnsName = dpnsName + self.mainDpnsName = mainDpnsName + self.identityType = identityType.rawValue + self.votingPrivateKeyIdentifier = votingPrivateKeyIdentifier + self.ownerPrivateKeyIdentifier = ownerPrivateKeyIdentifier + self.payoutPrivateKeyIdentifier = payoutPrivateKeyIdentifier + self.networkRaw = network.rawValue + self.identityIndex = identityIndex + self.publicKeys = [] + self.documents = [] + self.tokenBalances = [] + self.dpnsNames = [] + self.dashpayProfile = nil + self.contactRequests = [] + self.dashpayPayments = [] + self.dashpayIgnoredSenders = [] + self.contactProfiles = [] + self.ownedDataContracts = [] + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + } + + // MARK: - Computed Properties + var identityIdString: String { + identityId.toHexString() + } + + var identityIdBase58: String { + identityId.toBase58String() + } + + var formattedBalance: String { + let dashAmount = Double(balance) / 100_000_000_000 + return String(format: "%.8f DASH", dashAmount) + } + + /// User-facing short name. Priority: `alias` → `mainDpnsName` + /// → `dpnsName` → truncated hex id. Mirrors the old + /// `IdentityModel.displayName` extension so views that read + /// this don't change behavior post-migration. + var displayName: String { + if let alias = alias, !alias.isEmpty { + return alias + } + if let mainDpnsName = mainDpnsName, !mainDpnsName.isEmpty { + return mainDpnsName + } + if let dpnsName = dpnsName, !dpnsName.isEmpty { + return dpnsName + } + return String(identityIdString.prefix(12)) + "..." + } + + var identityTypeEnum: IdentityType { + IdentityType(rawValue: identityType) ?? .user + } + + // MARK: - Methods + func updateBalance(_ newBalance: Int64) { + self.balance = newBalance + self.lastUpdated = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = newRevision + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func updateDPNSName(_ name: String?) { + self.dpnsName = name + self.lastUpdated = Date() + } + + func addPublicKey(_ key: PersistentPublicKey) { + publicKeys.append(key) + lastUpdated = Date() + } + + func removePublicKey(withId keyId: Int32) { + publicKeys.removeAll { $0.keyId == keyId } + lastUpdated = Date() + } + } + + @Model + final class PersistentIndex { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + // Index configuration + var unique: Bool + var nullSearchable: Bool + var contested: Bool + + // Count / sum axes (meta-schema v3, protocol version 14). Every + // keyword is persisted VERBATIM as authored in the contract JSON — + // `countable` keeps its boolean-or-string spelling ("true" / + // "countable" / "countableAllowingOffset"), and the `averageable` / + // `rangeAverageable` sugar is stored as-is rather than desugared. + // Interpreting the spellings (DPP's normalization rules) is protocol + // logic and stays out of the SDK; display layers map them for + // presentation. + var countable: String? + var rangeCountable: Bool = false + var summable: String? + var rangeSummable: Bool = false + var averageable: String? + var rangeAverageable: Bool = false + + // Ranking axes (each adds one ordered secondary tree) + var rankedCountable: Bool = false + var rankedSummable: Bool = false + var rankedAverageable: Bool = false + + // indexOnly member key (the property whose value keys each entry). + // Persisted only when declared; an omitted terminal on an indexOnly + // type means $ownerId per DPP, a default display layers apply. + var terminal: String? + + // Preallocation: creating the refersTo-referenced document also + // creates this index's trees, and deleting the last entry keeps them + var preallocated: Bool = false + + // Time-range bucketing transform ({on, range, step, phase}), if any + var timeRangeJSON: Data? + + // Properties in the index with sorting + var propertiesJSON: Data + + // Contested details (if contested) + var contestedDetailsJSON: Data? + + // Timestamps + var createdAt: Date + + // Relationship to document type + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, properties: [String]) { + // Create unique ID by combining contract ID, document type name, and index name + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.unique = false + self.nullSearchable = false + self.contested = false + + // Store properties as JSON array + if let jsonData = try? JSONSerialization.data(withJSONObject: properties, options: []) { + self.propertiesJSON = jsonData + } else { + self.propertiesJSON = Data() + } + + self.createdAt = Date() + } + } + + @Model + final class PersistentKeyword { + @Attribute(.unique) var id: String + var keyword: String + var contractId: String + + // Relationship + var dataContract: PersistentDataContract? + + init(keyword: String, contractId: String) { + self.id = "\(contractId)_\(keyword)" + self.keyword = keyword + self.contractId = contractId + } + } + + @Model + final class PersistentPendingInput { + /// Two single-column indexes: + /// * `outpoint` — the per-outpoint reconciliation lookup that + /// runs on every `upsertUtxo`. + /// * `walletId` — per-wallet pending-input scans (cleanup when + /// a wallet is removed, the storage explorer's network + /// scope, "long-lived non-zero pending count" diagnostics). + /// + /// SwiftData allows only a single `#Index` macro per model; + /// passing multiple key-path arrays declares multiple separate + /// indexes from one macro call. + #Index([\.outpoint], [\.walletId]) + var outpoint: Data + + /// Position of this input in the spending transaction's input + /// list. Carried so a future UI surface can render the input + /// index correctly without re-deriving from the raw tx bytes; + /// the resolution flow itself only uses `outpoint`. + var inputIndex: UInt32 + + /// 32-byte canonical txid of the spending transaction. Stored + /// in addition to the relationship below so the entry remains + /// usable if the parent `PersistentTransaction` isn't yet in the + /// background context (re-upsert ordering, fault-in lag, …). + var spendingTxid: Data + + /// The transaction this input belongs to. Cascade-deleted from + /// the parent side via `PersistentTransaction.pendingInputs` so + /// removing a tx doesn't leave dangling pending rows. + var spendingTransaction: PersistentTransaction? + + /// Wallet id (`PersistentTxo.walletId` denorm) so cleanup / + /// per-wallet diagnostics can scope without joining through the + /// transaction relationship. + var walletId: Data + + /// Insertion timestamp — useful for spotting stale entries that + /// never resolved (orphans whose previous output isn't ours). + var createdAt: Date + + init( + outpoint: Data, + inputIndex: UInt32, + spendingTxid: Data, + spendingTransaction: PersistentTransaction?, + walletId: Data + ) { + self.outpoint = outpoint + self.inputIndex = inputIndex + self.spendingTxid = spendingTxid + self.spendingTransaction = spendingTransaction + self.walletId = walletId + self.createdAt = Date() + } + } + + @Model + final class PersistentPlatformAddress { + /// Index `walletId` so per-wallet platform-address scans — + /// `predicate(walletId:)`, the storage explorer's network scope + /// fallback, BLAST-sync re-upsert paths — hit an index instead + /// of scanning the whole table. + #Index([\.walletId]) + + /// DIP-0018 bech32m-encoded address (`dash1…` / `tdash1…`). Unique + /// across the SwiftData store — a collision would imply a wallet- + /// id / derivation path collision. + @Attribute(.unique) var address: String + /// `PlatformAddress` type byte: 0 = P2PKH, 1 = P2SH. Matches the + /// discriminant emitted by the Rust-side FFI. + var addressType: UInt8 + /// 20-byte address hash. Kept denormalized so the BLAST balance + /// callback (which gets hashes, not full addresses) can upsert in + /// one fetch. + @Attribute(.unique) var addressHash: Data + /// 33-byte compressed secp256k1 public key, or empty Data if the + /// Rust side couldn't produce one (pool entries that stored only + /// a script, etc.). + var publicKey: Data + /// DIP-17 account index (field `account` in `PlatformPayment`). + var accountIndex: UInt32 + /// DIP-17 derivation index within the account. + var addressIndex: UInt32 + /// BIP32 derivation path (e.g. `"m/9'/5'/17'/0'/0'/0"`). + var derivationPath: String + /// Marked used by the Rust address pool (first-seen tx or explicit + /// `mark_used`), or auto-flipped by BLAST when a non-zero + /// balance / nonce first arrives. + var isUsed: Bool + /// Credit balance in credits (1e11 credits per DASH). + var balance: UInt64 + /// Current anti-replay nonce. + var nonce: UInt32 + /// Platform block height where this address first appeared in a + /// balance changeset. Zero until the address is seen on-chain. + var firstSeenHeight: UInt32 + /// Platform block height this row's `balance` is current **as of** + /// — the balance height pin (`AddressFunds::as_of_height` in Rust). + /// Round-tripped verbatim through the persistence callbacks so the + /// sync's delta-replay gate survives restarts. Zero means "unknown + /// provenance" (rows persisted before the pin existed). + var lastSeenHeight: UInt64 + /// 32-byte wallet ID that owns this address. Denormalized from + /// `account.wallet.walletId` so per-wallet `@Query` filters don't + /// have to traverse two optional relationships. + var walletId: Data + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Parent account (PlatformPayment, type tag 14). + var account: PersistentAccount? + + init( + address: String, + addressType: UInt8, + addressHash: Data, + publicKey: Data = Data(), + accountIndex: UInt32, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0, + nonce: UInt32 = 0, + walletId: Data + ) { + self.address = address + self.addressType = addressType + self.addressHash = addressHash + self.publicKey = publicKey + self.accountIndex = accountIndex + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.balance = balance + self.nonce = nonce + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.walletId = walletId + self.createdAt = Date() + self.lastUpdated = Date() + } + } + + @Model + final class PersistentProperty { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + // Property type and constraints + var type: String + var format: String? + var contentMediaType: String? + var byteArray: Bool + var minItems: Int? + var maxItems: Int? + var pattern: String? + var minLength: Int? + var maxLength: Int? + var minValue: Int? + var maxValue: Int? + var fieldDescription: String? + + // Property attributes + var transient: Bool + var isRequired: Bool + + // Timestamps + var createdAt: Date + + // Relationship to document type + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, type: String) { + // Create unique ID by combining contract ID, document type name, and property name + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.type = type + self.byteArray = false + self.transient = false + self.isRequired = false + self.createdAt = Date() + } + } + + @Model + final class PersistentPublicKey { + // MARK: - Core Properties + var keyId: Int32 + var purpose: String + var securityLevel: String + var keyType: String + var readOnly: Bool + var disabledAt: Int64? + + // MARK: - Key Data + var publicKeyData: Data + + // MARK: - Contract Bounds + /// JSON-encoded `[base64(contractId)]` — legacy storage shape + /// that only retains the contract id, never the document-type + /// name. New code paths still write here for the id portion; + /// `contractBoundsDocumentTypeName` carries the doc-type so + /// the `SingleContractDocumentType` variant round-trips + /// faithfully. Keeping the field shape lets old SwiftData + /// stores that predate the doc-type column continue to load + /// without migration (the doc-type column is just `nil`). + var contractBoundsData: Data? + + /// When set, the key's bounds are + /// `.singleContractDocumentType(id: contractBoundsData[0], + /// documentTypeName: contractBoundsDocumentTypeName)`. When + /// `nil`, the key is either unbounded (when `contractBoundsData` + /// is also nil) or bounded to a whole contract via + /// `.singleContract(id:)`. Optional so old stores load cleanly. + var contractBoundsDocumentTypeName: String? + + // MARK: - Private Key Reference (optional) + var privateKeyKeychainIdentifier: String? + + // MARK: - Derivation breadcrumb (derive-sign-destroy) + /// 32-byte wallet id that owns this identity key, denormalized from the + /// discovery breadcrumb. Paired with `identityDerivationPath`, it lets the + /// signer derive this key on demand from the Keychain-held seed instead of + /// reading a stored scalar. `nil` for rows persisted before this column + /// existed and for keys with no wallet association; such rows fall back to + /// the stored scalar until the backfill populates them. Additive optional + /// column => SwiftData lightweight migration. + var walletId: Data? + + /// Full DIP-9 identity-authentication path + /// `m/9'/coin'/5'/0'/ECDSA'/identityIndex'/keyIndex'` the signer feeds to + /// the mnemonic resolver to derive this key's private scalar at sign time. + /// The authoritative breadcrumb; `nil` until written on persist or + /// backfilled from the key's Keychain metadata. + var identityDerivationPath: String? + + // MARK: - Metadata + var identityId: String + var createdAt: Date + var lastAccessed: Date? + + // MARK: - Relationships + @Relationship(inverse: \PersistentIdentity.publicKeys) + var identity: PersistentIdentity? + + // MARK: - Initialization + init( + keyId: Int32, + purpose: KeyPurpose, + securityLevel: SecurityLevel, + keyType: KeyType, + publicKeyData: Data, + readOnly: Bool = false, + disabledAt: Int64? = nil, + contractBounds: [Data]? = nil, + contractBoundsDocumentTypeName: String? = nil, + identityId: String + ) { + self.keyId = keyId + self.purpose = String(purpose.rawValue) + self.securityLevel = String(securityLevel.rawValue) + self.keyType = String(keyType.rawValue) + self.publicKeyData = publicKeyData + self.readOnly = readOnly + self.disabledAt = disabledAt + if let contractBounds = contractBounds { + self.contractBoundsData = try? JSONSerialization.data(withJSONObject: contractBounds.map { $0.base64EncodedString() }) + } else { + self.contractBoundsData = nil + } + self.contractBoundsDocumentTypeName = contractBoundsDocumentTypeName + self.identityId = identityId + self.createdAt = Date() + } + + // MARK: - Computed Properties + var contractBounds: [Data]? { + get { + guard let data = contractBoundsData, + let json = try? JSONSerialization.jsonObject(with: data), + let strings = json as? [String] else { + return nil + } + return strings.compactMap { Data(base64Encoded: $0) } + } + set { + // Always clear the doc-type column when the contract- + // bounds ids change through this setter. The + // `documentTypeName` is paired with a SPECIFIC id, so + // mutating ids without explicitly carrying the doc- + // type would leave the columns inconsistent and make + // `toIdentityPublicKey()` reconstruct a stale variant. + // Callers that want the full `.singleContractDocumentType` + // round-trip should write `contractBoundsDocumentTypeName` + // explicitly after this setter, or go through + // `PersistentPublicKey.from(IdentityPublicKey, identityId:)` + // which sets both columns atomically. + contractBoundsDocumentTypeName = nil + if let newValue = newValue { + contractBoundsData = try? JSONSerialization.data(withJSONObject: newValue.map { $0.base64EncodedString() }) + } else { + contractBoundsData = nil + } + } + } + + var purposeEnum: KeyPurpose? { + guard let purposeInt = UInt8(purpose) else { return nil } + return KeyPurpose(rawValue: purposeInt) + } + + var securityLevelEnum: SecurityLevel? { + guard let levelInt = UInt8(securityLevel) else { return nil } + return SecurityLevel(rawValue: levelInt) + } + + var keyTypeEnum: KeyType? { + guard let typeInt = UInt8(keyType) else { return nil } + return KeyType(rawValue: typeInt) + } + + var isDisabled: Bool { + disabledAt != nil + } + + /// Check if this public key has an associated private key identifier + var hasPrivateKeyIdentifier: Bool { + privateKeyKeychainIdentifier != nil + } + } + + @Model + final class PersistentToken { + @Attribute(.unique) var id: Data + var contractId: Data + var position: Int + var name: String + + // Basic token supply info + var baseSupply: String + var maxSupply: String? + var decimals: Int + + // Token conventions + var localizations: [String: TokenLocalization]? + + // Status flags + var isPaused: Bool + var allowTransferToFrozenBalance: Bool + + // History keeping rules + var keepsTransferHistory: Bool + var keepsFreezingHistory: Bool + var keepsMintingHistory: Bool + var keepsBurningHistory: Bool + var keepsDirectPricingHistory: Bool + var keepsDirectPurchaseHistory: Bool + + // Control rules + var conventionsChangeRules: ChangeControlRules? + var maxSupplyChangeRules: ChangeControlRules? + var manualMintingRules: ChangeControlRules? + var manualBurningRules: ChangeControlRules? + var freezeRules: ChangeControlRules? + var unfreezeRules: ChangeControlRules? + var destroyFrozenFundsRules: ChangeControlRules? + var emergencyActionRules: ChangeControlRules? + + // Distribution rules + var perpetualDistribution: TokenPerpetualDistribution? + var preProgrammedDistribution: TokenPreProgrammedDistribution? + var newTokensDestinationIdentity: Data? + var mintingAllowChoosingDestination: Bool + var distributionChangeRules: TokenDistributionChangeRules? + + // Marketplace rules + var tradeMode: TokenTradeMode + var tradeModeChangeRules: ChangeControlRules? + + // Main control group + var mainControlGroupPosition: Int? + var mainControlGroupCanBeModified: String? + + // Description + var tokenDescription: String? + + // Timestamps + var createdAt: Date + var lastUpdatedAt: Date + + // Relationships + var dataContract: PersistentDataContract? + + @Relationship(deleteRule: .cascade) + var balances: [PersistentTokenBalance]? + + @Relationship(deleteRule: .cascade) + var historyEvents: [PersistentTokenHistoryEvent]? + + init(contractId: Data, position: Int, name: String, baseSupply: String, decimals: Int = 8) { + // Create unique ID by combining contract ID and position + var idData = contractId + withUnsafeBytes(of: position.bigEndian) { bytes in + idData.append(contentsOf: bytes) + } + self.id = idData + + self.contractId = contractId + self.position = position + self.name = name + self.baseSupply = baseSupply + self.decimals = decimals + + // Default values + self.isPaused = false + self.allowTransferToFrozenBalance = true + self.keepsTransferHistory = true + self.keepsFreezingHistory = true + self.keepsMintingHistory = true + self.keepsBurningHistory = true + self.keepsDirectPricingHistory = true + self.keepsDirectPurchaseHistory = true + self.mintingAllowChoosingDestination = true + self.tradeMode = TokenTradeMode.notTradeable + + self.createdAt = Date() + self.lastUpdatedAt = Date() + } + } + + @Model + final class PersistentTokenBalance { + /// Index `networkRaw` for per-network balance scans. Token-balance + /// rows are aggregated per-identity per-token; UI surfaces always + /// scope to the active network. + #Index([\.networkRaw]) + + // MARK: - Core Properties + var tokenId: String + var identityId: Data + /// Schema-stable signed carrier for the protocol's unsigned balance. + /// SwiftData/SQLite keep the original `balance` Int64 column unchanged; + /// interpret its bits through `unsignedBalance` at every API boundary. + var balance: Int64 + var frozen: Bool + + // MARK: - Timestamps + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + // MARK: - Token Info (Cached) + var tokenName: String? + var tokenSymbol: String? + var tokenDecimals: Int32? + + // MARK: - Network + /// Stored as the `Network.rawValue` `UInt32` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + var networkRaw: UInt32 + + /// Type-safe accessor over `networkRaw`. Setter writes through. + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + // MARK: - Relationships + @Relationship(deleteRule: .nullify) var identity: PersistentIdentity? + @Relationship(inverse: \PersistentToken.balances) var token: PersistentToken? + + // MARK: - Initialization + init( + tokenId: String, + identityId: Data, + balance: Int64 = 0, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.tokenId = tokenId + self.identityId = identityId + self.balance = balance + self.frozen = frozen + self.tokenName = tokenName + self.tokenSymbol = tokenSymbol + self.tokenDecimals = tokenDecimals + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + self.networkRaw = network.rawValue + } + + /// Full-domain unsigned initializer. The distinct argument label preserves + /// the original public `balance: Int64` source API without making integer + /// literals ambiguous between signed and unsigned overloads. + public convenience init( + tokenId: String, + identityId: Data, + unsignedBalance: UInt64, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.init( + tokenId: tokenId, + identityId: identityId, + balance: Int64(bitPattern: unsignedBalance), + frozen: frozen, + tokenName: tokenName, + tokenSymbol: tokenSymbol, + tokenDecimals: tokenDecimals, + network: network + ) + } + + // MARK: - Computed Properties + /// Lossless full-domain view over the schema-stable signed carrier. + var unsignedBalance: UInt64 { + get { UInt64(bitPattern: balance) } + set { balance = Int64(bitPattern: newValue) } + } + + var formattedBalance: String { + let decimals: Int + if let tokenDecimals { + decimals = Int(tokenDecimals) + } else if let tokenDecimals = token?.decimals { + decimals = tokenDecimals + } else { + return "\(unsignedBalance)" + } + + guard decimals > 0 else { return String(unsignedBalance) } + + // Place the decimal point in the exact integer string. A Double + // conversion loses low digits well before UInt64.max. + let digits = String(unsignedBalance) + let scale = decimals + if digits.count <= scale { + return "0." + String(repeating: "0", count: scale - digits.count) + digits + } + let split = digits.index(digits.endIndex, offsetBy: -scale) + return String(digits[.. [String: Any]? { + guard let data = additionalDataJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + } + + @Model + final class PersistentTransaction { + /// Index on `firstSeen` so per-wallet queries — which fetch + /// `PersistentTxo` rows by `walletId` then sort their parent + /// transactions by `firstSeen` — get a sorted scan instead of + /// an in-memory O(N log N) pass. The unique `txid` index covers + /// point-lookups; this one covers the timeline. + #Index([\.firstSeen]) + + /// Transaction ID (32-byte hash, raw little-endian wire bytes — + /// the same orientation Rust hands us via the FFI `[u8; 32]`). + /// Stored as raw `Data` so the unique index covers 32 bytes + /// instead of a 64-char hex string, and the persistence + /// handler avoids a hex round-trip on every write. + @Attribute(.unique) var txid: Data + /// Raw transaction bytes (consensus-encoded — the same wire + /// format `dashcore::consensus::encode::serialize` produces and + /// `Transaction::consensus_decode` round-trips). The FFI write + /// path always populates this; the persister-fallback read path + /// (`PlatformWalletPersistence::get_core_tx_record`) hands it + /// back over FFI so Rust can decode a real `Transaction` + /// without a placeholder body. + var transactionData: Data + /// Context: 0=mempool, 1=instantSend, 2=inBlock, 3=inChainLockedBlock. + var context: UInt32 + /// Block height (0 for mempool). + var blockHeight: UInt32 + /// Block hash (nil for mempool). + var blockHash: Data? + /// Block timestamp. + var blockTimestamp: UInt32 + /// The transaction's index within its block (`block.vtx` order), + /// meaningful only when [`hasBlockPosition`]. Pure storage of the + /// Rust-stamped value (rust-dashcore#891): restored provider special + /// transactions hand it back so the masternode aggregation keeps + /// Core's same-block apply order across restarts. `false` on rows + /// persisted before the field existed and on unconfirmed contexts. + var blockPosition: UInt32 = 0 + var hasBlockPosition: Bool = false + /// Direction: 0=incoming, 1=outgoing, 2=internal, 3=coinJoin. + var direction: UInt32 + /// Transaction type name (Standard, CoinJoin, etc.). Sourced + /// from Rust's `Debug` repr of `TransactionType` for human + /// display only — DO NOT use this string as a discriminant; + /// match on [`transactionTypeKind`] instead. The string is + /// not a stable wire contract (a `#[derive(Debug)]` rename on + /// the Rust side would silently change it). + var transactionType: String + /// Typed discriminant of Rust's + /// `key_wallet::transaction_checking::transaction_router::TransactionType`, + /// kept in lockstep with [`TransactionTypeKind`]. Use this byte + /// (via [`typedKind`] / [`isAssetLock`] / [`isAssetUnlock`]) to + /// branch on transaction kind in UI code; the parallel + /// [`transactionType`] string is human-readable only and not + /// stable. + /// + /// Sentinel `0xFF` means "pre-feature row whose discriminant + /// hasn't been populated yet" — SPV's next upsert round + /// replaces it with the real discriminant on touch. Accessors + /// treat the sentinel as unknown (no branch fires). + var transactionTypeKind: UInt8 = 0xFF + /// Net amount in duffs (signed: positive=received, negative=sent). + var netAmount: Int64 + /// Fee in duffs (nil if unknown). + var fee: UInt64? + /// User-assigned label. + var label: String + /// Timestamp when first observed (Unix seconds). + var firstSeen: UInt64 + + // MARK: - Provider (masternode) special-transaction payload + + /// Fields lifted by the Rust FFI from a ProRegTx / ProUpServTx + /// DIP-3 payload (see `provider_payload_fields` in + /// `rs-platform-wallet-ffi`). All optional — populated only when + /// [`typedKind`] is `.providerRegistration` / `.providerUpdateService`. + /// The Swift side never decodes the payload; these are pure storage. + /// + /// Masternode service endpoint as `"ip:port"`. + var providerServiceAddress: String? = nil + /// ProUpServTx `proTxHash` (32 raw wire bytes) linking the update to + /// its registration. `nil` for ProRegTx (whose own txid is the + /// proTxHash). + var providerProTxHash: Data? = nil + /// ProRegTx collateral outpoint txid (32 raw wire bytes); pair with + /// [`providerCollateralVout`]. `nil` when not a ProRegTx. + var providerCollateralTxid: Data? = nil + var providerCollateralVout: UInt32 = 0 + /// ProRegTx owner / voting key hashes (hash160, 20 bytes each). + var providerOwnerKeyHash: Data? = nil + var providerVotingKeyHash: Data? = nil + + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Transaction outputs created by this transaction. + /// + /// Cascade-deletes the matching `PersistentTxo` rows when the + /// transaction is removed — outputs cannot meaningfully exist + /// without their containing transaction (the outpoint, script, + /// amount, and address are all derived from it). + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.transaction) + var outputs: [PersistentTxo] = [] + + /// Transaction outputs spent *by* this transaction. + /// + /// Inverse of `PersistentTxo.spendingTransaction`. Default + /// `.nullify` delete rule (do not pass `.cascade`!) — those TXOs + /// are owned by their *creating* transaction, not this one. + /// Cascading from the spending side would let a recent tx wipe + /// outputs of an older tx on delete: a data-loss bug. Removing + /// this transaction merely detaches the spend-link and the TXOs + /// flip back to "unspent" until something else claims them. + @Relationship(inverse: \PersistentTxo.spendingTransaction) + var inputs: [PersistentTxo] = [] + + /// Pending input outpoints — entries this transaction's input + /// list references but for which no `PersistentTxo` has been + /// upserted yet. Filled by `PlatformWalletPersistenceHandler. + /// upsertTransaction` via the FFI's `input_outpoints` slice; + /// each entry is consumed (deleted) by `upsertUtxo` when the + /// matching previous-output finally arrives. See + /// `PersistentPendingInput` for the full reconciliation flow. + /// Cascade-delete: removing the spending tx drops every pending + /// row that hasn't resolved yet. + @Relationship(deleteRule: .cascade, inverse: \PersistentPendingInput.spendingTransaction) + var pendingInputs: [PersistentPendingInput] = [] + + /// Every account whose changeset bucket carried this tx record. + /// + /// This is a **superset** of the TXO-derived membership: it + /// includes payload-only involvement (special-tx payloads whose + /// Provider Owner / Voting key addresses matched an account) where + /// no `PersistentTxo` exists in the account, so the TXO join can + /// never surface it. The persistence handler appends the matched + /// account here for every record it upserts, mirroring how + /// `WalletChangeSetFFI::from_changeset` buckets `cs.records` by + /// `record.account_type` on the Rust side. + /// + /// The TXO join (`outputs` / `inputs` → `PersistentTxo.account`) + /// remains the canonical path for **funds** — balances, spend + /// tracking, per-address history all flow through it. This join + /// exists only so payload-only involvement is representable at + /// all; treat it as "account participation," not "account owns + /// value in this tx." + /// + /// Inverse of `PersistentAccount.involvedTransactions`, declared + /// on this side only (SwiftData needs the `inverse:` on exactly + /// one end of a many-to-many pair). Default `.nullify` delete rule + /// on both sides — deleting an account merely detaches it from the + /// tx (and vice versa); neither end cascades, since the tx row is + /// shared across accounts / wallets and the account outlives any + /// single tx. + @Relationship(inverse: \PersistentAccount.involvedTransactions) + var involvedAccounts: [PersistentAccount] = [] + + init( + txid: Data, + transactionData: Data, + context: UInt32 = 0, + blockHeight: UInt32 = 0, + direction: UInt32 = 0, + transactionType: String = "Standard", + netAmount: Int64 = 0, + firstSeen: UInt64 = 0 + ) { + self.txid = txid + self.transactionData = transactionData + self.context = context + self.blockHeight = blockHeight + self.blockTimestamp = 0 + self.direction = direction + self.transactionType = transactionType + self.netAmount = netAmount + self.firstSeen = firstSeen + self.label = "" + self.createdAt = Date() + self.lastUpdated = Date() + } + + // MARK: - Display Helpers + + /// Hex-encoded txid for UI / log sites. The on-disk row stores + /// the raw 32 bytes in wire/internal order (matches what + /// `dashcore::Txid::as_ref()` hands the FFI). The canonical + /// Bitcoin/Dash display convention is the *reverse* of those + /// bytes (the `Txid: Display` impl in dashcore-rust does the + /// same flip), so block-explorer hex matches what users see + /// here. Storage stays unflipped — predicate fetches compare + /// wire-order `Data` directly without re-encoding. + var txidHex: String { + txid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var contextName: String { + switch context { + case 0: return "Mempool" + case 1: return "InstantSend" + case 2: return "In Block" + case 3: return "Chain Locked" + default: return "Unknown" + } + } + + var directionName: String { + switch direction { + case 0: return "Incoming" + case 1: return "Outgoing" + case 2: return "Internal" + case 3: return "CoinJoin" + default: return "Unknown" + } + } + + /// Typed view onto [`transactionTypeKind`]. `nil` only for the + /// `0xFF` sentinel (pre-feature row not yet re-persisted by SPV) + /// or for a future Rust-side variant addition Swift hasn't + /// learned about yet — both treated as "unknown" by the + /// `isAssetLock` / `isAssetUnlock` accessors so an unexpected + /// byte never silently fires the wrong branch. + var typedKind: TransactionTypeKind? { + TransactionTypeKind(rawValue: transactionTypeKind) + } + + /// `true` when this transaction is a Dash Platform asset-lock + /// funding tx — a Layer-1 burn that mints Layer-2 credits. The + /// wallet's `direction` classifier reports `Internal` because the + /// credit output is derived from this wallet's identity-funding + /// account, but the *intent* is conversion to L2 credits, not + /// "transaction to myself." + var isAssetLock: Bool { + typedKind == .assetLock + } + + /// Companion to [`isAssetLock`] — withdrawal back to L1. + var isAssetUnlock: Bool { + typedKind == .assetUnlock + } + + /// `true` for a masternode provider-registration (ProRegTx). + var isProviderRegistration: Bool { + typedKind == .providerRegistration + } + + /// `true` for a masternode provider-update-service (ProUpServTx). + var isProviderUpdateService: Bool { + typedKind == .providerUpdateService + } + + /// ProUpServTx proTxHash in block-explorer (reversed) hex, or `nil`. + /// Matches [`txidHex`]'s display-order convention. + var providerProTxHashHex: String? { + providerProTxHash.map { $0.reversed().map { String(format: "%02x", $0) }.joined() } + } + + /// ProRegTx collateral outpoint as `"txidHex:vout"` in display order, + /// or `nil` when there's no collateral field. + var providerCollateralDisplay: String? { + guard let txid = providerCollateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(providerCollateralVout)" + } + + /// ProRegTx owner key hash (hash160) in hex — key hashes are shown + /// in their natural forward byte order, unlike txids. + var providerOwnerKeyHashHex: String? { + providerOwnerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + /// ProRegTx voting key hash (hash160) in forward-order hex. + var providerVotingKeyHashHex: String? { + providerVotingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + /// `true` for masternode provider special transactions (ProRegTx + /// and the three ProUp*Tx kinds). Like asset locks, these get + /// classified `Internal` by the wallet's direction logic (the + /// wallet only sees its own owner/voting/payout keys referenced + /// in the payload), so direction-derived labels like + /// "Self-Transfer" are misleading for them. + var isProviderSpecial: Bool { + providerSpecialName != nil + } + + /// Human-readable name for provider special transactions, `nil` + /// for every other kind. + var providerSpecialName: String? { + switch typedKind { + case .providerRegistration: return "Provider Registration" + case .providerUpdateRegistrar: return "Provider Update Registrar" + case .providerUpdateService: return "Provider Update Service" + case .providerUpdateRevocation: return "Provider Update Revocation" + default: return nil + } + } + + /// Direction text for UI surfaces, overridden for asset-lock / + /// asset-unlock txs (the L1 DASH isn't going "to myself" — it's + /// being converted to / from L2 platform credits) and for + /// provider special txs (the payload references our keys but no + /// value moves "to myself"). + /// + /// Use this anywhere a human-readable "what happened" label is + /// needed; fall back to [`directionName`] only when the consumer + /// genuinely needs the raw direction (e.g. the filter dropdown). + var displayDirection: String { + if isAssetLock { return "Asset Lock" } + if isAssetUnlock { return "Asset Unlock" } + if let name = providerSpecialName { return name } + return directionName + } + + var formattedAmount: String { + let dash = Double(abs(netAmount)) / 100_000_000.0 + let sign = netAmount >= 0 ? "+" : "-" + return String(format: "%@%.8f DASH", sign, dash) + } + } + + @Model + final class PersistentTxo { + /// Index `walletId` so per-wallet TXO scans — the canonical + /// "show every TXO (and, by union of `transaction` + + /// `spendingTransaction`, every transaction) that touches wallet + /// W" path — hit an index instead of scanning the entire TXO + /// table. The denorm is what makes the predicate translatable + /// to SQL in the first place; this just makes the resulting + /// query fast at scale. + #Index([\.walletId]) + + /// Outpoint: 36 raw bytes (32-byte txid in wire orientation + + /// 4-byte vout little-endian) — the standard Bitcoin outpoint + /// serialization. Unique identifier stored explicitly so + /// SwiftData predicate fetches can hit a single column without + /// traversing the `transaction` relationship. Always equals + /// `PersistentTxo.makeOutpoint(txid: transaction.txid, vout: vout)`. + @Attribute(.unique) var outpoint: Data + /// Output index within the transaction. + var vout: UInt32 + /// Value in duffs. + var amount: UInt64 + /// Owning address (Base58Check). + var address: String + /// Script pubkey bytes. + var scriptPubKey: Data + /// Block height where created. + var height: UInt32 + /// Whether this is a coinbase output. + var isCoinbase: Bool + /// Whether confirmed in a block. + var isConfirmed: Bool + /// Whether locked by InstantSend. + var isInstantLocked: Bool + /// Whether reserved/locked for a specific purpose. + var isLocked: Bool + /// Whether this TXO has been spent. + /// + /// Denormalized: should track `spendingTransaction != nil`. Kept + /// as an explicit column because per-row spent/unspent filters + /// are a hot query path, and chasing the optional relationship + /// in a predicate drops SwiftData onto the same nested-optional + /// codepath that crashes elsewhere. The persistence handler is + /// responsible for keeping the two in sync; do not enforce + /// invariants here. + var isSpent: Bool + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// 32-byte wallet ID this TXO belongs to. Denormalized from + /// `account?.wallet.walletId` so per-wallet `@Query` predicates + /// can filter with a single equality check instead of chaining + /// through the optional `account` relationship — SwiftData's + /// predicate compiler can't translate that chain into SQLite and + /// crashes with `Unsupported function expression TERNARY(...).walletId`. + /// This is the single column callers filter on for "show every + /// TXO (and, by union of `transaction` + `spendingTransaction`, + /// every transaction) that touches wallet W". Empty `Data()` for + /// rows migrated from older schema; the next sync pass will + /// populate it. + var walletId: Data = Data() + + /// Containing transaction (the one that *created* this output). + /// Cascade-deleted from the parent side (see + /// `PersistentTransaction.outputs`). Optional only because the + /// underlying SwiftData inverse must allow nil during the brief + /// window between row insert and relationship attachment; in + /// steady state every TXO has a non-nil `transaction`. + var transaction: PersistentTransaction? + + /// The transaction that *spent* this output, or nil if the TXO + /// is unspent. Inverse of `PersistentTransaction.inputs`. Uses + /// the default `.nullify` delete rule from that side — deleting + /// the spending tx must not cascade-delete this row. + var spendingTransaction: PersistentTransaction? + + /// Position of this output within `spendingTransaction.input` + /// (i.e. the canonical "vin index"). Captured at the moment the + /// spend is reconciled — sourced from + /// `TransactionRecordFFI.input_outpoints` index, which itself + /// comes from `tx.input.iter()` on the Rust side, so the value + /// matches the serialized transaction's input ordering exactly. + /// `nil` when the TXO is unspent (no spending tx, no vin index) + /// or when migrated from an older row that predates the column. + /// Surfaced by `TransactionStorageDetailView` so input rows + /// render in serialized vin order with their real positions + /// rather than being re-sorted by outpoint hex (which loses + /// the relationship between row and serialized index). + var spendingInputIndex: UInt32? = nil + + /// Parent account. No longer paired with an inverse on the + /// account side — the canonical account path is + /// `coreAddress?.account`. This field is the fallback when the + /// address row isn't yet linked (out-of-order flush, address + /// pool rebuild, etc.). + var account: PersistentAccount? + + /// Owning `PersistentCoreAddress` row, if it exists in the + /// account's address pool. Linked alongside `address` (the + /// Base58Check string) — the string is the authoritative + /// identifier and survives even when the address pool is rebuilt + /// or the TXO was paid to an address never in our pool (e.g. an + /// outgoing recipient). The relationship is the convenient + /// pointer for navigating to derivation metadata, balance, and + /// pool tag without a separate fetch. Inverse of + /// `PersistentCoreAddress.txos`; `.cascade` on that side so + /// account / wallet teardown drops TXOs cleanly. + var coreAddress: PersistentCoreAddress? + + init( + transaction: PersistentTransaction, + vout: UInt32, + amount: UInt64, + address: String, + scriptPubKey: Data = Data(), + height: UInt32 = 0 + ) { + self.outpoint = Self.makeOutpoint(txid: transaction.txid, vout: vout) + self.vout = vout + self.amount = amount + self.address = address + self.scriptPubKey = scriptPubKey + self.height = height + self.isCoinbase = false + self.isConfirmed = false + self.isInstantLocked = false + self.isLocked = false + self.isSpent = false + self.createdAt = Date() + self.lastUpdated = Date() + self.transaction = transaction + } + + /// Build the 36-byte outpoint key (32-byte txid raw bytes + + /// 4-byte vout little-endian). Exposed so the persistence + /// handler can compose predicates / lookups directly from the + /// FFI's `[u8; 32]` + `u32` without going through string + /// formatting. + static func makeOutpoint(txid: Data, vout: UInt32) -> Data { + var data = Data(capacity: 36) + data.append(txid) + var v = vout.littleEndian + withUnsafeBytes(of: &v) { data.append(contentsOf: $0) } + return data + } + + /// Convenience accessor for the containing transaction's txid + /// as raw 32-byte `Data`. Prefers the `transaction` relationship; + /// falls back to the first 32 bytes of `outpoint` when the + /// inverse is briefly nil during insert (so storage-explorer + /// rows still render a stable identifier rather than collapsing + /// to empty). + var txid: Data { + if let transaction { + return transaction.txid + } + return outpoint.count >= 32 ? Data(outpoint.prefix(32)) : Data() + } + + /// Hex-encoded txid for UI / log sites. Reverses bytes to match + /// the canonical block-explorer display (same flip as + /// `dashcore::Txid: Display`). Mirrors + /// `PersistentTransaction.txidHex` directly so the two stay in + /// sync; can't simply forward to it because we want the same + /// hex even when `transaction` is briefly unattached. + var txidHex: String { + let rawTxid = txid + guard rawTxid.count == 32 else { return "" } + return rawTxid.reversed().map { String(format: "%02x", $0) }.joined() + } + + /// Human-readable outpoint (`:`) for UI / log + /// sites. Reconstructs from `txidHex` so the byte-flip stays + /// consistent across all display surfaces. + var outpointHex: String { + let hex = txidHex + return hex.isEmpty ? "" : "\(hex):\(vout)" + } + + var formattedAmount: String { + let dash = Double(amount) / 100_000_000.0 + return String(format: "%.8f DASH", dash) + } + } + + @Model + final class PersistentWallet { + /// Index `networkRaw` so per-network wallet scans (used everywhere + /// from the network-scoped storage explorer to the per-network + /// "is there a wallet on this chain yet" lookups) don't degrade + /// to a table scan. Also index `walletGroupId` so the Wallet Info + /// "Networks" lookup — which fetches every sibling-network row for + /// a seed by its group id — stays a keyed scan. + #Index([\.networkRaw], [\.walletGroupId]) + #Unique([\.walletId]) + + /// 32-byte NETWORK-SCOPED wallet ID, and the row's primary + /// uniqueness key. Since the network-scoping change the same seed + /// yields a DISTINCT `walletId` per network (a domain-tagged network + /// byte is folded into the digest), so a wallet that exists on + /// multiple chains has one row per network, each with its own id — + /// the network is already baked into the id, so `walletId` alone is + /// globally unique (an earlier `(walletId, networkRaw)` composite + /// was a leftover from the pre-scoping model, where one seed shared + /// a single id across networks and `networkRaw` was the only + /// distinguishing column). To gather a seed's sibling-network rows, + /// group by `walletGroupId` (which is the same across networks), + /// not by this id. + var walletId: Data + /// 32-byte NETWORK-INDEPENDENT group id shared by every network's + /// wallet derived from the same seed (Rust computes it as the + /// no-network digest of the root key). Distinct from `walletId`, + /// which is network-scoped. Used to group a seed's sibling-network + /// rows in the Wallet Info "Networks" section. Defaults to empty + /// for rows written before this column existed (pre-release, no + /// migration); consumers treat empty as "legacy — this single row + /// only". + var walletGroupId: Data = Data() + /// Network this wallet belongs to. `nil` means "not yet known" — + /// the row was created by a changeset before `persistWalletMetadata` + /// filled the network in. Views treat `nil` as unknown. + /// + /// Stored as the `Network.rawValue` `UInt32?` so SwiftData + /// `#Predicate` expressions can evaluate it directly. See + /// `PersistentIdentity.networkRaw` for the full rationale. + var networkRaw: UInt32? + + /// Type-safe accessor over `networkRaw`. `nil` round-trips as + /// `nil`; non-nil reads fall back to `.testnet` if the stored + /// raw value ever drifts out of the `Network` range. + var network: Network? { + get { + guard let raw = networkRaw else { return nil } + return Network(rawValue: raw) ?? .testnet + } + set { networkRaw = newValue?.rawValue } + } + /// Optional wallet name. + var name: String? + /// Optional free-form user-supplied description. Mirrored into + /// the keychain metadata blob (see `WalletKeychainMetadata`) so + /// it survives a SwiftData wipe / reinstall via the + /// orphan-mnemonic recovery flow. No UI surfaces this yet, but + /// the column is wired so existing rows roll forward without a + /// schema migration when it lands. + var walletDescription: String? + /// Birth height — block height when the wallet was created. + var birthHeight: UInt32 + /// Last synced core block height. + var syncedHeight: UInt32 + /// Timestamp of last sync (Unix seconds). + var lastSynced: UInt64 + /// Bincode-serialised + /// `dashcore::ephemerealdata::chain_lock::ChainLock` carrying the + /// wallet's `WalletMetadata::last_applied_chain_lock` from the + /// previous session. Roundtripped across app launches so the + /// asset-lock-resume CL-from-metadata fallback in Rust's + /// `proof.rs` can fire on catch-up at launch without waiting + /// for SPV to re-apply a fresh ChainLock. `nil` when no + /// ChainLock has ever been observed for this wallet (fresh + /// wallet, or pre-feature row). + var lastAppliedChainLockBytes: Data? + /// User imported this wallet from an existing mnemonic (as + /// opposed to generating a fresh one). Cosmetic flag that + /// drives the "📥 Imported" badge; defaulted to `false` for + /// rows that predate the column. + var isImported: Bool = false + /// Verified seed-binding marker: the BIP44 account-0 xpub that the + /// Keychain-resolved seed was proven to derive, bound to the mnemonic + /// Keychain item's identity stamp, written after one successful + /// `platform_wallet_verify_seed_binds_to_wallet_cached` run. On later + /// launches the unlock path hands this back to Rust (with the item's + /// current stamp), which skips the mnemonic-resolving derivation when + /// it still matches — and re-verifies when the xpub OR the Keychain + /// item changed. Opaque to Swift — Rust decides match-vs-verify; this + /// column only stores and returns it. `nil` (rows predating the + /// column, or never verified) means the full check runs at the next + /// unlock. + var seedBindingVerifiedMarker: String? + /// Record timestamps. + var createdAt: Date + var lastUpdated: Date + + /// Accounts belonging to this wallet. + @Relationship(deleteRule: .cascade, inverse: \PersistentAccount.wallet) + var accounts: [PersistentAccount] + + /// Identities registered against this wallet. Cardinality is + /// 0..N — a wallet may have zero identities (freshly created) + /// or many. Deletion semantics: `.nullify` so an identity + /// survives a wallet delete as an orphaned row (useful for + /// post-mortem inspection and possible re-association if the + /// wallet is re-imported from the same seed). + /// + /// Paired with `PersistentIdentity.wallet` (plain stored + /// property; the inverse key lives on this side). + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.wallet) + var identities: [PersistentIdentity] + + init( + walletId: Data, + walletGroupId: Data = Data(), + network: Network? = nil, + name: String? = nil, + walletDescription: String? = nil, + birthHeight: UInt32 = 0, + syncedHeight: UInt32 = 0, + isImported: Bool = false + ) { + self.walletId = walletId + self.walletGroupId = walletGroupId + self.networkRaw = network?.rawValue + self.name = name + self.walletDescription = walletDescription + self.birthHeight = birthHeight + self.syncedHeight = syncedHeight + self.lastSynced = 0 + self.isImported = isImported + self.createdAt = Date() + self.lastUpdated = Date() + self.accounts = [] + self.identities = [] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift index a3e5f5626de..ff4261be818 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift @@ -39,17 +39,23 @@ import SwiftData /// upsert outright. @Model public final class PersistentPendingInput { - /// Two single-column indexes: + /// Three indexes: /// * `outpoint` — the per-outpoint reconciliation lookup that /// runs on every `upsertUtxo`. /// * `walletId` — per-wallet pending-input scans (cleanup when /// a wallet is removed, the storage explorer's network /// scope, "long-lived non-zero pending count" diagnostics). + /// * `(walletId, isSweptTombstone)` — the tombstone collector's + /// once-per-round scan (`collectFinalizedSweptTombstones`), + /// which must select tombstones only: the ordinary rows on this + /// table — one per foreign input of every incoming payment — + /// are never pruned, so a scan that materialised them would grow + /// with the wallet's history. /// /// SwiftData allows only a single `#Index` macro per model; /// passing multiple key-path arrays declares multiple separate /// indexes from one macro call. - #Index([\.outpoint], [\.walletId]) + #Index([\.outpoint], [\.walletId], [\.walletId, \.isSweptTombstone]) public var outpoint: Data /// Position of this input in the spending transaction's input @@ -78,6 +84,55 @@ public final class PersistentPendingInput { /// never resolved (orphans whose previous output isn't ours). public var createdAt: Date + /// Set when `applySweptTransaction` repurposes this row as a durable + /// claim rather than an ordinary in-flight spend — or writes it fresh + /// for a held input that had no row: the original spending transaction + /// turned out to be a loser, this input wasn't released, and the + /// funding `PersistentTxo` still hasn't arrived to hold the claim + /// itself. `spendingTxid` is overwritten to the winner + /// (`superseded_by`) and `spendingTransaction` is detached so the row + /// survives the loser's deletion. `upsertUtxo` checks this flag on + /// resolve: a tombstone forces `PersistentTxo.isSpent = true` + /// unconditionally (a sweep's winner is already final, unlike an + /// ordinary pending spend whose confirmation is still pending) and + /// stamps `PersistentTxo.supersededByTxid` so the mark survives even + /// when the winner's own row never materializes — and nothing else: + /// the spender link and the vin index come only from an ordinary row, + /// never from a tombstone (its `inputIndex` is the loser's). Rows are + /// per (outpoint, spending txid, wallet); the drain prefers the + /// tombstone tagged with the wallet delivering the coin. Defaulted + /// `false` so existing rows migrate as ordinary pending entries. + public var isSweptTombstone: Bool = false + + /// The WINNER'S own mined block height, stamped when a block-context + /// sweep (`SweepBatchFFI.has_winner_mined_height`) repurposes this row + /// into a tombstone — the projection of upstream key-wallet's + /// `observed_spent_outpoints`, which maps each outpoint observed spent + /// in a block to the height of the block that spent it and deliberately + /// records nothing for a mempool/IS-lock spend ("an unconfirmed spend + /// must not invalidate a coin"). Not an observation watermark: the + /// height rides the sweep event itself, so nothing here guesses when + /// the winner mined. It is the row's whole lifetime rule — + /// `collectFinalizedSweptTombstones` deletes the tombstone exactly when + /// the finality boundary `min(chainlockHeight, syncedHeight)` reaches + /// this stamp (upstream's `prune_finalized_observed_spends` condition + /// verbatim, no margin): every BIP158 filter at or below the boundary + /// has been matched with no false negatives, so the funding transaction + /// of the guarded outpoint — necessarily mined at or below the spend's + /// own height — has either been delivered (draining the row) or + /// provably never will be. A mempool-context sweep (IS-locked winner, + /// unmined) writes its tombstone with this NIL on purpose: under + /// DIP-10 the 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 collector never touches an unstamped row, + /// and the hold lasts until the funding TXO drains it, a later + /// block-context sweep stamps it, or a release deletes it. + /// Re-pointing an existing tombstone on a mempool-context sweep keeps + /// the earlier block-context stamp untouched (upstream never retracts + /// an observed-spend entry for an unconfirmed conflict). + /// Optional, so existing stores lightweight-migrate. + public var winnerMinedHeight: UInt32? + public init( outpoint: Data, inputIndex: UInt32, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift index 1775eda311e..f459cc30d09 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift @@ -86,6 +86,36 @@ public final class PersistentTxo { /// the spending tx must not cascade-delete this row. public var spendingTransaction: PersistentTransaction? + /// 32-byte txid of the transaction a sweep's winner is known to have + /// beaten this coin to — the durable carrier of a sweep hold, + /// mirroring the SQLite store's `spent_in_txid`. Two writers set it: + /// `applySweptTransaction` holding an already-materialized input, and + /// `upsertUtxo` resolving a `PersistentPendingInput` tombstone + /// (`isSweptTombstone`) — the funding output arrived only after its + /// loser was already swept and deleted. The winner named here need not + /// have a row of its own (it can pay only outside addresses), which is + /// why the stamp is a bare txid rather than a relationship. + /// + /// What the stamp does on a materialised row: it keeps `isSpent` up on + /// the record and spend-emit channels (`reconcileSpendObservation` + /// treats a stamped row as spent whatever the arriving spender's + /// context — the sharp case is the winner's own record arriving + /// IS-locked, below in-block), and it names a claimant the sweep + /// release veto (`releaseIsVetoed`) checks against the named + /// transaction's stored bytes. It does NOT refuse a re-delivery: a + /// coin the wallet hands back as unspent through `utxos_added` follows + /// the wallet — `isSpent` and this stamp clear together (see + /// `upsertUtxo`), because the wallet knows this coin, so any + /// network-final spender of it is wallet-relevant by BIP158 prevout + /// matching and the wallet's own scan re-discovers the spend; refusing + /// would lock a real coin out forever after a reorg of the winner. The + /// hold that must survive a funding delivery is the never-materialised + /// one, carried by a `PersistentPendingInput` tombstone until the + /// delivery drains it into this stamp. Also cleared by the sweep + /// release pass, when a later sweep proves the coin came free after + /// all. + public var supersededByTxid: Data? + /// Position of this output within `spendingTransaction.input` /// (i.e. the canonical "vin index"). Captured at the moment the /// spend is reconciled — sourced from diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 6d6e80644a4..52365db3006 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -88,6 +88,19 @@ public final class PersistentWallet { /// ChainLock has ever been observed for this wallet (fresh /// wallet, or pre-feature row). public var lastAppliedChainLockBytes: Data? + /// NUMERIC block height of the wallet's last applied ChainLock — + /// the same watermark whose bincode blob sits in + /// `lastAppliedChainLockBytes`, which is opaque on this side of the + /// FFI. Delivered separately through the persistence extension's + /// `on_persist_wallet_changeset_chain_lock_height_fn` and stored + /// with monotonic-max semantics (chain locks only move forward). + /// This is one half of the swept-tombstone collection boundary + /// `min(chainlockHeight, syncedHeight)` — see + /// `PersistentPendingInput.winnerMinedHeight`. `nil` (fresh wallet, + /// pre-feature row, or a native library too old to fill the slot) + /// means no finality boundary is known and no tombstone may be + /// collected. Optional, so existing stores lightweight-migrate. + public var lastAppliedChainLockHeight: UInt32? /// User imported this wallet from an existing mnemonic (as /// opposed to generating a fresh one). Cosmetic flag that /// drives the "📥 Imported" badge; defaulted to `false` for diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 8093bab0c03..8d9c85d9194 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -69,6 +69,26 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// across restarts. Mirrors /// `PersistenceCapabilities::TRACKED_MASTERNODES`. public static let trackedMasternodes: UInt64 = 1 << 10 + /// A round's sweep batches — delivered through the persistence + /// extension's size-negotiated sweep callback — are durably applied + /// batch by batch and in order: swept transactions and their outputs + /// are excluded from every restore and enumeration path (physical + /// deletion or a durable marker alike), released outpoints are freed + /// unless a surviving claim supersedes, and non-released spend claims + /// are retained durably. Mirrors + /// `PersistenceCapabilities::CORE_SWEEP_REMOVAL`; Rust only honours + /// the declaration when the extension actually carries the callback. + public static let coreSweepRemoval: UInt64 = 1 << 11 + /// DashPay payment rows delivered on a store round + /// (`dashpay_payments_overlay`) are durably applied. This is what the + /// wallet-event adapter keys on before coupling a sweep's + /// `Pending → Failed` payment flip to the sweep's own atomic round — + /// a non-attesting host (Android keeps payment recording + /// in-memory-only) gets the in-memory flip with nothing + /// round-coupled. Mirrors `PersistenceCapabilities::DASHPAY_PAYMENTS`; + /// Rust only honours the declaration when the payments callback is + /// actually wired. + public static let dashpayPayments: UInt64 = 1 << 12 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 4c0baa95899..49e97964602 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -146,6 +146,86 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// atomically. private var inChangeset = false + /// In-memory index over the rows the open changeset round has + /// inserted into `backgroundContext` but not yet saved, keyed by the + /// same columns the hot-path fetches filter on. + /// + /// Why it exists: a `FetchDescriptor` with the default + /// `includePendingChanges == true` evaluates its predicate IN MEMORY + /// against every unsaved insert of the target entity — + /// `Predicate.evaluate` walks the key path per row, with a dynamic + /// cast per step. The `#Index`/`.unique` declarations on the models + /// only accelerate the SQL half of the fetch; the pending-changes + /// half is always a linear scan. Because the whole round defers its + /// `save()` to `endChangeset` (the `inChangeset` contract above), a + /// large wallet's initial scan accumulates thousands of unsaved + /// inserts in one round, and every subsequent fetch paid O(inserts + /// so far) — quadratic over the round, and measured as ~99% of CPU + /// on `serialQueue` minutes after the SPV scan itself finished. + /// + /// How it is used: while the index is non-nil, the lookup helpers + /// (`fetchTransactionRow`, `fetchTxoRow`, `pendingInputRows`, + /// `coreAddressRow`) consult it first and run their store fetch with + /// `includePendingChanges = false`, so SQLite answers from its + /// indexes and never triggers the in-memory scan. The single-object + /// maps are READ-THROUGH: they hold both this round's unsaved + /// inserts (registered at the insert site) and every row a store + /// fetch has already resolved this round (registered by the helper). + /// Caching store hits is not an optimization — it is load-bearing + /// for correctness: a store-only fetch that matches an + /// already-registered object REFRESHES that object to its store + /// values, silently discarding the round's unsaved attribute + /// mutations (unlike the default pending-changes fetch, which + /// returns the object with its in-memory state; staged deletions do + /// survive the refresh). Registering every resolution means each + /// key touches the store at most once per round — at first touch, + /// before the round can have mutated the object — so the refresh + /// never has anything to discard. Both sources stay disjoint + /// because `beginChangeset` builds the index only over a clean + /// context. Rows deleted mid-round are filtered by `isDeleted` on + /// both sources (index entries are deliberately never + /// unregistered — `isDeleted` already answers the question, and it + /// also covers deletes on paths that don't know about the index, + /// e.g. wallet removal). + /// + /// Lifecycle: built by `beginChangeset`, discarded in + /// `endChangeset`'s `defer` on both the commit and rollback paths — + /// after a commit the cached rows are ordinary saved rows the store + /// fetch finds on its own, and on rollback the context un-inserts / + /// reverts every one of them, so the index dies with the round + /// either way and never leaks state across rounds. `nil` outside a + /// round (and inside a round that began on a dirty context — see + /// `beginChangeset`), in which case the lookup helpers run the + /// exact pre-index fetch, pending changes included. + private struct ChangesetRoundIndex { + var transactionsByTxid: [Data: PersistentTransaction] = [:] + var txosByOutpoint: [Data: PersistentTxo] = [:] + /// `PersistentPendingInput.outpoint` is deliberately not unique + /// (re-org / double-spend can stack rows on one outpoint — see + /// the model), so a key maps to the full set: this round's + /// staged inserts, plus — once the key is in + /// `pendingInputsFetched` — the saved rows `pendingInputRows` + /// resolved on first touch. Read-through for the same reason as + /// the single-object maps: the sweep phase re-points these rows + /// by scalar, and a later store-only fetch of the key would + /// refresh that away. + var pendingInputsByOutpoint: [Data: [PersistentPendingInput]] = [:] + /// Keys whose saved rows have been fetched this round — a key + /// present here answers from `pendingInputsByOutpoint` alone. + var pendingInputsFetched: Set = [] + var coreAddressesByAddress: [String: PersistentCoreAddress] = [:] + } + private var roundIndex: ChangesetRoundIndex? + + /// Set when the open round advanced either half of the tombstone + /// finality boundary — `syncedHeight` through the changeset callback or + /// the numeric chainlock height through its extension slot — and read + /// by `endChangeset`, which then runs `collectFinalizedSweptTombstones` + /// once, after every slice and every sweep of the round, before the + /// round's single save. Cleared by `beginChangeset` and `endChangeset`. + /// Confined to `serialQueue` like all other mutable handler state. + private var roundAdvancedFinalityBoundary = false + /// Breadcrumb backfills that arrived on the serial queue while a /// changeset round was open. The backfill both mutates /// `backgroundContext` and saves it, so running it mid-round would @@ -192,7 +272,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.network = network self.modelFetcher = modelFetcher self.backgroundContext = ModelContext(modelContainer) - self.backgroundContext.autosaveEnabled = true + // Autosave off: this context is the transaction buffer for the + // begin → changeset → sweeps → end sequence, and autosave can commit + // its pending mutations between those callbacks. Since sweeps moved + // to their own callback the round spans two calls, so an autosave + // landing in between would make the watermark and the additive rows + // durable while the removal is still unstaged — and `rollback()` + // cannot take back a save that already happened. The handler + // attests `ATOMIC_CHANGESETS`, which is what Rust now relies on to + // trust the split transport, so that guarantee has to be real. + // + // Nothing depends on the implicit commits: every path either runs + // inside a round, which `endChangeset` commits with its single + // `save()`, or saves itself when `inChangeset` is clear. + self.backgroundContext.autosaveEnabled = false self.trackedMasternodeContext = ModelContext(modelContainer) self.trackedMasternodeContext.autosaveEnabled = false } @@ -235,7 +328,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Best-effort save used by callback helpers that may also be invoked /// outside a Rust changeset. The legacy behavior remains non-throwing, - /// but failures are no longer invisible in exported diagnostics. + /// but failures are no longer invisible in exported diagnostics, and a + /// failed save rolls the context back: with autosave off, staged rows a + /// failed save left behind would otherwise ride the next round's single + /// `save()` — failing that round for a reason unrelated to its content + /// — and make `beginChangeset` run the round unindexed. private func saveBackgroundContextIfNeeded( operation: String, walletId: Data? = nil @@ -257,6 +354,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { fields: fields, error: error ) + backgroundContext.rollback() } } @@ -458,10 +556,17 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if let existing = try? backgroundContext.fetch(descriptor).first { // 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. + // historical lookup, and neither removal producer can + // legitimately name one — a Built row rejected at + // broadcast (`untrack_asset_lock`) never got that far, + // and a sweep of the funding transaction only + // tombstones entries still tracked, which a consumed + // lock no longer is. A removal reaching a consumed row + // is by construction a stale write. + // `AssetLockChangeSet::merge` guarantees one call never + // carries an upsert and a removal for the same + // outpoint, so the upserts-then-removals order above is + // layout, not load-bearing sequencing. if existing.statusRaw == 4 { continue } @@ -987,20 +1092,75 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // MARK: - Wallet Changeset (transactions, utxos, accounts, balance, chain) + /// Outcome of the wallet gate every round callback carrying a watermark + /// or subtractive write starts with (`persistWalletChangeset`, + /// `persistWalletChangesetChainLockHeight`, `persistWalletChangesetSweeps`). + private enum RoundWalletLookup { + case found(PersistentWallet) + /// A stale post-deletion callback: there is nothing left to write + /// to, and that is not a failure. + case absent + /// The fetch threw. Reporting success would let Rust discard a sweep + /// — or advance a watermark past one — that never landed, so the + /// caller fails the round. + case failed + } + + /// The shared wallet-lookup preamble: one place decides how a thrown + /// fetch and a missing row differ, and logs the former with the + /// callback that hit it. + private func roundWalletLookup(walletId: Data, callback: String) -> RoundWalletLookup { + do { + guard let wallet = try fetchWalletRecord(walletId: walletId) else { return .absent } + return .found(wallet) + } catch { + SDKLogger.event( + "persistence_round_wallet_lookup_failed", + category: .persistence, + severity: .error, + fields: [ + "callback": .publicText(callback), + "wallet_reference": .reference(walletId), + ], + error: error + ) + return .failed + } + } + /// Apply a full `WalletChangeSetFFI` to SwiftData. /// /// Called from the Rust persister when an SPV round produces core- /// wallet state changes. Upserts PersistentAccount / Transaction / /// Utxo records so views observing via `@Query` update automatically. - func persistWalletChangeset(walletId: Data, changeset: UnsafePointer) { + /// + /// Returns `false` when the round could not be applied, which the C shim + /// forwards to Rust so `store()` rolls the round back instead of treating + /// it as durable. Everything this method itself applies is additive, so + /// only a failed wallet lookup reports it here; the round's subtractive + /// part arrives through `persistWalletChangesetSweeps` below, with its + /// own failure path. + @discardableResult + func persistWalletChangeset( + walletId: Data, + changeset: UnsafePointer + ) -> Bool { onQueue { - guard let wallet = findWalletRecord(walletId: walletId) else { return } + let wallet: PersistentWallet + switch roundWalletLookup(walletId: walletId, callback: "wallet_changeset") { + case .failed: return false + case .absent: return true + case .found(let row): wallet = row + } let cs = changeset.pointee - // Chain update. + // Chain update. A synced-height write advances one half of the + // tombstone finality boundary, so the round's collector (see + // `endChangeset`) is armed. if cs.has_chain { if cs.chain.has_synced_height { wallet.syncedHeight = cs.chain.synced_height + roundAdvancedFinalityBoundary = true } wallet.lastUpdated = Date() } @@ -1014,6 +1174,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // wallet-side mirror, not a duplicate of SPV state. // Pre-feature rows / wallets that have never observed a // ChainLock carry `null` from Rust and stay `nil` here. + // The NUMERIC height arrives separately, through + // `persistWalletChangesetChainLockHeight` — these bytes are + // opaque here and prove nothing about how far finality + // reaches. if cs.last_applied_chain_lock_bytes_len > 0, let clPtr = cs.last_applied_chain_lock_bytes { let bytes = Data( @@ -1042,10 +1206,825 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // Swept transactions no longer ride this struct: they arrive + // through `persistWalletChangesetSweeps(walletId:sweeps:count:)` + // below, fired by Rust after this callback (and after the + // chainlock-height slot) in the same round. The struct crosses + // the C ABI by bare pointer, so a field appended to it cannot be + // proven present to a consumer built after a producer — the + // extension callback's negotiated `struct_size` is what carries + // that proof instead. + // No save() — bracketed by changesetBegin/End. + return true + } + } + + /// Delete this wallet's swept tombstones whose winner-height stamp the + /// finality boundary has reached: `winnerMinedHeight <= boundary`, with + /// `boundary = min(chainlockHeight, syncedHeight)` read off the STORED + /// wallet row — upstream key-wallet's `prune_finalized_observed_spends` + /// condition verbatim, and the SQLite store's + /// `collect_finalized_tombstones`. No observation-age margin: the stamp + /// IS the winner's own mined height, carried on the sweep event, so + /// nothing here guesses when the winner mined. Rows with no stamp are + /// never collected: a mempool-context sweep (IS-locked winner, unmined) + /// deliberately writes its tombstone unstamped, because such a winner + /// has no mining deadline and no watermark can prove its inputs' + /// funding delivered-or-never — an unstamped row is a live hold, + /// resolved only by the funding TXO draining it, a later block-context + /// sweep stamping it, or a release deleting it. See the property doc on + /// `PersistentPendingInput.winnerMinedHeight`. + /// + /// Runs ONCE per round, from `endChangeset`, after every account slice + /// and after the sweeps — the same position as the reference store's + /// collector. Running it earlier in the round was a funds bug: the + /// changeset callback wrote `syncedHeight` and collected BEFORE the + /// same round's `utxos_added`, so a funding output that should have + /// drained its tombstone into a durable `supersededByTxid` hold found + /// the tombstone already gone and landed unspent. `syncedHeight == 0` + /// or a missing numeric chainlock height means one half of the + /// boundary is unknown — nothing can be proven final, and nothing is + /// collected. + /// + /// Housekeeping, not correctness: a pass that cannot run self-heals on + /// the next boundary-carrying round, so a fetch failure logs and + /// returns instead of failing the round the way the sweep path must. + private func collectFinalizedSweptTombstones(walletId: Data) { + guard let wallet = findWalletRecord(walletId: walletId), + wallet.syncedHeight > 0, + let chainLockHeight = wallet.lastAppliedChainLockHeight + else { return } + let boundary = min(chainLockHeight, wallet.syncedHeight) + let tombstones: [PersistentPendingInput] + do { + tombstones = try fetchSweptTombstones(walletId: walletId) + } catch { + SDKLogger.event( + "persistence_tombstone_collection_failed", + category: .persistence, + severity: .warning, + fields: ["wallet_reference": .reference(walletId)], + error: error + ) + return + } + for tombstone in tombstones { + // A nil stamp is deliberately NOT back-filled. The unmined + // InstantSend sweep path produces one on purpose (the writer + // maps a missing winner height to nil), so these rows are live + // holds, not stragglers: they must stay outside this height + // collector until the funding materialises, a later + // block-context sweep stamps them, or an authoritative release + // deletes them. Stamping one here would convert "no proof of + // finality" into a fabricated horizon. + guard let stamp = tombstone.winnerMinedHeight, stamp <= boundary else { continue } + backgroundContext.delete(tombstone) + } + } + + /// The live swept tombstones — `isSweptTombstone == true` — of one + /// wallet, or of every wallet when `walletId` is nil. The one + /// tombstone-scan helper, shared by the collector (wallet-scoped) and + /// the sweep phase (global: the hold is global, see + /// `applySweptTransaction`). + /// + /// The predicate selects tombstones only, so ordinary pending rows — + /// one per foreign input of every incoming payment, never pruned — are + /// never materialised; the `[walletId, isSweptTombstone]` index on the + /// model serves the store half. Pending changes stay ON so a row + /// tombstoned earlier in this round (staged, unsaved) is found with its + /// live values: the in-memory half of the fetch evaluates the predicate + /// over this round's pending `PersistentPendingInput` objects only, and + /// a pending-changes fetch never refreshes an object away from its + /// staged state (see `roundIndex`). + private func fetchSweptTombstones(walletId: Data?) throws -> [PersistentPendingInput] { + var descriptor: FetchDescriptor + if let walletId { + descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId && $0.isSweptTombstone == true } + ) + } else { + descriptor = FetchDescriptor(predicate: #Predicate { $0.isSweptTombstone == true }) + } + descriptor.includePendingChanges = true + return try backgroundContext.fetch(descriptor).filter { !$0.isDeleted } + } + + /// Extension entry for the round's NUMERIC chainlock height — the + /// same watermark whose bincode blob rides + /// `WalletChangeSetFFI.last_applied_chain_lock_bytes` (still stored, + /// for the Rust-side metadata roundtrip), delivered separately because + /// that blob is opaque here and the tombstone collection boundary + /// needs the number. Fired inside the round's begin/end bracket, after + /// the changeset callback and before the sweeps, on every round whose + /// changeset carries a chainlock. + /// + /// Stores monotonic-max (chain locks only move forward; a late or + /// re-emitted lower height must not walk the boundary backwards). A + /// height that actually advanced arms the round's collector — see + /// `endChangeset` — which then runs with the completed boundary + /// `min(chainlockHeight, syncedHeight)`. Same fail-the-round contract + /// as every per-kind callback: a throwing wallet lookup returns `false` + /// so Rust does not treat the round as durable. + @discardableResult + func persistWalletChangesetChainLockHeight( + walletId: Data, + height: UInt32 + ) -> Bool { + onQueue { + let wallet: PersistentWallet + switch roundWalletLookup(walletId: walletId, callback: "wallet_changeset_chain_lock_height") { + case .failed: return false + case .absent: return true + case .found(let row): wallet = row + } + + let effective = max(wallet.lastAppliedChainLockHeight ?? 0, height) + if wallet.lastAppliedChainLockHeight != effective { + wallet.lastAppliedChainLockHeight = effective + wallet.lastUpdated = Date() + roundAdvancedFinalityBoundary = true + } + + // No save() — bracketed by changesetBegin/End. + return true + } + } + + /// Per-round state the sweep phase threads through its helpers: the + /// round-wide swept set and a memo of decoded input sets, so a stored + /// transaction's bytes are decoded at most once per round. + private struct SweepRound { + /// Every txid swept by ANY batch of this round — the co-swept + /// predicate (an input funded by a transaction that is itself + /// swept this round is a dead parent's output: deleted, never + /// tombstoned or released) and the release veto's "not swept this + /// round" clause are both evaluated against the whole round, not + /// the batch at hand: upstream's descendant closure sweeps parent + /// and child together, but not necessarily in one batch. + let sweptTxids: Set + /// `txid → input outpoints`, `nil` for a row whose bytes do not + /// decode. Filled lazily by `decodedInputOutpoints(of:)`. + var decodedInputs: [Data: [Data]?] = [:] + } + + /// Apply a round's sweep batches — the one subtractive part of the + /// changeset path, delivered through the size-negotiated + /// `PersistenceCallbacksExtension` slot rather than as a field on + /// `WalletChangeSetFFI` (see `persistWalletChangeset` for why). Rust + /// fires this after that callback (and after the chainlock-height + /// slot) within the same begin/end round, so a wallet-relevant winner + /// riding in the round has its claim on the shared inputs already + /// recorded when the removal here decides which links point at a dead + /// transaction. + /// + /// Returns `false` to fail the round, same contract as + /// `persistWalletChangeset`: a deletion that silently didn't happen + /// would have Rust clear the sweep while the dead row survives to be + /// replayed at the next load. + @discardableResult + func persistWalletChangesetSweeps( + walletId: Data, + sweeps: UnsafePointer?, + count: UInt + ) -> Bool { + onQueue { + switch roundWalletLookup(walletId: walletId, callback: "wallet_changeset_sweeps") { + case .failed: return false + case .absent: return true + case .found: break + } + guard count > 0, let sweepsPtr = sweeps else { return true } + + // The txids this round removes, across every batch — see + // `SweepRound.sweptTxids`. + var sweptTxids = Set() + for batchIndex in 0.. 0, let txidsPtr = batch.txids else { continue } + for i in 0..() + if batch.released_outpoints_count > 0, + let releasedPtr = batch.released_outpoints { + for i in 0.. 0, let txidsPtr = batch.txids { + for i in 0.., + row: PersistentTransaction?, + priorTombstones: [PersistentPendingInput], + winnerMinedHeight: UInt32?, + round: inout SweepRound + ) -> [PersistentPendingInput] { + var retargeted: [PersistentPendingInput] = [] + + if let row { + for output in row.outputs { + backgroundContext.delete(output) + } + + let inputs: [(outpoint: Data, inputIndex: UInt32)] + if let decoded = decodedInputOutpoints(of: row, round: &round) { + inputs = decoded.enumerated().map { (outpoint: $1, inputIndex: UInt32($0)) } + } else { + // No decodable body: the relationship-reachable inputs are + // all this row can name. Logged, because a hold keyed by + // link can miss a coin the loser claimed whose link had + // already moved on. + SDKLogger.event( + "persistence_sweep_loser_undecodable", + category: .persistence, + severity: .warning, + fields: [ + "txid": .reference(loserTxid), + "wallet_reference": .reference(walletId), + ] + ) + var seen = Set() + var fallback: [(outpoint: Data, inputIndex: UInt32)] = [] + for txo in row.inputs where seen.insert(txo.outpoint).inserted { + fallback.append((txo.outpoint, txo.spendingInputIndex ?? 0)) + } + for pending in row.pendingInputs where seen.insert(pending.outpoint).inserted { + fallback.append((pending.outpoint, pending.inputIndex)) + } + inputs = fallback + } + + var settled = Set() + for input in inputs where settled.insert(input.outpoint).inserted { + retargeted.append(contentsOf: settleSweptInput( + outpoint: input.outpoint, + inputIndex: input.inputIndex, + walletId: walletId, + loserTxid: loserTxid, + supersededBy: supersededBy, + released: released, + winnerMinedHeight: winnerMinedHeight, + createTombstone: true, + round: &round + )) + } + + // Hold before delete: every claim on the loser's inputs is by now + // carried by a stamp or a detached tombstone, so the cascade on + // `pendingInputs` and the nullify on `inputs` only clear links. + backgroundContext.delete(row) + } + + // Chained-sweep continuation. A prior tombstone whose outpoint the + // loop above already settled has been re-pointed (its `spendingTxid` + // is no longer the loser's) and is skipped here; the rest are + // tombstones on outpoints the loser's stored inputs do not name — + // a claim an earlier sweep held for a survivor other than the + // winner — or every tombstone when `row` is nil. + for tombstone in priorTombstones + where !tombstone.isDeleted && tombstone.spendingTxid == loserTxid { + retargeted.append(contentsOf: settleSweptInput( + outpoint: tombstone.outpoint, + inputIndex: tombstone.inputIndex, + walletId: walletId, + loserTxid: loserTxid, + supersededBy: supersededBy, + released: released, + winnerMinedHeight: winnerMinedHeight, + createTombstone: false, + round: &round + )) + } + return retargeted + } + + /// Settle one input outpoint of a swept loser — the per-input rule + /// `applySweptTransaction` documents. `createTombstone` is true when + /// the outpoint comes from the loser's stored inputs (a held input with + /// no row of this wallet's gets one), false for the chained + /// continuation, where the existing tombstone is the claim. + private func settleSweptInput( + outpoint: Data, + inputIndex: UInt32, + walletId: Data, + loserTxid: Data, + supersededBy: Data, + released: Set, + winnerMinedHeight: UInt32?, + createTombstone: Bool, + round: inout SweepRound + ) -> [PersistentPendingInput] { + if round.sweptTxids.contains(outpoint.prefix(32)) { + // A co-swept parent's output: nobody's coin. + if let txo = fetchTxoRow(outpoint: outpoint) { + backgroundContext.delete(txo) + } + for pending in pendingInputRows(outpoint: outpoint) + where pending.spendingTxid == loserTxid { + backgroundContext.delete(pending) + } + return [] + } + + let txo = fetchTxoRow(outpoint: outpoint) + let pendingRows = pendingInputRows(outpoint: outpoint) + let releasedHere = released.contains(outpoint) + let vetoed = releasedHere && releaseIsVetoed( + outpoint: outpoint, + txo: txo, + pendingRows: pendingRows, + round: &round + ) + // This wallet's verdict on the coin. Another wallet's rows are held + // regardless — its own callback releases them. + let freedForThisWallet = releasedHere && !vetoed + + if let txo { + if txo.spendingTransaction?.txid == loserTxid { + txo.spendingTransaction = nil + txo.spendingInputIndex = nil + } + let owned = Self.resolvedWalletId(of: txo) == walletId + if owned && freedForThisWallet { + txo.isSpent = false + txo.supersededByTxid = nil + } else if owned && releasedHere { + // Vetoed: the surviving claim that refused the release is + // the attribution — its link or stamp stays as it is. + txo.isSpent = true + } else { + txo.isSpent = true + txo.supersededByTxid = supersededBy + } + txo.lastUpdated = Date() + } + + var retargeted: [PersistentPendingInput] = [] + var walletsHoldingTombstones = Set() + // Existing tombstones first: where a wallet holds both an earlier + // tombstone and an ordinary claim row on the coin, the tombstone — + // which may carry a block-context stamp a mempool re-point must + // keep — is the one that survives as the hold. + let orderedRows = pendingRows.filter(\.isSweptTombstone) + pendingRows.filter { !$0.isSweptTombstone } + for pending in orderedRows { + if pending.spendingTxid == loserTxid { + if pending.walletId == walletId && freedForThisWallet { + backgroundContext.delete(pending) + continue + } + if !walletsHoldingTombstones.insert(pending.walletId).inserted { + // A second claim of the same wallet on the same coin — + // one tombstone carries it. + backgroundContext.delete(pending) + continue + } + pending.spendingTransaction = nil + pending.spendingTxid = supersededBy + pending.isSweptTombstone = true + if let winnerMinedHeight { + pending.winnerMinedHeight = winnerMinedHeight + } + retargeted.append(pending) + } else if pending.isSweptTombstone && pending.spendingTxid == supersededBy { + walletsHoldingTombstones.insert(pending.walletId) + } + } + + if createTombstone, txo == nil, !freedForThisWallet, + !walletsHoldingTombstones.contains(walletId) { + // Held, unfunded, and no claim row of this wallet's to carry the + // hold: create it. `inputIndex` is the loser's vin, kept for + // display only — the drain never copies it onto the winner. + let tombstone = PersistentPendingInput( + outpoint: outpoint, + inputIndex: inputIndex, + spendingTxid: supersededBy, + spendingTransaction: nil, + walletId: walletId + ) + tombstone.isSweptTombstone = true + tombstone.winnerMinedHeight = winnerMinedHeight + backgroundContext.insert(tombstone) + roundIndex?.pendingInputsByOutpoint[outpoint, default: []].append(tombstone) + retargeted.append(tombstone) + } + return retargeted + } + + /// Apply a batch's released set by OUTPOINT, after every loser in the + /// batch has been walked. `applySweptTransaction` reaches a claim only + /// through the loser's stored inputs, and a claim need not be + /// reachable that way: the loser's row can be gone (deleted by another + /// wallet's callback — each wallet's `store()` commits independently — + /// or lost to a fatal flush), leaving this wallet's hold on the coin + /// carried by a stamp or a tombstone with nothing to walk. Kotlin's + /// `releaseByOutpoint` and SQLite's outpoint-matched release pass both + /// cover exactly this; without it the release is silently dropped and + /// the coin stays spent forever. Idempotent against the loser loop for + /// the coins it already freed. + /// + /// Only this wallet's rows are touched — a released set is only ever + /// true of the wallet that computed it — and only when the release is + /// not vetoed (`releaseIsVetoed`). A released outpoint whose funding + /// transaction is swept this round is deleted whatever its shape, never + /// freed: a coin created by a dead transaction cannot be unspent, only + /// gone, and this pass runs regardless of whether the parent's own + /// record survived to delete it. + private func releaseByOutpoint( + walletId: Data, + released: Set, + round: inout SweepRound + ) { + for outpoint in released { + if round.sweptTxids.contains(outpoint.prefix(32)) { + if let txo = fetchTxoRow(outpoint: outpoint) { + backgroundContext.delete(txo) + } + for pending in pendingInputRows(outpoint: outpoint) + where pending.walletId == walletId { + backgroundContext.delete(pending) + } + continue + } + + let txo = fetchTxoRow(outpoint: outpoint) + let pendingRows = pendingInputRows(outpoint: outpoint) + if releaseIsVetoed( + outpoint: outpoint, + txo: txo, + pendingRows: pendingRows, + round: &round + ) { + if let txo, Self.resolvedWalletId(of: txo) == walletId, !txo.isSpent { + txo.isSpent = true + txo.lastUpdated = Date() + } + continue + } + + if let txo, Self.resolvedWalletId(of: txo) == walletId { + if let link = txo.spendingTransaction?.txid, round.sweptTxids.contains(link) { + txo.spendingTransaction = nil + txo.spendingInputIndex = nil + } + txo.isSpent = false + txo.supersededByTxid = nil + txo.lastUpdated = Date() + } + for pending in pendingRows + where pending.walletId == walletId + && (pending.isSweptTombstone || round.sweptTxids.contains(pending.spendingTxid)) { + backgroundContext.delete(pending) + } } } + /// Whether a release of `outpoint` must be refused — the mirror of the + /// reference store's `surviving_stored_input_claims`. + /// + /// Upstream computes `released_outpoints` from its LIVE records, and a + /// network-final spender it has pruned (a chainlocked record kept as a + /// bare txid) or lost across a restart is one it can no longer see. A + /// release naming a coin such a record still claims is upstream + /// reporting its own amnesia — honouring it hands a provably consumed + /// coin back as spendable. This store keeps those rows, so the claim is + /// re-evaluated here: the release is vetoed when a stored transaction + /// with context at or above InstantSend-locked, not swept in this + /// round, still claims the coin — through the TXO's spender link or an + /// ordinary pending row (both written from that transaction's own input + /// list, so the claim holds by construction), or through the TXO's + /// `supersededByTxid` stamp or a tombstone's `spendingTxid`, where the + /// named transaction's stored bytes must actually name the outpoint + /// among its inputs (a stamp is the winner of a sweep whose LOSER spent + /// the coin; the winner need not have). A stamp whose transaction has + /// no stored row (a chained sweep already deleted it) does not veto. + /// Bare mempool claimants never veto: a mempool row is the one context + /// that can go stale forever, and letting it refuse an authoritative + /// release would strand the coin. + /// + /// Fails closed on a network-final claimant whose bytes do not decode: + /// this is the last guard against re-crediting a consumed coin. + private func releaseIsVetoed( + outpoint: Data, + txo: PersistentTxo?, + pendingRows: [PersistentPendingInput], + round: inout SweepRound + ) -> Bool { + var linkClaimants: [Data] = [] + var stampClaimants: [Data] = [] + if let txo { + if let link = txo.spendingTransaction?.txid { linkClaimants.append(link) } + if let stamp = txo.supersededByTxid { stampClaimants.append(stamp) } + } + for pending in pendingRows { + if pending.isSweptTombstone { + stampClaimants.append(pending.spendingTxid) + } else { + linkClaimants.append(pending.spendingTxid) + } + } + func survives(_ txid: Data) -> PersistentTransaction? { + guard !round.sweptTxids.contains(txid), + let claimant = fetchTransactionRow(txid: txid), + claimant.context >= TransactionContextType.instantSend.rawValue + else { return nil } + return claimant + } + for txid in linkClaimants where survives(txid) != nil { + return true + } + for txid in stampClaimants { + guard let claimant = survives(txid) else { continue } + guard let inputs = decodedInputOutpoints(of: claimant, round: &round) else { + return true + } + if inputs.contains(outpoint) { return true } + } + return false + } + + /// The input outpoints of a stored transaction, decoded from its bytes + /// through the key-wallet FFI decoder and memoised per round. `nil` + /// when the row carries no decodable body (a stub whose record never + /// arrived, or corrupt bytes). + private func decodedInputOutpoints( + of row: PersistentTransaction, + round: inout SweepRound + ) -> [Data]? { + if let memo = round.decodedInputs[row.txid] { + return memo + } + var result: [Data]? = nil + if !row.transactionData.isEmpty, + let decoded = try? TransactionDecoder.decode( + row.transactionData, + network: network ?? .testnet + ) { + result = decoded.inputs + .filter { $0.prevTxid.count == 32 } + .map { PersistentTxo.makeOutpoint(txid: $0.prevTxid, vout: $0.prevVout) } + } + round.decodedInputs[row.txid] = result + return result + } + /// Find or create the `PersistentWallet` row for `walletId`. /// Used only by `persistWalletMetadata`; every other write path /// fetches via `findWalletRecord` and drops on missing so that @@ -1065,10 +2044,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Find the `PersistentWallet` row for `walletId`. Returns `nil` /// when no row exists. private func findWalletRecord(walletId: Data) -> PersistentWallet? { + try? fetchWalletRecord(walletId: walletId) + } + + /// Throwing form of `findWalletRecord`, for callers that must tell a + /// successful "no such wallet" apart from a failed lookup — anything + /// carrying a subtractive change, where swallowing the failure would + /// report a removal durable that never happened. + private func fetchWalletRecord(walletId: Data) throws -> PersistentWallet? { let descriptor = FetchDescriptor( predicate: walletRecordPredicate(walletId: walletId) ) - return try? backgroundContext.fetch(descriptor).first + return try backgroundContext.fetch(descriptor).first } /// Predicate matching the `PersistentWallet` row owned by THIS @@ -1213,6 +2200,177 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // MARK: - Round-indexed lookups + // + // The helpers below are the only way the changeset hot path + // (`upsertTransaction`, `upsertUtxo`, `resolveInputOutpoint`, + // `markUtxoSpent`, `markUtxoInstantLocked`, `removePendingInputs`, + // `persistAccountAddresses`) resolves rows by key. Each one reads + // `roundIndex` first, and on a miss — only while the index is + // active — fetches with `includePendingChanges = false` so the store + // lookup stays on SQLite's indexes instead of scanning the round's + // pending inserts in memory (see `roundIndex`); a store hit is + // registered in the index so the same key never fetches twice in one + // round (the store-only refetch would refresh the object and discard + // the round's unsaved mutations — see `roundIndex`). A miss on both + // sources may re-fetch on a later call, which is safe: there is no + // registered object for the refresh to clobber. With no active index + // the helpers degrade to the plain default fetch. Predicates only + // name immutable key columns (`txid`, `outpoint`, `address` are + // fixed at insert), so matching on store values instead of in-memory + // values cannot miss an in-round mutation; mutable-column filters + // (`spendingTxid` on pending rows) stay in Swift at the call sites, + // on live values. `isDeleted` is filtered on both sources because a + // store-only fetch still returns rows whose delete is staged but + // unsaved. + // + // The sweep phase (`persistWalletChangesetSweeps`) resolves every row it + // mutates through these same helpers — loser rows through the throwing + // form of `fetchTransactionRow`, TXOs through `fetchTxoRow`, pending + // rows through `pendingInputRows` — so each object it touches is + // registered before it is written. Its two non-keyed reads are + // pending-changes fetches, which never refresh: the once-per-round + // tombstone scan (`fetchSweptTombstones`, whose outpoints are primed + // into the pending-row cache before any re-point — see + // `primePendingInputIndex`) and `endChangeset`'s collector. The + // invariant that keeps the whole round sound is therefore simply that + // no keyed store-only lookup ever refreshes an object carrying staged + // state: every first touch of a key goes through a helper that + // registers it, and a registered key never touches the store again in + // the round. Rows a relationship hands over without a keyed lookup — + // a displaced spender faulted in through `spendingTransaction`, a + // winner resolved through a pending row's link — are registered at + // that site for the same reason. The identities and dashpay-payments + // callbacks that Rust fires after the sweeps in the same bracket do + // not use these helpers and touch none of these entities. + + /// Resolve a `PersistentTransaction` by its unique `txid`. A failed + /// fetch reads as a miss — the additive hot path's contract. + private func fetchTransactionRow(txid: Data) -> PersistentTransaction? { + try? fetchTransactionRow(txid: txid, prefetching: []) + } + + /// Throwing form of `fetchTransactionRow`, for the sweep phase: a + /// subtractive caller must tell "no such row" apart from a failed + /// fetch (reporting a deletion that never happened would let Rust + /// clear the sweep while the dead row survives), and it prefetches + /// the relationships it is about to walk. Index-first, store-only on + /// a miss, and the store hit is registered so the next lookup of the + /// same txid — a later batch of this round sweeping or chaining onto + /// it — returns the same object instead of re-fetching. The plain + /// pending-changes fetch with no active round keeps the old behaviour + /// for unbracketed callers. + private func fetchTransactionRow( + txid: Data, + prefetching: [PartialKeyPath] + ) throws -> PersistentTransaction? { + if let known = roundIndex?.transactionsByTxid[txid] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + descriptor.fetchLimit = 1 + if !prefetching.isEmpty { + descriptor.relationshipKeyPathsForPrefetching = prefetching + } + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = try backgroundContext.fetch(descriptor).first, + !row.isDeleted else { return nil } + roundIndex?.transactionsByTxid[txid] = row + return row + } + + /// Resolve a `PersistentTxo` by its unique 36-byte `outpoint`. + private func fetchTxoRow(outpoint: Data) -> PersistentTxo? { + if let known = roundIndex?.txosByOutpoint[outpoint] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + descriptor.fetchLimit = 1 + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = (try? backgroundContext.fetch(descriptor))?.first, + !row.isDeleted else { return nil } + roundIndex?.txosByOutpoint[outpoint] = row + return row + } + + /// Every live `PersistentPendingInput` row keyed on `outpoint` — + /// saved rows plus this round's staged inserts. Non-unique key, so + /// this returns the full set; callers filter further (by + /// `spendingTxid`, `walletId`, `createdAt`) on the live objects. + /// Read-through like the single-object maps: the first call for a key + /// in a round fetches the saved rows store-only, merges the staged + /// inserts (de-duped by object identity as insurance against a save + /// landing mid-round) and caches the set; every later call answers + /// from the cache. That registration is load-bearing, not an + /// optimisation — the sweep phase re-points and tombstones these rows + /// through this helper, and a second store-only fetch of the same key + /// would refresh those mutations away (see `roundIndex`). + private func pendingInputRows(outpoint: Data) -> [PersistentPendingInput] { + guard roundIndex != nil else { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return ((try? backgroundContext.fetch(descriptor)) ?? []).filter { !$0.isDeleted } + } + if roundIndex!.pendingInputsFetched.contains(outpoint) { + return (roundIndex!.pendingInputsByOutpoint[outpoint] ?? []).filter { !$0.isDeleted } + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + descriptor.includePendingChanges = false + let saved = (try? backgroundContext.fetch(descriptor)) ?? [] + registerPendingInputRows(saved, outpoint: outpoint) + return (roundIndex!.pendingInputsByOutpoint[outpoint] ?? []).filter { !$0.isDeleted } + } + + /// Cache the saved pending rows of `outpoint` for the rest of the + /// round, merged with whatever the round already staged under the key. + private func registerPendingInputRows(_ saved: [PersistentPendingInput], outpoint: Data) { + guard roundIndex != nil else { return } + var rows = saved + if let staged = roundIndex!.pendingInputsByOutpoint[outpoint] { + let seen = Set(rows.map { ObjectIdentifier($0) }) + rows.append(contentsOf: staged.filter { !seen.contains(ObjectIdentifier($0)) }) + } + roundIndex!.pendingInputsByOutpoint[outpoint] = rows + roundIndex!.pendingInputsFetched.insert(outpoint) + } + + /// Prime the pending-row cache for the outpoints of every tombstone + /// the sweep phase's once-per-round scan found: those rows are about to + /// be re-pointed by scalar, and the re-point must never be followed by + /// a first-touch store-only fetch of the same key (see `roundIndex`). + /// One keyed fetch per outpoint not yet touched this round — the + /// tombstone population is the bounded residue the collector keeps + /// small, and this runs only on a round that carries sweeps. + private func primePendingInputIndex(outpoints: Set) { + guard roundIndex != nil else { return } + for outpoint in outpoints where !roundIndex!.pendingInputsFetched.contains(outpoint) { + _ = pendingInputRows(outpoint: outpoint) + } + } + + /// Resolve a `PersistentCoreAddress` by its unique `address`. + private func coreAddressRow(address: String) -> PersistentCoreAddress? { + if let known = roundIndex?.coreAddressesByAddress[address] { + return known.isDeleted ? nil : known + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.address == address } + ) + descriptor.fetchLimit = 1 + if roundIndex != nil { descriptor.includePendingChanges = false } + guard let row = (try? backgroundContext.fetch(descriptor))?.first, + !row.isDeleted else { return nil } + roundIndex?.coreAddressesByAddress[address] = row + return row + } + private func upsertTransaction(account: PersistentAccount, tx: TransactionRecordFFI) { // The `account` parameter scopes the wallet-id used for the // input-reconciliation pass at the bottom of this method, and @@ -1234,9 +2392,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // let resolvedWalletId: Data = account.wallet.walletId let txidData = hashData(tx.txid) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txidData } - ) // The FFI projection always serializes the transaction body // (`dashcore::consensus::encode::serialize` upstream), so @@ -1258,8 +2413,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let firstSeen: UInt64 = tx.first_seen != 0 ? tx.first_seen : UInt64(Date().timeIntervalSince1970) + // A record naming a txid a sweep already removed is upstream's + // newer word — `CoreChangeSet::merge` documents the reachable + // sequence: an unconfirmed transaction swept by an IS-locked + // conflict can return chainlocked and sweep that conflict in turn. + // The sweep deleted the row outright, so the reinstatement is an + // ordinary insert below; its outputs come back only through the + // `utxos_added` entries riding alongside the record, the same way + // any transaction's outputs ordinarily arrive. + let existing = fetchTransactionRow(txid: txidData) + let record: PersistentTransaction - if let existing = try? backgroundContext.fetch(descriptor).first { + if let existing { record = existing } else { record = PersistentTransaction( @@ -1273,6 +2438,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { firstSeen: firstSeen ) backgroundContext.insert(record) + roundIndex?.transactionsByTxid[txidData] = record } record.context = tx.context @@ -1376,6 +2542,43 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { tx.context >= TransactionContextType.inBlock.rawValue } + /// Whether a TXO's existing spender link must survive an arriving + /// record that also claims the outpoint. The link is this store's spend + /// attribution, and the sweep release veto trusts it + /// (`releaseIsVetoed`): a network-final spender's link must never be + /// stolen by a later conflicting record — upstream prunes a chainlocked + /// spender to a bare txid (and after a restart holds no history at + /// all), so a loser reusing that coin arrives with upstream unable to + /// see the settled claim, and its own eventual sweep names the coin + /// released. With the link intact the release is refused; with it + /// stolen, the provably consumed coin reads unspent after the next + /// restart — a guaranteed double spend. + /// + /// Kept when the existing spender is network-final: IS-locked, + /// in-block, or chainlocked. Two mempool spenders keep + /// last-writer-wins. The single sanctioned takeover mirrors DIP-10 + /// precedence: a chainlocked arrival 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. A swept + /// spender needs no exclusion here: its row is deleted by its sweep, + /// and a deleted row links nothing. + private static func settledSpenderLinkIsKept( + existingTxid: Data, + existingContext: UInt32, + newTxid: Data, + newContext: UInt32 + ) -> Bool { + guard existingTxid != newTxid else { return false } + guard existingContext >= TransactionContextType.instantSend.rawValue else { + return false + } + let chainlockOverIsLock = + newContext >= TransactionContextType.inChainLockedBlock.rawValue + && existingContext == TransactionContextType.instantSend.rawValue + return !chainlockOverIsLock + } + /// Mark the `PersistentTxo` whose 36-byte `outpoint` matches the /// given input as spent and link it to `spendingTransaction`. /// If no matching TXO exists yet (in-Swift out-of-order, or @@ -1389,34 +2592,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { spendingTxid: Data, walletId: Data ) { - let txoDescriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - if let txo = try? backgroundContext.fetch(txoDescriptor).first { - // Flag and link move together — see - // `reconcileSpendObservation` for the finality rule. - let verdict = Self.reconcileSpendObservation( - currentSpenderTxid: txo.spendingTransaction?.txid, - currentIsSpent: txo.isSpent, - incoming: spendingTransaction, - incomingTxid: spendingTxid - ) - let linkageChanged = - txo.isSpent != verdict.isSpent - || (verdict.adoptLink && txo.spendingTransaction?.txid != spendingTxid) - || (verdict.adoptLink && txo.spendingInputIndex != inputIndex) - if linkageChanged { - txo.isSpent = verdict.isSpent - if verdict.adoptLink { - if txo.spendingTransaction?.txid != spendingTxid { - txo.spendingTransaction = spendingTransaction - } - // Capture the canonical vin index so the detail - // view can render inputs in serialized order. - txo.spendingInputIndex = inputIndex - } - txo.lastUpdated = Date() - } + if let txo = fetchTxoRow(outpoint: outpoint) { + // Flag and link move together under `reconcileSpendObservation`'s + // rule — including the sweep term: a TXO a sweep is holding + // (`supersededByTxid` set) was proved consumed by a winner this + // record may know nothing about, and the verdict never lowers + // it. The sharp case is the winner's own record arriving + // IS-locked — a context below in-block — for a coin the sweep + // already settled: the link is adopted, the hold stays. + adoptSpendObservation(txo: txo, spender: spendingTransaction, inputIndex: inputIndex) // A pending entry from an earlier write is now stale — // resolved by this fetch. Drop it. removePendingInputs(for: outpoint) @@ -1427,15 +2611,25 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // path in `upsertUtxo` keep the table from growing // unbounded. // - // Skip the write if a pending row for this exact - // (outpoint, spending-tx) pair already exists — re-upserts - // of the same transaction would otherwise produce - // duplicate pending rows that all resolve to the same - // TXO, wasting fetch work on the resolve side. - let pendingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint && $0.spendingTxid == spendingTxid } - ) - if (try? backgroundContext.fetch(pendingDescriptor).first) == nil { + // Skip the write if an ordinary pending row for this exact + // (outpoint, spending-tx, wallet) triple already exists — + // re-upserts of the same transaction would otherwise produce + // duplicate pending rows that all resolve to the same TXO, + // wasting fetch work on the resolve side. The key includes + // the recording wallet: a second wallet recording the same + // transaction gets its own claim row, because every sweep + // decision on a pending row is scoped by that tag and a + // release computed by one wallet must never decide another + // wallet's claim. A tombstone does not occupy the key: it is + // the sweep's hold, not this record's claim, and the drain + // reads the vin index and the spender link off the ordinary + // row while the tombstone supplies only the stamp. The + // mutable halves (`spendingTxid`, `isSweptTombstone`) are + // compared in Swift on the live rows. + let alreadyPending = pendingInputRows(outpoint: outpoint).contains { + !$0.isSweptTombstone && $0.spendingTxid == spendingTxid && $0.walletId == walletId + } + if !alreadyPending { let pending = PersistentPendingInput( outpoint: outpoint, inputIndex: inputIndex, @@ -1444,6 +2638,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { walletId: walletId ) backgroundContext.insert(pending) + roundIndex?.pendingInputsByOutpoint[outpoint, default: []].append(pending) } } } @@ -1454,13 +2649,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `upsertUtxo`'s resolve path so a freshly-arrived TXO doesn't /// keep its corresponding pending row alive. private func removePendingInputs(for outpoint: Data) { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - guard let rows = try? backgroundContext.fetch(descriptor), !rows.isEmpty else { - return - } - for row in rows { + // Deletes are not unregistered from `roundIndex` — the stale + // entry answers `isDeleted == true` and every lookup filters on + // that (see the index's doc). + for row in pendingInputRows(outpoint: outpoint) { backgroundContext.delete(row) } } @@ -1473,12 +2665,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let txidData = hashData(utxo.outpoint.txid) let outpoint = PersistentTxo.makeOutpoint(txid: txidData, vout: utxo.outpoint.vout) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) let record: PersistentTxo - if let existing = try? backgroundContext.fetch(descriptor).first { + let redelivered: Bool + if let existing = fetchTxoRow(outpoint: outpoint) { record = existing + redelivered = true // Backfill if the account or wallet linkage is missing — // the per-wallet query path filters on TXO.walletId, so // an empty value would silently hide the row. @@ -1496,11 +2687,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // arrives. Note we no longer set `parentTx.account` — // transactions don't carry account linkage anymore (they // can span multiple accounts). - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == txidData } - ) let parentTx: PersistentTransaction - if let existingTx = try? backgroundContext.fetch(txDescriptor).first { + if let existingTx = fetchTransactionRow(txid: txidData) { parentTx = existingTx } else { // Stub row — `transactionData` is left as empty @@ -1512,6 +2700,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // treats as miss. parentTx = PersistentTransaction(txid: txidData, transactionData: Data()) backgroundContext.insert(parentTx) + roundIndex?.transactionsByTxid[txidData] = parentTx } let script: Data = { @@ -1530,6 +2719,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.account = account record.walletId = resolvedWalletId backgroundContext.insert(record) + roundIndex?.txosByOutpoint[outpoint] = record + redelivered = false } record.amount = utxo.amount @@ -1540,6 +2731,32 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.isLocked = utxo.is_locked record.lastUpdated = Date() + // The wallet is handing a coin it already materialised back as + // UNSPENT, and it follows the wallet: `isSpent` and the sweep stamp + // clear together. The wallet knows this coin, so any network-final + // spender of it is wallet-relevant by BIP158 prevout matching and + // its own scan re-discovers the spend; refusing the re-delivery + // would instead lock a real coin out forever after a reorg of the + // winner — and a row with `isSpent == true` is never restored to + // Rust again. The same rule as the reference store's upsert valve, + // which holds only never-materialised placeholders; here those are + // the tombstones the drain below resolves. The one exception is a + // linked spender with context at or above InstantSend-locked: + // confirmed evidence on record is never displaced by a re-delivery + // (the pending-input resolve and the spend emit own that link). + if redelivered, record.isSpent { + let settledSpender = record.spendingTransaction.map { + $0.context >= TransactionContextType.instantSend.rawValue + } ?? false + if !settledSpender { + record.isSpent = false + record.supersededByTxid = nil + if record.spendingTransaction == nil { + record.spendingInputIndex = nil + } + } + } + // Attach the `PersistentCoreAddress` row, if we have one. The // address-emit pass typically runs ahead of the SPV-utxo pass // within a flush, so the row should exist; if it doesn't (TXO @@ -1547,11 +2764,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // leave the relationship nil — `record.address` stays as the // authoritative identifier. if record.coreAddress == nil, !record.address.isEmpty { - let addressLookup = record.address - let coreAddressDescriptor = FetchDescriptor( - predicate: #Predicate { $0.address == addressLookup } - ) - if let coreAddr = try? backgroundContext.fetch(coreAddressDescriptor).first { + if let coreAddr = coreAddressRow(address: record.address) { record.coreAddress = coreAddr } } @@ -1565,60 +2778,57 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // `upsertTransaction`, so the spend signal is order- // independent at this layer regardless of which side arrives // first. - let outpointKey = record.outpoint - let pendingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpointKey } - ) - if let pendingRows = try? backgroundContext.fetch(pendingDescriptor), - !pendingRows.isEmpty { - // Reconcile EVERY deferred observation, not just the newest — - // the rows are about to be deleted, and picking one would let - // a mempool competitor recorded after a confirmed spender - // erase that confirmed evidence with the rows. Applying the - // finality-aware rule per row makes the order irrelevant by - // construction: confirmed evidence wins and is never - // displaced by a mempool observation, so the oldest-first - // pass below converges to the same state any order would. + let pendingRows = pendingInputRows(outpoint: record.outpoint) + if !pendingRows.isEmpty { + // A tombstone is not an observation — it is a sweep's settled + // verdict that its winner consumed this coin — so it outranks + // every ordinary row regardless of age, and a drained tombstone + // STAMPS: `isSpent = true`, `supersededByTxid = winner`, and + // nothing else. It never mints a spender link or a vin index — + // its `inputIndex` is the LOSER'S vin, and the winner it names + // need not spend this coin at that position (or at all: the + // hold means "some survivor took it"). A sweep's winner is + // already final, so `isSpent` does not gate on resolving a + // spender the way an ordinary pending spend does; + // `supersededByTxid` is what makes the mark durable. Rows are + // per (outpoint, winner, wallet): the tombstone tagged with the + // delivering wallet is preferred, and failing that any + // tombstone on the outpoint still holds — the stamp is a txid + // fact, not a per-wallet fact. + let tombstones = pendingRows.filter(\.isSweptTombstone) + if let tombstone = tombstones.first(where: { $0.walletId == resolvedWalletId }) + ?? tombstones.max(by: { $0.createdAt < $1.createdAt }) + { + record.isSpent = true + record.supersededByTxid = tombstone.spendingTxid + } + // Reconcile EVERY deferred ordinary observation, not just the + // newest — the rows are about to be deleted, and picking one + // would let a mempool competitor recorded after a confirmed + // spender erase that confirmed evidence with the rows. The + // per-row rule is order-independent by construction (a settled + // spender is never displaced by a lower-context one, `isSpent` + // is monotonic), so the oldest-first pass converges to the + // same state any order would. Attribution survives a coexisting + // tombstone this way: the winner's own record staged its + // ordinary row moments before the sweep, and that row — not + // the tombstone — carries the link and the right vin index. var adoptedAny = false - for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) { - // Resolve the spending tx (prefer the relationship; fall - // back to a txid lookup if the row wasn't faulted in). - let resolvedSpending: PersistentTransaction? - if let spending = pending.spendingTransaction { - resolvedSpending = spending - } else { - let spendingTxid = pending.spendingTxid - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == spendingTxid } - ) - resolvedSpending = try? backgroundContext.fetch(txDescriptor).first - } - guard let spending = resolvedSpending else { continue } - // Flag and link move together — see - // `reconcileSpendObservation` for the finality rule. - let verdict = Self.reconcileSpendObservation( - currentSpenderTxid: record.spendingTransaction?.txid, - currentIsSpent: record.isSpent, - incoming: spending, - incomingTxid: spending.txid - ) - record.isSpent = verdict.isSpent - if verdict.adoptLink { - if record.spendingTransaction?.txid != spending.txid { - record.spendingTransaction = spending - } - // The vin index rides with the adopted claim so the - // spending tx's detail view renders inputs in the - // canonical serialized order. - record.spendingInputIndex = pending.inputIndex + for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) + where !pending.isSweptTombstone { + guard let spending = resolvePendingSpender(pending) else { continue } + adoptSpendObservation(txo: record, spender: spending, inputIndex: pending.inputIndex) + if record.spendingTransaction?.txid == spending.txid { adoptedAny = true } } - if !adoptedAny, let newest = pendingRows.max(by: { $0.createdAt < $1.createdAt }) { + if !adoptedAny, tombstones.isEmpty, + let newest = pendingRows.max(by: { $0.createdAt < $1.createdAt }) { // No row resolved a spending tx this flush: carry the // newest claim's vin index forward the way the old // single-row path did; the linkage itself catches up on - // the next flush that carries the spending tx. + // the next flush that carries the spending tx. Never off + // a tombstone — its index is the loser's. record.spendingInputIndex = newest.inputIndex } record.lastUpdated = Date() @@ -1628,39 +2838,111 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } - /// The one rule every spend-linkage writer follows, so `isSpent` and - /// `spendingTransaction` move as a single finality-aware state instead - /// of a monotonic flag beside a last-writer-wins link (which could - /// diverge: a mempool competitor replacing a confirmed link under a - /// stuck-true flag, or a reorg demotion never lowering it). + /// Resolve a pending row's spending transaction — the relationship when + /// it is faulted in, otherwise a txid lookup through the round index. + private func resolvePendingSpender(_ pending: PersistentPendingInput) -> PersistentTransaction? { + if let spending = pending.spendingTransaction { + // Resolved through the relationship, not the index — register it + // so a later `fetchTransactionRow` for this txid returns this + // same object instead of running a first-touch store fetch that + // would refresh away any staged writes it carries (see + // `roundIndex`). + roundIndex?.transactionsByTxid[spending.txid] = spending + return spending + } + return fetchTransactionRow(txid: pending.spendingTxid) + } + + /// The one rule every spend-linkage writer follows — the record pass + /// (`resolveInputOutpoint`), the `utxos_spent` emit (`markUtxoSpent`) + /// and the pending-row drain (`upsertUtxo`) all route through + /// `adoptSpendObservation`, which applies this verdict. /// - /// - Re-observation of the LINKED spender follows its context in both - /// directions: a demotion is chain truth — key-wallet emits - /// `InBlock` → `Mempool` context updates on a reorg — and keeping a - /// stale flag would wedge the coin out of the restore set. - /// - A DIFFERENT in-block spender takes the link and the flag: its - /// claim is chain-attested and mutually exclusive with the old one. - /// - A mempool competitor never displaces confirmed evidence: link and - /// flag both stay. - /// - When nothing confirmed is at stake, the newest observation wins - /// the link and the flag stays down. + /// - `isSpent` is MONOTONIC: `existing || incoming in block || stamped`. + /// A stamped hold (`supersededByTxid`) is a sweep's settled verdict + /// and outranks any observation; an in-block spender sets it; nothing + /// on these channels lowers it. A coin the wallet holds unspent again + /// comes back through `utxos_added`, whose recovery clear is the one + /// path down (see `upsertUtxo`). + /// - The link follows `settledSpenderLinkIsKept`: a network-final + /// spender keeps its link against a conflicting arrival (DIP-10 + /// precedence decides the one takeover), a mempool spender yields to + /// the newest observation, and a re-observation of the linked spender + /// keeps it. A stamped, unlinked row ADOPTS the arriving spender's + /// link — the attribution `walletFundedTransaction` reads — while the + /// hold stays: the hold is the stamp, not the link. private static func reconcileSpendObservation( currentSpenderTxid: Data?, + currentSpenderContext: UInt32?, currentIsSpent: Bool, + currentIsStamped: Bool, incoming: PersistentTransaction, incomingTxid: Data ) -> (adoptLink: Bool, isSpent: Bool) { - let incomingInBlock = spendIsInBlock(incoming) - if currentSpenderTxid == incomingTxid { - return (adoptLink: true, isSpent: incomingInBlock) + let isSpent = currentIsSpent || spendIsInBlock(incoming) || currentIsStamped + if let currentSpenderTxid, let currentSpenderContext, + settledSpenderLinkIsKept( + existingTxid: currentSpenderTxid, + existingContext: currentSpenderContext, + newTxid: incomingTxid, + newContext: incoming.context + ) { + return (adoptLink: false, isSpent: isSpent) + } + return (adoptLink: true, isSpent: isSpent) + } + + /// Apply `reconcileSpendObservation`'s verdict for `spender` to `txo` — + /// the single link writer for every channel. `inputIndex` is the + /// spender's vin for this coin when the channel carries it (the record + /// pass and a drained ordinary pending row do; the `utxos_spent` emit + /// does not). + /// + /// A spender the link moves away from is registered in the round index + /// before it is displaced: the relationship faulted it into the context + /// without any keyed lookup, and if a later store-only first-touch + /// fetch of its txid (the sweep phase looking up a loser this record + /// just beat) refreshed it, the refresh would reset its `inputs` + /// inverse and with it this very link — durably, since a chainlock + /// promotion never re-emits the record (see `roundIndex`). + private func adoptSpendObservation( + txo: PersistentTxo, + spender: PersistentTransaction, + inputIndex: UInt32? + ) { + let currentSpender = txo.spendingTransaction + let verdict = Self.reconcileSpendObservation( + currentSpenderTxid: currentSpender?.txid, + currentSpenderContext: currentSpender?.context, + currentIsSpent: txo.isSpent, + currentIsStamped: txo.supersededByTxid != nil, + incoming: spender, + incomingTxid: spender.txid + ) + var changed = false + if txo.isSpent != verdict.isSpent { + txo.isSpent = verdict.isSpent + changed = true } - if incomingInBlock { - return (adoptLink: true, isSpent: true) + if verdict.adoptLink { + if let currentSpender, currentSpender.txid != spender.txid { + roundIndex?.transactionsByTxid[currentSpender.txid] = currentSpender + txo.spendingTransaction = spender + changed = true + } else if currentSpender == nil { + txo.spendingTransaction = spender + changed = true + } + // The canonical vin index, so the detail view can render inputs + // in serialized order. + if let inputIndex, txo.spendingInputIndex != inputIndex { + txo.spendingInputIndex = inputIndex + changed = true + } } - if currentIsSpent { - return (adoptLink: false, isSpent: true) + if changed { + txo.lastUpdated = Date() } - return (adoptLink: true, isSpent: false) } private func markUtxoSpent(_ entry: SpentOutPointFFI) { @@ -1668,10 +2950,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { txid: hashData(entry.outpoint.txid), vout: entry.outpoint.vout ) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - guard let txo = try? backgroundContext.fetch(descriptor).first else { + guard let txo = fetchTxoRow(outpoint: outpoint) else { return } // Link the spending transaction. The FFI now carries @@ -1689,10 +2968,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if txo.spendingTransaction?.txid == spendingTxid { spendingTx = txo.spendingTransaction } else { - let txDescriptor = FetchDescriptor( - predicate: #Predicate { $0.txid == spendingTxid } - ) - spendingTx = try? backgroundContext.fetch(txDescriptor).first + spendingTx = fetchTransactionRow(txid: spendingTxid) } } // When the spending tx isn't resolved this flush, leave the row @@ -1702,17 +2978,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // `isSpent` on every reordered emit. if let spending = spendingTx { // Flag and link move together — see - // `reconcileSpendObservation` for the finality rule. - let verdict = Self.reconcileSpendObservation( - currentSpenderTxid: txo.spendingTransaction?.txid, - currentIsSpent: txo.isSpent, - incoming: spending, - incomingTxid: spendingTxid - ) - txo.isSpent = verdict.isSpent - if verdict.adoptLink, txo.spendingTransaction?.txid != spendingTxid { - txo.spendingTransaction = spending - } + // `reconcileSpendObservation` for the finality rule, stamped + // hold included: this emit can carry the sweep winner's own + // IS-locked spend of a coin the sweep already proved consumed, + // and the monotonic flag keeps the durable hold out of the + // restore set until the winner reaches a block. + adoptSpendObservation(txo: txo, spender: spending, inputIndex: nil) } txo.lastUpdated = Date() // The spend signal landed both via the legacy @@ -1728,10 +2999,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { private func markUtxoInstantLocked(_ op: OutPointFFI) { let outpoint = PersistentTxo.makeOutpoint(txid: hashData(op.txid), vout: op.vout) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.outpoint == outpoint } - ) - if let txo = try? backgroundContext.fetch(descriptor).first { + if let txo = fetchTxoRow(outpoint: outpoint) { txo.isInstantLocked = true txo.lastUpdated = Date() } @@ -1763,6 +3031,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.dpnsNameStates | PlatformWalletPersistenceCapabilities.trackedAssetLocks | PlatformWalletPersistenceCapabilities.trackedMasternodes + | PlatformWalletPersistenceCapabilities.coreSweepRemoval + | PlatformWalletPersistenceCapabilities.dashpayPayments ) } @@ -1778,6 +3048,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_persist_tracked_masternodes_fn = persistTrackedMasternodesCallback extensionCallbacks.on_load_tracked_masternodes_fn = loadTrackedMasternodesCallback extensionCallbacks.on_load_tracked_masternodes_free_fn = loadTrackedMasternodesFreeCallback + // Sweeps negotiate through this size-tagged structure rather than + // riding `WalletChangeSetFFI` because that struct crosses by bare + // pointer: `struct_size` above is what proves to an older native + // library that this slot exists, and proves to this build that an + // older library will simply never call it — rather than either side + // reading memory the other never allocated. + extensionCallbacks.on_persist_wallet_changeset_sweeps_fn = + persistWalletChangesetSweepsCallback + // The numeric chainlock height rides its own slot for the same + // reason: the bincode chainlock bytes on `WalletChangeSetFFI` are + // opaque to this side, and the tombstone-collection finality + // boundary `min(chainlockHeight, syncedHeight)` needs the number. + extensionCallbacks.on_persist_wallet_changeset_chain_lock_height_fn = + persistWalletChangesetChainLockHeightCallback return extensionCallbacks } @@ -1853,10 +3137,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `persistAccountChangeset`, …) fires between begin and end and /// only mutates `backgroundContext`; `save()` happens at the end. /// - /// Currently a no-op beyond the tag — `ModelContext`'s pending- - /// change buffer already gives us the batching we need. Kept as - /// a named hook so future work (explicit transaction scoping, - /// instrumented timing, etc.) has an obvious seam. + /// Beyond the tag, this builds the round's insert index (see + /// `roundIndex`) — `ModelContext`'s pending-change buffer already + /// gives us the batching we need. func beginChangeset(walletId: Data) { onQueue { self.inChangeset = true @@ -1865,6 +3148,30 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { category: .persistence, fields: ["wallet_reference": .reference(walletId)] ) + // The index's O(1) lookups are only equivalent to the plain + // pending-changes fetch when the index and the store + // partition the rows between them: index = this round's + // inserts, store = everything saved. A context that is + // already dirty here (an out-of-round writer whose `save()` + // threw and left its staged rows behind) breaks that + // partition — such a row is in neither source — so the + // round runs unindexed and the lookup helpers fall back to + // the exact pre-index fetch, pending changes included. + if backgroundContext.hasChanges { + SDKLogger.event( + "persistence_round_index_disabled", + category: .persistence, + severity: .warning, + fields: [ + "reason": .publicText("dirty_context_at_round_start"), + "wallet_reference": .reference(walletId), + ] + ) + self.roundIndex = nil + } else { + self.roundIndex = ChangesetRoundIndex() + } + self.roundAdvancedFinalityBoundary = false } } @@ -1890,8 +3197,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // Clear the flag before draining deferred backfills so each one's // save() lands cleanly outside the round; `drainDeferredBackfills` // is guarded on `!inChangeset`, so the ordering inside this `defer` - // (clear, then drain) is load-bearing. + // (clear, then drain) is load-bearing. The round index dies here + // on both paths — after the commit its entries are ordinary saved + // rows the store fetch finds on its own, and after a rollback the + // context has un-inserted every one of them. defer { + self.roundIndex = nil + self.roundAdvancedFinalityBoundary = false self.inChangeset = false self.drainDeferredBackfills() } @@ -1926,6 +3238,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { backgroundContext.rollback() return false } + // The round's one collector pass: after every account slice + // and every sweep, on the boundary the round's own writes + // left on the wallet row — see + // `collectFinalizedSweptTombstones` for why not earlier. + if roundAdvancedFinalityBoundary { + collectFinalizedSweptTombstones(walletId: walletId) + } do { try backgroundContext.save() SDKLogger.event( @@ -3102,6 +4421,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { fields: ["identity_reference": .reference(ownerIdentityId)], error: error ) + // Same reason as `saveBackgroundContextIfNeeded`: the + // staged rows must not ride the next round's save. + backgroundContext.rollback() } } } @@ -3138,7 +4460,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // No save here even outside a round: the Rust store() round // that invoked this callback brackets it with begin/end, so // `inChangeset` is set in practice; if a host ever fires it - // without a bracket, autosave/next round flushes the stage. + // without a bracket, the next round's own save flushes the + // stage (autosave is disabled on this context — see init). } } @@ -3575,12 +4898,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { for entry in entries { let address = entry.address - let existingDescriptor = FetchDescriptor( - predicate: #Predicate { $0.address == address } - ) - let existing = try? backgroundContext.fetch(existingDescriptor).first let row: PersistentCoreAddress - if let existing = existing { + if let existing = coreAddressRow(address: address) { row = existing } else { row = PersistentCoreAddress( @@ -3594,6 +4913,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { balance: entry.balance ) backgroundContext.insert(row) + roundIndex?.coreAddressesByAddress[address] = row } // Mutation path for both insert + update. row.publicKey = entry.publicKey @@ -3616,12 +4936,27 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // address row now exists. Avoid the SwiftData // optional-relationship-in-predicate gotcha by // filtering nil-coreAddress in Swift after the fetch. + // + // Deliberately NOT a round-indexed store-only lookup: this + // joins TXOs by `address`, and the rows it returns are the + // same objects the outpoint-keyed hot path mutates — a + // store-only fetch here would refresh those objects and + // discard the round's unsaved writes (see `roundIndex`). + // The pending-changes scan this keeps is bounded by the + // round's TXO inserts per emitted address entry; the + // outpoint-keyed quadratic hot path stays indexed. let txoBackfillDescriptor = FetchDescriptor( predicate: #Predicate { $0.address == address } ) if let txosAtAddress = try? backgroundContext.fetch(txoBackfillDescriptor) { for txo in txosAtAddress where txo.coreAddress == nil { txo.coreAddress = row + // This write happened outside any keyed lookup, so + // register the row: a later first-touch + // `fetchTxoRow` for this outpoint would otherwise + // run a store-only fetch and refresh the link away + // (see `roundIndex`). + roundIndex?.txosByOutpoint[txo.outpoint] = txo } } } @@ -7555,8 +8890,64 @@ private func persistWalletChangesetCallback( .takeUnretainedValue() let walletId = Data(bytes: walletIdPtr, count: 32) - handler.persistWalletChangeset(walletId: walletId, changeset: changesetPtr) - return 0 + // Non-zero fails the round: `endChangeset(success: false)` rolls the + // staged writes back and Rust keeps its in-memory state instead of + // treating a partly-applied changeset as durable. + return handler.persistWalletChangeset(walletId: walletId, changeset: changesetPtr) ? 0 : 1 +} + +/// C shim for the extension's `on_persist_wallet_changeset_sweeps_fn` — +/// the round's sweep batches, fired right after the changeset callback +/// above within the same begin/end bracket. Same non-zero-fails-the-round +/// contract: a removal Rust believes durable but that never landed would +/// replay the dead row at the next load. +private func persistWalletChangesetSweepsCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + sweepsPtr: UnsafePointer?, + sweepsCount: UInt +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + + let walletId = Data(bytes: walletIdPtr, count: 32) + return handler.persistWalletChangesetSweeps( + walletId: walletId, + sweeps: sweepsPtr, + count: sweepsCount + ) ? 0 : 1 +} + +/// C shim for the extension's +/// `on_persist_wallet_changeset_chain_lock_height_fn` — the round's +/// NUMERIC chainlock height, fired inside the same begin/end bracket +/// after the changeset callback whenever the round advanced the chainlock +/// watermark. Same non-zero-fails-the-round contract as its siblings. +private func persistWalletChangesetChainLockHeightCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + chainLockHeight: UInt32 +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + + let walletId = Data(bytes: walletIdPtr, count: 32) + return handler.persistWalletChangesetChainLockHeight( + walletId: walletId, + height: chainLockHeight + ) ? 0 : 1 } /// C shim for `on_changeset_begin_fn`. Forwards to diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 1e28edf34d8..08d1f84c383 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -24,7 +24,11 @@ final class DashModelMigrationTests: XCTestCase { var v1Container: ModelContainer? = try ModelContainer( for: v1Schema, configurations: [v1Configuration]) - v1Container?.mainContext.insert(PersistentKeyword( + // V1 registers the FROZEN component (see `DashSchemaFrozenModels`), + // so a row written into a V1 container is that type — inserting the + // live one would materialise as the frozen entity and then fail its + // cast on read. + v1Container?.mainContext.insert(DashSchemaV1.PersistentKeyword( keyword: "preserved", contractId: "contract")) try v1Container?.mainContext.save() @@ -42,7 +46,9 @@ final class DashModelMigrationTests: XCTestCase { migrationPlan: DashMigrationPlan.self, configurations: [v2Configuration]) - let keywords = try migrated.mainContext.fetch(FetchDescriptor()) + // V2 registers the same frozen copy, so the read side is frozen too. + let keywords = try migrated.mainContext.fetch( + FetchDescriptor()) XCTAssertEqual(keywords.map(\.keyword), ["preserved"]) migrated.mainContext.insert(PersistentTrackedMasternode( @@ -58,6 +64,211 @@ final class DashModelMigrationTests: XCTestCase { 1) } + /// The stage this change adds: a V3 store must migrate to V4 and read + /// back with the sweep columns backfilled to their "nothing swept yet" + /// values. V3 registers the frozen component, so the row goes in as the + /// frozen type and comes out as the live one — which is the whole point + /// of the freeze: the same entity, one property wider. A pending-input + /// row rides along so the tombstone index V4 adds is exercised by the + /// migration too. + @MainActor + func testV3StoreMigratesToV4AndBackfillsTheSweepColumns() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("dash.store") + + let walletId = Data(repeating: 0x5A, count: 32) + + let v3Schema = Schema(versionedSchema: DashSchemaV3.self) + let v3Configuration = ModelConfiguration( + "DashSweepMigrationTest", + schema: v3Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + var v3Container: ModelContainer? = try ModelContainer( + for: v3Schema, + configurations: [v3Configuration]) + v3Container?.mainContext.insert(DashSchemaV1.PersistentWallet( + walletId: walletId, + network: .testnet)) + v3Container?.mainContext.insert(DashSchemaV1.PersistentPendingInput( + outpoint: Data(repeating: 0x11, count: 36), + inputIndex: 0, + spendingTxid: Data(repeating: 0x22, count: 32), + spendingTransaction: nil, + walletId: walletId)) + // A transaction with one spent output: the two other widened + // models, so the migration is exercised on every column V4 adds. + let v3Funding = DashSchemaV1.PersistentTransaction( + txid: Data(repeating: 0x33, count: 32), + transactionData: Data([0x03, 0x00]), + context: 2, + blockHeight: 100) + v3Container?.mainContext.insert(v3Funding) + let v3Coin = DashSchemaV1.PersistentTxo( + transaction: v3Funding, + vout: 0, + amount: 1_000, + address: "yV3Coin", + height: 100) + v3Coin.walletId = walletId + v3Coin.isSpent = true + v3Container?.mainContext.insert(v3Coin) + try v3Container?.mainContext.save() + v3Container = nil + + let v4Schema = Schema(versionedSchema: DashSchemaV4.self) + let v4Configuration = ModelConfiguration( + "DashSweepMigrationTest", + schema: v4Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + let migrated = try ModelContainer( + for: v4Schema, + migrationPlan: DashMigrationPlan.self, + configurations: [v4Configuration]) + + let wallets = try migrated.mainContext.fetch( + FetchDescriptor()) + XCTAssertEqual(wallets.count, 1, "the V3 row must survive the migration") + XCTAssertNil( + wallets.first?.lastAppliedChainLockHeight, + "a wallet migrated from V3 has no chainlock boundary yet, so no " + + "tombstone it later takes can be collected on a fabricated one") + let pending = try migrated.mainContext.fetch( + FetchDescriptor()) + XCTAssertEqual(pending.count, 1, "the V3 pending row must survive the migration") + XCTAssertEqual(pending.first?.isSweptTombstone, false, "backfilled as an ordinary claim") + XCTAssertNil(pending.first?.winnerMinedHeight, "and unstamped") + let coins = try migrated.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(coins.count, 1, "the V3 TXO row must survive the migration") + XCTAssertEqual(coins.first?.isSpent, true, "its spent flag is carried as stored") + XCTAssertNil( + coins.first?.supersededByTxid, + "a coin migrated from V3 was never held by a sweep — the stamp backfills to nil, " + + "so the release and re-delivery rules see an ordinary spent coin") + let transactions = try migrated.mainContext.fetch( + FetchDescriptor()) + XCTAssertEqual(transactions.map(\.context), [2], "the V3 transaction row survives unchanged") + } + + /// The whole chain from the oldest registered version, on the models this + /// change actually widens: a V1 store carrying a wallet, a transaction + /// and a coin must arrive at V4 with every row intact and the V4 columns + /// at their backfill values. V1 and V2 register the frozen component, + /// so the rows go in as frozen types and come out live — the property + /// the freeze exists to guarantee, pinned here where it matters most. + @MainActor + func testV1StoreWithWalletTransactionAndCoinMigratesToV4() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("dash.store") + + let walletId = Data(repeating: 0x1A, count: 32) + let txid = Data(repeating: 0x1B, count: 32) + + let v1Schema = Schema(versionedSchema: DashSchemaV1.self) + let v1Configuration = ModelConfiguration( + "DashChainMigrationTest", + schema: v1Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + var v1Container: ModelContainer? = try ModelContainer( + for: v1Schema, + configurations: [v1Configuration]) + v1Container?.mainContext.insert(DashSchemaV1.PersistentWallet( + walletId: walletId, + network: .testnet)) + let v1Funding = DashSchemaV1.PersistentTransaction( + txid: txid, + transactionData: Data([0x03, 0x00]), + context: 3, + blockHeight: 50, + netAmount: 2_000) + v1Container?.mainContext.insert(v1Funding) + let v1Coin = DashSchemaV1.PersistentTxo( + transaction: v1Funding, + vout: 1, + amount: 2_000, + address: "yV1Coin", + height: 50) + v1Coin.walletId = walletId + v1Container?.mainContext.insert(v1Coin) + try v1Container?.mainContext.save() + v1Container = nil + + let v4Schema = Schema(versionedSchema: DashSchemaV4.self) + let v4Configuration = ModelConfiguration( + "DashChainMigrationTest", + schema: v4Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + let migrated = try ModelContainer( + for: v4Schema, + migrationPlan: DashMigrationPlan.self, + configurations: [v4Configuration]) + + let wallets = try migrated.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(wallets.map(\.walletId), [walletId]) + XCTAssertNil(wallets.first?.lastAppliedChainLockHeight) + let transactions = try migrated.mainContext.fetch( + FetchDescriptor()) + XCTAssertEqual(transactions.map(\.txid), [txid]) + XCTAssertEqual(transactions.first?.context, 3) + XCTAssertEqual(transactions.first?.netAmount, 2_000) + let coins = try migrated.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(coins.count, 1) + XCTAssertEqual(coins.first?.vout, 1) + XCTAssertEqual(coins.first?.amount, 2_000) + XCTAssertEqual(coins.first?.walletId, walletId) + XCTAssertEqual(coins.first?.isSpent, false) + XCTAssertNil(coins.first?.supersededByTxid) + XCTAssertEqual( + coins.first?.transaction?.txid, txid, + "the coin's relationship to its funding transaction survives three stages") + } + + /// What makes the V3 -> V4 stage lightweight: the two versions name the + /// same entity set, and V4 only widens three of them. Also pins that + /// `PersistentTransaction` is NOT one of the three — a swept row is + /// deleted outright, so the transaction entity carries no sweep marker, + /// and one that came back would silently change V4's checksum. + func testV3AndV4NameTheSameEntitySet() throws { + let v3 = Schema(versionedSchema: DashSchemaV3.self) + let v4 = Schema(versionedSchema: DashSchemaV4.self) + XCTAssertEqual( + v3.entities.map(\.name).sorted(), + v4.entities.map(\.name).sorted()) + + let transaction = try XCTUnwrap(v4.entities.first { $0.name == "PersistentTransaction" }) + let frozenTransaction = try XCTUnwrap(v3.entities.first { $0.name == "PersistentTransaction" }) + XCTAssertEqual( + transaction.attributesByName.keys.sorted(), + frozenTransaction.attributesByName.keys.sorted(), + "V4 adds no column to PersistentTransaction") + let txo = try XCTUnwrap(v4.entities.first { $0.name == "PersistentTxo" }) + XCTAssertNotNil(txo.attributesByName["supersededByTxid"]) + let pendingInput = try XCTUnwrap(v4.entities.first { $0.name == "PersistentPendingInput" }) + XCTAssertNotNil(pendingInput.attributesByName["isSweptTombstone"]) + XCTAssertNotNil(pendingInput.attributesByName["winnerMinedHeight"]) + let wallet = try XCTUnwrap(v4.entities.first { $0.name == "PersistentWallet" }) + XCTAssertNotNil(wallet.attributesByName["lastAppliedChainLockHeight"]) + + // And V3's frozen copies do not carry them. + let frozenTxo = try XCTUnwrap(v3.entities.first { $0.name == "PersistentTxo" }) + XCTAssertNil(frozenTxo.attributesByName["supersededByTxid"]) + } + /// Guards the freeze itself: `DashSchemaV1.PersistentAssetLock` only /// keeps V1/V2 stores openable if SwiftData names its entity /// "PersistentAssetLock" — i.e. from the UNQUALIFIED type name. If a @@ -69,7 +280,8 @@ final class DashModelMigrationTests: XCTestCase { for schema in [ Schema(versionedSchema: DashSchemaV1.self), Schema(versionedSchema: DashSchemaV2.self), - Schema(versionedSchema: DashSchemaV3.self) + Schema(versionedSchema: DashSchemaV3.self), + Schema(versionedSchema: DashSchemaV4.self) ] { let names = schema.entities.map(\.name) XCTAssertTrue( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 478c114d0ca..4fd3c52c85c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -61,6 +61,12 @@ final class InvitationPersistenceTests: XCTestCase { // the persist/load/free trio onto `PersistentTrackedMasternode`, // so restart survival is genuinely attested. | PlatformWalletPersistenceCapabilities.trackedMasternodes + | PlatformWalletPersistenceCapabilities.coreSweepRemoval + // DashPay payment rows: the handler wires + // `on_persist_dashpay_payments_fn` and lands the overlay on + // `PersistentDashpayPayment` rows, so the sweep's Failed flip + // may ride this store's rounds — genuinely attested. + | PlatformWalletPersistenceCapabilities.dashpayPayments XCTAssertEqual( capabilities.version, @@ -83,6 +89,9 @@ final class InvitationPersistenceTests: XCTestCase { XCTAssertFalse(diagnostic.contains( PlatformWalletPersistenceCapabilities.pendingContactCrypto )) + XCTAssertTrue(diagnostic.contains( + PlatformWalletPersistenceCapabilities.coreSweepRemoval + )) } /// Create inserts one row (fields mapped, `walletId` set), a re-upsert of the @@ -95,7 +104,11 @@ final class InvitationPersistenceTests: XCTestCase { // 1. Create. handler.beginChangeset(walletId: walletId) - handler.persistInvitations(walletId: walletId, upserts: [snapshot(statusRaw: 0)], removed: []) + XCTAssertTrue( + handler.persistInvitations( + walletId: walletId, upserts: [snapshot(statusRaw: 0)], removed: [] + ) + ) _ = handler.endChangeset(walletId: walletId, success: true) var rows = try fetchRows(container) @@ -110,7 +123,11 @@ final class InvitationPersistenceTests: XCTestCase { // 2. Status change → upsert in place, no duplicate row. handler.beginChangeset(walletId: walletId) - handler.persistInvitations(walletId: walletId, upserts: [snapshot(statusRaw: 1)], removed: []) + XCTAssertTrue( + handler.persistInvitations( + walletId: walletId, upserts: [snapshot(statusRaw: 1)], removed: [] + ) + ) _ = handler.endChangeset(walletId: walletId, success: true) rows = try fetchRows(container) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift new file mode 100644 index 00000000000..0656e587879 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SweptTransactionPersistTests.swift @@ -0,0 +1,3417 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for the one subtractive part of the changeset path: the sweep +/// batches delivered through the persistence extension's +/// `on_persist_wallet_changeset_sweeps_fn` alongside each round's +/// `WalletChangeSetFFI`. +/// +/// A swept transaction was a recorded spend that a later, final transaction +/// provably beat to one of its inputs, so it can never confirm and Rust has +/// already dropped it. Everything else the round carries is additive, so a +/// mirror that ignores the sweeps keeps the dead row, hands it back at the +/// next load, and re-creates a balance the wallet has already corrected — +/// the bug the upstream sweep exists to fix, one layer up. +/// +/// The fixtures model the shape that makes the coins tricky: an unconfirmed +/// loser — upstream sweeps nothing else — spends A and B, and the winner +/// takes only A. Because the loser never reached a block, this store never +/// flipped `isSpent` on either coin, so both are one deleted row away from +/// re-entering the restore set, and only the released set upstream carries +/// says which of them belongs there. +/// +/// Loser rows carry REAL consensus bytes (`serializedTransaction`): the +/// sweep keys its hold on the loser's decoded inputs, not on the links +/// the row happens to hold, so a fixture with undecodable bytes would only +/// exercise the link-keyed fallback. +@MainActor +final class SweptTransactionPersistTests: XCTestCase { + + private let walletId = Data(repeating: 0x01, count: 32) + private let fundingTxid = Data(repeating: 0x41, count: 32) + private let sweptTxid = Data(repeating: 0x42, count: 32) + private let winnerTxid = Data(repeating: 0x44, count: 32) + + private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// File-backed variant of `makeHandler()` — an in-memory store can't + /// outlive its own `ModelContainer`, so simulating a restart (a fresh + /// load/persister over the same on-disk store) needs a real file two + /// separate containers can both point at. + private func makeHandler(url: URL) throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let configuration = ModelConfiguration(schema: DashModelContainer.schema, url: url) + let container = try ModelContainer( + for: DashModelContainer.schema, + migrationPlan: DashMigrationPlan.self, + configurations: [configuration] + ) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// Serialize a plain version-2 transaction spending `inputs` (empty + /// scriptSigs) with `outputs` empty-script outputs, in the form + /// `TransactionDecoder` parses — which is what `applySweptTransaction` + /// decodes a loser's inputs from. The txid of these bytes is NOT the + /// fixture's row key; nothing in the store compares the two. + private func serializedTransaction( + inputs: [(txid: Data, vout: UInt32)], + outputs: Int = 1 + ) -> Data { + var bytes = Data() + bytes.append(contentsOf: withUnsafeBytes(of: UInt32(2).littleEndian) { Data($0) }) + bytes.append(UInt8(inputs.count)) + for input in inputs { + bytes.append(input.txid) + bytes.append(contentsOf: withUnsafeBytes(of: input.vout.littleEndian) { Data($0) }) + bytes.append(0x00) // empty scriptSig + bytes.append(contentsOf: [0xff, 0xff, 0xff, 0xff]) // sequence + } + bytes.append(UInt8(outputs)) + for _ in 0.. PersistentTransaction { + PersistentTransaction( + txid: txid, + transactionData: serializedTransaction(inputs: inputs), + context: 0, + blockHeight: 0, + netAmount: netAmount + ) + } + + /// Seed the shape a confirmed spend leaves behind: a funding transaction + /// with two outputs, a spending transaction that claimed both (linked + /// and flagged spent), and the change that spend created. + /// + /// `winnerTakesA` models a wallet-relevant winner that already + /// re-pointed A at itself, which is what the additive half of the round + /// does before the sweep runs. + private func seedSpend(in container: ModelContainer, winnerTakesA: Bool) throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 140_000 + ) + // Mempool context: the only kind of record upstream sweeps. + let swept = loserRow( + txid: sweptTxid, + spending: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1)], + netAmount: -140_000 + ) + context.insert(funding) + context.insert(swept) + + let winner: PersistentTransaction? + if winnerTakesA { + let row = PersistentTransaction( + txid: winnerTxid, + transactionData: serializedTransaction(inputs: [(txid: fundingTxid, vout: 0)]), + context: 2, + blockHeight: 102, + netAmount: -100_000 + ) + context.insert(row) + winner = row + } else { + winner = nil + } + + // A — the coin the winner also takes. When the winner is + // wallet-relevant its confirmed record owns the link and the flag; + // otherwise A is left where the unconfirmed loser put it, linked and + // unspent, which is what makes it indistinguishable from B. + let coinA = PersistentTxo( + transaction: funding, + vout: 0, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + coinA.walletId = walletId + coinA.isSpent = winner != nil + coinA.spendingTransaction = winner ?? swept + context.insert(coinA) + + // B — named only by the loser, and so still unspent. + let coinB = PersistentTxo( + transaction: funding, + vout: 1, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinB.walletId = walletId + coinB.spendingTransaction = swept + context.insert(coinB) + + let change = PersistentTxo( + transaction: swept, + vout: 0, + amount: 60_000, + address: "yChangeAddr", + height: 0 + ) + change.walletId = walletId + context.insert(change) + + try context.save() + } + + /// Drive one changeset round of sweeps through the same entry point the + /// Rust persister calls. + /// One sweep batch: the transactions it removed, the winner it is + /// attributed to, the winner's finality context, and the coins it + /// freed. + private struct Batch { + var losers: [Data] + var winner: Data + /// The winner's own mined block height — `SweepBatchFFI`'s + /// `has_winner_mined_height`/`winner_mined_height` pair. Non-nil + /// models a block-context sweep (the winner is mined, tombstones + /// are written and stamped with this height); `nil` models a + /// mempool-context sweep (the winner is IS-locked and not yet + /// mined, and no tombstone may be created). Deliberately + /// undefaulted so every test states which world it is in. + var winnerMinedHeight: UInt32? + var released: [(txid: Data, vout: UInt32)] = [] + } + + /// Drive a changeset of sweep batches through the same entry point the + /// Rust persister calls, preserving their order. + /// + /// The nested buffers are allocated explicitly and freed after the call. + /// `withUnsafeMutableBufferPointer` only guarantees its pointer for the + /// duration of its own closure, so storing `baseAddress` in a struct the + /// FFI reads later would hand the consumer a dangling pointer. + @discardableResult + private func sweep( + _ handler: PlatformWalletPersistenceHandler, + _ batches: [Batch] + ) -> Bool { + sweep(handler, batches, walletId: walletId) + } + + /// `walletId`-parameterized form for the multi-wallet tests below, + /// where the same shared loser row needs a separate callback per wallet + /// — each carrying that wallet's own `released` set, the way two real + /// `persistWalletChangeset` calls would. + @discardableResult + private func sweep( + _ handler: PlatformWalletPersistenceHandler, + _ batches: [Batch], + walletId: Data + ) -> Bool { + var applied = false + round(handler, walletId: walletId) { + applied = stageSweeps(handler, batches, walletId: walletId) + return applied + } + return applied + } + + /// One begin/end bracket, the way every Rust `store()` round is + /// delivered. `body` returns the round's success, which `endChangeset` + /// commits or rolls back on. + private func round( + _ handler: PlatformWalletPersistenceHandler, + walletId: Data? = nil, + _ body: () -> Bool + ) { + let walletId = walletId ?? self.walletId + handler.beginChangeset(walletId: walletId) + let success = body() + _ = handler.endChangeset(walletId: walletId, success: success) + } + + /// The sweeps callback alone, inside whatever bracket the caller + /// opened — so a test can stage a record and a sweep between ONE + /// `beginChangeset`/`endChangeset` pair, the shape Rust produces when + /// it folds a winner's detection and the loser's sweep into one round. + @discardableResult + private func stageSweeps( + _ handler: PlatformWalletPersistenceHandler, + _ batches: [Batch], + walletId: Data + ) -> Bool { + typealias RawTxid = ( + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, + UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8 + ) + + var txidBuffers: [UnsafeMutablePointer] = [] + var releasedBuffers: [UnsafeMutablePointer] = [] + var ffiBatches: [SweepBatchFFI] = [] + defer { + for (i, buf) in txidBuffers.enumerated() { + buf.deinitialize(count: batches[i].losers.count) + buf.deallocate() + } + for (i, buf) in releasedBuffers.enumerated() { + buf.deinitialize(count: batches[i].released.count) + buf.deallocate() + } + } + + for batch in batches { + let txids = UnsafeMutablePointer.allocate(capacity: max(batch.losers.count, 1)) + for (i, loser) in batch.losers.enumerated() { + var tuple: RawTxid = (0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0) + Swift.withUnsafeMutableBytes(of: &tuple) { dst in + loser.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + txids.advanced(by: i).initialize(to: tuple) + } + txidBuffers.append(txids) + + let freed = UnsafeMutablePointer.allocate( + capacity: max(batch.released.count, 1) + ) + for (i, outpoint) in batch.released.enumerated() { + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = outpoint.vout + freed.advanced(by: i).initialize(to: entry) + } + releasedBuffers.append(freed) + + var entry = SweepBatchFFI() + entry.txids = UnsafePointer(txids) + entry.txids_count = UInt(batch.losers.count) + entry.released_outpoints = UnsafePointer(freed) + entry.released_outpoints_count = UInt(batch.released.count) + Swift.withUnsafeMutableBytes(of: &entry.superseded_by) { dst in + batch.winner.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + // The winner's finality context: `has_winner_mined_height` + // false is the mempool path (IS-locked, unmined winner — + // no tombstone may be created), true carries the winner's + // own mined block. + entry.has_winner_mined_height = batch.winnerMinedHeight != nil + entry.winner_mined_height = batch.winnerMinedHeight ?? 0 + ffiBatches.append(entry) + } + + let sweeps = UnsafeMutablePointer.allocate( + capacity: max(ffiBatches.count, 1) + ) + sweeps.initialize(from: ffiBatches, count: ffiBatches.count) + defer { + sweeps.deinitialize(count: ffiBatches.count) + sweeps.deallocate() + } + + // The extension entry point, not a `WalletChangeSetFFI` field: the + // Rust persister delivers sweeps through the size-negotiated + // `on_persist_wallet_changeset_sweeps_fn` in the same round as the + // changeset callback, and this drives the Swift side of exactly + // that call. + return handler.persistWalletChangesetSweeps( + walletId: walletId, + sweeps: UnsafePointer(sweeps), + count: UInt(ffiBatches.count) + ) + } + + private func transaction(_ container: ModelContainer, txid: Data) -> PersistentTransaction? { + let context = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + ) + return try? context.fetch(descriptor).first + } + + private func txo(_ container: ModelContainer, txid: Data, vout: UInt32) -> PersistentTxo? { + let outpoint = PersistentTxo.makeOutpoint(txid: txid, vout: vout) + let context = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return try? context.fetch(descriptor).first + } + + /// The row and everything it created go; the funding transaction and its + /// coins stay. + func testSweptTransactionAndItsOutputsAreDeleted() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) + ]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row is gone") + XCTAssertNil(txo(container, txid: sweptTxid, vout: 0), "the change it created is gone with it") + XCTAssertNotNil(transaction(container, txid: fundingTxid), "the funding transaction is untouched") + } + + /// The released set is applied verbatim: the coin it names comes back, + /// and the one it does not stays out — the winner took that one. + func testSweepFreesOnlyTheInputsTheWinnerDidNotTake() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) + ]) + + let takenByWinner = txo(container, txid: fundingTxid, vout: 0) + XCTAssertNotNil(takenByWinner) + XCTAssertTrue(takenByWinner!.isSpent, "the coin the winner took stays spent") + XCTAssertEqual(takenByWinner!.spendingTransaction?.txid, winnerTxid) + + let losersOwn = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(losersOwn) + XCTAssertFalse(losersOwn!.isSpent, "the loser's own input is free again") + XCTAssertNil(losersOwn!.spendingTransaction) + } + + /// The winner does not have to reach this store at all: it can spend our + /// coin while paying only to outside addresses, and then no record for it + /// is ever written here. Nothing on hand could separate the coin it took + /// from the loser's own — upstream can, and says so through the released + /// set, which is the entire reason that set is carried. + func testAnAbsentWinnerStillKeepsItsOwnInputSpent() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]) + ]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the swept row still goes") + + let takenByWinner = txo(container, txid: fundingTxid, vout: 0) + XCTAssertNotNil(takenByWinner) + XCTAssertTrue( + takenByWinner!.isSpent, + "a coin the chain has already spent must not come back" + ) + XCTAssertNil(takenByWinner!.spendingTransaction, "and no spender is invented for it") + XCTAssertEqual( + takenByWinner!.supersededByTxid, + winnerTxid, + "the hold is attributed to the winner — SQLite's spent_in_txid, mirrored" + ) + + let losersOwn = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(losersOwn) + XCTAssertFalse( + losersOwn!.isSpent, + "the loser's own input is free, winner record or not" + ) + } + + /// A MATERIALISED coin the wallet hands back as unspent follows the + /// wallet, stamped hold or not. This test used to pin the opposite — + /// "the recovery clear refuses stamped rows" — on the reasoning that a + /// restore-rescan re-finds the funding output blind to an unconfirmed + /// winner. That reasoning only holds for a coin the wallet has never + /// materialised (the tombstone's job, see the drain tests below): a + /// coin the wallet knows is one whose every network-final spender is + /// wallet-relevant by BIP158 prevout matching, so the wallet's own scan + /// re-discovers the spend and its view is authoritative — and refusing + /// the re-delivery locks a real coin out forever after a reorg of the + /// winner, since a row with `isSpent == true` is never restored to Rust + /// again. The reference store's upsert valve was narrowed to + /// never-materialised placeholders for exactly this reason; this is the + /// same rule. + func testWalletReDeliveringAMaterialisedHeldCoinFreesIt() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(held.isSpent, "sanity: the sweep held the coin") + XCTAssertEqual(held.supersededByTxid, winnerTxid) + + redeliverCoinB(handler) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(freed.isSpent, "the wallet re-delivering a coin it knows frees it") + XCTAssertNil(freed.supersededByTxid, "the stamp clears with the hold") + XCTAssertNil(freed.spendingTransaction) + } + + /// The one shape the re-delivery does not free: a coin linked to a + /// spender with context at or above InstantSend-locked. Confirmed + /// evidence on record is never displaced by a re-delivery — the spend + /// emit and the record pass own that link. + func testWalletReDeliveringACoinLinkedToASettledSpenderKeepsItSpent() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + let context = ModelContext(container) + let coinB = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) + let row = try XCTUnwrap( + try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.outpoint == coinB } + )).first + ) + let winnerTxid = self.winnerTxid + row.isSpent = true + row.spendingTransaction = try XCTUnwrap( + try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.txid == winnerTxid } + )).first + ) + try context.save() + + redeliverCoinB(handler) + + let kept = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(kept.isSpent, "a coin an in-block spender holds stays spent") + XCTAssertEqual(kept.spendingTransaction?.txid, winnerTxid) + } + + /// 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. + /// Both writers it flows through resolved the in-block gate to false + /// and wrote it outright: `resolveInputOutpoint` on the record pass, + /// then `markUtxoSpent` on the `utxos_spent` emit riding the same + /// round. Either flipped the durable stamped hold back into the + /// restore set until the winner confirmed — contradicting the verdict + /// the sweep already recorded (and the handler's own "winner is + /// already final" reasoning). + func testAWinnersLateRecordDoesNotDowngradeAStampedHold() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let l = loserRow(txid: sweptTxid, spending: [(txid: fundingTxid, vout: 0)]) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // The sweep holds the claim; the funding TXO then materializes it + // as a stamped hold. + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).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. + deliverRecordWithSpentEmit( + handler, + txid: winnerTxid, + context: 1, + inputOutpoint: (txid: fundingTxid, vout: 0) + ) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + held.isSpent, + "the winner's own unconfirmed arrival must not downgrade the stamped hold" + ) + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertEqual( + held.spendingTransaction?.txid, + winnerTxid, + "the spender is linked all the same" + ) + } + + /// The record-only half of the scenario above: a flush can deliver the + /// winner's record without a `utxos_spent` emit (the wallet had no live + /// UTXO to classify — the coin sits as a stamped hold), so + /// `resolveInputOutpoint`'s own monotonic guard must carry the hold by + /// itself. Pinned separately because the combined test's spent emit + /// re-applies the hold through `markUtxoSpent`'s guard, masking a + /// regression in the record pass alone. + func testAWinnersLateRecordAloneDoesNotDowngradeAStampedHold() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let l = loserRow(txid: sweptTxid, spending: [(txid: fundingTxid, vout: 0)]) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + + deliverRecordWithSpentEmit( + handler, + txid: winnerTxid, + context: 1, + inputOutpoint: (txid: fundingTxid, vout: 0), + includeSpentEmit: false + ) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + held.isSpent, + "the record pass alone must not downgrade the stamped hold" + ) + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertEqual(held.spendingTransaction?.txid, winnerTxid) + } + + /// One changeset round carrying a transaction record and — unless the + /// caller opts out to pin the record pass alone — the `utxos_spent` + /// emit for the input it consumed, the shape a real round takes when + /// the wallet classifies the spend in the same flush as the record. + private func deliverRecordWithSpentEmit( + _ handler: PlatformWalletPersistenceHandler, + txid: Data, + context: UInt32, + inputOutpoint: (txid: Data, vout: UInt32), + includeSpentEmit: Bool = true + ) { + let name = strdup("Standard { index: 0 }") + defer { free(name) } + + var input = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &input.txid) { dst in + inputOutpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + input.vout = inputOutpoint.vout + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = 0 + + var spent = SpentOutPointFFI() + spent.outpoint = input + Swift.withUnsafeMutableBytes(of: &spent.spending_txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &input) { inputPtr in + record.input_outpoints = inputPtr + record.input_outpoints_count = 1 + withUnsafeMutablePointer(to: &record) { recordPtr in + withUnsafeMutablePointer(to: &spent) { spentPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + if includeSpentEmit { + account.utxos_spent = spentPtr + account.utxos_spent_count = 1 + } + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Multi-input record delivery with no spent emit — the shape a + /// wallet-relevant loser takes when its inputs were never classified + /// against live UTXOs (`input_outpoints` carries every raw input either + /// way). + private func deliverRecord( + _ handler: PlatformWalletPersistenceHandler, + walletId: Data? = nil, + txid: Data, + context: UInt32, + inputOutpoints: [(txid: Data, vout: UInt32)] + ) { + let walletId = walletId ?? self.walletId + let name = strdup("Standard { index: 0 }") + defer { free(name) } + + var inputs: [OutPointFFI] = inputOutpoints.map { outpoint in + var input = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &input.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + input.vout = outpoint.vout + return input + } + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = 0 + + handler.beginChangeset(walletId: walletId) + inputs.withUnsafeMutableBufferPointer { inputsPtr in + record.input_outpoints = inputsPtr.baseAddress + record.input_outpoints_count = UInt(inputsPtr.count) + withUnsafeMutablePointer(to: &record) { recordPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// The pruned-finalized-release defect, on this store's terms: an + /// InstantSend-locked spender F — settled under DIP-10, and one upstream + /// no longer sees after a restart — is linked to a coin a later loser L + /// reuses (alongside an attacker-owned input) while paying this wallet, + /// so L's sweep names F's coin wrongly in the released set. F's row and + /// its `spendingTransaction` link survive HERE: the settled-link guard + /// keeps L's record pass from stealing the attribution, and the release + /// veto (`releaseIsVetoed`) refuses the release the link contradicts, + /// while the coin only L claimed still comes free in the same batch. + /// + /// F is seeded IS-locked (context 1) with `isSpent == false` — an + /// unmined spender leaves the flag down — so the first assertion is + /// carried by the guard alone: a mempool arrival against a linked + /// spender that is merely flagged spent was already refused by the + /// pre-existing `isSpent` branch, which this fixture deliberately does + /// not exercise. + func testAReleaseNamingACoinASettledSpenderStillClaimsIsRefused() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let finalizedTxid = Data(repeating: 0x46, count: 32) + let attackerTxid = Data(repeating: 0x47, count: 32) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 200_000 + ) + // F: the IS-locked spender of the settled coin — upstream holds no + // history for it after a restart; this store keeps the row and the + // link. + let finalized = PersistentTransaction( + txid: finalizedTxid, + transactionData: serializedTransaction(inputs: [(txid: fundingTxid, vout: 0)]), + context: 1, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(funding) + context.insert(finalized) + + let settledCoin = PersistentTxo( + transaction: funding, + vout: 0, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + settledCoin.walletId = walletId + settledCoin.isSpent = false + settledCoin.spendingTransaction = finalized + context.insert(settledCoin) + + let losersOwnCoin = PersistentTxo( + transaction: funding, + vout: 1, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + losersOwnCoin.walletId = walletId + context.insert(losersOwnCoin) + try context.save() + + // 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. + deliverRecord( + handler, + txid: sweptTxid, + context: 0, + inputOutpoints: [ + (txid: fundingTxid, vout: 0), + (txid: attackerTxid, vout: 0), + (txid: fundingTxid, vout: 1), + ] + ) + XCTAssertEqual( + try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).spendingTransaction?.txid, + finalizedTxid, + "a settled spender's link is not stolen by a conflicting record" + ) + XCTAssertEqual( + try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)).spendingTransaction?.txid, + sweptTxid, + "the loser's own coin links normally" + ) + + // 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. + sweep(handler, [Batch( + losers: [sweptTxid], + winner: winnerTxid, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1)] + )]) + + let settled = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + settled.isSpent, + "a released coin a settled stored spender still claims is held spent" + ) + XCTAssertEqual(settled.spendingTransaction?.txid, finalizedTxid) + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(freed.isSpent, "a coin only the swept loser claimed must come free") + XCTAssertNil(freed.spendingTransaction) + XCTAssertNil(freed.supersededByTxid) + } + + /// The case `settledSpenderLinkIsKept` exists for, which the mempool + /// variant above does not reach: a plain in-block arrival (context 2) + /// against a spender that is only IS-locked (context 1, unmined). Under + /// DIP-10 the lock already settled the input, so an in-block + /// double-spend of it is the losing side of a conflict, not newer + /// evidence — the link stays with F. The one sanctioned takeover is + /// chainlock-over-IS-lock, pinned in the second half. + func testAnInBlockArrivalDoesNotTakeTheLinkFromAnInstantSendLockedSpender() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let finalizedTxid = Data(repeating: 0x46, count: 32) + let chainlockedTxid = Data(repeating: 0x48, count: 32) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 200_000 + ) + let finalized = PersistentTransaction( + txid: finalizedTxid, + transactionData: serializedTransaction(inputs: [(txid: fundingTxid, vout: 0)]), + context: 1, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(funding) + context.insert(finalized) + let settledCoin = PersistentTxo( + transaction: funding, + vout: 0, + amount: 100_000, + address: "yFundAddr", + height: 100 + ) + settledCoin.walletId = walletId + settledCoin.isSpent = false + settledCoin.spendingTransaction = finalized + context.insert(settledCoin) + try context.save() + + // L arrives IN A BLOCK, spending the coin F holds under its lock. + deliverRecord( + handler, + txid: sweptTxid, + context: 2, + inputOutpoints: [(txid: fundingTxid, vout: 0)] + ) + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertEqual( + held.spendingTransaction?.txid, + finalizedTxid, + "an in-block arrival does not take the link from an IS-locked spender" + ) + XCTAssertTrue(held.isSpent, "but the coin is spent either way — the flag never lowers") + + // L is swept (its block lost to the lock) with the coin in the + // release set upstream computed from live records that no longer + // include F: the surviving link vetoes the release. + sweep(handler, [Batch( + losers: [sweptTxid], + winner: winnerTxid, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + )]) + let afterSweep = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(afterSweep.isSpent, "the coin an IS-locked spender consumed stays spent") + XCTAssertEqual(afterSweep.spendingTransaction?.txid, finalizedTxid) + + // A chainlocked arrival is the one thing that outranks the lock. + deliverRecord( + handler, + txid: chainlockedTxid, + context: 3, + inputOutpoints: [(txid: fundingTxid, vout: 0)] + ) + XCTAssertEqual( + try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).spendingTransaction?.txid, + chainlockedTxid, + "chainlock-over-IS-lock is the sanctioned takeover" + ) + } + + /// The backstop for rows written before holds named their winner: a + /// coin held spent with neither a spender nor a `supersededByTxid` + /// stamp has nothing durable behind it, so the wallet re-delivering it + /// as a UTXO — the authority on what it holds — still lifts the mark. + /// Every hold written today is stamped; this pins the migration path + /// for the ones already on disk. + func testAPreStampHoldStillFreesOnRedelivery() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 40_000 + ) + context.insert(funding) + let coinB = PersistentTxo( + transaction: funding, + vout: 1, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinB.walletId = walletId + coinB.isSpent = true + context.insert(coinB) + try context.save() + + redeliverCoinB(handler) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(freed.isSpent, "a hold with nothing durable behind it frees on re-delivery") + XCTAssertNil(freed.spendingTransaction) + } + + /// Hand coin B back through the ordinary account changeset, the way a + /// rescan that re-finds the funding transaction does. + private func redeliverCoinB(_ handler: PlatformWalletPersistenceHandler) { + let name = strdup("Standard { index: 0 }") + let address = strdup("yFundAddr") + defer { + free(name) + free(address) + } + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + fundingTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = 1 + utxo.amount = 40_000 + utxo.address = address + utxo.height = 100 + utxo.is_confirmed = true + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Two sweeps in one round, the later disagreeing with the earlier. + /// + /// The first frees coin B; a second transaction spends it; the second + /// sweep removes that spender and frees nothing, because its own winner + /// took B. The later answer is the true one — and it only sticks because + /// the batches are applied in sequence. Folding their release sets would + /// leave the first "B is free" outliving the last "B is spent". + func testALaterSweepKeepingACoinSpentOverridesAnEarlierRelease() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + // A second transaction takes coin B after the first sweep freed it. + let secondLoser = Data(repeating: 0x55, count: 32) + let context = ModelContext(container) + let reclaimer = loserRow( + txid: secondLoser, + spending: [(txid: fundingTxid, vout: 1)], + netAmount: -40_000 + ) + context.insert(reclaimer) + let coinB = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == coinB } + ) + let row = try XCTUnwrap(try context.fetch(descriptor).first) + row.spendingTransaction = reclaimer + try context.save() + + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 1)]), + // Its winner consumed B, so this batch frees nothing. + Batch(losers: [secondLoser], winner: Data(repeating: 0x56, count: 32), winnerMinedHeight: 400), + ]) + + let contested = txo(container, txid: fundingTxid, vout: 1) + XCTAssertNotNil(contested) + XCTAssertTrue( + contested!.isSpent, + "the later sweep kept the coin spent, so it must not come back" + ) + } + + /// Seed the review finding's exact shape: one loser transaction shared + /// by two wallets, spending a coin from each. `walletA` owns P, `walletB` + /// owns Q; neither wallet's `PersistentTransaction` row for the winner is + /// ever created here, matching the "winner can pay only outside + /// addresses" case the released set exists to handle. The two coins live + /// in the same funding transaction only for setup convenience — nothing + /// about the fix depends on that; what makes `loser` shared is that its + /// `row.inputs` spans two different owning wallets. + private func seedSharedLoserAcrossTwoWallets( + in container: ModelContainer, + walletA: Data, + walletB: Data, + loserTxid: Data + ) throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletA, network: .testnet)) + context.insert(PersistentWallet(walletId: walletB, network: .testnet)) + + let funding = PersistentTransaction( + txid: fundingTxid, + transactionData: Data(repeating: 0x04, count: 10), + context: 2, + blockHeight: 100, + netAmount: 140_000 + ) + context.insert(funding) + + let loser = loserRow( + txid: loserTxid, + spending: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1)], + netAmount: -140_000 + ) + context.insert(loser) + + // P — wallet A's coin, claimed only by the shared loser. + let coinP = PersistentTxo( + transaction: funding, vout: 0, amount: 100_000, address: "yWalletA", height: 100 + ) + coinP.walletId = walletA + coinP.spendingTransaction = loser + context.insert(coinP) + + // Q — wallet B's coin, also claimed only by the shared loser. + let coinQ = PersistentTxo( + transaction: funding, vout: 1, amount: 40_000, address: "yWalletB", height: 100 + ) + coinQ.walletId = walletB + coinQ.spendingTransaction = loser + context.insert(coinQ) + + try context.save() + } + + /// The BLOCKING finding's exact shape, built on top of + /// `seedSharedLoserAcrossTwoWallets`: the shared loser also created an + /// output of its own — phantom money, since a transaction that never + /// confirms funded nothing — and was `involvedAccounts`-linked to an + /// account under `walletA` from back when it was still a live candidate + /// (the ordinary `upsertTransaction` path does this before a later round + /// ever learns the tx lost a double-spend). That link is what makes this + /// fixture actually exercise the fix: were the row to survive, + /// `walletOwnsTransaction` would find `walletA` through + /// `involvedAccounts` alone, regardless of what happens to P. + private func seedSharedLoserWithOutputAndInvolvedAccount( + in container: ModelContainer, + walletA: Data, + walletB: Data, + loserTxid: Data + ) throws { + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletA, walletB: walletB, loserTxid: loserTxid + ) + let context = ModelContext(container) + let walletRecord = try XCTUnwrap( + try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.walletId == walletA }) + ).first + ) + let account = PersistentAccount( + wallet: walletRecord, accountType: 0, accountIndex: 0, accountTypeName: "Standard" + ) + context.insert(account) + + let loserDescriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == loserTxid } + ) + let loser = try XCTUnwrap(try context.fetch(loserDescriptor).first) + loser.involvedAccounts.append(account) + + let phantomChange = PersistentTxo( + transaction: loser, vout: 2, amount: 60_000, address: "yLoserChange", height: 0 + ) + phantomChange.walletId = walletA + context.insert(phantomChange) + + try context.save() + } + + /// The hold is global, the release is per wallet — order 1: wallet B's + /// callback, the one that releases nothing, runs first. It is the first + /// callback to see the sweep, so it holds EVERY wallet's coins the + /// loser claimed (P is wallet A's, and is held all the same: a released + /// set is only ever true of the wallet that computed it, and B's says + /// nothing about P) and deletes the shared row outright. Wallet A's + /// later callback finds no row and still applies its release by + /// outpoint, freeing P; B's hold on Q is untouched by it. + func testSharedLoserAppliesBothWalletsReleaseSetsRegardlessOfOrder_BThenA() throws { + let (handler, container) = try makeHandler() + let loserTxid = Data(repeating: 0x81, count: 32) + let winner = Data(repeating: 0x82, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Wallet B first: its own released set names nothing. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + XCTAssertNil( + transaction(container, txid: loserTxid), + "the first callback to see the sweep deletes the row — hold before delete" + ) + let heldP = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(heldP.isSpent, "wallet A's coin is held by wallet B's callback — the hold is global") + XCTAssertEqual(heldP.supersededByTxid, winner) + XCTAssertNil(heldP.spendingTransaction, "the link to the dead loser is gone") + + // Wallet A second: its own released set names P. + sweep(handler, [ + Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ], walletId: walletId) + + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(p.isSpent, "wallet A's own release frees its own coin, row or no row") + XCTAssertNil(p.supersededByTxid) + XCTAssertNil(p.spendingTransaction) + + let q = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(q.isSpent, "wallet B's hold on Q must survive wallet A's callback") + XCTAssertEqual(q.supersededByTxid, winner) + XCTAssertNil(q.spendingTransaction) + } + + /// Order 2: wallet A — the one that releases P — runs first. It frees + /// its own P, holds wallet B's Q (global hold) and deletes the row; + /// wallet B's callback then finds no row and, releasing nothing, leaves + /// its hold on Q as it is. Order-independent: the exact same end state + /// as the B-then-A ordering above. + func testSharedLoserAppliesBothWalletsReleaseSetsRegardlessOfOrder_AThenB() throws { + let (handler, container) = try makeHandler() + let loserTxid = Data(repeating: 0x91, count: 32) + let winner = Data(repeating: 0x92, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Wallet A first: releases P. + sweep(handler, [ + Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ], walletId: walletId) + + XCTAssertNil( + transaction(container, txid: loserTxid), + "the first callback to see the sweep deletes the row — hold before delete" + ) + let heldQ = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(heldQ.isSpent, "wallet B's coin is held by wallet A's callback — the hold is global") + XCTAssertEqual(heldQ.supersededByTxid, winner) + XCTAssertNil(heldQ.spendingTransaction) + + // Wallet B second: releases nothing. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(p.isSpent, "wallet A's earlier release must survive wallet B's callback") + XCTAssertNil(p.supersededByTxid) + XCTAssertNil(p.spendingTransaction) + + let q = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(q.isSpent, "wallet B's coin stays held — its own callback released nothing") + XCTAssertEqual(q.supersededByTxid, winner) + XCTAssertNil(q.spendingTransaction) + } + + /// The BLOCKING review finding: a shared loser's own output, and its + /// reachability through `walletCoreTxids`, must not survive across a + /// restart when only ONE wallet's callback ever commits and the other's + /// never arrives at all — a crash, a rejection, or simply never coming. + /// + /// `commit_batch` calls `store()` once per wallet and each commits + /// independently. A row held back for another wallet's still-pending + /// callback is a row that survives forever when that callback never + /// comes — with its phantom output and its `involvedAccounts` link to + /// wallet A fully live, so `walletCoreTxids` would hand the dead + /// transaction back to wallet A as its own after every future restart. + /// So the first callback to see the sweep deletes the row and its + /// outputs for every wallet, after holding every wallet's inputs; a + /// surviving swept row is a shape that no longer exists, and no reader + /// needs a guard against it. + /// + /// Only wallet B's callback ever runs here, and it releases nothing — + /// the worst case for the old deferred delete. + func testSharedLoserOutputAndEnumerationAreExcludedAfterOnlyOneWalletsCallbackCommits() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-shared-durability-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + let loserTxid = Data(repeating: 0xA1, count: 32) + let winner = Data(repeating: 0xA2, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + + do { + let (handler, container) = try makeHandler(url: storeURL) + try seedSharedLoserWithOutputAndInvolvedAccount( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Only wallet B's callback ever runs, and it releases nothing — + // wallet A's own callback (which would release P) never arrives + // in this test at all. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + XCTAssertNil( + transaction(container, txid: loserTxid), + "the row is deleted by whichever wallet's callback sees the sweep first" + ) + XCTAssertNil( + txo(container, txid: loserTxid, vout: 2), + "the loser's own output must not survive even a single committed callback, " + + "regardless of which wallet's callback that was" + ) + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(p.isSpent, "wallet A's coin is held for the winner until A's own release") + XCTAssertEqual(p.supersededByTxid, winner) + } + + // Restart: a fresh handler/container over the same file. Wallet A's + // callback never happens in this test, simulating a crash or a + // rejection that stops it from ever arriving — the exact scenario + // the finding describes. + let (handler, container) = try makeHandler(url: storeURL) + + XCTAssertNil( + txo(container, txid: loserTxid, vout: 2), + "the phantom output must not resurrect across a restart" + ) + XCTAssertNil(transaction(container, txid: loserTxid), "nor the row") + let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) + XCTAssertFalse(erroredA) + XCTAssertFalse( + txidsA.contains { $0.txid == loserTxid }, + "wallet A must not be able to enumerate the swept loser as its own transaction " + + "after a restart, even though its own callback never ran" + ) + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(p.isSpent, "and the hold on its coin is durable") + XCTAssertEqual(p.supersededByTxid, winner) + } + + /// Cross-round reinstatement. The sweep and its reinstating record land + /// in two SEPARATE `persistWalletChangeset` rounds: round 1 deletes the + /// shared row and holds wallet A's coin for the winner; round 2's record + /// — upstream's newer word, per `CoreChangeSet::merge`'s documented + /// IS-lock-precedence sequence (swept by an IS-locked conflict, then + /// returns chainlocked and sweeps that conflict in turn) — arrives like + /// any freshly detected transaction, inserts a fresh row, re-adopts the + /// held coin's link and brings its output back through the + /// `utxos_added` riding alongside. Verified across a restart: the + /// reinstatement has to be durable, not merely visible in the context + /// that just applied it. + func testAReinstatingRecordInALaterRoundRevivesASweptTransactionAndItsOutputs() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-reinstatement-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + let loserTxid = Data(repeating: 0xB1, count: 32) + let winner = Data(repeating: 0xB2, count: 32) + let walletB = Data(repeating: 0x02, count: 32) + + do { + let (handler, container) = try makeHandler(url: storeURL) + try seedSharedLoserWithOutputAndInvolvedAccount( + in: container, walletA: walletId, walletB: walletB, loserTxid: loserTxid + ) + + // Round 1: only wallet B's own sweep callback runs, releasing + // nothing. The row and its phantom output go, and wallet A's + // coin P is held for the winner. + sweep(handler, [Batch(losers: [loserTxid], winner: winner, winnerMinedHeight: 400)], walletId: walletB) + + XCTAssertNil(transaction(container, txid: loserTxid), "sanity: the row is gone after round 1") + XCTAssertNil( + txo(container, txid: loserTxid, vout: 2), + "sanity: the loser's own output is gone after round 1" + ) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + + // Round 2, a SEPARATE callback (not coalesced with round 1's + // sweep — the cross-round shape the merge-level fix in + // `CoreChangeSet::merge` cannot reach): the wallet returns + // chainlocked and sweeps the erstwhile winner in turn. Arrives + // here exactly like any freshly-detected transaction would — + // nothing marks it as "the reinstating one" — with its own + // output riding along in the same round the way a transaction's + // outputs ordinarily do. + deliverReinstatingRecord( + handler, + walletId: walletId, + txid: loserTxid, + context: 3, // inChainLockedBlock + blockHeight: 200, + inputOutpoints: [(txid: fundingTxid, vout: 0)], + outputVout: 2, + outputAmount: 60_000, + outputAddress: "yLoserChange" + ) + + let reinstated = try XCTUnwrap( + transaction(container, txid: loserTxid), + "the reinstating record must not be discarded" + ) + XCTAssertEqual(reinstated.blockHeight, 200) + + let revivedOutput = try XCTUnwrap( + txo(container, txid: loserTxid, vout: 2), + "the reinstated transaction's own output must come back" + ) + XCTAssertEqual(revivedOutput.amount, 60_000) + + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(p.isSpent, "wallet A reclaims its input once its own record is live again") + XCTAssertEqual(p.spendingTransaction?.txid, loserTxid) + + let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) + XCTAssertFalse(erroredA) + XCTAssertTrue( + txidsA.contains { $0.txid == loserTxid }, + "wallet A must be able to enumerate the reinstated transaction as its own again" + ) + } + + // Restart: a fresh handler/container over the same file. The + // reinstatement has to be durable, not just visible to the context + // that applied it. + let (handler, container) = try makeHandler(url: storeURL) + + XCTAssertNotNil(transaction(container, txid: loserTxid), "the reinstatement must survive a restart") + XCTAssertNotNil( + txo(container, txid: loserTxid, vout: 2), + "the revived output must survive a restart" + ) + let p = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(p.isSpent, "the reclaimed input must survive a restart") + XCTAssertEqual(p.spendingTransaction?.txid, loserTxid) + + let (txidsA, erroredA) = handler.walletCoreTxids(walletId: walletId) + XCTAssertFalse(erroredA) + XCTAssertTrue( + txidsA.contains { $0.txid == loserTxid }, + "the reinstated transaction must still enumerate as wallet A's own after a restart" + ) + } + + /// A failed wallet lookup must fail the round, not read as "no such + /// wallet". + /// + /// `try?` collapsed the two: a thrown SwiftData fetch returned success + /// without applying the sweep, Rust discarded the subtractive event, and + /// a later round could then persist a height beyond a removal that never + /// landed. Driving the real failure is awkward, so this pins the + /// distinction that makes it impossible — a wallet that genuinely is not + /// there is still a successful no-op. + func testAMissingWalletIsASuccessfulNoOp() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + // Delete the wallet row, leaving the fetch to succeed and find + // nothing — the branch that must stay a success. + let context = ModelContext(container) + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + for row in try context.fetch(descriptor) { + context.delete(row) + } + try context.save() + + let applied = sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + XCTAssertTrue(applied, "a stale post-deletion callback is not a failure") + XCTAssertNotNil( + transaction(container, txid: sweptTxid), + "and it must not have applied anything either" + ) + } + + /// Companion to `testAMissingWalletIsASuccessfulNoOp` above, which its + /// own doc admits does not distinguish the fix from the old `try?` + /// behavior — a successful empty fetch reads identically either way. + /// This drives a genuinely THROWING fetch instead, using a real seam + /// rather than a mock: a file-backed store (so the container's SQLite + /// connection is live and long-lived, unlike the in-memory variant) is + /// truncated on disk, out from under that open connection, between + /// seeding and the sweep. `fetchWalletRecord`'s `context.fetch` then has + /// to perform real I/O against a file that is no longer a valid SQLite + /// database, which is the only way found to make it throw without + /// adding a test-only injection point to production code. + func testAThrowingWalletLookupFailsTheRound() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-throwing-lookup-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + + let (handler, _) = try makeHandler(url: storeURL) + + // Corrupt the on-disk store out from under the still-open container + // BEFORE any context — including a seed helper's — reads or writes + // through it: SwiftData's row cache is scoped to the persistent + // store coordinator, not to any one `ModelContext`, so a row + // touched by a throwaway seeding context would still be served from + // that shared cache here and never reach disk at all. With nothing + // cached yet, `fetchWalletRecord`'s fetch is the first real read + // this store ever performs, and it hits the truncated file — well + // short of a valid SQLite header — directly. + let handle = try FileHandle(forWritingTo: storeURL) + handle.truncateFile(atOffset: 16) + try handle.close() + + let applied = sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + XCTAssertFalse(applied, "a genuinely failed wallet lookup must fail the round") + } + + /// Two wallets, each holding an unresolved pending input on the same + /// shared loser, and a winner that took neither — so upstream names + /// both coins released in BOTH wallets' views (a released set is + /// computed from the loser's and the winner's inputs, the same for + /// every wallet). A release only ever touches the releasing wallet's + /// own rows: the first callback (A's) deletes its own released row, + /// holds B's — A's released set is not the authority on B's claim — + /// and deletes the loser; B's callback then applies its own release by + /// outpoint against the tombstone. Nothing is left behind: no row, no + /// pending entry of either wallet's. + func testEachWalletsReleaseReachesItsOwnPendingRowOnASharedLoser() throws { + let (handler, container) = try makeHandler() + let walletB = Data(repeating: 0x02, count: 32) + try seedSharedLoserAcrossTwoWallets( + in: container, walletA: walletId, walletB: walletB, loserTxid: sweptTxid + ) + + // Each wallet has one pending input on the loser, and each will be + // released by its own wallet's sweep. The loser's bytes name both. + let context = ModelContext(container) + let loserTxid = sweptTxid + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.txid == loserTxid } + ) + descriptor.fetchLimit = 1 + let loser = try XCTUnwrap(try context.fetch(descriptor).first) + loser.transactionData = serializedTransaction(inputs: [ + (txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1), + (txid: fundingTxid, vout: 8), (txid: fundingTxid, vout: 9), + ]) + let pendingA = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 8), + inputIndex: 0, + spendingTxid: loserTxid, + spendingTransaction: loser, + walletId: walletId + ) + let pendingB = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 9), + inputIndex: 1, + spendingTxid: loserTxid, + spendingTransaction: loser, + walletId: walletB + ) + context.insert(pendingA) + context.insert(pendingB) + try context.save() + + let releasedInBothViews = [(txid: fundingTxid, vout: UInt32(8)), (txid: fundingTxid, vout: UInt32(9))] + sweep(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: releasedInBothViews) + ]) + XCTAssertNil(transaction(container, txid: sweptTxid), "the first callback deletes the row") + XCTAssertTrue( + try pendingRows(container, spentTxid: fundingTxid, vout: 8).isEmpty, + "wallet A's released row is deleted outright — never a released tombstone" + ) + let heldB = try XCTUnwrap( + try pendingRows(container, spentTxid: fundingTxid, vout: 9).first, + "wallet B's row is held for the winner by wallet A's callback" + ) + XCTAssertTrue(heldB.isSweptTombstone) + XCTAssertEqual(heldB.spendingTxid, winnerTxid) + XCTAssertEqual(heldB.walletId, walletB) + + sweep( + handler, + [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400, released: releasedInBothViews)], + walletId: walletB + ) + XCTAssertTrue( + try pendingRows(container, spentTxid: fundingTxid, vout: 9).isEmpty, + "wallet B's own release reaches its tombstone with the row already gone" + ) + } + + /// A txid the store has never seen is not an error: sweeps are + /// idempotent, and a round can name a transaction this mirror never + /// recorded in the first place. + func testSweepingAnUnknownTransactionIsANoOp() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: true) + + let applied = sweep(handler, [ + Batch(losers: [Data(repeating: 0x99, count: 32)], winner: winnerTxid, winnerMinedHeight: 400) + ]) + + XCTAssertTrue(applied, "an absent row is a successful no-op, not a failed round") + XCTAssertNotNil(transaction(container, txid: sweptTxid)) + XCTAssertNotNil(transaction(container, txid: fundingTxid)) + } + + /// The loser can be persisted before its own funding output ever is — + /// `upsertTransaction` parks a spend like that as a `PersistentPendingInput` + /// rather than a `PersistentTxo` update (see `resolveInputOutpoint`). + /// When the sweep holds that input (it's not in `released`), there is no + /// `PersistentTxo` row to mark — the only record of the claim is the + /// pending row, which cascades away with the loser it names unless + /// `applySweptTransaction` 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. + func testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("swept-pending-input-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + + do { + let (handler, container) = try makeHandler(url: storeURL) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let swept = loserRow(txid: sweptTxid, spending: [(txid: fundingTxid, vout: 0)]) + context.insert(swept) + // What `resolveInputOutpoint` would have written: the funding + // TXO for (fundingTxid, 0) has never been seen here. + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: swept, + walletId: walletId + )) + try context.save() + XCTAssertNil( + txo(container, txid: fundingTxid, vout: 0), + "sanity: the funding TXO has not arrived yet" + ) + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + XCTAssertNil(transaction(container, txid: sweptTxid), "the loser is gone") + } + + // Restart: a fresh persister loading the same on-disk store. + let (handler, container) = try makeHandler(url: storeURL) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the funding UTXO's own upsert must still create the row" + ) + XCTAssertTrue( + coin.isSpent, + "the winner's claim must survive the loser's deletion, a restart, " + + "and the funding UTXO's own arrival" + ) + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// 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 — and the tombstone keeps the loser's + /// original, older `createdAt`. A newest-wins pick over all rows once + /// selected the winner's ordinary row, took the gated branch (`isSpent` + /// stays false until the winner confirms — never, for an IS-locked + /// unconfirmed winner), skipped the `supersededByTxid` stamp, and + /// deleted every pending row including the tombstone: the durable hold + /// evaporated and the consumed coin re-entered the restore set. The + /// tombstone supplies the stamp regardless of age; the winner's own row + /// supplies the link beside it. + func testAWinnersOwnPendingRowDoesNotEvaporateTheSweepTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let outpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + + // The doomed spend arrived before its funding output — parked as a + // pending row, exactly what `resolveInputOutpoint` writes. Backdated + // so the winner's row below is strictly newer, as it always is in + // reality (the loser's record preceded the winner's by definition). + let loser = loserRow(txid: sweptTxid, spending: [(txid: fundingTxid, vout: 0)]) + context.insert(loser) + let losersClaim = PersistentPendingInput( + outpoint: outpoint, + inputIndex: 0, + spendingTxid: sweptTxid, + spendingTransaction: loser, + walletId: walletId + ) + losersClaim.createdAt = Date(timeIntervalSinceNow: -10) + context.insert(losersClaim) + + // The winner's own record — IS-locked, still unconfirmed — lands in + // the same round as the sweep, records first, and stages its own + // ordinary pending row for the same still-unfunded outpoint. + let winner = PersistentTransaction( + txid: winnerTxid, + transactionData: serializedTransaction(inputs: [(txid: fundingTxid, vout: 0)]), + context: 1, + blockHeight: 0, + netAmount: -100_000 + ) + context.insert(winner) + context.insert(PersistentPendingInput( + outpoint: outpoint, + inputIndex: 0, + spendingTxid: winnerTxid, + spendingTransaction: winner, + walletId: walletId + )) + try context.save() + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + // Sanity: the coexisting pair this regression is about — the + // winner's ordinary row plus the repointed tombstone. + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + let rows = try context.fetch(pendingDescriptor) + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(rows.filter(\.isSweptTombstone).count, 1) + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + coin.isSpent, + "the sweep's hold must survive the winner's own coexisting pending row" + ) + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + XCTAssertEqual( + coin.spendingTransaction?.txid, winnerTxid, + "and the winner's own row supplies the attribution the tombstone cannot" + ) + } + + /// Chained-sweep continuation of `testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent` + /// above: L spends P; W spends P and Q and sweeps L, holding P (still + /// unfunded); X spends Q and sweeps W, this time releasing P. The + /// tombstone `applySweptTransaction` wrote for P when L was swept + /// already detached from `spendingTransaction`, so the second sweep of + /// W cannot find it through `row.pendingInputs` the way the first sweep + /// did — it can only be found by the scalar `spendingTxid` it now + /// carries. This is the review finding: without that second lookup, the + /// second sweep's release of P is silently dropped, and P's funding TXO + /// resurrects the coin attributed to the wrong (already deleted) + /// transaction instead of coming back spendable. + func testChainedSweepBeforeFundingReleasesAnEarlierTombstoneOnASecondSweep() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x61, count: 32) // L + let secondLoser = Data(repeating: 0x62, count: 32) // W + let finalWinner = Data(repeating: 0x63, count: 32) // X + + let l = loserRow(txid: firstLoser, spending: [(txid: fundingTxid, vout: 0)]) + context.insert(l) + // P (fundingTxid:0) has never been observed as a TXO — parked as a + // pending input, the same as `testSpendBeforeFundingSweptThenRestartedThenFundedStaysSpent`. + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding P (still unfunded). + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) + + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let tombstoneDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + let tombstone = try XCTUnwrap(try context.fetch(tombstoneDescriptor).first) + XCTAssertTrue(tombstone.isSweptTombstone, "the first sweep must tombstone the pending row") + XCTAssertEqual(tombstone.spendingTxid, secondLoser) + XCTAssertNil(tombstone.spendingTransaction, "must have detached from the doomed loser's FK") + + // W's own row, plus a materialized claim on Q, needed for the + // second sweep to find W at all — the same requirement any sweep of + // a wallet-relevant loser has. + let w = loserRow( + txid: secondLoser, + spending: [(txid: fundingTxid, vout: 0), (txid: Data(repeating: 0x65, count: 32), vout: 0)], + netAmount: -90_000 + ) + context.insert(w) + let qFunding = PersistentTransaction( + txid: Data(repeating: 0x65, count: 32), + transactionData: Data(repeating: 0x09, count: 10), + context: 2, + blockHeight: 100, + netAmount: 40_000 + ) + context.insert(qFunding) + let coinQ = PersistentTxo( + transaction: qFunding, + vout: 0, + amount: 40_000, + address: "yFundAddr", + height: 100 + ) + coinQ.walletId = walletId + coinQ.spendingTransaction = w + context.insert(coinQ) + try context.save() + + // Second sweep: X beats W, this time releasing P. + sweep(handler, [ + Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ]) + + let survivingTombstones = try context.fetch(tombstoneDescriptor) + XCTAssertTrue( + survivingTombstones.isEmpty, + "a released outpoint's tombstone must not survive a chained sweep" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the funding UTXO's own upsert must still create the row" + ) + XCTAssertFalse( + coin.isSpent, + "the final sweep released this coin, so it must come back spendable even " + + "though an earlier sweep in the chain had tombstoned it" + ) + XCTAssertNil(coin.supersededByTxid) + } + + /// 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. + func testChainedSweepBeforeFundingRepointsAnEarlierTombstoneToTheNewWinner() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x71, count: 32) // L + let secondLoser = Data(repeating: 0x72, count: 32) // W + let finalWinner = Data(repeating: 0x73, count: 32) // X + + let l = loserRow(txid: firstLoser, spending: [(txid: fundingTxid, vout: 0)]) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding P. + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) + + // W's own row — this time claiming ONLY P, so the second sweep has + // no other input to reason about. + let w = loserRow(txid: secondLoser, spending: [(txid: fundingTxid, vout: 0)]) + context.insert(w) + try context.save() + + // Second sweep: X beats W, still holding the same input. + sweep(handler, [Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400)]) + + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let tombstoneDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + let tombstone = try XCTUnwrap(try context.fetch(tombstoneDescriptor).first) + XCTAssertTrue(tombstone.isSweptTombstone) + XCTAssertEqual( + tombstone.spendingTxid, + finalWinner, + "the tombstone must be repointed at the FINAL winner, not the intermediate " + + "one the second sweep already removed" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue( + coin.isSpent, + "the final winner's claim must survive both sweeps and the funding UTXO's own arrival" + ) + XCTAssertEqual(coin.supersededByTxid, finalWinner) + } + + /// 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`, recovery clear + /// refusing stamped holds. Permanently unspendable. A dead parent's + /// output is nobody's coin; the claim must be deleted with the batch. + func testABatchSweepingParentAndChildDeletesTheChildsClaimOnTheParentsOutput() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + // P is `fundingTxid` (so the redelivery helper reaches it) and its + // record was never persisted — the weaker-preconditions shape. C's + // claim on P:0 is parked as a pending row, exactly what + // `resolveInputOutpoint` writes. + let childTxid = Data(repeating: 0xB5, count: 32) // C + let winner = Data(repeating: 0xB6, count: 32) // W + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + + let c = loserRow(txid: childTxid, spending: [(txid: fundingTxid, vout: 0)], netAmount: -50_000) + context.insert(c) + context.insert(PersistentPendingInput( + outpoint: pOutpoint, + inputIndex: 0, + spendingTxid: childTxid, + spendingTransaction: c, + walletId: walletId + )) + try context.save() + + // One batch removes both; upstream excludes P:0 from the released + // set because its funder is itself a loser. + sweep(handler, [Batch(losers: [fundingTxid, childTxid], winner: winner, winnerMinedHeight: 400)]) + + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + XCTAssertTrue( + try context.fetch(pendingDescriptor).isEmpty, + "a claim on a co-swept parent's output must be deleted, not tombstoned" + ) + + // The chainlocked return: P reinstated with its output re-delivered + // must land spendable — nothing the batch left behind may hold it. + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse( + coin.isSpent, + "the reinstated parent's output must not be wedged by its dead child's claim" + ) + XCTAssertNil(coin.supersededByTxid) + } + + /// The whole chain inside ONE round: a single sweeps callback can carry + /// two batches where the second sweeps the first's winner, so the + /// tombstone the first batch just wrote — staged, unsaved, retargeted by + /// nothing but in-memory mutation — must reach the second batch. Pins + /// the once-per-round tombstone map being re-keyed in memory as batches + /// run: a second store fetch would not see the re-point, and a + /// store-side predicate on the mutable column would test the stale + /// saved value and miss the row entirely. + func testChainedSweepAcrossTwoBatchesInOneRoundReleasesTheFreshTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0xA1, count: 32) // L + let secondLoser = Data(repeating: 0xA2, count: 32) // W — batch 1's winner + let finalWinner = Data(repeating: 0xA3, count: 32) // X + + let l = loserRow(txid: firstLoser, spending: [(txid: fundingTxid, vout: 0)], netAmount: -50_000) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // One callback, two batches: W beats L holding the unfunded coin, + // then X beats W and frees it. + sweep(handler, [ + Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400), + Batch( + losers: [secondLoser], + winner: finalWinner, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + ), + ]) + + let pOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let pendingDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == pOutpoint } + ) + XCTAssertTrue( + try context.fetch(pendingDescriptor).isEmpty, + "the second batch must find and release the tombstone the first batch just wrote" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(coin.isSpent, "the released coin funds as spendable") + XCTAssertNil(coin.supersededByTxid) + } + + /// 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 + /// `PersistentTxo.supersededByTxid` and the pending row is gone by the + /// time the release runs. With the intermediate winner's own record on + /// hand the drain links `spendingTransaction` too, so the release + /// reaches the row through the winner's decoded inputs — and must clear + /// the marker with the hold: a released coin keeping its dead winner's + /// marker would read as a durable claim on every later channel. + func testAReleasedCoinDropsItsDeadWinnersMarker() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x91, count: 32) // L + let secondLoser = Data(repeating: 0x92, count: 32) // W + let finalWinner = Data(repeating: 0x93, count: 32) // X + + let l = loserRow(txid: firstLoser, spending: [(txid: fundingTxid, vout: 0)], netAmount: -50_000) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding the still-unfunded coin. + sweep(handler, [Batch(losers: [firstLoser], winner: secondLoser, winnerMinedHeight: 400)]) + + // W's own record lands before the funding TXO does — through the + // record pass, which stages W's own ordinary claim row beside the + // tombstone — so the drain below links `spendingTransaction` as + // well as stamping the marker. + deliverRecord(handler, txid: secondLoser, context: 0, inputOutpoints: [(txid: fundingTxid, vout: 0)]) + + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let stamped = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(stamped.isSpent, "sanity: the drained claim holds the coin") + XCTAssertEqual(stamped.supersededByTxid, secondLoser) + XCTAssertEqual(stamped.spendingTransaction?.txid, secondLoser, "sanity: linked through W's own row") + + // Second sweep: X beats W, and this time upstream frees the coin. + sweep(handler, [ + Batch(losers: [secondLoser], winner: finalWinner, winnerMinedHeight: 400, released: [(txid: fundingTxid, vout: 0)]) + ]) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(freed.isSpent, "the released coin is spendable again") + XCTAssertNil(freed.spendingTransaction) + XCTAssertNil( + freed.supersededByTxid, + "the dead winner's marker goes with the hold it carried" + ) + } + + /// The unreachable-claim variant of the same ordering: the claim + /// drained into `PersistentTxo.supersededByTxid`, its pending row is + /// gone, and the winner it names was NEVER recorded here — so when that + /// winner is swept in turn there is no `row` to fetch, no `row.inputs` + /// to walk, and no tombstone left for the scalar reconciliation to + /// find. Only an outpoint-keyed release — the form Kotlin's + /// `releaseByOutpoint` and SQLite's outpoint-matched UPDATE both + /// implement — can reach the coin; without it the release is silently + /// dropped and the coin stays spent forever. + func testAReleaseReachesAClaimDrainedToTheTxoWhenTheWinnerWasNeverRecorded() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + + let firstLoser = Data(repeating: 0x94, count: 32) // L + let unrecordedWinner = Data(repeating: 0x95, count: 32) // W — never a row here + let finalWinner = Data(repeating: 0x96, count: 32) // X + + let l = loserRow(txid: firstLoser, spending: [(txid: fundingTxid, vout: 0)], netAmount: -50_000) + context.insert(l) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: firstLoser, + spendingTransaction: l, + walletId: walletId + )) + try context.save() + + // First sweep: W beats L, holding the still-unfunded coin. + sweep(handler, [Batch(losers: [firstLoser], winner: unrecordedWinner, winnerMinedHeight: 400)]) + + // The funding TXO arrives with W still unrecorded: the drain stamps + // the marker but has no row to link. + deliverFundingUtxo(handler, vout: 0, amount: 50_000) + + let stamped = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(stamped.isSpent, "sanity: the drained claim holds the coin") + XCTAssertEqual(stamped.supersededByTxid, unrecordedWinner) + XCTAssertNil(stamped.spendingTransaction, "sanity: no relationship to reach it by") + + // Second sweep: X beats the never-recorded W, freeing the coin. + sweep(handler, [ + Batch( + losers: [unrecordedWinner], + winner: finalWinner, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + ) + ]) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse( + freed.isSpent, + "the release must reach a drained claim even with no row and no tombstone left" + ) + XCTAssertNil(freed.supersededByTxid) + } + + /// The multi-wallet continuation of the chained scenarios above. A + /// shared loser L spends one still-unfunded coin of wallet A's and two + /// of wallet B's, so the first sweep leaves each wallet's claims as + /// detached tombstones pointing at winner W. W's own record then + /// arrives through wallet A, staging A's own ordinary claim rows beside + /// B's tombstones (rows are per wallet). When X — spending only B's + /// second coin — sweeps W, both wallets release the other two coins; + /// wallet A's callback runs first, frees its own coin, holds both of + /// B's for X and deletes W's row. Wallet B's independently committed + /// callback then runs against a row that no longer exists and must + /// still apply its own release by outpoint: without it B's released + /// coin would later come back spent under X, and B's held coin's + /// tombstone could not follow any further sweep. + func testSharedWinnerDeletedByAnotherWalletsCallbackStillReconcilesThisWalletsTombstones() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + let walletB = Data(repeating: 0x02, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(PersistentWallet(walletId: walletB, network: .testnet)) + + let sharedLoser = Data(repeating: 0xC1, count: 32) // L + let sharedWinner = Data(repeating: 0xC2, count: 32) // W + let finalWinner = Data(repeating: 0xC3, count: 32) // X + + let l = loserRow( + txid: sharedLoser, + spending: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1), (txid: fundingTxid, vout: 2)], + netAmount: -140_000 + ) + context.insert(l) + // None of the three coins L claims has been funded here yet: one of + // wallet A's (vout 0) and two of wallet B's (vouts 1 and 2), all + // parked as pending inputs the way `resolveInputOutpoint` does. + for (vout, owner) in [(UInt32(0), walletId), (1, walletB), (2, walletB)] { + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: vout), + inputIndex: vout, + spendingTxid: sharedLoser, + spendingTransaction: l, + walletId: owner + )) + } + try context.save() + + // First sweep, one independently committed callback per wallet: W + // beats L, holding everything (nothing funded, nothing released). + sweep(handler, [Batch(losers: [sharedLoser], winner: sharedWinner, winnerMinedHeight: 400)], walletId: walletId) + sweep(handler, [Batch(losers: [sharedLoser], winner: sharedWinner, winnerMinedHeight: 400)], walletId: walletB) + XCTAssertNil(transaction(container, txid: sharedLoser), "L is gone once both wallets ran") + + // W's own record arrives through wallet A, claiming all three + // outpoints: wallet A's ordinary claim rows are staged beside B's + // tombstones (the tombstone does not occupy A's key on vout 0, and + // B's tombstones are not A's rows on vouts 1 and 2). + deliverReinstatingRecord( + handler, + walletId: walletId, + txid: sharedWinner, + context: 0, + blockHeight: 0, + inputOutpoints: [ + (txid: fundingTxid, vout: 0), + (txid: fundingTxid, vout: 1), + (txid: fundingTxid, vout: 2), + ], + outputVout: 0, + outputAmount: 120_000, + outputAddress: "yWinnerChange" + ) + + // Second sweep: X beats W on vout 1 alone, so upstream releases + // vouts 0 and 2 in both wallets' views. Wallet A's callback runs + // first, frees its own coin, holds B's for X and deletes the shared + // row. + sweep(handler, [ + Batch( + losers: [sharedWinner], + winner: finalWinner, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 2)] + ) + ], walletId: walletId) + XCTAssertNil( + transaction(container, txid: sharedWinner), + "sanity: wallet A's callback deleted the shared winner row — the premise " + + "wallet B's callback below has to survive" + ) + + // Wallet B's callback arrives after the row is gone, releasing one + // of its two coins and holding the other. + sweep(handler, [ + Batch( + losers: [sharedWinner], + winner: finalWinner, + winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 2)] + ) + ], walletId: walletB) + + let heldOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 1) + let heldDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == heldOutpoint } + ) + let heldTombstone = try XCTUnwrap( + try context.fetch(heldDescriptor).first { $0.walletId == walletB }, + "wallet B's held tombstone must survive the row's absence" + ) + XCTAssertEqual( + heldTombstone.spendingTxid, + finalWinner, + "the held tombstone must follow the chain to X even though W's row was " + + "already deleted by wallet A's callback" + ) + let releasedOutpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 2) + let releasedDescriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == releasedOutpoint } + ) + XCTAssertTrue( + try context.fetch(releasedDescriptor).isEmpty, + "wallet B's release decision must reach its tombstone even though W's row " + + "was already deleted by wallet A's callback" + ) + + // The funding TXOs finally arrive, one per owning wallet. + deliverFundingUtxo(handler, walletId: walletId, vout: 0, amount: 100_000) + deliverFundingUtxo(handler, walletId: walletB, vout: 1, amount: 40_000) + deliverFundingUtxo(handler, walletId: walletB, vout: 2, amount: 20_000) + + let coinA = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(coinA.isSpent, "wallet A's released coin comes back spendable") + let heldB = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(heldB.isSpent, "wallet B's held coin stays spent") + XCTAssertEqual( + heldB.supersededByTxid, + finalWinner, + "the held coin must be attributed to the final winner, not the deleted W" + ) + let releasedB = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 2)) + XCTAssertFalse( + releasedB.isSpent, + "wallet B's released coin must not resurrect spent under the obsolete winner" + ) + XCTAssertNil(releasedB.supersededByTxid) + } + + /// Hand a UTXO for `(fundingTxid, vout)` back through the ordinary + /// account changeset — the same entry point `redeliverCoinB` drives, but + /// generalized so a fresh outpoint can be delivered rather than the one + /// baked into `seedSpend`. + private func deliverFundingUtxo( + _ handler: PlatformWalletPersistenceHandler, + vout: UInt32, + amount: UInt64 + ) { + deliverFundingUtxo(handler, walletId: walletId, vout: vout, amount: amount) + } + + /// `walletId`-parameterized form for the multi-wallet tests, where each + /// wallet's own funding UTXO has to arrive through that wallet's own + /// changeset — the drain in `upsertUtxo` resolves the tombstone by + /// outpoint, but the round itself is wallet-scoped like every real one. + private func deliverFundingUtxo( + _ handler: PlatformWalletPersistenceHandler, + walletId: Data, + vout: UInt32, + amount: UInt64 + ) { + let name = strdup("Standard { index: 0 }") + let address = strdup("yFundAddr") + defer { + free(name) + free(address) + } + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + fundingTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = vout + utxo.amount = amount + utxo.address = address + utxo.height = 100 + utxo.is_confirmed = true + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Deliver a plain transaction record — with a fresh output of its own + /// riding along in the same round — through the ordinary account + /// changeset entry point. Models the reinstating event the BLOCKING + /// finding describes: upstream reports a previously-swept txid to + /// `records` exactly the way it reports any freshly-detected + /// transaction, with nothing on the wire flagging it as "the one that + /// used to be swept" — `upsertTransaction` has to infer that entirely + /// from the row it finds already sitting in the store. + private func deliverReinstatingRecord( + _ handler: PlatformWalletPersistenceHandler, + walletId: Data, + txid: Data, + context: UInt32, + blockHeight: UInt32, + inputOutpoints: [(txid: Data, vout: UInt32)], + outputVout: UInt32, + outputAmount: UInt64, + outputAddress: String + ) { + let name = strdup("Standard { index: 0 }") + let address = strdup(outputAddress) + defer { + free(name) + free(address) + } + + let inputs = UnsafeMutablePointer.allocate( + capacity: max(inputOutpoints.count, 1) + ) + for (i, input) in inputOutpoints.enumerated() { + var entry = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &entry.txid) { dst in + input.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.vout = input.vout + inputs.advanced(by: i).initialize(to: entry) + } + defer { + inputs.deinitialize(count: inputOutpoints.count) + inputs.deallocate() + } + + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = blockHeight + record.input_outpoints = inputs + record.input_outpoints_count = UInt(inputOutpoints.count) + + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = outputVout + utxo.amount = outputAmount + utxo.address = address + utxo.height = blockHeight + utxo.is_confirmed = true + + handler.beginChangeset(walletId: walletId) + withUnsafeMutablePointer(to: &record) { recordPtr in + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + // MARK: - Bounded tombstone lifetime + + /// The block-context winner's mined height used across the bounded- + /// lifetime tests — the stamp every tombstone carries, and the exact + /// boundary value at which it collects. + private static let winnerHeight: UInt32 = 400 + + /// One committed round carrying chain progress: the synced height, + /// (unless the caller opts out) opaque chainlock bytes, and — when + /// `chainLockHeight` is supplied — the NUMERIC chainlock height + /// through the extension's dedicated slot, fired inside the same + /// begin/end bracket after the changeset callback exactly the way the + /// Rust persister fires it. The bytes and the number are deliberately + /// independent knobs: the reviewer's point is precisely that bytes + /// alone must not enable collection. + private func heightsRound( + _ handler: PlatformWalletPersistenceHandler, + synced: UInt32, + chainLock: Bool = true, + chainLockHeight: UInt32? = nil + ) { + handler.beginChangeset(walletId: walletId) + var cs = WalletChangeSetFFI() + cs.has_chain = true + cs.chain.has_synced_height = true + cs.chain.synced_height = synced + var clBytes = [UInt8](repeating: 9, count: 84) + clBytes.withUnsafeMutableBufferPointer { buf in + if chainLock { + cs.last_applied_chain_lock_bytes = buf.baseAddress + cs.last_applied_chain_lock_bytes_len = UInt(buf.count) + } + withUnsafePointer(to: &cs) { csPtr in + _ = handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + if let chainLockHeight { + _ = handler.persistWalletChangesetChainLockHeight( + walletId: walletId, + height: chainLockHeight + ) + } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + /// Record a loser spending `(spentTxid, 0)` with the funding side + /// unobserved, then sweep it in the given winner context — + /// `winnerMinedHeight` non-nil leaves the stamped tombstone the + /// collection tests reason about; `nil` (an IS-locked, unmined winner) + /// must leave nothing. + private func seedSweptTombstone( + _ handler: PlatformWalletPersistenceHandler, + _ container: ModelContainer, + winnerMinedHeight: UInt32?, + spentTxid: Data? = nil, + loser: Data? = nil, + winner: Data? = nil + ) throws { + let loser = loser ?? sweptTxid + let context = ModelContext(container) + let swept = loserRow(txid: loser, spending: [(txid: spentTxid ?? fundingTxid, vout: 0)]) + context.insert(swept) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: spentTxid ?? fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: loser, + spendingTransaction: swept, + walletId: walletId + )) + try context.save() + sweep(handler, [Batch( + losers: [loser], + winner: winner ?? winnerTxid, + winnerMinedHeight: winnerMinedHeight + )]) + } + + private func pendingRows( + _ container: ModelContainer, + spentTxid: Data? = nil, + vout: UInt32 = 0 + ) throws -> [PersistentPendingInput] { + let outpoint = PersistentTxo.makeOutpoint(txid: spentTxid ?? fundingTxid, vout: vout) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + ) + return try ModelContext(container).fetch(descriptor) + } + + /// Every pending-input row this wallet holds, regardless of outpoint — + /// the attacker-growth metric the mempool-context tests measure. + private func walletPendingRows( + _ container: ModelContainer + ) throws -> [PersistentPendingInput] { + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + return try ModelContext(container).fetch(descriptor) + } + + /// This wallet's persisted row, for asserting on the stored numeric + /// chainlock height. + private func walletRow(_ container: ModelContainer) throws -> PersistentWallet? { + let walletId = self.walletId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + return try ModelContext(container).fetch(descriptor).first + } + + /// The attacker-shaped row's lawful cousin: a block-context sweep's + /// tombstone stores the WINNER'S own mined height and is collected + /// exactly when the finality boundary `min(chainlockHeight, + /// syncedHeight)` reaches it — upstream key-wallet's + /// `prune_finalized_observed_spends` condition verbatim, no + /// observation-age margin. At that boundary the funding transaction of + /// the guarded outpoint (necessarily mined at or below the winner's + /// height) has been filter-scanned with no false negatives, so an + /// undrained tombstone is provably not guarding the wallet's coin. + func testASweptTombstoneIsCollectedAtFinalityAndNotBefore() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + let tombstone = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(tombstone.isSweptTombstone, "sanity: the sweep flagged the row") + XCTAssertEqual( + tombstone.winnerMinedHeight, Self.winnerHeight, + "the tombstone is stamped with the WINNER'S own mined height — " + + "not any observation watermark" + ) + + heightsRound( + handler, + synced: Self.winnerHeight - 1, + chainLockHeight: Self.winnerHeight - 1 + ) + XCTAssertEqual( + try pendingRows(container).count, 1, + "boundary \(Self.winnerHeight - 1) has not reached the winner's " + + "height \(Self.winnerHeight) — the hold stays" + ) + + heightsRound(handler, synced: Self.winnerHeight, chainLockHeight: Self.winnerHeight) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the boundary reaching the winner's height collects the row — no margin" + ) + } + + /// The reviewer's "weaker still" point, named: synced-height progress + /// plus even PRESENT chainlock BYTES must not collect — the bincode + /// blob proves a chainlock was once applied, but says nothing about + /// how far finality reaches. Only the NUMERIC chainlock height + /// delivered through the extension slot supplies the boundary's + /// chainlock half, mirroring upstream's (and the SQLite store's) + /// "no-op until a chainlock height has been persisted". + func testASweptTombstoneOutlivesSyncProgressWithoutANumericChainLockHeight() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + heightsRound(handler, synced: 10_000, chainLock: true) + XCTAssertEqual( + try pendingRows(container).count, 1, + "chainlock BYTES exist and the synced height is far past the " + + "stamp — but no numeric chainlock height has ever been " + + "stored, so no finality boundary exists and the hold stays" + ) + + heightsRound(handler, synced: 10_000, chainLockHeight: 10_000) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the first NUMERIC chainlock height supplies the boundary and " + + "the long-aged stamp collects" + ) + } + + /// 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 boundary progress + /// may touch the materialised hold. + func testADrainedClaimIsImmuneToTheCollector() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + XCTAssertEqual( + try XCTUnwrap(try pendingRows(container).first).winnerMinedHeight, + Self.winnerHeight, + "sanity: held, undrained, stamped with the winner's height" + ) + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "sanity: the drain consumed the pending rows" + ) + + heightsRound(handler, synced: 10_000, chainLockHeight: 10_000) + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the materialised claim's row survives collection" + ) + XCTAssertTrue(coin.isSpent, "still held spent by the winner's claim") + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// A held tombstone with a nil winner-height 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 read identically. With no proof of finality the safe + /// reading is to hold it forever rather than guess. + /// Replaces the rejected back-fill design, which stamped such a row + /// with the current height and thereby fabricated a finality horizon. + func testATombstoneWithoutAWinnerHeightIsNeverCollected() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + // The real writer: an IS-context sweep of a loser whose funding + // TXO never arrived. + try seedSweptTombstone(handler, container, winnerMinedHeight: nil) + + // 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. + heightsRound(handler, synced: 1_000_000, chainLockHeight: 1_000_000) + heightsRound(handler, synced: 1_000_010, chainLockHeight: 1_000_010) + + let row = try XCTUnwrap( + try pendingRows(container).first, + "no winner height, no proof of finality — the hold outlasts any boundary" + ) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertNil( + row.winnerMinedHeight, + "and the stamp is never back-filled — that would fabricate the horizon" + ) + } + + /// 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. + func testARepointedTombstoneIsRestampedToTheLaterSweep() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + XCTAssertEqual( + try XCTUnwrap(try pendingRows(container).first).winnerMinedHeight, + Self.winnerHeight, + "sanity: stamped with the first winner's mined height" + ) + + // The first winner is itself swept — by a winner mined 50 blocks + // later — the chained-sweep continuation that re-points the + // earlier tombstone (no row needed: the tombstone is found by the + // scalar `spendingTxid` it carries). + let finalWinner = Data(repeating: 0x66, count: 32) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: Self.winnerHeight + 50 + )]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.winnerMinedHeight, Self.winnerHeight + 50, + "re-pointed to a later block-context winner ⇒ re-stamped to " + + "THAT winner's mined height" + ) + + // And the horizon moved with it: the old height no longer collects, + // the new one does. + heightsRound( + handler, + synced: Self.winnerHeight + 49, + chainLockHeight: Self.winnerHeight + 49 + ) + XCTAssertEqual( + try pendingRows(container).count, 1, + "the boundary reaching only the FIRST winner's height must no " + + "longer collect the re-stamped claim" + ) + heightsRound( + handler, + synced: Self.winnerHeight + 50, + chainLockHeight: Self.winnerHeight + 50 + ) + XCTAssertTrue(try pendingRows(container).isEmpty) + } + + /// 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. + func testAMempoolContextSweepPreservesAnUnstampedTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + + for i in 0..<3 { + let spent = Data(repeating: UInt8(0x70 + i), count: 32) + try seedSweptTombstone( + handler, + container, + winnerMinedHeight: nil, + spentTxid: spent, + loser: Data(repeating: UInt8(0x80 + i), count: 32), + winner: Data(repeating: UInt8(0x90 + i), count: 32) + ) + let row = try XCTUnwrap( + try pendingRows(container, spentTxid: spent).first, + "an unmined IS-locked winner must leave a held tombstone for input #\(i)" + ) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertNil(row.winnerMinedHeight, "and it carries no finality stamp") + } + // Arbitrary chainlock/height advancement never collects an + // unstamped hold — two rounds, so a back-filling collector would + // be caught too. + heightsRound(handler, synced: 1_000_000, chainLockHeight: 1_000_000) + heightsRound(handler, synced: 1_000_010, chainLockHeight: 1_000_010) + XCTAssertEqual( + try walletPendingRows(container).count, 3, + "every unstamped hold outlasts any boundary — only funding " + + "materialization, a block-context re-stamp, or a release " + + "resolves one" + ) + } + + /// The mempool-context sweep still spend-marks a coin that HAS + /// materialised — that path is unchanged: the row carries real funding + /// data and `supersededByTxid` is its durable hold. The + /// never-materialised claim the same loser carries survives too, as an + /// unstamped tombstone — the pending row is the only durable carrier + /// of a hold upstream keeps in `spent_outpoints` and cannot rebuild + /// after the loser's record is gone. + func testAMempoolContextSweepStillSpendMarksAMaterialisedCoin() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + // The same loser also claims an input whose funding side was never + // observed — the shape that would have become a tombstone. Its + // bytes name that input too, the way a real record's would. + let unfundedTxid = Data(repeating: 0x77, count: 32) + let context = ModelContext(container) + let loserTxid = sweptTxid + let loser = try XCTUnwrap( + try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.txid == loserTxid } + )).first + ) + loser.transactionData = serializedTransaction(inputs: [ + (txid: fundingTxid, vout: 0), (txid: fundingTxid, vout: 1), (txid: unfundedTxid, vout: 0), + ]) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: unfundedTxid, vout: 0), + inputIndex: 2, + spendingTxid: sweptTxid, + spendingTransaction: loser, + walletId: walletId + )) + try context.save() + + sweep(handler, [Batch( + losers: [sweptTxid], + winner: winnerTxid, + winnerMinedHeight: nil + )]) + + let coinB = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue( + coinB.isSpent, + "a materialised coin is spend-marked by the IS-locked winner exactly as before" + ) + XCTAssertEqual(coinB.supersededByTxid, winnerTxid) + let claim = try XCTUnwrap( + try pendingRows(container, spentTxid: unfundedTxid).first, + "while the never-materialised claim survives as a tombstone" + ) + XCTAssertTrue(claim.isSweptTombstone) + XCTAssertEqual(claim.spendingTxid, winnerTxid, "re-pointed at the winner") + XCTAssertNil(claim.winnerMinedHeight, "unstamped — the winner is unmined") + } + + /// 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. + func testAFundingOutputArrivingAfterAMempoolSweepAndRestartLandsSpent() throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("mempool-sweep-restart-\(UUID().uuidString).store") + defer { try? FileManager.default.removeItem(at: storeURL) } + + do { + let (handler, container) = try makeHandler(url: storeURL) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: nil) + XCTAssertNil(transaction(container, txid: sweptTxid), "sanity: the loser is gone") + let tombstone = try XCTUnwrap( + try walletPendingRows(container).first, + "sanity: the mempool sweep left the hold behind" + ) + XCTAssertTrue(tombstone.isSweptTombstone) + XCTAssertNil(tombstone.winnerMinedHeight, "unstamped — no finality horizon exists") + } + + // Restart: a fresh persister loading the same on-disk store, then + // arbitrary chainlock/height advancement while the winner stays + // unmined — none of it may collect the unstamped hold — and only + // then the funding delivery. + let (handler, container) = try makeHandler(url: storeURL) + heightsRound(handler, synced: 25_000, chainLockHeight: 25_000) + XCTAssertEqual( + try walletPendingRows(container).count, 1, + "the unstamped hold survives the restart and every boundary" + ) + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap( + txo(container, txid: fundingTxid, vout: 0), + "the funding UTXO's own upsert must still create the row" + ) + XCTAssertTrue( + coin.isSpent, + "an input the IS-locked winner consumed must never come back " + + "spendable — the sweep's claim outlives the restart" + ) + XCTAssertEqual(coin.supersededByTxid, winnerTxid, "held by the winner the sweep named") + XCTAssertTrue( + try walletPendingRows(container).isEmpty, + "the claim drained into the TXO row" + ) + } + + /// The unrelated-advancement scenario, block-context half: the + /// chainlock can run arbitrarily far ahead, but while `syncedHeight` + /// 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 synced height catches up. + func testABlockContextTombstoneOutlivesUnrelatedAdvancementBelowItsWinnersHeight() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + // Chainlocks race ahead by thousands of blocks; the filter scan + // has only reached one block short of the winner. + heightsRound( + handler, + synced: Self.winnerHeight - 1, + chainLockHeight: Self.winnerHeight + 10_000 + ) + XCTAssertEqual( + try pendingRows(container).count, 1, + "min(chainlock, synced) = \(Self.winnerHeight - 1) is below the " + + "winner's height — any amount of unrelated chainlock " + + "progress must not collect the hold" + ) + + // No fresh chainlock this round: the changeset-path collector runs + // off the STORED numeric height. + heightsRound(handler, synced: Self.winnerHeight) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "the scan reaching the winner's height completes the boundary and collects" + ) + } + + /// 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. + func testAnUnstampedTombstoneRestampedByABlockContextSweepBecomesCollectible() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + // IS-context sweep: the hold lands unstamped. + try seedSweptTombstone(handler, container, winnerMinedHeight: nil) + XCTAssertNil( + try XCTUnwrap(try pendingRows(container).first).winnerMinedHeight, + "sanity: held and unstamped" + ) + + // The IS-locked first winner is itself beaten by a mined conflict + // still claiming the unfunded input — the chained-sweep + // continuation finds the tombstone by its scalar `spendingTxid`. + let finalWinner = Data(repeating: 0x66, count: 32) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: Self.winnerHeight + 50 + )]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertTrue(row.isSweptTombstone) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.winnerMinedHeight, Self.winnerHeight + 50, + "the block-context re-point stamps the previously unstamped hold" + ) + + heightsRound( + handler, + synced: Self.winnerHeight + 50, + chainLockHeight: Self.winnerHeight + 50 + ) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "once stamped, the ordinary finality boundary collects the row" + ) + } + + /// 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. + func testAMempoolRepointedTombstoneKeepsItsBlockContextStamp() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + + // The first winner is evicted by an IS-locked, unmined conflict. + let finalWinner = Data(repeating: 0x66, count: 32) + sweep(handler, [Batch( + losers: [winnerTxid], + winner: finalWinner, + winnerMinedHeight: nil + )]) + + let row = try XCTUnwrap(try pendingRows(container).first) + XCTAssertEqual(row.spendingTxid, finalWinner) + XCTAssertEqual( + row.winnerMinedHeight, Self.winnerHeight, + "an unmined winner re-points the claim without touching the " + + "earlier block-context stamp" + ) + + heightsRound(handler, synced: Self.winnerHeight, chainLockHeight: Self.winnerHeight) + XCTAssertTrue( + try pendingRows(container).isEmpty, + "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" + ) + } + + /// The chainlock-height extension callback stores monotonic-max on the + /// wallet row: chain locks only move forward, and a late or re-emitted + /// lower height must not walk the finality boundary backwards. + func testTheChainLockHeightCallbackStoresMonotonicMaxOnTheWalletRow() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + XCTAssertNil( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, + "sanity: fresh row, no numeric chainlock height yet" + ) + + heightsRound(handler, synced: 10, chainLockHeight: 500) + XCTAssertEqual( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, 500, + "the first height lands as stored" + ) + + heightsRound(handler, synced: 11, chainLockHeight: 300) + XCTAssertEqual( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, 500, + "a lower height must not walk the watermark backwards" + ) + + heightsRound(handler, synced: 12, chainLockHeight: 700) + XCTAssertEqual( + try XCTUnwrap(try walletRow(container)).lastAppliedChainLockHeight, 700, + "a higher height advances it" + ) + } + + // MARK: - Review round: hold by outpoint, per-wallet rows, one-round shapes + + /// The hold is keyed by the loser's decoded inputs, not by the links + /// its row happens to hold. Coin A's link already moved to a surviving + /// mempool spender M when L is swept: A is not released, so it is held + /// for the winner, and M's link — not the loser's — is kept; coin B, + /// linked to the loser, is detached. A link-keyed walk never saw A. + func testASweepHoldsEveryDecodedInputAndDetachesOnlyTheLosersOwnLinks() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + let survivorTxid = Data(repeating: 0x4A, count: 32) + let context = ModelContext(container) + let survivor = loserRow(txid: survivorTxid, spending: [(txid: fundingTxid, vout: 0)]) + context.insert(survivor) + let coinA = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let a = try XCTUnwrap( + try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.outpoint == coinA } + )).first + ) + a.spendingTransaction = survivor + try context.save() + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(held.isSpent, "an input the loser's bytes name is held even with its link elsewhere") + XCTAssertEqual(held.supersededByTxid, winnerTxid) + XCTAssertEqual(held.spendingTransaction?.txid, survivorTxid, "a link that is not the loser's is kept") + + let detached = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertTrue(detached.isSpent) + XCTAssertNil(detached.spendingTransaction, "the loser's own link is detached") + } + + /// A held input with no `PersistentTxo` and no pending row of this + /// wallet's gets its tombstone created: the loser's stored bytes name + /// the coin, and the claim must not depend on a row `resolveInputOutpoint` + /// happened to leave behind. + func testASweepCreatesTheTombstoneForAHeldInputWithNoClaimRow() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(loserRow(txid: sweptTxid, spending: [(txid: fundingTxid, vout: 0)])) + try context.save() + XCTAssertTrue(try pendingRows(container).isEmpty, "sanity: no claim row at all") + + sweep(handler, [Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: 400)]) + + let tombstone = try XCTUnwrap(try pendingRows(container).first, "the hold is created from the bytes") + XCTAssertTrue(tombstone.isSweptTombstone) + XCTAssertEqual(tombstone.spendingTxid, winnerTxid) + XCTAssertEqual(tombstone.walletId, walletId) + XCTAssertEqual(tombstone.winnerMinedHeight, 400) + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent) + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + } + + /// Pending rows are per (outpoint, spending txid, wallet): a second + /// wallet recording the same transaction gets its own claim row. Every + /// sweep decision on a pending row is scoped by that tag, so a claim + /// tagged with the first recorder alone would let one wallet's released + /// set decide the other wallet's coin. + func testASecondWalletRecordingTheSameSpendGetsItsOwnPendingRow() throws { + let (handler, container) = try makeHandler() + let walletB = Data(repeating: 0x02, count: 32) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(PersistentWallet(walletId: walletB, network: .testnet)) + try context.save() + + deliverRecord(handler, walletId: walletId, txid: sweptTxid, context: 0, inputOutpoints: [(txid: fundingTxid, vout: 0)]) + deliverRecord(handler, walletId: walletB, txid: sweptTxid, context: 0, inputOutpoints: [(txid: fundingTxid, vout: 0)]) + + let rows = try pendingRows(container) + XCTAssertEqual(Set(rows.map(\.walletId)), [walletId, walletB], "one claim row per recording wallet") + XCTAssertEqual(rows.count, 2) + + // And a re-upsert by the same wallet still does not duplicate. + deliverRecord(handler, walletId: walletB, txid: sweptTxid, context: 0, inputOutpoints: [(txid: fundingTxid, vout: 0)]) + XCTAssertEqual(try pendingRows(container).count, 2) + } + + /// At drain time the tombstone tagged with the delivering wallet wins + /// over another wallet's, whatever their ages: the stamp is that + /// wallet's own sweep verdict on its own coin. + func testTheDrainPrefersTheTombstoneTaggedWithTheDeliveringWallet() throws { + let (handler, container) = try makeHandler() + let walletB = Data(repeating: 0x02, count: 32) + let winnerForA = Data(repeating: 0x5A, count: 32) + let winnerForB = Data(repeating: 0x5B, count: 32) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(PersistentWallet(walletId: walletB, network: .testnet)) + let outpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + // B's tombstone is the OLDER one, so a newest-wins pick would take A's. + let forB = PersistentPendingInput( + outpoint: outpoint, inputIndex: 0, spendingTxid: winnerForB, spendingTransaction: nil, walletId: walletB + ) + forB.isSweptTombstone = true + forB.createdAt = Date(timeIntervalSinceNow: -10) + let forA = PersistentPendingInput( + outpoint: outpoint, inputIndex: 0, spendingTxid: winnerForA, spendingTransaction: nil, walletId: walletId + ) + forA.isSweptTombstone = true + context.insert(forB) + context.insert(forA) + try context.save() + + deliverFundingUtxo(handler, walletId: walletB, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent) + XCTAssertEqual(coin.supersededByTxid, winnerForB, "the delivering wallet's own tombstone supplies the stamp") + XCTAssertTrue(try pendingRows(container).isEmpty, "every pending row on the outpoint is consumed by the drain") + } + + /// A drained tombstone stamps and nothing more. Its `inputIndex` is the + /// LOSER'S vin (L spent F at vin 0), and the winner it names spends F — + /// if at all — somewhere else; copying the index onto the winner's link + /// mislabelled the winner's own inputs, and minting the link from a + /// tombstone attributed a coin to a transaction that need not spend it. + /// W's row exists here precisely so an old drain WOULD have linked it. + func testADrainedTombstoneStampsWithoutMintingALinkOrAVinIndex() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + context.insert(loserRow(txid: winnerTxid, spending: [(txid: Data(repeating: 0x58, count: 32), vout: 0)])) + let tombstone = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0), + inputIndex: 0, + spendingTxid: winnerTxid, + spendingTransaction: nil, + walletId: walletId + ) + tombstone.isSweptTombstone = true + tombstone.winnerMinedHeight = 400 + context.insert(tombstone) + try context.save() + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent, "the stamp holds the coin") + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + XCTAssertNil(coin.spendingTransaction, "no link is minted from a tombstone") + XCTAssertNil(coin.spendingInputIndex, "and no vin index — the tombstone's is the loser's") + } + + /// The winner's own ordinary claim row beside the tombstone is what + /// carries the link and the RIGHT vin index: W spends X at vin 0 and F + /// at vin 1, while the loser had spent F at vin 0. + func testTheWinnersOwnPendingRowSuppliesTheLinkAndVinIndexBesideATombstone() throws { + let (handler, container) = try makeHandler() + let otherCoinTxid = Data(repeating: 0x58, count: 32) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let winner = loserRow( + txid: winnerTxid, + spending: [(txid: otherCoinTxid, vout: 0), (txid: fundingTxid, vout: 0)] + ) + context.insert(winner) + let outpoint = PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0) + let tombstone = PersistentPendingInput( + outpoint: outpoint, inputIndex: 0, spendingTxid: winnerTxid, spendingTransaction: nil, walletId: walletId + ) + tombstone.isSweptTombstone = true + tombstone.createdAt = Date(timeIntervalSinceNow: -10) + context.insert(tombstone) + context.insert(PersistentPendingInput( + outpoint: outpoint, inputIndex: 1, spendingTxid: winnerTxid, spendingTransaction: winner, walletId: walletId + )) + try context.save() + + deliverFundingUtxo(handler, vout: 0, amount: 100_000) + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent) + XCTAssertEqual(coin.supersededByTxid, winnerTxid, "the tombstone supplies the stamp") + XCTAssertEqual(coin.spendingTransaction?.txid, winnerTxid, "the ordinary row supplies the link") + XCTAssertEqual(coin.spendingInputIndex, 1, "and the winner's own vin index, not the loser's") + } + + /// One bracket carrying the winner's record and the loser's sweep — the + /// shape Rust produces when it folds `TransactionDetected(W)` and + /// `TransactionsSwept{[L]}` into one `store()`. The record pass moves + /// coin A's link from L to W; the sweep must then find L through the + /// round index rather than a store-only fetch, because a store-only + /// refetch of L resets its `inputs` inverse to the saved `[A, B]` and + /// with it A's freshly written link. After the commit W must still own + /// A: `walletFundedTransaction(W)` reads exactly that link, and a + /// chainlock promotion never re-emits the record. + func testAWinnerRecordedAndItsLoserSweptInOneRoundKeepsTheWinnersInputLink() throws { + let (handler, container) = try makeHandler() + try seedSpend(in: container, winnerTakesA: false) + + round(handler) { + stageRecord(handler, txid: winnerTxid, context: 1, inputOutpoints: [(txid: fundingTxid, vout: 0)]) + return stageSweeps(handler, [ + Batch(losers: [sweptTxid], winner: winnerTxid, winnerMinedHeight: nil, released: [(txid: fundingTxid, vout: 1)]) + ], walletId: walletId) + } + + XCTAssertNil(transaction(container, txid: sweptTxid), "the loser is gone") + let taken = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertEqual(taken.spendingTransaction?.txid, winnerTxid, "the winner keeps the link it recorded this round") + XCTAssertTrue(taken.isSpent) + let winner = try XCTUnwrap(transaction(container, txid: winnerTxid)) + XCTAssertEqual(winner.inputs.map(\.outpoint), [PersistentTxo.makeOutpoint(txid: fundingTxid, vout: 0)]) + XCTAssertTrue( + PlatformWalletPersistenceHandler.walletFundedTransaction(walletId: walletId, transaction: winner), + "the winner reads as wallet-funded after the commit" + ) + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 1)) + XCTAssertFalse(freed.isSpent) + } + + /// Stage a transaction record inside whatever bracket the caller + /// opened — the record half of `deliverRecord`, without the round. + private func stageRecord( + _ handler: PlatformWalletPersistenceHandler, + txid: Data, + context: UInt32, + inputOutpoints: [(txid: Data, vout: UInt32)] + ) { + let name = strdup("Standard { index: 0 }") + defer { free(name) } + var inputs: [OutPointFFI] = inputOutpoints.map { outpoint in + var input = OutPointFFI() + Swift.withUnsafeMutableBytes(of: &input.txid) { dst in + outpoint.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + input.vout = outpoint.vout + return input + } + var record = TransactionRecordFFI() + Swift.withUnsafeMutableBytes(of: &record.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + record.context = context + record.block_height = 0 + inputs.withUnsafeMutableBufferPointer { inputsPtr in + record.input_outpoints = inputsPtr.baseAddress + record.input_outpoints_count = UInt(inputsPtr.count) + withUnsafeMutablePointer(to: &record) { recordPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + } + } + + /// The collector runs once per round, at the END — after the round's + /// own `utxos_added`. Rust folds `BlockProcessed` and + /// `SyncHeightAdvanced` into one `store()`, so the round that advances + /// `syncedHeight` to the winner's height can be the very round that + /// delivers the funding output the tombstone guards. Collecting first + /// deleted the tombstone, and the funding output then landed unspent — + /// a coin the chainlocked winner consumed, handed back as spendable. + func testAFundingOutputDeliveredInTheRoundThatCompletesTheBoundaryStillDrainsItsTombstone() throws { + let (handler, container) = try makeHandler() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try seedSweptTombstone(handler, container, winnerMinedHeight: Self.winnerHeight) + // The chainlock half is already past the stamp; the synced half is + // one block short. + heightsRound(handler, synced: Self.winnerHeight - 1, chainLockHeight: Self.winnerHeight + 100) + XCTAssertEqual(try pendingRows(container).count, 1, "sanity: boundary not reached yet") + + // ONE round: the synced height reaches the stamp AND the funding + // output arrives. + round(handler) { + let name = strdup("Standard { index: 0 }") + let address = strdup("yFundAddr") + defer { + free(name) + free(address) + } + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + fundingTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = 0 + utxo.amount = 100_000 + utxo.address = address + utxo.height = Self.winnerHeight - 5 + utxo.is_confirmed = true + var applied = false + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.has_chain = true + cs.chain.has_synced_height = true + cs.chain.synced_height = Self.winnerHeight + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + applied = handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + return applied + } + + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent, "the funding output drained the tombstone before anything could collect it") + XCTAssertEqual(coin.supersededByTxid, winnerTxid) + XCTAssertTrue(try pendingRows(container).isEmpty, "the drain consumed the tombstone") + } + + /// The release veto's stamp half: a coin held by a stamp naming a + /// stored, network-final winner F whose bytes DO spend it stays spent + /// when a later sweep of an unrelated loser names it released — + /// upstream reporting its own amnesia about F. + func testAReleaseNamingACoinAStoredFinalWinnerStampedIsRefused() throws { + let (handler, container) = try makeHandler() + let finalWinner = Data(repeating: 0x46, count: 32) + let unrelatedLoser = Data(repeating: 0x48, count: 32) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction( + txid: fundingTxid, transactionData: Data(repeating: 0x04, count: 10), + context: 2, blockHeight: 100, netAmount: 100_000 + ) + context.insert(funding) + context.insert(PersistentTransaction( + txid: finalWinner, + transactionData: serializedTransaction(inputs: [(txid: fundingTxid, vout: 0)]), + context: 3, blockHeight: 120, netAmount: -100_000 + )) + let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100_000, address: "yFundAddr", height: 100) + coin.walletId = walletId + coin.isSpent = true + coin.supersededByTxid = finalWinner + context.insert(coin) + context.insert(loserRow(txid: unrelatedLoser, spending: [(txid: fundingTxid, vout: 0)])) + try context.save() + + sweep(handler, [Batch( + losers: [unrelatedLoser], winner: winnerTxid, winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + )]) + + let held = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(held.isSpent, "a stored chainlocked spender of the coin refuses the release") + XCTAssertEqual(held.supersededByTxid, finalWinner, "and keeps its attribution") + } + + /// The veto's other half: a stamp naming a stored, network-final + /// transaction whose bytes do NOT spend the coin does not refuse the + /// release — the stamp was a global hold written by another loser's + /// sweep, not a claim of that transaction's. + func testAReleaseNamingACoinStampedWithAFinalTransactionThatDoesNotSpendItIsHonoured() throws { + let (handler, container) = try makeHandler() + let stampedWinner = Data(repeating: 0x46, count: 32) + let unrelatedLoser = Data(repeating: 0x48, count: 32) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction( + txid: fundingTxid, transactionData: Data(repeating: 0x04, count: 10), + context: 2, blockHeight: 100, netAmount: 100_000 + ) + context.insert(funding) + context.insert(PersistentTransaction( + txid: stampedWinner, + transactionData: serializedTransaction(inputs: [(txid: Data(repeating: 0x49, count: 32), vout: 0)]), + context: 3, blockHeight: 120, netAmount: -100_000 + )) + let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100_000, address: "yFundAddr", height: 100) + coin.walletId = walletId + coin.isSpent = true + coin.supersededByTxid = stampedWinner + context.insert(coin) + context.insert(loserRow(txid: unrelatedLoser, spending: [(txid: fundingTxid, vout: 0)])) + try context.save() + + sweep(handler, [Batch( + losers: [unrelatedLoser], winner: winnerTxid, winnerMinedHeight: 400, + released: [(txid: fundingTxid, vout: 0)] + )]) + + let freed = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertFalse(freed.isSpent, "a stamp whose transaction does not spend the coin is no claim") + XCTAssertNil(freed.supersededByTxid) + } + + /// A released outpoint whose funding transaction is swept in the same + /// round is deleted, never freed. Here the parent's record was lost + /// (a `PersistentTxo` cannot outlive its transaction row, so the only + /// claim left on the dead output is a pending row of this wallet's), + /// and the release names that output: the claim is deleted with the + /// batch rather than left behind as a claim on a coin that can never + /// exist. + func testAReleaseNamingAnOutputOfACoSweptParentDeletesItsClaimRatherThanFreeingIt() throws { + let (handler, container) = try makeHandler() + let parentTxid = Data(repeating: 0xD1, count: 32) + let childTxid = Data(repeating: 0xD2, count: 32) + let claimantTxid = Data(repeating: 0xD3, count: 32) + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + // The child's own claim on the parent's output, and a third + // transaction's claim on the same dead output; the parent has no + // row at all. + let child = loserRow(txid: childTxid, spending: [(txid: parentTxid, vout: 0)], netAmount: -50_000) + context.insert(child) + let pOutpoint = PersistentTxo.makeOutpoint(txid: parentTxid, vout: 0) + context.insert(PersistentPendingInput( + outpoint: pOutpoint, inputIndex: 0, spendingTxid: childTxid, spendingTransaction: child, walletId: walletId + )) + context.insert(PersistentPendingInput( + outpoint: pOutpoint, inputIndex: 0, spendingTxid: claimantTxid, spendingTransaction: nil, walletId: walletId + )) + try context.save() + + sweep(handler, [Batch( + losers: [parentTxid, childTxid], winner: winnerTxid, winnerMinedHeight: 400, + released: [(txid: parentTxid, vout: 0)] + )]) + + XCTAssertNil(transaction(container, txid: childTxid)) + XCTAssertTrue( + try pendingRows(container, spentTxid: parentTxid).isEmpty, + "no claim on a dead parent's output survives the batch, released or not" + ) + } +}