diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bfbb7183c65..86d9675f743 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -307,6 +307,7 @@ jobs: filters: | swift-sdk-changed: - .github/workflows/swift-sdk-build.yml + - .github/workflows/tests.yml - packages/swift-sdk/** - packages/dapi-grpc/** - packages/dashpay-contract/** @@ -477,6 +478,26 @@ jobs: secrets: inherit uses: ./.github/workflows/swift-sdk-build.yml + swift-sdk-frozen-schema: + name: Swift SDK frozen schema check + needs: changes + if: ${{ needs.changes.outputs.swift-sdk-changed == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out repo + uses: actions/checkout@v4 + with: + # The generator rebuilds every frozen SwiftData model from the + # commits its FREEZES table names, so the check needs history. + fetch-depth: 0 + + - name: Check the frozen SwiftData models match FREEZES + run: python3 packages/swift-sdk/scripts/freeze_schema_models.py --check + + - name: Test the freeze generator + run: python3 -m unittest discover -s packages/swift-sdk/scripts -p 'test_*.py' -v + js-packages: name: JS packages needs: diff --git a/packages/swift-sdk/Package.swift b/packages/swift-sdk/Package.swift index 253d84fcccd..496f8eb5408 100644 --- a/packages/swift-sdk/Package.swift +++ b/packages/swift-sdk/Package.swift @@ -32,7 +32,10 @@ let package = Package( .testTarget( name: "SwiftDashSDKTests", dependencies: ["SwiftDashSDK"], - path: "SwiftTests/SwiftDashSDKTests" + path: "SwiftTests/SwiftDashSDKTests", + // Persistent stores written by the builds that shipped each + // released schema version; `DashModelMigrationTests` opens them. + resources: [.copy("Fixtures")] ), // Integration tests against a local dashmate devnet. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index eaa0ff44317..2d66c4b8ea3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -3,63 +3,27 @@ import SwiftData /// Factory for creating SwiftData model containers for Dash Platform persistence public enum DashModelContainer { - /// Every registered schema version's model list, parameterised on the - /// one model whose shape differs between versions. + /// The wallet-and-platform model graph as every released schema version + /// registered it, built from the frozen copies under `FrozenSchemas/` + /// and parameterised on the one slot whose frozen shape differs between + /// versions (`PersistentAssetLock`, which V3 changed). /// - /// Ordering is load-bearing only in the sense that it must not need to - /// change: keeping `assetLock` in the slot the live `PersistentAssetLock` - /// occupied means a frozen version's list is positionally identical to - /// what that version shipped. - private static func allModelTypes( - assetLock: any PersistentModel.Type - ) -> [any PersistentModel.Type] { - [ - PersistentIdentity.self, - PersistentDPNSName.self, - PersistentDashpayProfile.self, - PersistentDashpayContactProfile.self, - PersistentDashpayContactRequest.self, - PersistentDashpayPayment.self, - PersistentDashpayIgnoredSender.self, - PersistentDocument.self, - PersistentDataContract.self, - PersistentPublicKey.self, - PersistentTokenBalance.self, - PersistentKeyword.self, - PersistentToken.self, - PersistentDocumentType.self, - PersistentIndex.self, - PersistentProperty.self, - PersistentTokenHistoryEvent.self, - PersistentPlatformAddress.self, - PersistentPlatformAddressesSyncState.self, - PersistentWallet.self, - PersistentAccount.self, - PersistentCoreAddress.self, - PersistentTransaction.self, - PersistentTxo.self, - PersistentPendingInput.self, - PersistentWalletManagerMetadata.self, - PersistentShieldedNote.self, - PersistentShieldedOutgoingNote.self, - PersistentShieldedSyncState.self, - PersistentShieldedActivity.self, - PersistentShieldedViewingKey.self, - assetLock, - PersistentInvitation.self, - PersistentMasternode.self - ] - } - - - /// 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. + /// Every entry is a nested frozen type, never a live one. A released + /// version's checksum is the hash of every entity it declares — and a + /// relationship binds its destination by entity NAME, so a version that + /// mixed one live model into an otherwise frozen graph would have that + /// live model's current shape hashed into it (the entity name resolves + /// to whichever Swift type claimed it first in the process). Freezing + /// the whole relationship-connected graph per version is what keeps a + /// released checksum stable no matter what the live models do next, and + /// `DashModelMigrationTests` proves it against stores an older build + /// actually wrote. /// - /// Positionally identical to `allModelTypes` — a released version's - /// list must describe exactly the entities that version shipped. - private static func componentFrozenModelTypes( + /// Ordering is load-bearing only in the sense that it must not need to + /// change: keeping each model in the slot its live counterpart occupies + /// makes a frozen version's list positionally identical to what that + /// version shipped. + private static func frozenModelGraph( assetLock: any PersistentModel.Type ) -> [any PersistentModel.Type] { [ @@ -81,54 +45,96 @@ public enum DashModelContainer { DashSchemaV1.PersistentProperty.self, DashSchemaV1.PersistentTokenHistoryEvent.self, DashSchemaV1.PersistentPlatformAddress.self, - PersistentPlatformAddressesSyncState.self, + DashSchemaV1.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, + DashSchemaV1.PersistentWalletManagerMetadata.self, + DashSchemaV1.PersistentShieldedNote.self, + DashSchemaV1.PersistentShieldedOutgoingNote.self, + DashSchemaV1.PersistentShieldedSyncState.self, + DashSchemaV1.PersistentShieldedActivity.self, + DashSchemaV1.PersistentShieldedViewingKey.self, assetLock, - PersistentInvitation.self, - PersistentMasternode.self + DashSchemaV1.PersistentInvitation.self, + DashSchemaV1.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`). + /// migration identifies an existing store by this schema's checksum. fileprivate static var v1ModelTypes: [any PersistentModel.Type] { - componentFrozenModelTypes(assetLock: DashSchemaV1.PersistentAssetLock.self) + frozenModelGraph(assetLock: DashSchemaV1.PersistentAssetLock.self) } /// The exact model set registered as schema V2 — V1 plus /// `PersistentTrackedMasternode`. Frozen for the same reason as /// `v1ModelTypes`. fileprivate static var v2ModelTypes: [any PersistentModel.Type] { - v1ModelTypes + [PersistentTrackedMasternode.self] + v1ModelTypes + [DashSchemaV2.PersistentTrackedMasternode.self] } - /// 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`. + /// The exact model set registered as schema V3 — V2 with the asset-lock + /// shape that gained `recipientIsExternal`. Frozen for the same reason + /// as `v1ModelTypes`. fileprivate static var v3ModelTypes: [any PersistentModel.Type] { - componentFrozenModelTypes(assetLock: PersistentAssetLock.self) - + [PersistentTrackedMasternode.self] + frozenModelGraph(assetLock: DashSchemaV3.PersistentAssetLock.self) + + [DashSchemaV2.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. + /// Unlike the released versions above this list tracks the LIVE models, + /// so it moves whenever a model gains a property — which is exactly why + /// the released versions must not. When the next property lands: freeze + /// every model here into the version being retired + /// (`scripts/freeze_schema_models.py`), add a version, add a stage, and + /// commit a store written by this build for the new version under the + /// test fixtures (`DashModelMigrationTests.testWriteTheLiveSchemaFixtureStore`). + /// `DashModelMigrationTests` proves a version's shape (what the entity + /// hash covers, plus its indexes) only against such a store, for the + /// live version too: changing a model here before the version ships + /// means rewriting the live fixture on purpose in the same change. public static var modelTypes: [any PersistentModel.Type] { - allModelTypes(assetLock: PersistentAssetLock.self) + [PersistentTrackedMasternode.self] + [ + PersistentIdentity.self, + PersistentDPNSName.self, + PersistentDashpayProfile.self, + PersistentDashpayContactProfile.self, + PersistentDashpayContactRequest.self, + PersistentDashpayPayment.self, + PersistentDashpayIgnoredSender.self, + PersistentDocument.self, + PersistentDataContract.self, + PersistentPublicKey.self, + PersistentTokenBalance.self, + PersistentKeyword.self, + PersistentToken.self, + PersistentDocumentType.self, + PersistentIndex.self, + PersistentProperty.self, + PersistentTokenHistoryEvent.self, + PersistentPlatformAddress.self, + PersistentPlatformAddressesSyncState.self, + PersistentWallet.self, + PersistentAccount.self, + PersistentCoreAddress.self, + PersistentTransaction.self, + PersistentTxo.self, + PersistentPendingInput.self, + PersistentWalletManagerMetadata.self, + PersistentShieldedNote.self, + PersistentShieldedOutgoingNote.self, + PersistentShieldedSyncState.self, + PersistentShieldedActivity.self, + PersistentShieldedViewingKey.self, + PersistentAssetLock.self, + PersistentInvitation.self, + PersistentMasternode.self, + PersistentTrackedMasternode.self + ] } /// Create the schema for all Dash Platform models @@ -152,13 +158,37 @@ public enum DashModelContainer { groupContainer: groupContainer, cloudKitDatabase: cloudKit ? .automatic : .none ) + return try makeContainer(configuration: modelConfiguration) + } + + /// Open (or create) the store at an explicit file URL through the same + /// schema and migration plan as `create(cloudKit:groupContainer:)`. The + /// migration tests use it to open stores written by older builds exactly + /// the way the app would. + static func create(url: URL) throws -> ModelContainer { + let modelConfiguration = ModelConfiguration( + schema: schema, + url: url, + allowsSave: true, + cloudKitDatabase: .none + ) + return try makeContainer(configuration: modelConfiguration) + } + /// The one place a persistent container is built: the live schema is + /// constructed first (`schema`), and the container then runs the + /// migration plan over it. That order is what the frozen versions are + /// tested against, because it is the order under which a mixed + /// live/frozen graph would rebind a released version's entities. + private static func makeContainer( + configuration: ModelConfiguration + ) throws -> ModelContainer { // Always wire the migration plan so stores created by an older SDK // advance through the registered versioned schemas. - return try ModelContainer( + try ModelContainer( for: schema, migrationPlan: DashMigrationPlan.self, - configurations: [modelConfiguration] + configurations: [configuration] ) } @@ -169,12 +199,7 @@ public enum DashModelContainer { schema: schema, isStoredInMemoryOnly: true ) - - return try ModelContainer( - for: schema, - migrationPlan: DashMigrationPlan.self, - configurations: [modelConfiguration] - ) + return try makeContainer(configuration: modelConfiguration) } } @@ -383,8 +408,9 @@ public enum DashSchemaV3: VersionedSchema { /// 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`. +/// Registering it required freezing every model V1–V3 register — the +/// generated copies under `FrozenSchemas/`, see +/// `scripts/freeze_schema_models.py`. public enum DashSchemaV4: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift deleted file mode 100644 index 1b535ec001d..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashSchemaFrozenModels.swift +++ /dev/null @@ -1,3384 +0,0 @@ -import Foundation -import SwiftData - -// MARK: - Frozen model definitions for already-released schema versions -// -// A `VersionedSchema` identifies a store by the CHECKSUM of the entities it -// declares, not by the identity of the Swift type list. So a registered -// schema version may only ever reference model types whose *shape* is frozen -// at the moment that version shipped. Pointing `DashSchemaVN.models` at a -// live `@Model` type means the next property added to that type silently -// mutates version N's checksum in place: a store written by the previously -// released binary then matches no schema in `DashMigrationPlan.schemas`, and -// `ModelContainer(for:migrationPlan:configurations:)` fails to open it with -// Cocoa error 134504 ("Cannot use staged migration with an unknown model -// version") instead of migrating it. -// -// This file holds the frozen copies. A frozen copy must be a *nested* type, -// because SwiftData derives the entity name from the unqualified type name — -// `DashSchemaV1.PersistentAssetLock` and the top-level `PersistentAssetLock` -// are two distinct Swift types that both describe the entity named -// "PersistentAssetLock", which is exactly what lets a migration stage map one -// onto the other. (`DashModelMigrationTests` asserts that entity naming, so a -// future SwiftData change to the derivation would fail loudly rather than -// silently renaming an entity.) -// -// ## Scope of the freeze -// -// 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 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 - /// shipped — i.e. everything the live model has today EXCEPT - /// `recipientIsExternal`, which is what V3 adds. - /// - /// Referenced by both `DashSchemaV1.models` and `DashSchemaV2.models`. - /// Do not add properties here and do not "fix" its doc comments to - /// match the live model: every attribute, its optionality, its default - /// value, the `@Attribute(.unique)` marker and the `#Index` are all - /// inputs to the V2 checksum, and changing any of them re-breaks the - /// V2 stores this type exists to keep openable. Doc comments are not - /// inputs to the checksum, but keeping them minimal here keeps the - /// live model the single place worth reading. - /// - /// See the live ``SwiftDashSDK/PersistentAssetLock`` for what each - /// column means. - @Model - final class PersistentAssetLock { - #Index([\.walletId]) - - @Attribute(.unique) var outPointHex: String - var walletId: Data - var transactionBytes: Data - var fundingTypeRaw: Int - var identityIndexRaw: Int32 - var accountIndexRaw: Int32 = 0 - var amountDuffs: Int64 - var statusRaw: Int - var proofBytes: Data? - var recipientPlatformAddressHash: Data? - var recipientPlatformAddressType: UInt8? - var createdAt: Date - var updatedAt: Date - - init( - outPointHex: String, - walletId: Data, - transactionBytes: Data, - fundingTypeRaw: Int, - identityIndexRaw: Int32, - accountIndexRaw: Int32 = 0, - amountDuffs: Int64, - statusRaw: Int, - proofBytes: Data? = nil - ) { - self.outPointHex = outPointHex - self.walletId = walletId - self.transactionBytes = transactionBytes - self.fundingTypeRaw = fundingTypeRaw - self.identityIndexRaw = identityIndexRaw - self.accountIndexRaw = accountIndexRaw - self.amountDuffs = amountDuffs - self.statusRaw = statusRaw - self.proofBytes = proofBytes - self.createdAt = Date() - self.updatedAt = Date() - } - } -} - -// 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/FrozenSchemas/DashSchemaV1+PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentAccount.swift new file mode 100644 index 00000000000..939b4527762 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentAccount.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData + +// `PersistentAccount` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentAccount { + #Unique([ + \.wallet, + \.accountType, + \.accountIndex, + \.standardTag, + \.registrationIndex, + \.keyClass, + \.userIdentityId, + \.friendIdentityId, + ]) + + var accountType: UInt32 + var accountIndex: UInt32 + var accountTypeName: String + var balanceConfirmed: UInt64 + var balanceUnconfirmed: UInt64 + var externalHighestUsed: Int32 + var internalHighestUsed: Int32 + var standardTag: UInt8 + var registrationIndex: UInt32 + var keyClass: UInt32 + var userIdentityId: Data + var friendIdentityId: Data + @Attribute(.unique) var accountExtendedPubKeyBytes: Data? + var createdAt: Date + var lastUpdated: Date + + var wallet: PersistentWallet + + @Relationship(deleteRule: .cascade, inverse: \PersistentCoreAddress.account) + var coreAddresses: [PersistentCoreAddress] + + @Relationship(deleteRule: .cascade, inverse: \PersistentPlatformAddress.account) + var platformAddresses: [PersistentPlatformAddress] + + 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 = [] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentAssetLock.swift new file mode 100644 index 00000000000..b042a5da7a8 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentAssetLock.swift @@ -0,0 +1,100 @@ +import Foundation +import SwiftData + +// `PersistentAssetLock` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 7127c38566. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentAssetLock { + #Index([\.walletId]) + + @Attribute(.unique) var outPointHex: String + + var walletId: Data + + var transactionBytes: Data + + var fundingTypeRaw: Int + + var identityIndexRaw: Int32 + + var accountIndexRaw: Int32 = 0 + + var amountDuffs: Int64 + + var statusRaw: Int + + var proofBytes: Data? + + var recipientPlatformAddressHash: Data? + + var recipientPlatformAddressType: UInt8? + + var createdAt: Date + var updatedAt: Date + + init( + outPointHex: String, + walletId: Data, + transactionBytes: Data, + fundingTypeRaw: Int, + identityIndexRaw: Int32, + accountIndexRaw: Int32 = 0, + amountDuffs: Int64, + statusRaw: Int, + proofBytes: Data? = nil + ) { + self.outPointHex = outPointHex + self.walletId = walletId + self.transactionBytes = transactionBytes + self.fundingTypeRaw = fundingTypeRaw + self.identityIndexRaw = identityIndexRaw + self.accountIndexRaw = accountIndexRaw + self.amountDuffs = amountDuffs + self.statusRaw = statusRaw + self.proofBytes = proofBytes + self.createdAt = Date() + self.updatedAt = Date() + } + } +} + +extension DashSchemaV1.PersistentAssetLock { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static func predicate( + walletId: Data, + identityIndex: UInt32 + ) -> Predicate { + let identityIndexRaw = Int32(bitPattern: identityIndex) + return #Predicate { entry in + entry.walletId == walletId && entry.identityIndexRaw == identityIndexRaw + } + } +} + +extension DashSchemaV1.PersistentAssetLock { + static func encodeOutPoint(rawBytes: Data) -> String { + precondition(rawBytes.count == 36, "outpoint must be 36 bytes") + let txid = rawBytes.prefix(32) + let voutBytes = rawBytes.suffix(4) + let vout = voutBytes.withUnsafeBytes { raw -> UInt32 in + var value: UInt32 = 0 + withUnsafeMutableBytes(of: &value) { dst in + dst.copyBytes(from: raw.prefix(MemoryLayout.size)) + } + return UInt32(littleEndian: value) + } + let txidHex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(txidHex):\(vout)" + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentCoreAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentCoreAddress.swift new file mode 100644 index 00000000000..5ea85933116 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentCoreAddress.swift @@ -0,0 +1,68 @@ +import Foundation +import SwiftData + +// `PersistentCoreAddress` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentCoreAddress { + @Attribute(.unique) var address: String + var publicKey: Data + var keyType: UInt8 = 0 + var poolTypeTag: UInt8 + var addressIndex: UInt32 + var derivationPath: String + var isUsed: Bool + var firstSeenHeight: UInt32 + var lastSeenHeight: UInt32 + var balance: UInt64 + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + + @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() + } + } +} + +extension DashSchemaV1.PersistentCoreAddress { + var poolTypeName: String { + switch poolTypeTag { + case 0: return "External" + case 1: return "Internal" + case 2: return "Additional" + case 3: return "Additional (Hardened)" + default: return "Unknown(\(poolTypeTag))" + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDPNSName.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDPNSName.swift new file mode 100644 index 00000000000..0a62bbb1d3e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDPNSName.swift @@ -0,0 +1,132 @@ +import Foundation +import SwiftData + +// `PersistentDPNSName` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentDPNSName { + #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var label: String + + var normalizedLabel: String + + var parentDomainName: String + + var normalizedParentDomainName: String + + var acquiredAt: UInt64 + + var isOwned: Bool = true + + var documentIdBase58: String? + + var priceCredits: Int64? + + var saleStatusRaw: Int16 = 0 + + var counterpartyIdBase58: String? + + var documentCreatedAtMs: UInt64? + + var documentUpdatedAtMs: UInt64? + + var documentTransferredAtMs: UInt64? + + var marketplaceUpdatedAt: UInt64 = 0 + + var identity: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + 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 = Self.normalize(label) + self.parentDomainName = parentDomainName + self.normalizedParentDomainName = Self.normalize(parentDomainName) + self.acquiredAt = acquiredAt + self.isOwned = isOwned + 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() + } + } +} + +extension DashSchemaV1.PersistentDPNSName { + var saleStatus: DpnsNameSaleStatus? { + guard documentIdBase58 != nil else { return nil } + switch saleStatusRaw { + case 0: + return .owned + case 1: + guard let to = counterpartyId else { return nil } + return .sold(to: to) + case 2: + guard let to = counterpartyId else { return nil } + return .transferred(to: to) + default: + return nil + } + } + + var counterpartyId: Data? { + counterpartyIdBase58.flatMap { Data.identifier(fromBase58: $0) } + } + + var listedPriceCredits: UInt64? { + guard documentIdBase58 != nil, let priceCredits else { return nil } + return UInt64(bitPattern: priceCredits) + } +} + +extension DashSchemaV1.PersistentDPNSName { + static func normalize(_ input: String) -> String { + String(input.map { c -> Character in + switch c { + case "o", "O": return "0" + case "i", "I": return "1" + case "l", "L": return "1" + default: return Character(c.lowercased()) + } + }) + } +} + +extension DashSchemaV1.PersistentDPNSName { + static func predicate(identityId: Data) -> Predicate { + let target = identityId + return #Predicate { name in + name.identity.identityId == target && name.isOwned == true + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayContactProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayContactProfile.swift new file mode 100644 index 00000000000..b1d42ed97c8 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayContactProfile.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData + +// `PersistentDashpayContactProfile` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentDashpayContactProfile { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var contactIdentityId: Data + + var displayName: String? + + var publicMessage: String? + + var bio: String? + + var avatarUrl: String? + + var avatarHash: Data? + + var avatarFingerprint: Data? + + var checkedAtMs: UInt64 + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + 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() + } + } +} + +extension DashSchemaV1.PersistentDashpayContactProfile { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + contactIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let contact = contactIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.contactIdentityId == contact + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayContactRequest.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayContactRequest.swift new file mode 100644 index 00000000000..1c4661ef170 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayContactRequest.swift @@ -0,0 +1,118 @@ +import Foundation +import SwiftData + +// `PersistentDashpayContactRequest` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentDashpayContactRequest { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId, \.isOutgoing + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var contactIdentityId: Data + + var isOutgoing: Bool + + var senderKeyIndex: UInt32 + + var recipientKeyIndex: UInt32 + + var accountReference: UInt32 + + var encryptedPublicKey: Data + + var encryptedAccountLabel: Data? + + var autoAcceptProof: Data? + + var coreHeightCreatedAt: UInt32 + + var createdAtMillis: UInt64 + + var paymentChannelBroken: Bool = false + + var contactAlias: String? + + var contactNote: String? + + var contactHidden: Bool = false + + var contactAccountLabel: String? + + var contactAcceptedAccounts: [UInt32] = [] + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + 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() + } + } +} + +extension DashSchemaV1.PersistentDashpayContactRequest { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + isOutgoing: Bool + ) -> Predicate { + let target = ownerIdentityId + let direction = isOutgoing + return #Predicate { row in + row.ownerIdentityId == target && row.isOutgoing == direction + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayIgnoredSender.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayIgnoredSender.swift new file mode 100644 index 00000000000..445685f43de --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayIgnoredSender.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftData + +// `PersistentDashpayIgnoredSender` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentDashpayIgnoredSender { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.ignoredSenderId + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var ignoredSenderId: Data + + var owner: PersistentIdentity + + var ignoredAt: Date + + init( + owner: PersistentIdentity, + ignoredSenderId: Data + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.ignoredSenderId = ignoredSenderId + self.ignoredAt = Date() + } + } +} + +extension DashSchemaV1.PersistentDashpayIgnoredSender { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + ignoredSenderId: Data + ) -> Predicate { + let target = ownerIdentityId + let sender = ignoredSenderId + return #Predicate { row in + row.ownerIdentityId == target + && row.ignoredSenderId == sender + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayPayment.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayPayment.swift new file mode 100644 index 00000000000..89b18cbb51a --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayPayment.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftData + +// `PersistentDashpayPayment` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentDashpayPayment { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.txid + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var counterpartyIdentityId: Data + + var amountDuffs: UInt64 + + var directionRaw: UInt8 + + var direction: DashPayPaymentDirection { + get { DashPayPaymentDirection(rawValue: directionRaw) ?? .sent } + set { directionRaw = newValue.rawValue } + } + + var statusRaw: UInt8 + + var status: DashPayPaymentStatus { + get { DashPayPaymentStatus(rawValue: statusRaw) ?? .pending } + set { statusRaw = newValue.rawValue } + } + + var txid: String + + var memo: String? + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + 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() + } + } +} + +extension DashSchemaV1.PersistentDashpayPayment { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + counterpartyIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let counterparty = counterpartyIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.counterpartyIdentityId == counterparty + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayProfile.swift new file mode 100644 index 00000000000..341eda78560 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDashpayProfile.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftData + +// `PersistentDashpayProfile` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentDashpayProfile { + #Unique([\.networkRaw, \.identity]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var displayName: String? + + var publicMessage: String? + + var bio: String? + + var avatarUrl: String? + + var avatarHash: Data? + + var avatarFingerprint: Data? + + var identity: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + 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() + } + } +} + +extension DashSchemaV1.PersistentDashpayProfile { + static func predicate(identityId: Data) -> Predicate { + let target = identityId + return #Predicate { profile in + profile.identity.identityId == target + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDataContract.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDataContract.swift new file mode 100644 index 00000000000..49e35d37776 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDataContract.swift @@ -0,0 +1,286 @@ +import Foundation +import SwiftData + +// `PersistentDataContract` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentDataContract { + #Index([\.networkRaw]) + + @Attribute(.unique) var id: Data + var name: String + var serializedContract: Data + var createdAt: Date + var lastAccessedAt: Date + + var binarySerialization: Data? + + var version: Int? + var ownerId: Data? + + @Relationship(deleteRule: .cascade, inverse: \PersistentKeyword.dataContract) + var keywordRelations: [PersistentKeyword] + var contractDescription: String? + + var schemaData: Data + var documentTypesData: Data + + var groupsData: Data? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var lastUpdated: Date + var lastSyncedAt: Date? + + var canBeDeleted: Bool + var readonly: Bool + var keepsHistory: Bool + var schemaDefs: Int? + + var documentsKeepHistoryContractDefault: Bool + var documentsMutableContractDefault: Bool + var documentsCanBeDeletedContractDefault: Bool + + @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] + + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.ownedDataContracts) + var ownerIdentity: PersistentIdentity? + + var hasTokens: Bool + var tokensData: Data? + + 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 + + self.schemaData = (try? JSONSerialization.data(withJSONObject: schema)) ?? Data() + self.documentTypesData = (try? JSONSerialization.data(withJSONObject: documentTypesList)) ?? Data() + + self.keywordRelations = keywords.map { PersistentKeyword(keyword: $0, contractId: id.toBase58String()) } + self.contractDescription = description + + self.hasTokens = hasTokens + self.tokensData = nil + + self.groupsData = nil + + self.documents = [] + + self.ownerIdentity = nil + + self.networkRaw = network.rawValue + self.lastUpdated = Date() + self.lastSyncedAt = nil + + 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() + } + } +} + +extension DashSchemaV1.PersistentDataContract { + static func predicate(contractId: String) -> Predicate { + guard let idData = Data.identifier(fromBase58: contractId) else { + return #Predicate { _ in false } + } + return #Predicate { contract in + contract.id == idData + } + } + + static func predicate(ownerId: Data) -> Predicate { + #Predicate { contract in + contract.ownerId == ownerId + } + } + + static func predicate(name: String) -> Predicate { + #Predicate { contract in + contract.name.localizedStandardContains(name) + } + } + + static var contractsWithTokensPredicate: Predicate { + #Predicate { contract in + contract.hasTokens == true + } + } + + static func predicate(keyword: String) -> Predicate { + #Predicate { contract in + contract.keywordRelations.contains { $0.keyword == keyword } + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { contract in + contract.lastSyncedAt == nil || contract.lastSyncedAt! < date + } + } + + static func predicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { contract in + contract.networkRaw == target + } + } + + static func contractsWithTokensPredicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { contract in + contract.hasTokens == true && contract.networkRaw == target + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDocument.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDocument.swift new file mode 100644 index 00000000000..cebae0f140d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDocument.swift @@ -0,0 +1,181 @@ +import Foundation +import SwiftData + +// `PersistentDocument` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentDocument { + #Index([\.networkRaw]) + + @Attribute(.unique) var documentId: String + + var documentType: String + var revision: Int32 + var data: Data + + var contractId: String + var ownerId: String + + var contractIdData: Data + var ownerIdData: Data + + var createdAt: Date + var updatedAt: Date + var transferredAt: Date? + + var createdAtBlockHeight: Int64? + var updatedAtBlockHeight: Int64? + var transferredAtBlockHeight: Int64? + + var createdAtCoreBlockHeight: Int64? + var updatedAtCoreBlockHeight: Int64? + var transferredAtCoreBlockHeight: Int64? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var isDeleted: Bool = false + + var localCreatedAt: Date + var localUpdatedAt: Date + + var documentType_relation: PersistentDocumentType? + var dataContract: PersistentDataContract? + + var ownerIdentity: PersistentIdentity? + + 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)") + + 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() + } + + 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() + } + + static func predicate(documentId: String) -> Predicate { + #Predicate { doc in + doc.documentId == documentId && doc.isDeleted == false + } + } + + static func predicate(contractId: String, network: Network) -> Predicate { + 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 + } + } + + 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)") + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDocumentType.swift new file mode 100644 index 00000000000..a58b4ff625d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentDocumentType.swift @@ -0,0 +1,101 @@ +import Foundation +import SwiftData + +// `PersistentDocumentType` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentDocumentType { + @Attribute(.unique) var id: Data + var contractId: Data + var name: String + + var schemaJSON: Data + var propertiesJSON: Data + + var documentsKeepHistory: Bool + var documentsMutable: Bool + var documentsCanBeDeleted: Bool + var documentsTransferable: Bool + + var indexOnly: Bool = false + + var requiredFieldsJSON: Data? + + var securityLevel: Int + + var tradeMode: Int + var creationRestrictionMode: Int + + var requiresIdentityEncryptionBoundedKey: Bool + var requiresIdentityDecryptionBoundedKey: Bool + + var createdAt: Date + var lastAccessedAt: Date + + var dataContract: PersistentDataContract? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.documentType_relation) + var documents: [PersistentDocument]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentIndex.documentType) + var indices: [PersistentIndex]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentProperty.documentType) + var propertiesList: [PersistentProperty]? + + init(contractId: Data, name: String, schemaJSON: Data, propertiesJSON: Data) { + 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() + } + } +} + +extension DashSchemaV1.PersistentDocumentType { + var contractIdBase58: String { + contractId.toBase58String() + } + + var schema: [String: Any]? { + try? JSONSerialization.jsonObject(with: schemaJSON, options: []) as? [String: Any] + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String: Any] + } + + var persistentProperties: [DashSchemaV1.PersistentProperty]? { + return propertiesList + } + + var requiredFields: [String]? { + guard let data = requiredFieldsJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String] + } + + var documentCount: Int { + documents?.count ?? 0 + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentIdentity.swift new file mode 100644 index 00000000000..363a6245d10 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentIdentity.swift @@ -0,0 +1,274 @@ +import Foundation +import SwiftData + +// `PersistentIdentity` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentIdentity { + #Index([\.networkRaw]) + + @Attribute(.unique) var identityId: Data + var balance: Int64 + var revision: Int64 + var isLocal: Bool + var alias: String? + var dpnsName: String? + var mainDpnsName: String? + var identityType: String + + var votingPrivateKeyIdentifier: String? + var ownerPrivateKeyIdentifier: String? + var payoutPrivateKeyIdentifier: String? + + @Relationship(deleteRule: .cascade) var publicKeys: [PersistentPublicKey] + + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var wallet: PersistentWallet? + var identityIndex: UInt32 = 0 + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.ownerIdentity) var documents: [PersistentDocument] + @Relationship(deleteRule: .nullify) var tokenBalances: [PersistentTokenBalance] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDPNSName.identity) + var dpnsNames: [PersistentDPNSName] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayProfile.identity) + var dashpayProfile: PersistentDashpayProfile? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactRequest.owner) + var contactRequests: [PersistentDashpayContactRequest] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayPayment.owner) + var dashpayPayments: [PersistentDashpayPayment] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayIgnoredSender.owner) + var dashpayIgnoredSenders: [PersistentDashpayIgnoredSender] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactProfile.owner) + var contactProfiles: [PersistentDashpayContactProfile] = [] + + var ownedDataContracts: [PersistentDataContract] + + 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 + } + + 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) + } + + var identityPublicKeys: [IdentityPublicKey] { + publicKeys.compactMap { $0.toIdentityPublicKey() } + } + + 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 + } + + 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() + } + } +} + +extension DashSchemaV1.PersistentIdentity { + static func predicate(identityId: Data) -> Predicate { + #Predicate { identity in + identity.identityId == identityId + } + } + + static var walletOwnedIdentitiesPredicate: Predicate { + #Predicate { identity in + identity.wallet != nil + } + } + + static func predicate(type: IdentityType) -> Predicate { + let typeString = type.rawValue + return #Predicate { identity in + identity.identityType == typeString + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { identity in + identity.lastSyncedAt == nil || identity.lastSyncedAt! < date + } + } + + static func predicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { identity in + identity.networkRaw == target + } + } + + static func walletOwnedIdentitiesPredicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { identity in + identity.wallet != nil && identity.networkRaw == target + } + } + + static func fetch( + in context: ModelContext, + identityId: Data + ) -> DashSchemaV1.PersistentIdentity? { + let target = identityId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == target } + ) + return try? context.fetch(descriptor).first + } +} + +extension DashSchemaV1.PersistentIdentity { + @discardableResult + static func updateBalance( + in context: ModelContext, + identityId: Data, + balance: UInt64 + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.balance = Int64(bitPattern: balance) + row.lastUpdated = Date() + return true + } + + @discardableResult + static func updateDpnsName( + in context: ModelContext, + identityId: Data, + dpnsName: String? + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.dpnsName = dpnsName + row.lastUpdated = Date() + return true + } + + @discardableResult + static func updateMainDpnsName( + in context: ModelContext, + identityId: Data, + mainDpnsName: String? + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.mainDpnsName = mainDpnsName + row.lastUpdated = Date() + return true + } + + @discardableResult + static func remove( + in context: ModelContext, + identityId: Data + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + context.delete(row) + return true + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentIndex.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentIndex.swift new file mode 100644 index 00000000000..9688fb11540 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentIndex.swift @@ -0,0 +1,86 @@ +import Foundation +import SwiftData + +// `PersistentIndex` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentIndex { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + var unique: Bool + var nullSearchable: Bool + var contested: Bool + + var countable: String? + var rangeCountable: Bool = false + var summable: String? + var rangeSummable: Bool = false + var averageable: String? + var rangeAverageable: Bool = false + + var rankedCountable: Bool = false + var rankedSummable: Bool = false + var rankedAverageable: Bool = false + + var terminal: String? + + var preallocated: Bool = false + + var timeRangeJSON: Data? + + var propertiesJSON: Data + + var contestedDetailsJSON: Data? + + var createdAt: Date + + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, properties: [String]) { + 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 + + if let jsonData = try? JSONSerialization.data(withJSONObject: properties, options: []) { + self.propertiesJSON = jsonData + } else { + self.propertiesJSON = Data() + } + + self.createdAt = Date() + } + } +} + +extension DashSchemaV1.PersistentIndex { + var properties: [String]? { + try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String] + } + + var contestedDetails: [String: Any]? { + guard let data = contestedDetailsJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var timeRange: [String: Any]? { + guard let data = timeRangeJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentInvitation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentInvitation.swift new file mode 100644 index 00000000000..fcdb604dec2 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentInvitation.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftData + +// `PersistentInvitation` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentInvitation { + #Index([\.walletId]) + + @Attribute(.unique) var outPointHex: String + + var rawOutPoint: Data + + var walletId: Data + + var fundingIndexRaw: Int + + var amountDuffs: Int64 + + var expiryUnix: Int + + var createdAtSecs: Int + + var hasInviter: Bool + + var statusRaw: Int + + var reclaimInFlight: Bool = false + + var createdAt: Date + var updatedAt: Date + + init( + outPointHex: String, + rawOutPoint: Data, + walletId: Data, + fundingIndexRaw: Int, + amountDuffs: Int64, + expiryUnix: Int, + createdAtSecs: Int, + hasInviter: Bool, + statusRaw: Int, + reclaimInFlight: Bool = false + ) { + self.outPointHex = outPointHex + self.rawOutPoint = rawOutPoint + self.walletId = walletId + self.fundingIndexRaw = fundingIndexRaw + self.amountDuffs = amountDuffs + self.expiryUnix = expiryUnix + self.createdAtSecs = createdAtSecs + self.hasInviter = hasInviter + self.statusRaw = statusRaw + self.reclaimInFlight = reclaimInFlight + self.createdAt = Date() + self.updatedAt = Date() + } + } +} + +extension DashSchemaV1.PersistentInvitation { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentKeyword.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentKeyword.swift new file mode 100644 index 00000000000..acfc92b9a38 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentKeyword.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftData + +// `PersistentKeyword` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentKeyword { + @Attribute(.unique) var id: String + var keyword: String + var contractId: String + + var dataContract: PersistentDataContract? + + init(keyword: String, contractId: String) { + self.id = "\(contractId)_\(keyword)" + self.keyword = keyword + self.contractId = contractId + } + } +} + +extension DashSchemaV1.PersistentKeyword { + static func predicate(keyword: String) -> Predicate { + #Predicate { item in + item.keyword.localizedStandardContains(keyword) + } + } + + static func predicate(contractId: String) -> Predicate { + #Predicate { item in + item.contractId == contractId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentMasternode.swift new file mode 100644 index 00000000000..d39f405f069 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentMasternode.swift @@ -0,0 +1,185 @@ +import Foundation +import SwiftData + +// `PersistentMasternode` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentMasternode { + #Unique([\.walletId, \.proTxHash]) + + var walletId: Data + var proTxHash: Data + var registrationTxid: Data + + var serviceAddress: String? + var isEvonode: Bool + + var ownerKeyHash: Data? + var votingKeyHash: Data? + var ownerAddress: String? + var votingAddress: String? + var operatorPublicKey: Data? + var platformNodeId: Data? + var payoutAddress: String? + var operatorPseudoAddress: String? + var platformNodeAddress: String? + + var ownerInWallet: Bool = false + var ownerAccountType: UInt8 = 0 + var ownerKeyIndex: UInt32 = 0 + var votingInWallet: Bool = false + var votingAccountType: UInt8 = 0 + var votingKeyIndex: UInt32 = 0 + var operatorInWallet: Bool = false + var operatorAccountType: UInt8 = 0 + var operatorKeyIndex: UInt32 = 0 + var platformInWallet: Bool = false + var platformAccountType: UInt8 = 0 + var platformKeyIndex: UInt32 = 0 + + var collateralTxid: Data? + var collateralVout: UInt32 + + var revoked: Bool + var revocationReason: UInt16 + var statusRaw: UInt8 = 3 + + var registrationHeight: UInt32 + var hasRegistration: Bool + var txCount: UInt32 + + var orderIndex: UInt32 + var typeIndex: UInt32 = 0 + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + proTxHash: Data, + registrationTxid: Data, + serviceAddress: String? = nil, + isEvonode: Bool = false, + ownerKeyHash: Data? = nil, + votingKeyHash: Data? = nil, + ownerAddress: String? = nil, + votingAddress: String? = nil, + operatorPublicKey: Data? = nil, + platformNodeId: Data? = nil, + payoutAddress: String? = nil, + collateralTxid: Data? = nil, + collateralVout: UInt32 = 0, + revoked: Bool = false, + revocationReason: UInt16 = 0, + statusRaw: UInt8 = 3, + registrationHeight: UInt32 = 0, + hasRegistration: Bool = false, + txCount: UInt32 = 0, + orderIndex: UInt32 = 0, + typeIndex: UInt32 = 0 + ) { + self.walletId = walletId + self.proTxHash = proTxHash + self.registrationTxid = registrationTxid + self.serviceAddress = serviceAddress + self.isEvonode = isEvonode + self.ownerKeyHash = ownerKeyHash + self.votingKeyHash = votingKeyHash + self.ownerAddress = ownerAddress + self.votingAddress = votingAddress + self.operatorPublicKey = operatorPublicKey + self.platformNodeId = platformNodeId + self.payoutAddress = payoutAddress + self.collateralTxid = collateralTxid + self.collateralVout = collateralVout + self.revoked = revoked + self.revocationReason = revocationReason + self.statusRaw = statusRaw + self.registrationHeight = registrationHeight + self.hasRegistration = hasRegistration + self.txCount = txCount + self.orderIndex = orderIndex + self.typeIndex = typeIndex + self.createdAt = Date() + self.lastUpdated = Date() + } + + var proTxHashHex: String { + proTxHash.reversed().map { String(format: "%02x", $0) }.joined() + } + + var proTxHashShort: String { + let hex = proTxHashHex + guard hex.count >= 12 else { return hex } + return "\(String(hex.prefix(6)))…\(String(hex.suffix(6)))" + } + + var ownerKeyHashHex: String? { + ownerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var votingKeyHashHex: String? { + votingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + static func providerAccountTypeName(_ tag: UInt8) -> String { + switch tag { + case 8: return "ProviderVotingKeys" + case 9: return "ProviderOwnerKeys" + case 10: return "ProviderOperatorKeys" + case 11: return "ProviderPlatformKeys" + default: return "Unknown(\(tag))" + } + } + + static func keyOwnershipLabel( + inWallet: Bool, + accountType: UInt8, + index: UInt32 + ) -> String { + inWallet + ? "\(providerAccountTypeName(accountType)) #\(index)" + : "not in this wallet" + } + + var operatorPublicKeyHex: String? { + operatorPublicKey.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var platformNodeIdHex: String? { + platformNodeId.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var collateralDisplay: String? { + guard let txid = collateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(collateralVout)" + } + + var displayNumber: Int { + Int(typeIndex) + } + + var typeName: String { + isEvonode ? "Evonode" : "Masternode" + } + + var displayTitle: String { + "\(typeName) \(displayNumber)" + } + + var status: MasternodeStatus { + MasternodeStatus(rawValue: statusRaw) ?? .unknown + } + + var statusName: String { + status.displayName + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPendingInput.swift new file mode 100644 index 00000000000..df3730338b2 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPendingInput.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftData + +// `PersistentPendingInput` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentPendingInput { + #Index([\.outpoint], [\.walletId]) + var outpoint: Data + + var inputIndex: UInt32 + + var spendingTxid: Data + + var spendingTransaction: PersistentTransaction? + + var walletId: Data + + 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() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPlatformAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPlatformAddress.swift new file mode 100644 index 00000000000..a4e84c8d35d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPlatformAddress.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData + +// `PersistentPlatformAddress` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentPlatformAddress { + #Index([\.walletId]) + + @Attribute(.unique) var address: String + var addressType: UInt8 + @Attribute(.unique) var addressHash: Data + var publicKey: Data + var accountIndex: UInt32 + var addressIndex: UInt32 + var derivationPath: String + var isUsed: Bool + var balance: UInt64 + var nonce: UInt32 + var firstSeenHeight: UInt32 + var lastSeenHeight: UInt64 + var walletId: Data + var createdAt: Date + var lastUpdated: Date + + 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() + } + } +} + +extension DashSchemaV1.PersistentPlatformAddress { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static var nonZeroBalancesPredicate: Predicate { + #Predicate { entry in + entry.balance > 0 + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPlatformAddressesSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPlatformAddressesSyncState.swift new file mode 100644 index 00000000000..6724d51380b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPlatformAddressesSyncState.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftData + +// `PersistentPlatformAddressesSyncState` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentPlatformAddressesSyncState { + @Attribute(.unique) var walletId: Data + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + var syncHeight: UInt64 + var syncTimestamp: UInt64 + var lastKnownRecentBlock: UInt64 + var lastUpdated: Date + + init( + walletId: Data, + network: Network, + syncHeight: UInt64, + syncTimestamp: UInt64, + lastKnownRecentBlock: UInt64 + ) { + self.walletId = walletId + self.networkRaw = network.rawValue + self.syncHeight = syncHeight + self.syncTimestamp = syncTimestamp + self.lastKnownRecentBlock = lastKnownRecentBlock + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentProperty.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentProperty.swift new file mode 100644 index 00000000000..69183b3c63a --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentProperty.swift @@ -0,0 +1,55 @@ +import Foundation +import SwiftData + +// `PersistentProperty` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentProperty { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + 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? + + var transient: Bool + var isRequired: Bool + + var createdAt: Date + + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, type: String) { + 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() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPublicKey.swift new file mode 100644 index 00000000000..a7a2bf3b023 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentPublicKey.swift @@ -0,0 +1,171 @@ +import Foundation +import SwiftData + +// `PersistentPublicKey` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentPublicKey { + var keyId: Int32 + var purpose: String + var securityLevel: String + var keyType: String + var readOnly: Bool + var disabledAt: Int64? + + var publicKeyData: Data + + var contractBoundsData: Data? + + var contractBoundsDocumentTypeName: String? + + var privateKeyKeychainIdentifier: String? + + var walletId: Data? + + var identityDerivationPath: String? + + var identityId: String + var createdAt: Date + var lastAccessed: Date? + + @Relationship(inverse: \PersistentIdentity.publicKeys) + var identity: PersistentIdentity? + + 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() + } + + 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 { + 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 + } + + var hasPrivateKeyIdentifier: Bool { + privateKeyKeychainIdentifier != nil + } + } +} + +extension DashSchemaV1.PersistentPublicKey { + func toIdentityPublicKey() -> IdentityPublicKey? { + guard let purpose = purposeEnum, + let securityLevel = securityLevelEnum, + let keyType = keyTypeEnum else { + return nil + } + + let bounds: ContractBounds? + if let id = contractBounds?.first, id.count == 32 { + if let docTypeName = contractBoundsDocumentTypeName, !docTypeName.isEmpty { + bounds = .singleContractDocumentType(id: id, documentTypeName: docTypeName) + } else { + bounds = .singleContract(id: id) + } + } else { + bounds = nil + } + + return IdentityPublicKey( + id: KeyID(keyId), + purpose: purpose, + securityLevel: securityLevel, + contractBounds: bounds, + keyType: keyType, + readOnly: readOnly, + data: publicKeyData, + disabledAt: disabledAt.map { TimestampMillis($0) } + ) + } + + static func from(_ publicKey: IdentityPublicKey, identityId: String) -> DashSchemaV1.PersistentPublicKey? { + let boundsIds: [Data]? + let docTypeName: String? + switch publicKey.contractBounds { + case .singleContract(let id): + boundsIds = [id] + docTypeName = nil + case .singleContractDocumentType(let id, let name): + boundsIds = [id] + docTypeName = name + case .none: + boundsIds = nil + docTypeName = nil + } + return DashSchemaV1.PersistentPublicKey( + keyId: Int32(publicKey.id), + purpose: publicKey.purpose, + securityLevel: publicKey.securityLevel, + keyType: publicKey.keyType, + publicKeyData: publicKey.data, + readOnly: publicKey.readOnly, + disabledAt: publicKey.disabledAt.map { Int64($0) }, + contractBounds: boundsIds, + contractBoundsDocumentTypeName: docTypeName, + identityId: identityId + ) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedActivity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedActivity.swift new file mode 100644 index 00000000000..c595dee2a4f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedActivity.swift @@ -0,0 +1,89 @@ +import Foundation +import SwiftData + +// `PersistentShieldedActivity` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentShieldedActivity { + #Unique([\.walletId, \.accountIndex, \.entryId]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var entryId: Data + + var kindTag: Int + var direction: Int + var status: Int + + var amount: UInt64 + var fee: UInt64 + var hasFee: Bool + var blockHeight: UInt64 + var hasBlockHeight: Bool + var createdAtMs: UInt64 + + var minNotePosition: UInt64 = 0 + var hasMinNotePosition: Bool = false + + var identityId: Data + var counterparty: Data + var memo: Data + var noteCmxs: Data + var spentNullifiers: Data + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + entryId: Data, + kindTag: Int, + direction: Int, + status: Int, + amount: UInt64, + fee: UInt64, + hasFee: Bool, + blockHeight: UInt64, + hasBlockHeight: Bool, + createdAtMs: UInt64, + minNotePosition: UInt64 = 0, + hasMinNotePosition: Bool = false, + identityId: Data, + counterparty: Data, + memo: Data, + noteCmxs: Data, + spentNullifiers: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.entryId = entryId + self.kindTag = kindTag + self.direction = direction + self.status = status + self.amount = amount + self.fee = fee + self.hasFee = hasFee + self.blockHeight = blockHeight + self.hasBlockHeight = hasBlockHeight + self.createdAtMs = createdAtMs + self.minNotePosition = minNotePosition + self.hasMinNotePosition = hasMinNotePosition + self.identityId = identityId + self.counterparty = counterparty + self.memo = memo + self.noteCmxs = noteCmxs + self.spentNullifiers = spentNullifiers + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedNote.swift new file mode 100644 index 00000000000..0beb2719a74 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedNote.swift @@ -0,0 +1,66 @@ +import Foundation +import SwiftData + +// `PersistentShieldedNote` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentShieldedNote { + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var position: UInt64 + var cmx: Data + @Attribute(.unique) var nullifier: Data + var blockHeight: UInt64 + var isSpent: Bool + var value: UInt64 + var noteData: Data + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + position: UInt64, + cmx: Data, + nullifier: Data, + blockHeight: UInt64, + isSpent: Bool, + value: UInt64, + noteData: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.position = position + self.cmx = cmx + self.nullifier = nullifier + self.blockHeight = blockHeight + self.isSpent = isSpent + self.value = value + self.noteData = noteData + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} + +extension DashSchemaV1.PersistentShieldedNote { + static func unspentPredicate(walletId: Data) -> Predicate { + #Predicate { + $0.walletId == walletId && $0.isSpent == false + } + } + + static var unspentPredicate: Predicate { + #Predicate { $0.isSpent == false } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedOutgoingNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedOutgoingNote.swift new file mode 100644 index 00000000000..6954db1b097 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedOutgoingNote.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftData + +// `PersistentShieldedOutgoingNote` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentShieldedOutgoingNote { + #Unique([\.walletId, \.accountIndex, \.cmx]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var cmx: Data + var recipient: Data + var value: UInt64 + var memo: Data + var blockHeight: UInt64 + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + cmx: Data, + recipient: Data, + value: UInt64, + memo: Data, + blockHeight: UInt64 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.cmx = cmx + self.recipient = recipient + self.value = value + self.memo = memo + self.blockHeight = blockHeight + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedSyncState.swift new file mode 100644 index 00000000000..688a911d444 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedSyncState.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentShieldedSyncState` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentShieldedSyncState { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + var lastSyncedIndex: UInt64 + + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + lastSyncedIndex: UInt64 = 0 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.lastSyncedIndex = lastSyncedIndex + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedViewingKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedViewingKey.swift new file mode 100644 index 00000000000..624f2c8b27c --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentShieldedViewingKey.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentShieldedViewingKey` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentShieldedViewingKey { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + var fvkBytes: Data + + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + fvkBytes: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.fvkBytes = fvkBytes + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentToken.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentToken.swift new file mode 100644 index 00000000000..890bcbca49a --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentToken.swift @@ -0,0 +1,376 @@ +import Foundation +import SwiftData + +// `PersistentToken` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentToken { + @Attribute(.unique) var id: Data + var contractId: Data + var position: Int + var name: String + + var baseSupply: String + var maxSupply: String? + var decimals: Int + + var localizations: [String: TokenLocalization]? + + var isPaused: Bool + var allowTransferToFrozenBalance: Bool + + var keepsTransferHistory: Bool + var keepsFreezingHistory: Bool + var keepsMintingHistory: Bool + var keepsBurningHistory: Bool + var keepsDirectPricingHistory: Bool + var keepsDirectPurchaseHistory: Bool + + var conventionsChangeRules: ChangeControlRules? + var maxSupplyChangeRules: ChangeControlRules? + var manualMintingRules: ChangeControlRules? + var manualBurningRules: ChangeControlRules? + var freezeRules: ChangeControlRules? + var unfreezeRules: ChangeControlRules? + var destroyFrozenFundsRules: ChangeControlRules? + var emergencyActionRules: ChangeControlRules? + + var perpetualDistribution: TokenPerpetualDistribution? + var preProgrammedDistribution: TokenPreProgrammedDistribution? + var newTokensDestinationIdentity: Data? + var mintingAllowChoosingDestination: Bool + var distributionChangeRules: TokenDistributionChangeRules? + + var tradeMode: TokenTradeMode + var tradeModeChangeRules: ChangeControlRules? + + var mainControlGroupPosition: Int? + var mainControlGroupCanBeModified: String? + + var tokenDescription: String? + + var createdAt: Date + var lastUpdatedAt: Date + + 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) { + 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 + + 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() + } + } +} + +extension DashSchemaV1.PersistentToken { + var displayName: String { + if let desc = tokenDescription, !desc.isEmpty { + return desc + } + return getSingularForm() ?? name + } + + var formattedBaseSupply: String { + Self.formatSupply(baseSupply, decimals: decimals) + } + + static func formatSupply(_ raw: String, decimals: Int) -> String { + guard !raw.isEmpty, raw.allSatisfy({ $0.isASCII && $0.isNumber }) else { + return raw + } + let normalized = String(raw.drop(while: { $0 == "0" })) + let digits = normalized.isEmpty ? "0" : normalized + let scale = max(0, decimals) + let integer: String + var fraction = "" + if scale == 0 { + integer = digits + } else if digits.count <= scale { + integer = "0" + fraction = String(repeating: "0", count: scale - digits.count) + digits + } else { + let split = digits.index(digits.endIndex, offsetBy: -scale) + integer = String(digits[.. 0 && offset.isMultiple(of: 3) { grouped.append(",") } + grouped.append(character) + } + grouped = String(grouped.reversed()) + return fraction.isEmpty ? grouped : "\(grouped).\(fraction)" + } + + var contractIdBase58: String { + contractId.toBase58String() + } + + var canManuallyMint: Bool { + manualMintingRules != nil + } + + var canManuallyBurn: Bool { + manualBurningRules != nil + } + + var canFreeze: Bool { + freezeRules != nil + } + + var canUnfreeze: Bool { + unfreezeRules != nil + } + + var canDestroyFrozenFunds: Bool { + destroyFrozenFundsRules != nil + } + + var hasEmergencyActions: Bool { + emergencyActionRules != nil + } + + var canChangeMaxSupply: Bool { + maxSupplyChangeRules != nil + } + + var canChangeConventions: Bool { + conventionsChangeRules != nil + } + + var hasDistribution: Bool { + perpetualDistribution != nil || preProgrammedDistribution != nil + } + + var canChangeTradeMode: Bool { + tradeModeChangeRules != nil + } + + var keepsAnyHistory: Bool { + keepsTransferHistory || + keepsFreezingHistory || + keepsMintingHistory || + keepsBurningHistory || + keepsDirectPricingHistory || + keepsDirectPurchaseHistory + } + + var totalSupply: String { + guard let balances = balances, !balances.isEmpty else { return baseSupply } + return Self.sumUnsignedBalances(balances.map(\.unsignedBalance)) + } + + var totalFrozenBalance: String { + guard let balances = balances else { return "0" } + return Self.sumUnsignedBalances( + balances.lazy.filter(\.frozen).map(\.unsignedBalance) + ) + } + + var activeHolders: Int { + balances?.filter { $0.unsignedBalance > 0 }.count ?? 0 + } + + private static func sumUnsignedBalances(_ values: S) -> String + where S.Element == UInt64 { + var digits: [UInt8] = [0] // little-endian decimal digits + + for value in values { + var carry = 0 + let addend = String(value).utf8.reversed().map { Int($0 - 48) } + let width = max(digits.count, addend.count) + if digits.count < width { + digits.append(contentsOf: repeatElement(0, count: width - digits.count)) + } + + for index in 0.. 0 { + digits.append(UInt8(carry % 10)) + carry /= 10 + } + } + + return String(digits.reversed().map { Character(String($0)) }) + } + + var hasMaxSupply: Bool { + maxSupply != nil + } + + var isTradeable: Bool { + tradeMode != .notTradeable + } + + var newTokensDestinationIdentityBase58: String? { + newTokensDestinationIdentity?.toBase58String() + } +} + +extension DashSchemaV1.PersistentToken { + func setLocalization(languageCode: String, singularForm: String, pluralForm: String, description: String? = nil) { + if localizations == nil { + localizations = [:] + } + localizations?[languageCode] = DashSchemaV1.TokenLocalization( + singularForm: singularForm, + pluralForm: pluralForm, + description: description + ) + lastUpdatedAt = Date() + } + + func getSingularForm(languageCode: String = "en") -> String? { + return localizations?[languageCode]?.singularForm ?? localizations?["en"]?.singularForm + } + + func getPluralForm(languageCode: String = "en") -> String? { + return localizations?[languageCode]?.pluralForm ?? localizations?["en"]?.pluralForm + } +} + +extension DashSchemaV1.PersistentToken { + func getChangeControlRules(for type: ChangeControlRuleType) -> DashSchemaV1.ChangeControlRules? { + switch type { + case .conventions: return conventionsChangeRules + case .maxSupply: return maxSupplyChangeRules + case .manualMinting: return manualMintingRules + case .manualBurning: return manualBurningRules + case .freeze: return freezeRules + case .unfreeze: return unfreezeRules + case .destroyFrozenFunds: return destroyFrozenFundsRules + case .emergencyAction: return emergencyActionRules + case .tradeMode: return tradeModeChangeRules + } + } + + func setChangeControlRules(_ rules: DashSchemaV1.ChangeControlRules, for type: ChangeControlRuleType) { + switch type { + case .conventions: conventionsChangeRules = rules + case .maxSupply: maxSupplyChangeRules = rules + case .manualMinting: manualMintingRules = rules + case .manualBurning: manualBurningRules = rules + case .freeze: freezeRules = rules + case .unfreeze: unfreezeRules = rules + case .destroyFrozenFunds: destroyFrozenFundsRules = rules + case .emergencyAction: emergencyActionRules = rules + case .tradeMode: tradeModeChangeRules = rules + } + + lastUpdatedAt = Date() + } +} + +extension DashSchemaV1.PersistentToken { + static func mintableTokensPredicate() -> Predicate { + #Predicate { token in + token.manualMintingRules != nil + } + } + + static func burnableTokensPredicate() -> Predicate { + #Predicate { token in + token.manualBurningRules != nil + } + } + + static func freezableTokensPredicate() -> Predicate { + #Predicate { token in + token.freezeRules != nil + } + } + + static func distributionTokensPredicate() -> Predicate { + #Predicate { token in + token.perpetualDistribution != nil || token.preProgrammedDistribution != nil + } + } + + static func pausedTokensPredicate() -> Predicate { + #Predicate { token in + token.isPaused == true + } + } + + static func tokensByContractPredicate(contractId: Data) -> Predicate { + #Predicate { token in + token.contractId == contractId + } + } + + static func tokensWithControlRulePredicate(rule: ControlRuleType) -> Predicate { + switch rule { + case .manualMinting: + return #Predicate { token in + token.manualMintingRules != nil + } + case .manualBurning: + return #Predicate { token in + token.manualBurningRules != nil + } + case .freeze: + return #Predicate { token in + token.freezeRules != nil + } + case .unfreeze: + return #Predicate { token in + token.unfreezeRules != nil + } + case .destroyFrozenFunds: + return #Predicate { token in + token.destroyFrozenFundsRules != nil + } + case .emergencyAction: + return #Predicate { token in + token.emergencyActionRules != nil + } + case .conventions: + return #Predicate { token in + token.conventionsChangeRules != nil + } + case .maxSupply: + return #Predicate { token in + token.maxSupplyChangeRules != nil + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTokenBalance.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTokenBalance.swift new file mode 100644 index 00000000000..558a916d1cf --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTokenBalance.swift @@ -0,0 +1,198 @@ +import Foundation +import SwiftData + +// `PersistentTokenBalance` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentTokenBalance { + #Index([\.networkRaw]) + + var tokenId: String + var identityId: Data + var balance: Int64 + var frozen: Bool + + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + var tokenName: String? + var tokenSymbol: String? + var tokenDecimals: Int32? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + @Relationship(deleteRule: .nullify) var identity: PersistentIdentity? + @Relationship(inverse: \PersistentToken.balances) var token: PersistentToken? + + 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 + } + + 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 + ) + } + + 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) } + + 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[.. (tokenId: String, balance: UInt64, frozen: Bool) { + return (tokenId: tokenId, balance: unsignedBalance, frozen: frozen) + } +} + +extension DashSchemaV1.PersistentTokenBalance { + static func predicate(tokenId: String, identityId: Data) -> Predicate { + #Predicate { balance in + balance.tokenId == tokenId && balance.identityId == identityId + } + } + + static func predicate(identityId: Data) -> Predicate { + #Predicate { balance in + balance.identityId == identityId + } + } + + static func predicate(tokenId: String) -> Predicate { + #Predicate { balance in + balance.tokenId == tokenId + } + } + + static var nonZeroBalancesPredicate: Predicate { + #Predicate { balance in + balance.balance != 0 + } + } + + static var frozenBalancesPredicate: Predicate { + #Predicate { balance in + balance.frozen == true + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { balance in + balance.lastSyncedAt == nil || balance.lastSyncedAt! < date + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTokenHistoryEvent.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTokenHistoryEvent.swift new file mode 100644 index 00000000000..9b58631a31e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTokenHistoryEvent.swift @@ -0,0 +1,112 @@ +import Foundation +import SwiftData + +// `PersistentTokenHistoryEvent` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentTokenHistoryEvent { + @Attribute(.unique) var id: UUID + + var eventType: String + var transactionId: Data? + var blockHeight: Int64? + var coreBlockHeight: Int64? + + var fromIdentity: Data? + var toIdentity: Data? + var performedByIdentity: Data + + var amount: String? + var balanceBefore: String? + var balanceAfter: String? + + var additionalDataJSON: Data? + + var eventDescription: String? + + var createdAt: Date + var eventTimestamp: Date + + @Relationship(inverse: \PersistentToken.historyEvents) + var token: PersistentToken? + + init( + eventType: TokenEventType, + performedByIdentity: Data, + eventTimestamp: Date = Date() + ) { + self.id = UUID() + self.eventType = eventType.rawValue + self.performedByIdentity = performedByIdentity + self.eventTimestamp = eventTimestamp + self.createdAt = Date() + } + + var eventTypeEnum: TokenEventType { + TokenEventType(rawValue: eventType) ?? .unknown + } + + var fromIdentityBase58: String? { + fromIdentity?.toBase58String() + } + + var toIdentityBase58: String? { + toIdentity?.toBase58String() + } + + var performedByIdentityBase58: String { + performedByIdentity.toBase58String() + } + + var displayTitle: String { + switch eventTypeEnum { + case .mint: + return "Minted \(formattedAmount)" + case .burn: + return "Burned \(formattedAmount)" + case .transfer: + return "Transfer \(formattedAmount)" + case .freeze: + return "Frozen \(formattedAmount)" + case .unfreeze: + return "Unfrozen \(formattedAmount)" + case .destroyFrozenFunds: + return "Destroyed Frozen Funds \(formattedAmount)" + case .configUpdate: + return "Configuration Updated" + case .emergencyAction: + return "Emergency Action" + case .perpetualDistribution: + return "Perpetual Distribution \(formattedAmount)" + case .preProgrammedRelease: + return "Pre-programmed Release \(formattedAmount)" + case .directPricing: + return "Direct Pricing Updated" + case .directPurchase: + return "Direct Purchase \(formattedAmount)" + case .unknown: + return "Unknown Event" + } + } + + private var formattedAmount: String { + guard let amount = amount else { return "" } + return amount + } + + func setAdditionalData(_ data: [String: Any]) { + additionalDataJSON = try? JSONSerialization.data(withJSONObject: data) + } + + func getAdditionalData() -> [String: Any]? { + guard let data = additionalDataJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTransaction.swift new file mode 100644 index 00000000000..834bfa42056 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTransaction.swift @@ -0,0 +1,167 @@ +import Foundation +import SwiftData + +// `PersistentTransaction` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentTransaction { + #Index([\.firstSeen]) + + @Attribute(.unique) var txid: Data + var transactionData: Data + var context: UInt32 + var blockHeight: UInt32 + var blockHash: Data? + var blockTimestamp: UInt32 + var blockPosition: UInt32 = 0 + var hasBlockPosition: Bool = false + var direction: UInt32 + var transactionType: String + var transactionTypeKind: UInt8 = 0xFF + var netAmount: Int64 + var fee: UInt64? + var label: String + var firstSeen: UInt64 + + var providerServiceAddress: String? = nil + var providerProTxHash: Data? = nil + var providerCollateralTxid: Data? = nil + var providerCollateralVout: UInt32 = 0 + var providerOwnerKeyHash: Data? = nil + var providerVotingKeyHash: Data? = nil + + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.transaction) + var outputs: [PersistentTxo] = [] + + @Relationship(inverse: \PersistentTxo.spendingTransaction) + var inputs: [PersistentTxo] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentPendingInput.spendingTransaction) + var pendingInputs: [PersistentPendingInput] = [] + + @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() + } + + 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" + } + } + + var typedKind: TransactionTypeKind? { + TransactionTypeKind(rawValue: transactionTypeKind) + } + + var isAssetLock: Bool { + typedKind == .assetLock + } + + var isAssetUnlock: Bool { + typedKind == .assetUnlock + } + + var isProviderRegistration: Bool { + typedKind == .providerRegistration + } + + var isProviderUpdateService: Bool { + typedKind == .providerUpdateService + } + + var providerProTxHashHex: String? { + providerProTxHash.map { $0.reversed().map { String(format: "%02x", $0) }.joined() } + } + + var providerCollateralDisplay: String? { + guard let txid = providerCollateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(providerCollateralVout)" + } + + var providerOwnerKeyHashHex: String? { + providerOwnerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var providerVotingKeyHashHex: String? { + providerVotingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var isProviderSpecial: Bool { + providerSpecialName != nil + } + + 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 + } + } + + 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) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTxo.swift new file mode 100644 index 00000000000..2568f004c68 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentTxo.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData + +// `PersistentTxo` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentTxo { + #Index([\.walletId]) + + @Attribute(.unique) var outpoint: Data + var vout: UInt32 + var amount: UInt64 + var address: String + var scriptPubKey: Data + var height: UInt32 + var isCoinbase: Bool + var isConfirmed: Bool + var isInstantLocked: Bool + var isLocked: Bool + var isSpent: Bool + var createdAt: Date + var lastUpdated: Date + + var walletId: Data = Data() + + var transaction: PersistentTransaction? + + var spendingTransaction: PersistentTransaction? + + var spendingInputIndex: UInt32? = nil + + var account: PersistentAccount? + + 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 + } + + 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 + } + + var txid: Data { + if let transaction { + return transaction.txid + } + return outpoint.count >= 32 ? Data(outpoint.prefix(32)) : Data() + } + + var txidHex: String { + let rawTxid = txid + guard rawTxid.count == 32 else { return "" } + return rawTxid.reversed().map { String(format: "%02x", $0) }.joined() + } + + 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) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentWallet.swift new file mode 100644 index 00000000000..ccfabe3b740 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentWallet.swift @@ -0,0 +1,94 @@ +import Foundation +import SwiftData + +// `PersistentWallet` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentWallet { + #Index([\.networkRaw], [\.walletGroupId]) + #Unique([\.walletId]) + + var walletId: Data + var walletGroupId: Data = Data() + var networkRaw: UInt32? + + var network: Network? { + get { + guard let raw = networkRaw else { return nil } + return Network(rawValue: raw) ?? .testnet + } + set { networkRaw = newValue?.rawValue } + } + var name: String? + var walletDescription: String? + var birthHeight: UInt32 + var syncedHeight: UInt32 + var lastSynced: UInt64 + var lastAppliedChainLockBytes: Data? + var isImported: Bool = false + var seedBindingVerifiedMarker: String? + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentAccount.wallet) + var accounts: [PersistentAccount] + + @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 = [] + } + } +} + +extension DashSchemaV1.PersistentWallet { + var label: String { + if let name = name, !name.isEmpty { + return name + } + let hex = walletId.prefix(4) + .map { String(format: "%02x", $0) } + .joined() + return hex.isEmpty ? "Wallet" : "Wallet \(hex)…" + } +} + +extension DashSchemaV1.PersistentWallet { + static func predicate(walletId: Data) -> Predicate { + #Predicate { $0.walletId == walletId } + } + + static func predicate( + walletGroupId: Data + ) -> Predicate { + #Predicate { $0.walletGroupId == walletGroupId } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentWalletManagerMetadata.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentWalletManagerMetadata.swift new file mode 100644 index 00000000000..7e328101e50 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+PersistentWalletManagerMetadata.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentWalletManagerMetadata` exactly as schema DashSchemaV1 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV1 { + @Model + final class PersistentWalletManagerMetadata { + @Attribute(.unique) var networkRaw: UInt32 + var combinedSyncHeight: UInt32 + var combinedSyncBlockHash: Data? + var walletCount: Int + var createdAt: Date + var lastUpdated: Date + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + init(network: Network) { + self.networkRaw = network.rawValue + self.combinedSyncHeight = 0 + self.walletCount = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+TokenTypes.swift new file mode 100644 index 00000000000..c499469000b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV1+TokenTypes.swift @@ -0,0 +1,153 @@ +import Foundation +import SwiftData + +// Inline value types exactly as schema DashSchemaV1 stored them, generated +// by scripts/freeze_schema_models.py from TokenTypes.swift +// at commit 5f58417079. SwiftData expands a stored Codable struct into composite +// attributes of the owning entity, so these shapes are inputs to that +// version's checksum just like the model's own properties. Do not edit. +extension DashSchemaV1 { + struct ChangeControlRules: Codable, Equatable, Sendable { + var authorizedToMakeChange: String + var adminActionTakers: String + var changingAuthorizedActionTakersToNoOneAllowed: Bool + var changingAdminActionTakersToNoOneAllowed: Bool + var selfChangingAdminActionTakersAllowed: Bool + + init( + authorizedToMakeChange: String = AuthorizedActionTakers.noOne.rawValue, + adminActionTakers: String = AuthorizedActionTakers.noOne.rawValue, + changingAuthorizedActionTakersToNoOneAllowed: Bool = false, + changingAdminActionTakersToNoOneAllowed: Bool = false, + selfChangingAdminActionTakersAllowed: Bool = false + ) { + self.authorizedToMakeChange = authorizedToMakeChange + self.adminActionTakers = adminActionTakers + self.changingAuthorizedActionTakersToNoOneAllowed = changingAuthorizedActionTakersToNoOneAllowed + self.changingAdminActionTakersToNoOneAllowed = changingAdminActionTakersToNoOneAllowed + self.selfChangingAdminActionTakersAllowed = selfChangingAdminActionTakersAllowed + } + + static func mostRestrictive() -> ChangeControlRules { + return ChangeControlRules() + } + + static func contractOwnerControlled() -> ChangeControlRules { + return ChangeControlRules( + authorizedToMakeChange: AuthorizedActionTakers.contractOwner.rawValue, + adminActionTakers: AuthorizedActionTakers.noOne.rawValue, + selfChangingAdminActionTakersAllowed: true + ) + } + } + + enum AuthorizedActionTakers: String, CaseIterable, Codable, Sendable { + case noOne = "NoOne" + case contractOwner = "ContractOwner" + case mainGroup = "MainGroup" + + static func identity(_ id: Data) -> String { + return "Identity:\(id.toBase58String())" + } + + static func group(_ position: Int) -> String { + return "Group:\(position)" + } + } + + struct TokenPerpetualDistribution: Codable, Equatable, Sendable { + var distributionType: String + var distributionRecipient: String + var enabled: Bool + var lastDistributionTime: Date? + var nextDistributionTime: Date? + + init(distributionRecipient: String = "AllEqualShare", enabled: Bool = true) { + self.distributionType = "{}" + self.distributionRecipient = distributionRecipient + self.enabled = enabled + } + } + + struct TokenPreProgrammedDistribution: Codable, Equatable, Sendable { + var distributionSchedule: [DistributionEvent] + var currentEventIndex: Int + var totalDistributed: String + var remainingToDistribute: String + var isActive: Bool + var isPaused: Bool + var isCompleted: Bool + + init() { + self.distributionSchedule = [] + self.currentEventIndex = 0 + self.totalDistributed = "0" + self.remainingToDistribute = "0" + self.isActive = true + self.isPaused = false + self.isCompleted = false + } + } + + struct DistributionEvent: Codable, Equatable, Sendable { + var id: UUID + var triggerType: String + var triggerTime: Date? + var triggerBlock: Int64? + var triggerCondition: String? + var amount: String + var recipient: String + var description: String? + + init(triggerTime: Date, amount: String, recipient: String = "AllHolders", description: String? = nil) { + self.id = UUID() + self.triggerType = "Time" + self.triggerTime = triggerTime + self.amount = amount + self.recipient = recipient + self.description = description + } + } + + struct TokenDistributionChangeRules: Codable, Equatable, Sendable { + var perpetualDistributionRules: ChangeControlRules? + var newTokensDestinationIdentityRules: ChangeControlRules? + var mintingAllowChoosingDestinationRules: ChangeControlRules? + var changeDirectPurchasePricingRules: ChangeControlRules? + + init( + perpetualDistributionRules: ChangeControlRules? = nil, + newTokensDestinationIdentityRules: ChangeControlRules? = nil, + mintingAllowChoosingDestinationRules: ChangeControlRules? = nil, + changeDirectPurchasePricingRules: ChangeControlRules? = nil + ) { + self.perpetualDistributionRules = perpetualDistributionRules + self.newTokensDestinationIdentityRules = newTokensDestinationIdentityRules + self.mintingAllowChoosingDestinationRules = mintingAllowChoosingDestinationRules + self.changeDirectPurchasePricingRules = changeDirectPurchasePricingRules + } + } + + enum TokenTradeMode: String, CaseIterable, Codable, Sendable { + case notTradeable = "NotTradeable" + + var displayName: String { + switch self { + case .notTradeable: + return "Not Tradeable" + } + } + } + + struct TokenLocalization: Codable, Equatable, Sendable { + let singularForm: String + let pluralForm: String + let description: String? + + init(singularForm: String, pluralForm: String, description: String? = nil) { + self.singularForm = singularForm + self.pluralForm = pluralForm + self.description = description + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift new file mode 100644 index 00000000000..b4c09ccc3c1 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftData + +// `PersistentTrackedMasternode` exactly as schema DashSchemaV2 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV2 { + @Model + final class PersistentTrackedMasternode { + #Unique([\.networkRaw, \.proTxHash]) + #Index([\.networkRaw]) + + var networkRaw: UInt32 + var proTxHash: Data + var label: String? + var addedAt: UInt64 + var snapshotJSON: String + + var network: Network? { + get { Network(rawValue: networkRaw) } + set { networkRaw = newValue?.rawValue ?? networkRaw } + } + + init( + networkRaw: UInt32, + proTxHash: Data, + label: String?, + addedAt: UInt64, + snapshotJSON: String + ) { + self.networkRaw = networkRaw + self.proTxHash = proTxHash + self.label = label + self.addedAt = addedAt + self.snapshotJSON = snapshotJSON + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift new file mode 100644 index 00000000000..d54a4343f1f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift @@ -0,0 +1,102 @@ +import Foundation +import SwiftData + +// `PersistentAssetLock` exactly as schema DashSchemaV3 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 5f58417079. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV3 { + @Model + final class PersistentAssetLock { + #Index([\.walletId]) + + @Attribute(.unique) var outPointHex: String + + var walletId: Data + + var transactionBytes: Data + + var fundingTypeRaw: Int + + var identityIndexRaw: Int32 + + var accountIndexRaw: Int32 = 0 + + var amountDuffs: Int64 + + var statusRaw: Int + + var proofBytes: Data? + + var recipientPlatformAddressHash: Data? + + var recipientPlatformAddressType: UInt8? + + var recipientIsExternal: Bool? + + var createdAt: Date + var updatedAt: Date + + init( + outPointHex: String, + walletId: Data, + transactionBytes: Data, + fundingTypeRaw: Int, + identityIndexRaw: Int32, + accountIndexRaw: Int32 = 0, + amountDuffs: Int64, + statusRaw: Int, + proofBytes: Data? = nil + ) { + self.outPointHex = outPointHex + self.walletId = walletId + self.transactionBytes = transactionBytes + self.fundingTypeRaw = fundingTypeRaw + self.identityIndexRaw = identityIndexRaw + self.accountIndexRaw = accountIndexRaw + self.amountDuffs = amountDuffs + self.statusRaw = statusRaw + self.proofBytes = proofBytes + self.createdAt = Date() + self.updatedAt = Date() + } + } +} + +extension DashSchemaV3.PersistentAssetLock { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static func predicate( + walletId: Data, + identityIndex: UInt32 + ) -> Predicate { + let identityIndexRaw = Int32(bitPattern: identityIndex) + return #Predicate { entry in + entry.walletId == walletId && entry.identityIndexRaw == identityIndexRaw + } + } +} + +extension DashSchemaV3.PersistentAssetLock { + static func encodeOutPoint(rawBytes: Data) -> String { + precondition(rawBytes.count == 36, "outpoint must be 36 bytes") + let txid = rawBytes.prefix(32) + let voutBytes = rawBytes.suffix(4) + let vout = voutBytes.withUnsafeBytes { raw -> UInt32 in + var value: UInt32 = 0 + withUnsafeMutableBytes(of: &value) { dst in + dst.copyBytes(from: raw.prefix(MemoryLayout.size)) + } + return UInt32(littleEndian: value) + } + let txidHex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(txidHex):\(vout)" + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index e928306eb9c..676d7ebf599 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -199,8 +199,8 @@ public final class PersistentAssetLock { /// `ModelContainer(for:migrationPlan:configurations:)` would fail to /// open it with Cocoa error 134504 ("Cannot use staged migration with /// an unknown model version"). So V1 and V2 now reference a frozen - /// copy of this model (`DashSchemaV1.PersistentAssetLock`, in - /// `DashSchemaFrozenModels.swift`), this property is what schema + /// copy of this model (`DashSchemaV1.PersistentAssetLock`, generated + /// under `FrozenSchemas/`), this property is what schema /// `DashSchemaV3` adds, and a lightweight V2 -> V3 stage carries /// existing stores across. Do the same for the next property added /// here. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 08d1f84c383..7daaabbe52d 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -1,10 +1,520 @@ +import CoreData import Foundation +import SQLite3 import SwiftData import XCTest @testable import SwiftDashSDK +/// Migration coverage from two directions: source stores built in this +/// process from each registered version, and stores that OLDER BUILDS +/// actually wrote. +/// +/// The fixture stores under `Fixtures/SchemaStores/` were written by the +/// builds that shipped each version: `dash-v1` to `dash-v3` by a build of +/// the persistence sources as of commit 5f58417079 — the last state before +/// V4, the state the frozen copies under `FrozenSchemas/` are generated +/// from — through that build's own `DashSchemaV1` / `DashSchemaV2` / +/// `DashSchemaV3`, and `dash-v4` by the build that registered V4, through +/// `DashModelContainer.create`. They pin the frozen copies as the pre-V4 +/// build defined them, not what the original V1 release wrote (see the +/// `DashSchemaV1` doc for why those stores are expected to fail open and +/// be rebuilt). Each carries a wallet, an account, a core address, two +/// transactions, a TXO linked to both, a pending input, an identity, a +/// keyword, an asset lock and (from V2) a tracked masternode — enough to +/// exercise every relationship in the wallet graph; the rows are the ones +/// `testWriteTheLiveSchemaFixtureStore` writes. +/// +/// The live version has a fixture too, so a change to a live model's +/// shape fails the hash test against that version's own store — the +/// failure a store in the field would otherwise report as Cocoa error +/// 134504. Changing the live shape before it ships is legitimate; doing +/// so means regenerating the live fixture on purpose, with +/// `testWriteTheLiveSchemaFixtureStore`, in the same change. +/// +/// A source store written in this process by `Schema(versionedSchema:)` +/// cannot replace them: SwiftData binds an entity name to the first Swift +/// type that claims it, so such a store carries whatever shape the process +/// had already bound, and a frozen version whose entities had silently +/// rebound to the live shape would round-trip itself and pass vacuously. +/// Only a store from a build that knew nothing of the live shape can tell. final class DashModelMigrationTests: XCTestCase { + /// SwiftData binds an entity name to the first Swift type that claims it + /// in the process, so whether the live schema is built before or after + /// a frozen version is decided once per process, not per test. Build it + /// first here, as `DashModelContainer.create` does, so every test below + /// runs in the order the app would. + override class func setUp() { + super.setUp() + _ = DashModelContainer.schema + } + + private struct Fixture { + let name: String + let version: any VersionedSchema.Type + let hasTrackedMasternode: Bool + let assetLockRecipientIsExternal: Bool? + } + + private static let fixtures: [Fixture] = [ + Fixture( + name: "dash-v1", version: DashSchemaV1.self, + hasTrackedMasternode: false, assetLockRecipientIsExternal: nil), + Fixture( + name: "dash-v2", version: DashSchemaV2.self, + hasTrackedMasternode: true, assetLockRecipientIsExternal: nil), + Fixture( + name: "dash-v3", version: DashSchemaV3.self, + hasTrackedMasternode: true, assetLockRecipientIsExternal: true), + Fixture( + name: "dash-v4", version: DashSchemaV4.self, + hasTrackedMasternode: true, assetLockRecipientIsExternal: true), + ] + + /// Every schema version that has ever shipped, oldest first, as + /// `major.minor.patch`. APPEND-ONLY: a version that shipped wrote stores + /// that exist in the field, so it can never be removed from, reordered + /// in, or replaced in the migration plan, and the plan is checked + /// against this list rather than the other way round. Adding a version + /// to the plan is shipping it: append it here in the same change and + /// give it a fixture store in `fixtures`, written by that build with + /// `testWriteTheLiveSchemaFixtureStore`. Every entry has a fixture, + /// the live one included. + private static let shippedVersions = ["1.0.0", "2.0.0", "3.0.0", "4.0.0"] + + private static let fixtureWalletId = Data(repeating: 0x31, count: 32) + private static let fixtureSpendTxid = Data(repeating: 0x32, count: 32) + private static let fixtureFundingTxid = Data(repeating: 0x34, count: 32) + private static let fixtureIdentityId = Data(repeating: 0x35, count: 32) + + /// A private, writable copy of a fixture store. + private func copyFixture(_ fixture: Fixture) throws -> (URL, URL) { + let source = try XCTUnwrap( + Bundle.module.url( + forResource: fixture.name, withExtension: "store", + subdirectory: "Fixtures/SchemaStores"), + "missing fixture \(fixture.name).store") + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + let copy = directory.appendingPathComponent("\(fixture.name).store") + try FileManager.default.copyItem(at: source, to: copy) + return (directory, copy) + } + + private static func storeHashes(at url: URL) throws -> (String, [String: Data]) { + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore( + type: .sqlite, at: url) + let checksum = try XCTUnwrap( + metadata["NSStoreModelVersionChecksumKey"] as? String, + "store has no model checksum") + let hashes = try XCTUnwrap( + metadata["NSStoreModelVersionHashes"] as? [String: Data], + "store has no entity hashes") + return (checksum, hashes) + } + + /// Every fixture opens through `DashModelContainer.create`'s exact + /// order — live schema built first, then the migration plan — and its + /// rows come back through the live types with the relationships intact + /// and the new columns at their migration defaults. + @MainActor + func testStoresWrittenByOlderBuildsMigrateThroughTheContainerFactory() throws { + for fixture in Self.fixtures { + let (directory, url) = try copyFixture(fixture) + defer { try? FileManager.default.removeItem(at: directory) } + + let container: ModelContainer + do { + container = try DashModelContainer.create(url: url) + } catch { + XCTFail("\(fixture.name): migration failed to open: \(error)") + continue + } + let context = container.mainContext + + let wallets = try context.fetch(FetchDescriptor()) + XCTAssertEqual(wallets.map(\.walletId), [Self.fixtureWalletId], fixture.name) + let wallet = try XCTUnwrap(wallets.first) + XCTAssertEqual(wallet.name, "fixture wallet", fixture.name) + XCTAssertEqual(wallet.syncedHeight, 120, fixture.name) + XCTAssertNil(wallet.lastAppliedChainLockHeight, fixture.name) + XCTAssertEqual(wallet.accounts.count, 1, fixture.name) + XCTAssertEqual( + wallet.identities.map(\.identityId), [Self.fixtureIdentityId], fixture.name) + + let accounts = try context.fetch(FetchDescriptor()) + let account = try XCTUnwrap(accounts.first, fixture.name) + XCTAssertEqual(accounts.count, 1, fixture.name) + XCTAssertEqual(account.wallet.walletId, Self.fixtureWalletId, fixture.name) + XCTAssertEqual(account.coreAddresses.map(\.address), ["yFixtureAddress"], fixture.name) + XCTAssertEqual( + Set(account.involvedTransactions.map(\.txid)), + [Self.fixtureSpendTxid, Self.fixtureFundingTxid], fixture.name) + + let transactions = try context.fetch(FetchDescriptor()) + XCTAssertEqual(transactions.count, 2, fixture.name) + let funding = try XCTUnwrap( + transactions.first { $0.txid == Self.fixtureFundingTxid }, fixture.name) + let spend = try XCTUnwrap( + transactions.first { $0.txid == Self.fixtureSpendTxid }, fixture.name) + XCTAssertEqual(funding.outputs.count, 1, fixture.name) + XCTAssertEqual(spend.inputs.count, 1, fixture.name) + XCTAssertEqual(spend.pendingInputs.count, 1, fixture.name) + + let txos = try context.fetch(FetchDescriptor()) + XCTAssertEqual(txos.count, 1, fixture.name) + let txo = try XCTUnwrap(txos.first) + XCTAssertEqual(txo.amount, 1_000, fixture.name) + XCTAssertEqual(txo.transaction?.txid, Self.fixtureFundingTxid, fixture.name) + XCTAssertEqual(txo.spendingTransaction?.txid, Self.fixtureSpendTxid, fixture.name) + XCTAssertEqual(txo.coreAddress?.address, "yFixtureAddress", fixture.name) + XCTAssertEqual(txo.account?.accountIndex, 0, fixture.name) + XCTAssertNil(txo.supersededByTxid, fixture.name) + + let pendingInputs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(pendingInputs.count, 1, fixture.name) + let pending = try XCTUnwrap(pendingInputs.first) + XCTAssertEqual(pending.spendingTxid, Self.fixtureSpendTxid, fixture.name) + XCTAssertEqual(pending.spendingTransaction?.txid, Self.fixtureSpendTxid, fixture.name) + XCTAssertFalse(pending.isSweptTombstone, fixture.name) + XCTAssertNil(pending.winnerMinedHeight, fixture.name) + + let identities = try context.fetch(FetchDescriptor()) + XCTAssertEqual(identities.map(\.identityId), [Self.fixtureIdentityId], fixture.name) + XCTAssertEqual(identities.first?.balance, 5, fixture.name) + XCTAssertEqual(identities.first?.wallet?.walletId, Self.fixtureWalletId, fixture.name) + + let keywords = try context.fetch(FetchDescriptor()) + XCTAssertEqual(keywords.map(\.keyword), ["preserved"], fixture.name) + + let locks = try context.fetch(FetchDescriptor()) + XCTAssertEqual(locks.count, 1, fixture.name) + XCTAssertEqual(locks.first?.amountDuffs, 100_000, fixture.name) + XCTAssertEqual( + locks.first?.recipientIsExternal, fixture.assetLockRecipientIsExternal, + fixture.name) + + let tracked = try context.fetchCount(FetchDescriptor()) + XCTAssertEqual(tracked, fixture.hasTrackedMasternode ? 1 : 0, fixture.name) + + // The migrated store is writable through the new columns. + wallet.lastAppliedChainLockHeight = 130 + txo.supersededByTxid = Data(repeating: 0x36, count: 32) + try context.save() + XCTAssertEqual( + try context.fetch(FetchDescriptor()).first? + .lastAppliedChainLockHeight, + 130, fixture.name) + } + } + + /// Each frozen version, built after the live schema (the order + /// `DashModelContainer.create` uses, established process-wide in + /// `setUp`), still hashes every entity exactly as the build that shipped + /// it did. A partial freeze cannot give this: a frozen wallet reached + /// from a live `PersistentAccount.wallet` is rebound to the live + /// wallet's shape the moment the live schema is built first, and the + /// released checksum moves with it. + /// + /// This test is THE authority on whether a freeze is complete in every + /// respect the entity hash covers: stored properties, their types, + /// optionality and defaults, relationships and their inverses, and + /// `#Unique` constraints. The generator's `--check` + /// (`scripts/freeze_schema_models.py`) only proves the committed frozen + /// files are the generator's byte-for-byte output; it does not, and + /// must not try to, decide whether the `FREEZES` table covers every + /// relationship target and stored value type. A static scan of Swift + /// source cannot: it misses whatever syntax it does not understand, and + /// it flags references SwiftData does not hash at all (a struct stored + /// directly on a model is part of the entity hash; an array of structs + /// nested inside it is not), so it fails silently in both directions. + /// Only building the schema and reading the hash SwiftData computes, + /// against a store a shipping build wrote, answers the question, and + /// that is what this does: an omitted relationship target fails here as + /// soon as the live target has changed shape, an omitted stored value + /// type fails here on the change that would have broken the store, and + /// a version registering the wrong entity set fails on membership. + /// + /// What the hash does NOT cover is `#Index`: Core Data leaves indexes + /// out of entity version hashes, so an index that drifted in a frozen + /// copy, or one a migration never created, passes here. That is what + /// `testFixturesAndMigratedStoresCarryTheIndexesFreshStoresHave` is for. + /// + /// Its reach is exactly the fixtures: it guards a version only once a + /// store written by a build that shipped that version is committed + /// under `Fixtures/SchemaStores/` and listed in `fixtures`. So the first + /// thing checked is that `fixtures` lists every version in + /// `shippedVersions`, the live one included, once each: cutting a new + /// schema version fails this test until its fixture is committed, and + /// changing a live model's shape fails it against the live fixture + /// until that fixture is deliberately rewritten. The expectation comes + /// from the append-only list, not from the migration plan, so a plan + /// that dropped a version cannot shrink it + /// (`testShippedSchemaVersionsStayInTheMigrationPlan`). + func testFrozenVersionsBuiltAfterTheLiveSchemaHashLikeTheStoresTheyShipped() throws { + XCTAssertEqual( + Self.fixtures.map { Self.describe($0.version.versionIdentifier) }, + Self.shippedVersions, + "every shipped schema version, the live one included, needs a fixture store " + + "written by the build that shipped it, listed once in `fixtures`; without " + + "one its shape is unguarded") + + for fixture in Self.fixtures { + let (directory, url) = try copyFixture(fixture) + defer { try? FileManager.default.removeItem(at: directory) } + let (shippedChecksum, shippedHashes) = try Self.storeHashes(at: url) + + let scratch = directory.appendingPathComponent("scratch.store") + let schema = Schema(versionedSchema: fixture.version) + let configuration = ModelConfiguration( + "DashSchemaScratch", schema: schema, url: scratch, allowsSave: true, + cloudKitDatabase: .none) + _ = try ModelContainer(for: schema, configurations: [configuration]) + let (builtChecksum, builtHashes) = try Self.storeHashes(at: scratch) + + let drifted = shippedHashes.keys.filter { shippedHashes[$0] != builtHashes[$0] } + .sorted() + XCTAssertEqual( + drifted, [], + "\(fixture.name): entities whose frozen shape no longer matches the shipped store") + XCTAssertEqual( + Set(builtHashes.keys), Set(shippedHashes.keys), + "\(fixture.name): entity membership differs from the shipped store") + XCTAssertEqual(builtChecksum, shippedChecksum, "\(fixture.name): checksum") + } + } + + private static func describe(_ version: Schema.Version) -> String { + "\(version.major).\(version.minor).\(version.patch)" + } + + /// The migration plan must list exactly the versions that ever shipped, + /// in the order they shipped, with nothing removed, reordered or + /// replaced: a store written by any of them is still in the field and + /// must be recognised. The expectation is the append-only + /// `shippedVersions`, never the plan itself, so editing the plan cannot + /// move the goalposts; a version can only enter the plan by being + /// appended to that list in the same change. + /// + /// Versions are compared by identifier, not by enum: two enums both + /// declaring `4.0.0` are indistinguishable here, so the identifiers in + /// the list must be unique, and whether the enum behind an identifier + /// still has the shape that shipped is decided by that version's + /// fixture in the hash test. + func testShippedSchemaVersionsStayInTheMigrationPlan() { + XCTAssertEqual( + Set(Self.shippedVersions).count, Self.shippedVersions.count, + "a version identifier can ship once") + XCTAssertEqual( + DashMigrationPlan.schemas.map { Self.describe($0.versionIdentifier) }, + Self.shippedVersions, + "the migration plan must list exactly the shipped versions, oldest first; a new " + + "version is appended to `shippedVersions` in the same change, and nothing " + + "that shipped is ever removed, reordered or replaced") + } + + /// The schema the app opens stores with must be the last version of + /// the migration plan. Both are declared separately, and SwiftData + /// accepts a plan whose tail is newer than the schema it is asked to + /// migrate to, so a version appended to the plan but not made the + /// container's schema would leave the app writing the older shape while + /// every migration test targets it. + func testTheLiveSchemaIsTheMigrationPlansLastVersion() throws { + let last = try XCTUnwrap(DashMigrationPlan.schemas.last) + XCTAssertEqual( + Self.describe(DashModelContainer.schema.version), + Self.describe(last.versionIdentifier), + "DashModelContainer.schema must be built from the migration plan's last version") + } + + /// Writes the live version's fixture store — the rows every fixture + /// carries, through `DashModelContainer.create`, so the file is what + /// this build ships. Skipped unless `DASH_SCHEMA_FIXTURE_OUTPUT` names a + /// directory to write into; run it on purpose when the live shape + /// changes before shipping, or when a new version is cut: + /// + /// DASH_SCHEMA_FIXTURE_OUTPUT=/some/dir swift test \ + /// --filter DashModelMigrationTests/testWriteTheLiveSchemaFixtureStore + /// + /// then move `dash-vN.store` into `Fixtures/SchemaStores/`. A retired + /// version's fixture is never rewritten: only the build that shipped it + /// could write it. + @MainActor + func testWriteTheLiveSchemaFixtureStore() throws { + guard let output = ProcessInfo.processInfo.environment["DASH_SCHEMA_FIXTURE_OUTPUT"] + else { + throw XCTSkip("set DASH_SCHEMA_FIXTURE_OUTPUT to write the live schema's fixture") + } + let version = try XCTUnwrap(DashMigrationPlan.schemas.last).versionIdentifier + let url = URL(fileURLWithPath: output, isDirectory: true) + .appendingPathComponent("dash-v\(version.major).store") + // Stop before opening: creating a container over an existing store rewrites its + // checksum and leaves WAL sidecars behind, so a second run would silently + // replace the committed fixture rather than refusing to. + guard !FileManager.default.fileExists(atPath: url.path) else { + XCTFail("\(url.path) already exists; delete it to rewrite the fixture") + return + } + + var container: ModelContainer? = try DashModelContainer.create(url: url) + let context = try XCTUnwrap(container?.mainContext) + let wallet = PersistentWallet( + walletId: Self.fixtureWalletId, network: .testnet, name: "fixture wallet", + syncedHeight: 120) + context.insert(wallet) + let account = PersistentAccount( + wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "standard") + context.insert(account) + let address = PersistentCoreAddress( + address: "yFixtureAddress", poolTypeTag: 0, addressIndex: 0, derivationPath: "m/0") + address.account = account + context.insert(address) + let funding = PersistentTransaction( + txid: Self.fixtureFundingTxid, transactionData: Data([3, 0]), context: 2, + blockHeight: 100) + let spend = PersistentTransaction( + txid: Self.fixtureSpendTxid, transactionData: Data([3, 0]), context: 2, + blockHeight: 110) + context.insert(funding) + context.insert(spend) + account.involvedTransactions = [funding, spend] + let txo = PersistentTxo( + transaction: funding, vout: 0, amount: 1_000, address: "yFixtureAddress", + height: 100) + txo.walletId = Self.fixtureWalletId + txo.isSpent = true + txo.spendingTransaction = spend + txo.coreAddress = address + txo.account = account + context.insert(txo) + context.insert(PersistentPendingInput( + outpoint: Data(repeating: 0x11, count: 36), inputIndex: 0, + spendingTxid: Self.fixtureSpendTxid, spendingTransaction: spend, + walletId: Self.fixtureWalletId)) + let identity = PersistentIdentity( + identityId: Self.fixtureIdentityId, balance: 5, network: .testnet) + identity.wallet = wallet + context.insert(identity) + context.insert(PersistentKeyword(keyword: "preserved", contractId: "contract")) + let lock = PersistentAssetLock( + outPointHex: String(repeating: "ab", count: 32) + ":0", + walletId: Self.fixtureWalletId, transactionBytes: Data([1, 2, 3]), + fundingTypeRaw: 4, identityIndexRaw: -1, amountDuffs: 100_000, statusRaw: 4) + lock.recipientIsExternal = true + context.insert(lock) + context.insert(PersistentTrackedMasternode( + networkRaw: Network.testnet.rawValue, proTxHash: Data(repeating: 7, count: 32), + label: "fixture", addedAt: 1, snapshotJSON: "{}")) + try context.save() + container = nil + + // A fixture has to be one self-contained file that opens read-only + // from any directory, so the write-ahead log is folded back in and + // the store left in rollback-journal mode, which also removes the + // -wal and -shm sidecars. + var database: OpaquePointer? + XCTAssertEqual( + sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READWRITE, nil), SQLITE_OK) + XCTAssertEqual(sqlite3_exec(database, "PRAGMA journal_mode=DELETE", nil, nil, nil), SQLITE_OK) + sqlite3_close(database) + XCTAssertFalse( + FileManager.default.fileExists(atPath: url.path + "-wal"), + "the store still has a write-ahead log") + } + + /// The SQLite indexes of a store, one line per index: table, name and + /// the statement that created it. Auto-indexes SQLite makes for its + /// own constraints have no statement and are listed as such. + private static func indexes(at url: URL) throws -> Set { + var database: OpaquePointer? + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(database) + struct StoreUnreadable: Error {} + XCTFail("\(url.lastPathComponent): could not be opened as SQLite") + throw StoreUnreadable() + } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + let query = "SELECT tbl_name, name, sql FROM sqlite_master WHERE type = 'index'" + XCTAssertEqual(sqlite3_prepare_v2(database, query, -1, &statement, nil), SQLITE_OK) + defer { sqlite3_finalize(statement) } + var rows = Set() + var step = sqlite3_step(statement) + while step == SQLITE_ROW { + let table = String(cString: sqlite3_column_text(statement, 0)) + let name = String(cString: sqlite3_column_text(statement, 1)) + let sql = sqlite3_column_text(statement, 2).map { String(cString: $0) } ?? "(auto)" + rows.insert("\(table) \(name): \(sql)") + step = sqlite3_step(statement) + } + // Anything but SQLITE_DONE (busy, corrupt, I/O, memory) means the + // listing is partial, and a partial listing must not be compared. + guard step == SQLITE_DONE else { + struct PartialListing: Error {} + XCTFail("\(url.lastPathComponent): index listing stopped with sqlite result \(step)") + throw PartialListing() + } + return rows + } + + /// The check the entity hash cannot give. Core Data leaves `#Index` out + /// of version hashes, so the test above stays green when a frozen + /// copy's index differs from what shipped, and a lightweight migration + /// whose only change is an index can complete without creating it. + /// Both show up in SQLite, so that is where they are checked: + /// + /// - a fixture, as written, has exactly the indexes a store built + /// fresh from its frozen version has (the frozen `#Index` is what + /// shipped); + /// - after migrating through `DashModelContainer.create`, a fixture + /// has every index a store built fresh at the live version has (the + /// migration created what the live model declares). + /// + /// The second is a superset check, not equality: a migrated store can + /// keep an index from an earlier layout, or one the live model no + /// longer declares. That is not a compatibility problem, but it is not + /// free either (the index takes storage and is maintained on every + /// write), and this check does not look for it. + @MainActor + func testFixturesAndMigratedStoresCarryTheIndexesFreshStoresHave() 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 freshLive = directory.appendingPathComponent("live.store") + _ = try DashModelContainer.create(url: freshLive) + let liveIndexes = try Self.indexes(at: freshLive) + XCTAssertFalse(liveIndexes.isEmpty) + + for fixture in Self.fixtures { + let (fixtureDirectory, url) = try copyFixture(fixture) + defer { try? FileManager.default.removeItem(at: fixtureDirectory) } + + let freshAtVersion = fixtureDirectory.appendingPathComponent("fresh.store") + let schema = Schema(versionedSchema: fixture.version) + _ = try ModelContainer( + for: schema, + configurations: [ + ModelConfiguration( + "DashSchemaIndexes", schema: schema, url: freshAtVersion, + allowsSave: true, cloudKitDatabase: .none) + ]) + XCTAssertEqual( + try Self.indexes(at: url).symmetricDifference(try Self.indexes(at: freshAtVersion)), + [], + "\(fixture.name): the frozen version's indexes differ from what shipped") + + _ = try DashModelContainer.create(url: url) + XCTAssertEqual( + liveIndexes.subtracting(try Self.indexes(at: url)), [], + "\(fixture.name): indexes a fresh live store has that the migration did not create") + } + } + @MainActor func testV1StoreMigratesToV2AndAcceptsTrackedMasternodes() throws { let directory = FileManager.default.temporaryDirectory @@ -24,7 +534,7 @@ final class DashModelMigrationTests: XCTestCase { var v1Container: ModelContainer? = try ModelContainer( for: v1Schema, configurations: [v1Configuration]) - // V1 registers the FROZEN component (see `DashSchemaFrozenModels`), + // V1 registers the frozen graph (see `FrozenSchemas/`), // 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. @@ -51,7 +561,9 @@ final class DashModelMigrationTests: XCTestCase { FetchDescriptor()) XCTAssertEqual(keywords.map(\.keyword), ["preserved"]) - migrated.mainContext.insert(PersistentTrackedMasternode( + // V2 registers the frozen `PersistentTrackedMasternode`, so the row + // written into a V2 container is that type too. + migrated.mainContext.insert(DashSchemaV2.PersistentTrackedMasternode( networkRaw: Network.testnet.rawValue, proTxHash: Data(repeating: 7, count: 32), label: "new in V2", @@ -60,13 +572,13 @@ final class DashModelMigrationTests: XCTestCase { try migrated.mainContext.save() XCTAssertEqual( try migrated.mainContext.fetchCount( - FetchDescriptor()), + FetchDescriptor()), 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 + /// values. V3 registers the frozen graph, 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 @@ -160,7 +672,7 @@ final class DashModelMigrationTests: XCTestCase { /// 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, + /// at their backfill values. V1 and V2 register the frozen graph, /// 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 @@ -361,8 +873,10 @@ final class DashModelMigrationTests: XCTestCase { migrationPlan: DashMigrationPlan.self, configurations: [v3Configuration]) + // V3 registers the frozen `DashSchemaV3.PersistentAssetLock`, the + // shape that gained the column, so the read side is that type. let locks = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(locks.count, 1) let lock = try XCTUnwrap(locks.first) XCTAssertEqual(lock.outPointHex, outPointHex) @@ -376,7 +890,7 @@ final class DashModelMigrationTests: XCTestCase { lock.recipientIsExternal = true try migrated.mainContext.save() XCTAssertEqual( - try migrated.mainContext.fetch(FetchDescriptor()) + try migrated.mainContext.fetch(FetchDescriptor()) .first?.recipientIsExternal, true) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v1.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v1.store new file mode 100644 index 00000000000..48ff9ee6a67 Binary files /dev/null and b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v1.store differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store new file mode 100644 index 00000000000..6ce7443421e Binary files /dev/null and b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store new file mode 100644 index 00000000000..20d03939d0e Binary files /dev/null and b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v4.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v4.store new file mode 100644 index 00000000000..d0a9ea7aad8 Binary files /dev/null and b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v4.store differ diff --git a/packages/swift-sdk/scripts/freeze_schema_models.py b/packages/swift-sdk/scripts/freeze_schema_models.py new file mode 100755 index 00000000000..baf78f0f269 --- /dev/null +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +"""Generate the frozen SwiftData model copies for released schema versions. + +A `VersionedSchema` identifies a store by the checksum of the entities it +declares, so a released version may only reference model types whose shape +never changes again. Pointing a released version at a live `@Model` type +means the next property added to that type silently changes the released +checksum: a store written by the previously shipped build then matches no +registered version and fails to open with Cocoa error 134504 ("Cannot use +staged migration with an unknown model version") instead of migrating. + +This script copies each live `@Model` class as it existed at a given commit +into a nested type of the schema enum that version registers +(`extension DashSchemaV1 { final class PersistentX { ... } }`), one file per +model, under `Persistence/FrozenSchemas/`. SwiftData derives the entity name +from the unqualified type name, so `DashSchemaV1.PersistentX` and the live +`PersistentX` describe the same entity, which is what lets a migration stage +map one onto the other. + +The class body and the extensions declared in the model's own file are +copied; doc comments, `public` modifiers and top-level enums are dropped. +Extensions add no stored properties (so they are not part of the entity) +but the class body may call into them. The stored properties, their +optionality and defaults, `@Attribute`, `@Relationship` and `#Unique` are +what the checksum hashes, and they are copied verbatim. `#Index` is copied +verbatim too but is NOT part of the hash (Core Data leaves indexes out of +entity version hashes), which is why index drift needs its own check. + +Value types a model stores inline (Codable structs and raw enums SwiftData +expands into composite attributes, such as `ChangeControlRules` on +`PersistentToken`) are entity-hash inputs too, so they are frozen the same +way, into one nested file per schema, and every frozen model body then +resolves those names to the nested copies. Names are qualified per schema: +a model frozen only under `DashSchemaV2` that mentioned a `DashSchemaV1` +model by bare name in an extension would still bind to the live type. + +Every released version is frozen as a whole graph, never partially: a +relationship binds its destination by entity name, and SwiftData resolves +that name to whichever Swift type claimed it first in the process, so a +frozen model whose relationship pointed at a live type could be hashed with +the live type's current shape. + +`FREEZES` below is the record of what each released version registers and +the commit its shapes are taken from. Rows are append-only: retiring a +version means adding rows for it (normally one row listing every model at +the last commit before the change), adding the new `DashSchemaVN` and a +migration stage, and rerunning this script. Never edit an existing row; a +released checksum cannot move. + +Nothing here checks that the table is COMPLETE. That is deliberate. A +frozen model whose relationship target or stored value type is missing +from the table binds that bare name to the live type, and the released +checksum then moves with the live type's next change; but whether a given +Swift reference feeds the entity hash is decided by SwiftData (a struct +stored directly on a model does, an array of structs nested inside one does +not), and a text scan of Swift source cannot know that, nor keep up with +optionals, generics, extensions, nested types and enum payloads. Every +reference such a scan misses is a silent failure in the field, and every +one it wrongly flags is a false alarm. The authority for +hash-relevant completeness (properties, relationships, `#Unique`) is +`DashModelMigrationTests.testFrozenVersionsBuiltAfterTheLiveSchemaHashLikeTheStoresTheyShipped`, +which builds each released version after the live schema and compares the +hashes SwiftData computes against a store the shipping build wrote; its +sibling `testFixturesAndMigratedStoresCarryTheIndexesFreshStoresHave` +covers `#Index`, which the hash cannot see, by comparing SQLite indexes. +Do not add static validation here; extend those tests (and their +fixtures) instead. + +Usage, from anywhere inside the repository: + + scripts/freeze_schema_models.py # regenerate every frozen file + scripts/freeze_schema_models.py --check # exit 1 if any file would change + +`--check` is a regeneration check and nothing more: the frozen files are a +pure function of `FREEZES` and the repository history, so a clean check +proves that the committed files are exactly this generator's output, byte +for byte, and that no one edited a frozen copy by hand. It says nothing +about whether the freeze is complete. CI runs it (the +`swift-sdk-frozen-schema` job in `.github/workflows/tests.yml`) on a +full-history checkout, because it needs the commits named in `FREEZES`. + +The generator's own tests, from the repository root: + + python3 -m unittest discover -s packages/swift-sdk/scripts -p 'test_*.py' +""" + +import argparse +import dataclasses +import os +import re +import subprocess +import sys + +MODELS_DIR = "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models" +OUT_DIR = "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas" +TOKEN_TYPES_FILE = "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift" +# The value types `PersistentToken` stores inline, plus the enums their +# initializers reference. +TOKEN_VALUE_TYPES = [ + "ChangeControlRules", + "AuthorizedActionTakers", + "TokenPerpetualDistribution", + "TokenPreProgrammedDistribution", + "DistributionEvent", + "TokenDistributionChangeRules", + "TokenTradeMode", + "TokenLocalization", +] + +# Every model registered by the versions that share the V1 graph, in the +# order `DashModelContainer` lists them, minus the two that have their own +# rows below (`PersistentAssetLock`, whose shape differs between V2 and V3, +# and `PersistentTrackedMasternode`, which V2 added). +V1_GRAPH_MODELS = [ + "PersistentIdentity", + "PersistentDPNSName", + "PersistentDashpayProfile", + "PersistentDashpayContactProfile", + "PersistentDashpayContactRequest", + "PersistentDashpayPayment", + "PersistentDashpayIgnoredSender", + "PersistentDocument", + "PersistentDataContract", + "PersistentPublicKey", + "PersistentTokenBalance", + "PersistentKeyword", + "PersistentToken", + "PersistentDocumentType", + "PersistentIndex", + "PersistentProperty", + "PersistentTokenHistoryEvent", + "PersistentPlatformAddress", + "PersistentPlatformAddressesSyncState", + "PersistentWallet", + "PersistentAccount", + "PersistentCoreAddress", + "PersistentTransaction", + "PersistentTxo", + "PersistentPendingInput", + "PersistentWalletManagerMetadata", + "PersistentShieldedNote", + "PersistentShieldedOutgoingNote", + "PersistentShieldedSyncState", + "PersistentShieldedActivity", + "PersistentShieldedViewingKey", + "PersistentInvitation", + "PersistentMasternode", +] + + +@dataclasses.dataclass(frozen=True) +class Freeze: + """One schema's copy of some models, taken from one commit.""" + + schema: str + commit: str + models: tuple + value_types_file: str = "" + value_types: tuple = () + + +FREEZES = [ + # The asset lock as V1 and V2 shipped it: the last commit before + # `recipientIsExternal` was added to the live model. + Freeze("DashSchemaV1", "7127c38566", ("PersistentAssetLock",)), + # The rest of the graph, shared by V1, V2 and V3, at the last commit + # before V4 widened the wallet transaction models. + Freeze( + "DashSchemaV1", + "5f58417079", + tuple(V1_GRAPH_MODELS), + TOKEN_TYPES_FILE, + tuple(TOKEN_VALUE_TYPES), + ), + # V2 adds the tracked-masternode registry. + Freeze("DashSchemaV2", "5f58417079", ("PersistentTrackedMasternode",)), + # V3 replaces the asset lock with the shape that has `recipientIsExternal`. + Freeze("DashSchemaV3", "5f58417079", ("PersistentAssetLock",)), +] + +HEADER = "import Foundation\nimport SwiftData\n\n" + + +def git(root, *args): + try: + return subprocess.check_output( + ["git", *args], text=True, encoding="utf-8", cwd=root + ) + except subprocess.CalledProcessError as error: + raise SystemExit( + f"git {' '.join(args)} failed (exit {error.returncode}); a commit named in " + "FREEZES may not be fetched locally" + ) + + +def repo_root(): + return git(os.getcwd(), "rev-parse", "--show-toplevel").strip() + + +def strip_comments(lines): + """Drop comment lines and `public`, collapsing the blank runs left behind.""" + out = [] + for line in lines: + stripped = line.strip() + if stripped.startswith("///") or stripped.startswith("//"): + continue + line = re.sub(r"\bpublic(\(set\))? ", "", line) + if line.strip() == "" and out and out[-1].strip() == "": + continue + out.append(line) + while out and out[-1].strip() == "": + out.pop() + return out + + +def code_only(line): + """`line` with string literal contents and any trailing `//` comment removed. + + What remains is what brace counting and declaration matching may look + at: a `{` or a type name inside a string or a comment is not code. + Block comments are not parsed, so one is refused rather than risk a + silently truncated copy. + """ + if "/*" in line: + raise SystemExit(f"block comments are not supported: {line.strip()}") + line = re.sub(r'"(?:\\.|[^"\\])*"', '""', line) + return line.split("//", 1)[0] + + +def braces(line): + """Net brace depth change of one line of code.""" + line = code_only(line) + return line.count("{") - line.count("}") + + +def block_end(lines, start): + """Index of the line closing the brace block that opens at `start`.""" + depth = 0 + opened = False + for i in range(start, len(lines)): + depth += braces(lines[i]) + opened = opened or "{" in code_only(lines[i]) + if depth == 0 and opened: + return i + raise SystemExit("unbalanced braces") + + +def extract_value_type(source, name): + """A top-level `struct ` / `enum ` block, comments stripped.""" + lines = source.splitlines() + for i, line in enumerate(lines): + if re.match(rf"^(public )?(struct|enum) {name}\b", line): + return strip_comments(lines[i : block_end(lines, i) + 1]) + raise SystemExit(f"{name}: no top-level struct or enum found") + + +def extract_class(source, model): + """The `@Model ... final class { ... }` block, comments stripped.""" + lines = source.splitlines() + start = None + for i, line in enumerate(lines): + if re.match(rf"^(public )?final class {model}\b", line): + start = i + break + if start is None: + raise SystemExit(f"{model}: no top-level class found") + # Include the macro attributes directly above the class (`@Model`), + # looking past doc comments between them and the class. + while start > 0 and ( + lines[start - 1].startswith("@") or lines[start - 1].startswith("///") + ): + start -= 1 + end = block_end(lines, start) + return strip_comments(lines[start : end + 1]) + + +def extract_extensions(source, model): + """Every top-level `extension { ... }` body, comments stripped.""" + lines = source.splitlines() + bodies = [] + for i, line in enumerate(lines): + if not re.match(rf"^(public )?extension {model}\b", line): + continue + if not re.match(rf"^(public )?extension {model}\s*(:[^{{]*)?\{{", line): + raise SystemExit( + f"{model}: line {i + 1}: an extension header must open its brace " + "on the same line" + ) + end = block_end(lines, i) + inner = strip_comments(lines[i + 1 : end]) + conformance = re.search(r"extension \w+\s*(:[^{]*)\{", line) + bodies.append(((conformance.group(1).strip() if conformance else ""), inner)) + return bodies + + +def indent(body): + return "\n".join((" " + line) if line.strip() else "" for line in body) + + +def render_value_types(freeze, sha, source): + bodies = [indent(extract_value_type(source, name)) for name in freeze.value_types] + return ( + HEADER + + f"// Inline value types exactly as schema {freeze.schema} stored them, generated\n" + f"// by scripts/freeze_schema_models.py from {os.path.basename(freeze.value_types_file)}\n" + f"// at commit {sha}. SwiftData expands a stored Codable struct into composite\n" + "// attributes of the owning entity, so these shapes are inputs to that\n" + "// version's checksum just like the model's own properties. Do not edit.\n" + f"extension {freeze.schema} {{\n" + + "\n\n".join(bodies) + + "\n}\n" + ) + + +def render_model(freeze, sha, source, model, sibling): + # An extension of a nested type does not see its sibling nested types + # by bare name (that lookup lands on the live top-level type), so model + # and frozen value-type names inside extension bodies are qualified. + extensions = "" + for conformance, inner in extract_extensions(source, model): + inner = [sibling.sub(rf"{freeze.schema}.\1", line) for line in inner] + extensions += ( + f"\nextension {freeze.schema}.{model}{' ' + conformance if conformance else ''} {{\n" + + "\n".join(inner) + + "\n}\n" + ) + return ( + HEADER + + f"// `{model}` exactly as schema {freeze.schema} registered it, generated by\n" + f"// scripts/freeze_schema_models.py from the live model at commit {sha}.\n" + "// Do not edit: every stored property, its optionality and default, and\n" + "// every @Attribute / @Relationship / #Unique here is an input to that\n" + "// version's checksum, and every #Index to the store's SQLite indexes;\n" + "// changing any of them re-breaks the stores this copy exists to keep\n" + "// openable. See the live model for what each column means.\n" + f"extension {freeze.schema} {{\n" + f"{indent(extract_class(source, model))}\n" + "}\n" + + extensions + ) + + +def render_all(root): + """Every frozen file as {relative path: text}.""" + # Names frozen under a schema, across all of its rows: any of them + # mentioned inside an extension body must resolve to the nested copy. + per_schema = {} + for freeze in FREEZES: + per_schema.setdefault(freeze.schema, set()).update(freeze.models) + per_schema[freeze.schema].update(freeze.value_types) + + files = {} + + def emit(path, text): + if path in files: + raise SystemExit(f"{path}: produced by two FREEZES rows") + files[path] = text + + for freeze in FREEZES: + sha = git(root, "rev-parse", "--verify", freeze.commit).strip()[:10] + sibling = re.compile( + r"(?