diff --git a/docs/protocol/contract-scoped-authentication.md b/docs/protocol/contract-scoped-authentication.md new file mode 100644 index 00000000000..873a92cf359 --- /dev/null +++ b/docs/protocol/contract-scoped-authentication.md @@ -0,0 +1,106 @@ +# Contract-scoped authentication keys + +Protocol version 14 adds application authentication scopes. A wallet can register +a separate key for an application while retaining the identity's master key. +Validators enforce the registered scope on every batch member. Existing identity +ownership, key purpose, security level and document rules still apply. + +## Registering an application key + +The key must have AUTHENTICATION purpose and a non-MASTER security level. A HIGH +key is suitable for normal document operations. Its `contractBounds` is a new +`scoped` variant containing a versioned authentication scope: + +- `contracts`: explicit contract IDs with optional document-type restrictions. +- `permissions`: an action bitmask shared by every listed contract. +- `expiresAt`: an optional expiry in milliseconds, checked against block time. + +A missing/null document-type restriction authorizes all types in that contract, +including types added by later contract updates. An empty array is invalid. +Contract IDs and document-type names must be sorted and unique on the wire. There +are at most 16 contracts and 16 types per contract, and the encoded scope must not +exceed 2048 bytes. The WASM constructor sorts entries and rejects duplicates. + +For an application that creates, updates and deletes documents and pays their +configured token fees, construct the bounds with the WASM SDK: + +```javascript +const P = wasm.AuthenticationPermission; +const bounds = wasm.ContractBounds.Scoped( + [ + { id: socialContractId, documentTypes: ['like', 'post'] }, + { id: profileContractId, documentTypes: ['profile'] }, + ], + P.DocumentCreate | P.DocumentReplace | P.DocumentDelete | P.DocumentTokenPayment, + BigInt(Date.now() + 24 * 60 * 60 * 1000), +); + +const keyToAdd = new wasm.IdentityPublicKeyInCreation({ + keyId: nextKeyId, + purpose: 'authentication', + securityLevel: 'high', + keyType: 'ecdsa_hash160', + isReadOnly: false, + data: applicationPublicKeyHash160, + signature: new Uint8Array(), + contractBounds: bounds, +}); +``` + +Use the normal wallet-authorized identity-update procedure to register the key. +The registration signature binds the scope as well as the public-key material. +The browser only needs the application key's private material. Registering a +scope requires its referenced contracts/types to exist and its expiry, if any, +to be in the future. No contract encryption-key opt-in or unique-key setting is +required for scoped authentication. + +## Permissions and token fees + +Document create, replace, delete, ownership transfer, price updates and purchases +have separate bits. Index-only deletion uses the delete bit. Standalone token +transition kinds also have separate bits. New/unknown bits are rejected. + +`DocumentTokenPayment` permits the actual contract-defined token cost of an +otherwise-authorized document action. It also covers fees using a token issued +by another contract. That does not authorize document writes or standalone token +operations on the issuing contract. Without the bit, a document action with a +positive token cost is rejected, even if its create/replace/delete bit is set. + +A document-token payment permission does not implicitly authorize token transfer, +burn, mint, purchase or administration transitions. Explicitly granting one of +those bits still cannot override its normal purpose/security/ownership rules. +Contract updates can change document token fees; v0 scopes do not pin the fee +amount or currency. + +## Expiry, revocation and failures + +A key is expired when executing block time is greater than or equal to its expiry. +Mempool checks use the last committed block information; a transaction may expire +between admission and execution. Disable the key through the normal identity +update to revoke it. Extending expiry or expanding permissions requires a +wallet-authorized replacement. Expired keys are not automatically deleted. + +Scoped keys cannot execute non-batch transitions, including identity-key updates, +contract creation/updates, credit transfers/withdrawals or masternode votes. +Expired keys and non-batch use fail in identity-signature authorization. + +Batch scope violations follow normal paid validation-failure handling. Requested +document/token operations do not execute, but Platform credit validation fees +can be charged and the first batch member's identity-contract nonce can advance, +even if that member is outside the scope. Replays follow the usual nonce rules. + +There are no per-key budgets in scope version 0. A stolen key can exhaust credit +balances through fees and permitted token balances through allowed operations. +Expiry limits the time window, not total financial loss. + +## Compatibility + +The scoped variant is appended to the existing bounds enum; old key encodings +remain unchanged. Older protocols reject scoped registration, and older clients +cannot be assumed to decode scoped keys. SDK signing performs local structural +checks, but validator checks against current state remain authoritative. + +The native key ABI carries an encoded scope pointer/length. Native libraries, +generated headers and Swift/Kotlin consumers must be updated together. Key query, +persistence, restore and refresh paths must preserve scope metadata. It must +never be dropped or reconstructed as an unrestricted key. diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json new file mode 100644 index 00000000000..46b77e9278e --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/11.json @@ -0,0 +1,4125 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "f0b6aa7c9cca548dbd68af864850367a", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockPosition", + "columnName": "blockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockPosition", + "columnName": "hasBlockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "transaction_account_involvements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "transactionTxid", + "columnName": "transactionTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transactionTxid", + "accountId" + ] + }, + "indices": [ + { + "name": "index_transaction_account_involvements_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "transactionTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "invitations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `rawOutPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `reclaimInFlight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawOutPoint", + "columnName": "rawOutPoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingIndexRaw", + "columnName": "fundingIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryUnix", + "columnName": "expiryUnix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtSecs", + "columnName": "createdAtSecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviter", + "columnName": "hasInviter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reclaimInFlight", + "columnName": "reclaimInFlight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_invitations_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `contractBoundsScope` BLOB, `privateKeyKeychainIdentifier` TEXT, `derivationIdentityIndex` INTEGER, `derivationKeyIndex` INTEGER, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "contractBoundsScope", + "columnName": "contractBoundsScope", + "affinity": "BLOB" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "derivationIdentityIndex", + "columnName": "derivationIdentityIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "derivationKeyIndex", + "columnName": "derivationKeyIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `documentId` BLOB, `isOwned` INTEGER NOT NULL, `priceCredits` INTEGER, `saleStatusRaw` INTEGER NOT NULL, `counterpartyIdentityId` BLOB, `documentCreatedAtMs` INTEGER NOT NULL, `documentUpdatedAtMs` INTEGER NOT NULL, `documentTransferredAtMs` INTEGER NOT NULL, `marketplaceUpdatedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "BLOB" + }, + { + "fieldPath": "isOwned", + "columnName": "isOwned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priceCredits", + "columnName": "priceCredits", + "affinity": "INTEGER" + }, + { + "fieldPath": "saleStatusRaw", + "columnName": "saleStatusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB" + }, + { + "fieldPath": "documentCreatedAtMs", + "columnName": "documentCreatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentUpdatedAtMs", + "columnName": "documentUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTransferredAtMs", + "columnName": "documentTransferredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "marketplaceUpdatedAt", + "columnName": "marketplaceUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_dpns_names_documentId", + "unique": false, + "columnNames": [ + "documentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` ON `${TABLE_NAME}` (`documentId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` BLOB NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`walletId`, `address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId_addressHash", + "unique": true, + "columnNames": [ + "walletId", + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_walletId_addressHash` ON `${TABLE_NAME}` (`walletId`, `addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "shielded_viewing_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `fvkBytes` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fvkBytes", + "columnName": "fvkBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_viewing_keys_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_viewing_keys_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f0b6aa7c9cca548dbd68af864850367a')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index e6ce11bee92..c14a34b6b6f 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -400,7 +400,7 @@ class DashDatabaseMigrationTest { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 10, + 11, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -408,16 +408,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_7_8, DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, + DashDatabase.MIGRATION_10_11, ).close() } - /** The full chain from v1 must also land on a valid v10 schema. */ + /** The full chain from v1 must also land on a valid v11 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 10, + 11, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -428,6 +429,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_7_8, DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, + DashDatabase.MIGRATION_10_11, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 65c25e423d0..367b62770de 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -359,7 +359,7 @@ abstract class NativePersistenceBridge { // ── Identity keys ───────────────────────────────────────────────── - /** One `IdentityKeyEntryFFI` upsert. Descriptor `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;)I`. */ + /** One `IdentityKeyEntryFFI` upsert. Descriptor `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;[B)I`. */ @Suppress("LongParameterList") open fun onPersistIdentityKeyUpsert( walletId: ByteArray, @@ -381,6 +381,7 @@ abstract class NativePersistenceBridge { contractBoundsKind: Byte, contractBoundsId: ByteArray, contractBoundsDocumentType: String?, + contractBoundsScope: ByteArray = ByteArray(0), ): Int = 0 /** One `(identityId, keyId)` removal. Descriptor `([B[BI)I`. */ @@ -1106,9 +1107,10 @@ class ContactRequestRestoreData( * `keyType` / `purpose` / `securityLevel` are DPP `repr(u8)` discriminants * (out-of-range = 255 sentinel → Rust drops the row rather than coercing to * MASTER/AUTHENTICATION, matching the Swift loader's `UInt8.max` fallback). - * `contractBoundsKind`: 0 none, 1 SingleContract, 2 SingleContractDocumentType; - * `contractBoundsId` is 32 bytes (or empty for kind 0); + * `contractBoundsKind`: 0 none, 1 SingleContract, 2 SingleContractDocumentType, 3 Scoped; + * `contractBoundsId` is 32 bytes (or empty for kinds 0 and 3); * `contractBoundsDocumentType` is non-null only for kind 2. + * Kind 3 carries the complete versioned DPP bytes in `contractBoundsScope`. */ class IdentityKeyRestoreData( @JvmField val keyId: Int, @@ -1120,6 +1122,7 @@ class IdentityKeyRestoreData( @JvmField val contractBoundsKind: Byte, @JvmField val contractBoundsId: ByteArray, @JvmField val contractBoundsDocumentType: String?, + @JvmField val contractBoundsScope: ByteArray = ByteArray(0), ) /** Mirror of `ShieldedNoteRestoreFFI`. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index d41c25b7507..b65b39139fb 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -27,8 +27,9 @@ internal object TransactionsNative { * @param addPubkeysBlob big-endian rows for the keys to add: `u32 * rowCount` then per row `u32 keyId, u8 keyType, u8 purpose, u8 * securityLevel, u8 readOnly, u8 contractBoundsKind, u16 pubkeyLen, - * pubkey`, plus (when `contractBoundsKind != 0`) a 32-byte contract id - * and (when `== 2`) `u16 docTypeLen, docType`. May be empty. + * pubkey`, plus (for kinds 1 and 2) a 32-byte contract id + * and (when `== 2`) `u16 docTypeLen, docType`; kind 3 instead carries + * `u16 scopeLen, scopeBytes`. May be empty. * @param disablePublicKeyIds key ids to disable; may be empty. At least * one of add / disable must be non-empty. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityPubkeyCodec.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityPubkeyCodec.kt index 551794c731d..be2a263f8f5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityPubkeyCodec.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityPubkeyCodec.kt @@ -28,13 +28,15 @@ import java.io.DataOutputStream * u8 purpose (DPP Purpose discriminant, 0 = AUTHENTICATION) * u8 securityLevel (DPP SecurityLevel discriminant, 0 = MASTER) * u8 readOnly (0 / 1) - * u8 contractBoundsKind (0 none, 1 SingleContract, 2 SingleContractDocumentType) + * u8 contractBoundsKind (0 none, 1 SingleContract, 2 SingleContractDocumentType, 3 Scoped) * u16 pubkeyLen * u8[pubkeyLen] pubkeyBytes (compressed pubkey, or 20-byte HASH160) - * if contractBoundsKind != 0: + * if contractBoundsKind == 1 or contractBoundsKind == 2: * u8[32] contractBoundsId * if contractBoundsKind == 2: * u16 docTypeLen, u8[docTypeLen] docType (UTF-8) + * if contractBoundsKind == 3: + * u16 scopeLen, u8[scopeLen] versioned DPP scope bytes * ``` */ object IdentityPubkeyCodec { @@ -57,6 +59,11 @@ object IdentityPubkeyCodec { dos.writeShort(k.pubkeyBytes.size) dos.write(k.pubkeyBytes) when (val bounds = k.contractBounds) { + is ContractBounds.Scoped -> { + require(bounds.encodedScope.size in 1..2048) { "Invalid scope size" } + dos.writeShort(bounds.encodedScope.size) + dos.write(bounds.encodedScope) + } null -> Unit is ContractBounds.SingleContract -> dos.write(bounds.contractId) is ContractBounds.SingleContractDocumentType -> { @@ -71,8 +78,9 @@ object IdentityPubkeyCodec { return out.toByteArray() } - /** Discriminant matching the FFI: 0 none, 1 SingleContract, 2 with doc type. */ + /** Discriminant matching the FFI: 0 none, 1 SingleContract, 2 with doc type, 3 Scoped. */ internal fun contractBoundsKind(bounds: ContractBounds?): Int = when (bounds) { + is ContractBounds.Scoped -> 3 null -> 0 is ContractBounds.SingleContract -> 1 is ContractBounds.SingleContractDocumentType -> 2 diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index d9435126d55..853514273f2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -47,11 +47,17 @@ enum class SecurityLevel(val ffiValue: Int) { } /** - * Contract-bounds shape for an ENCRYPTION / DECRYPTION key — Kotlin mirror - * of Swift's `ManagedPlatformWallet.ContractBounds`. Required by Drive for - * those purposes; omitted (null) for AUTHENTICATION / TRANSFER. + * Kotlin mirror of Swift's `ManagedPlatformWallet.ContractBounds`. + * Legacy variants describe encryption bounds; Scoped carries authentication grants. */ sealed class ContractBounds { + /** Versioned scope bytes produced by DPP. Rust validates them on registration. */ + data class Scoped(val encodedScope: ByteArray) : ContractBounds() { + override fun equals(other: Any?): Boolean = + other is Scoped && encodedScope.contentEquals(other.encodedScope) + override fun hashCode(): Int = encodedScope.contentHashCode() + } + /** Bind the key to a single contract (any of its document types). */ data class SingleContract(val contractId: ByteArray) : ContractBounds() { init { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 13e78e16471..131e50fbc5e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -121,7 +121,7 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * owned, unlisted row until the first native marketplace sync refreshes it. */ @Database( - version = 10, + version = 11, exportSchema = true, entities = [ WalletEntity::class, @@ -519,6 +519,13 @@ abstract class DashDatabase : RoomDatabase() { } } + /** v10 → v11: preserve versioned authentication scope bytes. */ + val MIGRATION_10_11: Migration = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE public_keys ADD COLUMN contractBoundsScope BLOB") + } + } + /** v9 → v10: additive DPNS marketplace state on legacy label rows. */ val MIGRATION_9_10: Migration = object : Migration(9, 10) { override fun migrate(db: SupportSQLiteDatabase) { @@ -574,6 +581,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, + MIGRATION_10_11, ) .build() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index fa807e83c00..b429ffd4a51 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -1236,7 +1236,12 @@ class PlatformWalletPersistenceHandler( contractBoundsKind: Byte, contractBoundsId: ByteArray, contractBoundsDocumentType: String?, + contractBoundsScope: ByteArray, ): Int = guarded { + val boundsKind = contractBoundsKind.toInt() and 0xFF + require(boundsKind in 0..3) { "Unknown contract bounds kind: $boundsKind" } + require(boundsKind != 3 || contractBoundsScope.isNotEmpty()) { "Missing authentication scope" } + // Item 1 — private-key persistence (the CLAUDE.md "one allowed // exception" shape). The `IdentityKeyEntryFFI` payload carries only // a derivation breadcrumb (`wallet_id` + `identity_index` + @@ -1351,9 +1356,9 @@ class PlatformWalletPersistenceHandler( val existing = db.publicKeyDao().getByIdentityAndKeyId(identityBase58, keyId) // ContractBounds projection → the legacy JSON blob column + // doc-type name (Swift stores `[base64(contractId)]` JSON). - val boundsData = if ((contractBoundsKind.toInt() and 0xFF) != 0) + val boundsData = if (boundsKind in 1..2) contractBoundsIdToJson(contractBoundsId) else null - val docTypeName = if ((contractBoundsKind.toInt() and 0xFF) == 2) + val docTypeName = if (boundsKind == 2) contractBoundsDocumentType else null val row = PublicKeyEntity( id = existing?.id ?: 0, @@ -1366,6 +1371,7 @@ class PlatformWalletPersistenceHandler( publicKeyData = publicKeyData, contractBoundsData = boundsData, contractBoundsDocumentTypeName = docTypeName, + contractBoundsScope = if (boundsKind == 3) contractBoundsScope.copyOf() else null, // Set to the Keystore identifier when the deriver stored the // scalar; otherwise preserve any prior identifier (idempotent // re-persist) and fall back to watch-only (null) for @@ -2231,6 +2237,7 @@ class PlatformWalletPersistenceHandler( // kind 0 rather than crashing FFI marshalling. val boundsId = pk.contractBoundsData?.let { contractBoundsJsonToId(it) } val (kind, id) = when { + pk.contractBoundsScope != null -> 3.toByte() to ByteArray(0) boundsId == null -> 0.toByte() to ByteArray(0) pk.contractBoundsDocumentTypeName != null -> 2.toByte() to boundsId else -> 1.toByte() to boundsId @@ -2243,6 +2250,7 @@ class PlatformWalletPersistenceHandler( readOnly = pk.readOnly, data = pk.publicKeyData, contractBoundsKind = kind, + contractBoundsScope = pk.contractBoundsScope ?: ByteArray(0), contractBoundsId = id, contractBoundsDocumentType = if (kind.toInt() == 2) pk.contractBoundsDocumentTypeName else null, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt index 8ac11a37624..9c286567775 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt @@ -56,6 +56,9 @@ data class PublicKeyEntity( val contractBoundsData: ByteArray? = null, /** Document-type qualifier for `.singleContractDocumentType` bounds. */ val contractBoundsDocumentTypeName: String? = null, + + /** Versioned DPP authentication scope; null for legacy bounds. */ + val contractBoundsScope: ByteArray? = null, val privateKeyKeychainIdentifier: String? = null, /** * Derivation breadcrumb (DIP-9 identity index) captured from the diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 5008ade780b..c850f33bdce 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -2250,6 +2250,70 @@ class PlatformWalletPersistenceHandlerTest { assertEquals("contactRequest", key.contractBoundsDocumentType) } + @Test + fun loadWalletListPreservesScopedAuthenticationBytes() = runTest { + // Signing-critical restore path: a cold-started wallet must get its + // identities and public keys back exactly as persisted — keyId, + // repr(u8) discriminants, key bytes, and the (kind, id, docType) + // contract-bounds triple (kind 2 = SingleContractDocumentType). + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val xpub = ByteArray(78) { 30 } + handler.onPersistAccountRegistration( + walletId, 0, 0, 0, 0, 0, ByteArray(0), ByteArray(0), xpub, + ) + val identityId = ByteArray(32) { 12 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 7 } + val boundsId = ByteArray(0) + val scope = byteArrayOf(0, 1, 3, 0, 65, 1, 0) + + fun persistKey(kind: Byte, scopeBytes: ByteArray) = handler.onPersistIdentityKeyUpsert( + walletId = walletId, + identityId = identityId, + keyId = 4, + purpose = 0, + securityLevel = 2, + keyType = 0, + readOnly = true, + disabledAtIsSome = false, + disabledAt = 0, + publicKeyData = pubkey, + publicKeyHash = ByteArray(20), + walletIdIsSome = true, + keyWalletId = walletId, + derivationIndicesIsSome = false, + identityIndex = 0, + keyIndex = 0, + contractBoundsKind = kind, + contractBoundsId = boundsId, + contractBoundsDocumentType = null, + contractBoundsScope = scopeBytes, + ) + assertEquals(1, persistKey(4, scope)) + assertEquals(1, persistKey(3, ByteArray(0))) + handler.onChangesetBegin(walletId) + assertEquals(0, persistKey(3, scope)) + handler.onChangesetEnd(walletId, success = true) + + val list = handler.onLoadWalletList() + assertEquals(1, list.size) + assertEquals(1, list[0].identities.size) + val identity = list[0].identities[0] + assertTrue(identityId.contentEquals(identity.identityId)) + assertEquals(1, identity.keys.size) + val key = identity.keys[0] + assertEquals(4, key.keyId) + assertEquals(0.toByte(), key.keyType) + assertEquals(0.toByte(), key.purpose) + assertEquals(2.toByte(), key.securityLevel) + assertTrue(key.readOnly) + assertTrue(pubkey.contentEquals(key.data)) + assertEquals(3.toByte(), key.contractBoundsKind) + assertTrue(boundsId.contentEquals(key.contractBoundsId)) + assertNull(key.contractBoundsDocumentType) + assertTrue(scope.contentEquals(key.contractBoundsScope)) + } + // ── DashPay contacts: upsert metadata, ignore delta, restore ────── /** Persist one incoming contact row for [senderId] owned by [ownerId]. */ diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index d1bed746659..eeb97402b56 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -1,3 +1,4 @@ +use crate::consensus::basic::identity::InvalidAuthenticationScopeError; use crate::errors::ProtocolError; use bincode::{Decode, Encode}; use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; @@ -699,6 +700,8 @@ pub enum BasicError { #[error(transparent)] DataContractInvalidRequiredFieldsUpdateError(DataContractInvalidRequiredFieldsUpdateError), + #[error(transparent)] + InvalidAuthenticationScopeError(InvalidAuthenticationScopeError), } impl From for ConsensusError { diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs new file mode 100644 index 00000000000..283ee13b0e0 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs @@ -0,0 +1,28 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Invalid authentication scope: {reason}")] +#[platform_serialize(unversioned)] +pub struct InvalidAuthenticationScopeError { + reason: String, +} +impl InvalidAuthenticationScopeError { + pub fn new(reason: String) -> Self { + Self { reason } + } + pub fn reason(&self) -> &String { + &self.reason + } +} +impl From for ConsensusError { + fn from(error: InvalidAuthenticationScopeError) -> Self { + Self::BasicError(BasicError::InvalidAuthenticationScopeError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs index 9ab0839536d..dd2459ca9c4 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs @@ -68,3 +68,6 @@ mod missing_master_public_key_error; mod not_implemented_credit_withdrawal_transition_pooling_error; mod too_many_master_public_key_error; mod withdrawal_output_script_not_allowed_when_signing_with_owner_key; + +mod invalid_authentication_scope_error; +pub use invalid_authentication_scope_error::InvalidAuthenticationScopeError; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index c7a00352876..410efe61ae6 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -205,6 +205,7 @@ impl ErrorWithCode for BasicError { Self::WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError(_) => 10532, Self::InvalidKeyPurposeForContractBoundsError(_) => 10533, Self::IdentityAssetLockTransactionTooManyInputsError(_) => 10534, + Self::InvalidAuthenticationScopeError(_) => 10535, // State Transition Errors: 10600-10699 Self::InvalidStateTransitionTypeError { .. } => 10600, @@ -264,6 +265,9 @@ impl ErrorWithCode for SignatureError { Self::BasicBLSError(_) => 20010, Self::InvalidSignaturePublicKeyPurposeError(_) => 20011, Self::UncompressedPublicKeyNotAllowedError(_) => 20012, + Self::ScopedKeyOutOfScopeError(_) => 20015, + Self::ScopedKeyExpiredError(_) => 20014, + Self::ScopedKeyNonBatchError(_) => 20013, } } } diff --git a/packages/rs-dpp/src/errors/consensus/signature/mod.rs b/packages/rs-dpp/src/errors/consensus/signature/mod.rs index 91ff05114bd..6426c1ce61c 100644 --- a/packages/rs-dpp/src/errors/consensus/signature/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/signature/mod.rs @@ -27,3 +27,12 @@ pub use crate::consensus::signature::signature_error::SignatureError; pub use crate::consensus::signature::signature_should_not_be_present_error::SignatureShouldNotBePresentError; pub use crate::consensus::signature::uncompressed_public_key_not_allowed_error::UncompressedPublicKeyNotAllowedError; pub use crate::consensus::signature::wrong_public_key_purpose_error::WrongPublicKeyPurposeError; + +mod scoped_key_non_batch_error; +pub use scoped_key_non_batch_error::ScopedKeyNonBatchError; + +mod scoped_key_expired_error; +pub use scoped_key_expired_error::ScopedKeyExpiredError; + +mod scoped_key_out_of_scope_error; +pub use scoped_key_out_of_scope_error::ScopedKeyOutOfScopeError; diff --git a/packages/rs-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs new file mode 100644 index 00000000000..159036496e5 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs @@ -0,0 +1,28 @@ +use crate::consensus::signature::SignatureError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Scoped key {public_key_id} has expired")] +#[platform_serialize(unversioned)] +pub struct ScopedKeyExpiredError { + public_key_id: u32, +} +impl ScopedKeyExpiredError { + pub fn new(public_key_id: u32) -> Self { + Self { public_key_id } + } + pub fn public_key_id(&self) -> &u32 { + &self.public_key_id + } +} +impl From for ConsensusError { + fn from(error: ScopedKeyExpiredError) -> Self { + Self::SignatureError(SignatureError::ScopedKeyExpiredError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs new file mode 100644 index 00000000000..8773ddeb0d7 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs @@ -0,0 +1,28 @@ +use crate::consensus::signature::SignatureError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Scoped key {public_key_id} cannot sign a non-batch transition")] +#[platform_serialize(unversioned)] +pub struct ScopedKeyNonBatchError { + public_key_id: u32, +} +impl ScopedKeyNonBatchError { + pub fn new(public_key_id: u32) -> Self { + Self { public_key_id } + } + pub fn public_key_id(&self) -> &u32 { + &self.public_key_id + } +} +impl From for ConsensusError { + fn from(error: ScopedKeyNonBatchError) -> Self { + Self::SignatureError(SignatureError::ScopedKeyNonBatchError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs new file mode 100644 index 00000000000..1492707d00e --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs @@ -0,0 +1,28 @@ +use crate::consensus::signature::SignatureError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Batch member is outside key {public_key_id} scope")] +#[platform_serialize(unversioned)] +pub struct ScopedKeyOutOfScopeError { + public_key_id: u32, +} +impl ScopedKeyOutOfScopeError { + pub fn new(public_key_id: u32) -> Self { + Self { public_key_id } + } + pub fn public_key_id(&self) -> &u32 { + &self.public_key_id + } +} +impl From for ConsensusError { + fn from(error: ScopedKeyOutOfScopeError) -> Self { + Self::SignatureError(SignatureError::ScopedKeyOutOfScopeError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs b/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs index 72b0e7afb1a..fefe04e76dd 100644 --- a/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs +++ b/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs @@ -1,3 +1,6 @@ +use crate::consensus::signature::ScopedKeyExpiredError; +use crate::consensus::signature::ScopedKeyNonBatchError; +use crate::consensus::signature::ScopedKeyOutOfScopeError; use crate::consensus::signature::{ BasicBLSError, BasicECDSAError, IdentityNotFoundError, InvalidIdentityPublicKeyTypeError, InvalidSignaturePublicKeySecurityLevelError, InvalidStateTransitionSignatureError, @@ -60,6 +63,14 @@ pub enum SignatureError { #[error(transparent)] UncompressedPublicKeyNotAllowedError(UncompressedPublicKeyNotAllowedError), + #[error(transparent)] + ScopedKeyNonBatchError(ScopedKeyNonBatchError), + + #[error(transparent)] + ScopedKeyExpiredError(ScopedKeyExpiredError), + + #[error(transparent)] + ScopedKeyOutOfScopeError(ScopedKeyOutOfScopeError), } impl From for ConsensusError { diff --git a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs new file mode 100644 index 00000000000..d36267c68a1 --- /dev/null +++ b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs @@ -0,0 +1,513 @@ +//! Immutable application delegation. Budgets are deliberately not part of V0. +use crate::identifier::Identifier; +use crate::identity::TimestampMillis; +#[cfg(feature = "value-conversion")] +use crate::serialization::ValueConvertible; +#[cfg(feature = "json-conversion")] +use crate::serialization::{json_safe_fields, JsonConvertible}; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use serde::{Deserialize, Serialize}; + +pub const MAX_SCOPE_BYTES: usize = 2048; +pub const MAX_SCOPE_CONTRACTS: usize = 16; +pub const MAX_SCOPE_DOCUMENT_TYPES: usize = 16; + +/// Stable wire bits. Unknown bits are rejected, never ignored. +pub mod permissions { + pub const DOCUMENT_CREATE: u32 = 1 << 0; + pub const DOCUMENT_REPLACE: u32 = 1 << 1; + pub const DOCUMENT_DELETE: u32 = 1 << 2; + pub const DOCUMENT_TRANSFER: u32 = 1 << 3; + pub const DOCUMENT_UPDATE_PRICE: u32 = 1 << 4; + pub const DOCUMENT_PURCHASE: u32 = 1 << 5; + pub const DOCUMENT_TOKEN_PAYMENT: u32 = 1 << 6; + pub const TOKEN_BURN: u32 = 1 << 7; + pub const TOKEN_MINT: u32 = 1 << 8; + pub const TOKEN_TRANSFER: u32 = 1 << 9; + pub const TOKEN_FREEZE: u32 = 1 << 10; + pub const TOKEN_UNFREEZE: u32 = 1 << 11; + pub const TOKEN_DESTROY_FROZEN_FUNDS: u32 = 1 << 12; + pub const TOKEN_CLAIM: u32 = 1 << 13; + pub const TOKEN_EMERGENCY_ACTION: u32 = 1 << 14; + pub const TOKEN_CONFIG_UPDATE: u32 = 1 << 15; + pub const TOKEN_DIRECT_PURCHASE: u32 = 1 << 16; + pub const TOKEN_SET_PRICE: u32 = 1 << 17; + pub const ALL: u32 = (1 << 18) - 1; +} + +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode, Serialize, Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct ContractScope { + pub id: Identifier, + /// None grants all document types; Some(empty) is invalid. + pub document_types: Option>, +} + +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode, Serialize, Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticationScopeV0 { + pub contracts: Vec, + pub permissions: u32, + pub expires_at: Option, +} + +#[cfg_attr(feature = "json-conversion", derive(JsonConvertible))] +#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode, Serialize, Deserialize, +)] +#[serde(tag = "$formatVersion")] +pub enum AuthenticationScope { + #[serde(rename = "0")] + V0(AuthenticationScopeV0), +} + +impl AuthenticationScope { + pub fn v0(&self) -> &AuthenticationScopeV0 { + match self { + Self::V0(scope) => scope, + } + } + + pub fn contracts(&self) -> &[ContractScope] { + &self.v0().contracts + } + pub fn expires_at(&self) -> Option { + self.v0().expires_at + } + pub fn allows(&self, permission: u32) -> bool { + self.v0().permissions & permission == permission + } + pub fn is_expired(&self, time_ms: TimestampMillis) -> bool { + self.expires_at().is_some_and(|expiry| time_ms >= expiry) + } + pub fn allows_contract(&self, id: &Identifier) -> bool { + self.contracts().iter().any(|scope| scope.id == id) + } + pub fn allows_document(&self, id: &Identifier, name: &str) -> bool { + self.contracts().iter().any(|scope| { + scope.id == id + && scope + .document_types + .as_ref() + .is_none_or(|names| names.iter().any(|n| n == name)) + }) + } + + /// Validate before fetching any referenced contracts. + pub fn validate(&self) -> Result<(), ProtocolError> { + let invalid = + |reason: &str| ProtocolError::InvalidKeyContractBoundsError(reason.to_owned()); + let scope = self.v0(); + if scope.contracts.is_empty() || scope.contracts.len() > MAX_SCOPE_CONTRACTS { + return Err(invalid("scope must contain between 1 and 16 contracts")); + } + if scope.permissions == 0 || scope.permissions & !permissions::ALL != 0 { + return Err(invalid("scope must have a nonempty, known permission mask")); + } + if !scope + .contracts + .windows(2) + .all(|pair| pair[0].id < pair[1].id) + { + return Err(invalid("scope contract IDs must be sorted and unique")); + } + for contract in &scope.contracts { + if let Some(names) = &contract.document_types { + if names.is_empty() + || names.len() > MAX_SCOPE_DOCUMENT_TYPES + || names + .iter() + .any(|name| name.is_empty() || name.len() > MAX_SCOPE_BYTES) + || !names.windows(2).all(|pair| pair[0] < pair[1]) + { + return Err(invalid("scope document types must be nonempty, sorted, unique and at most 16 per contract")); + } + } + } + let bytes = bincode::encode_to_vec(self, bincode::config::standard()) + .map_err(|e| ProtocolError::EncodingError(e.to_string()))?; + if bytes.len() > MAX_SCOPE_BYTES { + return Err(invalid("encoded authentication scope exceeds 2048 bytes")); + } + Ok(()) + } + + /// Canonical native persistence / shielded preimage representation. + pub fn to_bytes(&self) -> Result, ProtocolError> { + self.validate()?; + bincode::encode_to_vec(self, bincode::config::standard()) + .map_err(|e| ProtocolError::EncodingError(e.to_string())) + } + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() > MAX_SCOPE_BYTES { + return Err(ProtocolError::DecodingError( + "scope exceeds 2048 bytes".into(), + )); + } + let (scope, consumed): (Self, usize) = bincode::decode_from_slice( + bytes, + bincode::config::standard().with_limit::<{ MAX_SCOPE_BYTES * 8 }>(), + ) + .map_err(|e| ProtocolError::DecodingError(e.to_string()))?; + if consumed != bytes.len() { + return Err(ProtocolError::DecodingError("trailing scope bytes".into())); + } + scope.validate()?; + Ok(scope) + } + + #[cfg(feature = "state-transitions")] + pub fn allows_transition( + &self, + transition: crate::state_transition::batch_transition::batched_transition::BatchedTransitionRef<'_>, + ) -> bool { + use crate::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; + use crate::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; + use crate::state_transition::batch_transition::batched_transition::{ + BatchedTransitionRef, DocumentTransition, TokenTransition, + }; + use permissions::*; + let permission = match transition { + BatchedTransitionRef::Document(doc) => { + if !self.allows_document(&doc.data_contract_id(), doc.document_type_name()) { + return false; + } + match doc { + DocumentTransition::Create(_) => DOCUMENT_CREATE, + DocumentTransition::Replace(_) => DOCUMENT_REPLACE, + DocumentTransition::Delete(_) | DocumentTransition::IndexOnlyDelete(_) => { + DOCUMENT_DELETE + } + DocumentTransition::Transfer(_) => DOCUMENT_TRANSFER, + DocumentTransition::UpdatePrice(_) => DOCUMENT_UPDATE_PRICE, + DocumentTransition::Purchase(_) => DOCUMENT_PURCHASE, + } + } + BatchedTransitionRef::Token(token) => { + if !self.allows_contract(&token.data_contract_id()) { + return false; + } + match token { + TokenTransition::Burn(_) => TOKEN_BURN, + TokenTransition::Mint(_) => TOKEN_MINT, + TokenTransition::Transfer(_) => TOKEN_TRANSFER, + TokenTransition::Freeze(_) => TOKEN_FREEZE, + TokenTransition::Unfreeze(_) => TOKEN_UNFREEZE, + TokenTransition::DestroyFrozenFunds(_) => TOKEN_DESTROY_FROZEN_FUNDS, + TokenTransition::Claim(_) => TOKEN_CLAIM, + TokenTransition::EmergencyAction(_) => TOKEN_EMERGENCY_ACTION, + TokenTransition::ConfigUpdate(_) => TOKEN_CONFIG_UPDATE, + TokenTransition::DirectPurchase(_) => TOKEN_DIRECT_PURCHASE, + TokenTransition::SetPriceForDirectPurchase(_) => TOKEN_SET_PRICE, + } + } + }; + self.allows(permission) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> AuthenticationScope { + AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([1; 32]), + document_types: Some(vec!["post".into()]), + }], + permissions: permissions::DOCUMENT_CREATE | permissions::DOCUMENT_TOKEN_PAYMENT, + expires_at: Some(100), + }) + } + #[test] + fn should_preserve_scope_and_reject_trailing_bytes() { + let scope = fixture(); + let mut bytes = scope.to_bytes().unwrap(); + assert_eq!(AuthenticationScope::from_bytes(&bytes).unwrap(), scope); + bytes.push(0); + assert!(AuthenticationScope::from_bytes(&bytes).is_err()); + } + #[test] + fn should_restrict_contracts_types_actions_and_expiry() { + let scope = fixture(); + assert!(scope.allows_document(&Identifier::from([1; 32]), "post")); + assert!(!scope.allows_document(&Identifier::from([2; 32]), "post")); + assert!(!scope.allows_document(&Identifier::from([1; 32]), "profile")); + assert!(!scope.allows(permissions::TOKEN_TRANSFER)); + assert!(!scope.is_expired(99)); + assert!(scope.is_expired(100)); + } + #[test] + fn should_reject_empty_types_unknown_bits_and_duplicate_contracts() { + let AuthenticationScope::V0(original) = fixture(); + let mut scope = original.clone(); + scope.contracts[0].document_types = Some(vec![]); + assert!(AuthenticationScope::V0(scope).validate().is_err()); + let mut scope = original.clone(); + scope.permissions |= 1 << 31; + assert!(AuthenticationScope::V0(scope).validate().is_err()); + let mut scope = original; + scope.contracts.push(scope.contracts[0].clone()); + assert!(AuthenticationScope::V0(scope).validate().is_err()); + } + #[test] + fn should_bound_scope_size_and_distinguish_unrestricted_types() { + let AuthenticationScope::V0(mut scope) = fixture(); + scope.contracts[0].document_types = None; + let unrestricted = AuthenticationScope::V0(scope.clone()); + assert!(unrestricted.validate().is_ok()); + assert!(unrestricted.allows_document(&scope.contracts[0].id, "anything")); + scope.contracts[0].document_types = Some(vec!["a".repeat(MAX_SCOPE_BYTES)]); + assert!(AuthenticationScope::V0(scope).validate().is_err()); + assert!(AuthenticationScope::from_bytes(&vec![0; MAX_SCOPE_BYTES + 1]).is_err()); + + let AuthenticationScope::V0(mut scope) = fixture(); + scope.contracts = (0..16) + .map(|id| ContractScope { + id: Identifier::from([id; 32]), + document_types: None, + }) + .collect(); + assert!(AuthenticationScope::V0(scope.clone()).validate().is_ok()); + scope.contracts.push(ContractScope { + id: Identifier::from([16; 32]), + document_types: None, + }); + assert!(AuthenticationScope::V0(scope.clone()).validate().is_err()); + scope.contracts.clear(); + assert!(AuthenticationScope::V0(scope).validate().is_err()); + + let AuthenticationScope::V0(mut scope) = fixture(); + let names = (0..16).map(|n| format!("type{n:02}")).collect::>(); + scope.contracts[0].document_types = Some(names.clone()); + assert!(AuthenticationScope::V0(scope.clone()).validate().is_ok()); + scope.contracts[0] + .document_types + .as_mut() + .unwrap() + .push("type16".into()); + assert!(AuthenticationScope::V0(scope.clone()).validate().is_err()); + scope.contracts[0].document_types = Some(names); + scope.permissions = 0; + assert!(AuthenticationScope::V0(scope).validate().is_err()); + } + + #[cfg(feature = "state-transitions")] + #[test] + fn should_require_each_token_permission_independently_and_reject_foreign_contracts() { + use crate::state_transition::batch_transition::batched_transition::{ + token_transfer_transition::TokenTransferTransitionV0, BatchedTransitionRef, + TokenTransition, + }; + use permissions::*; + + let cases = [ + (TokenTransition::Burn(Default::default()), TOKEN_BURN), + (TokenTransition::Mint(Default::default()), TOKEN_MINT), + ( + TokenTransition::Transfer(TokenTransferTransitionV0::default().into()), + TOKEN_TRANSFER, + ), + (TokenTransition::Freeze(Default::default()), TOKEN_FREEZE), + ( + TokenTransition::Unfreeze(Default::default()), + TOKEN_UNFREEZE, + ), + ( + TokenTransition::DestroyFrozenFunds(Default::default()), + TOKEN_DESTROY_FROZEN_FUNDS, + ), + (TokenTransition::Claim(Default::default()), TOKEN_CLAIM), + ( + TokenTransition::EmergencyAction(Default::default()), + TOKEN_EMERGENCY_ACTION, + ), + ( + TokenTransition::ConfigUpdate(Default::default()), + TOKEN_CONFIG_UPDATE, + ), + ( + TokenTransition::DirectPurchase(Default::default()), + TOKEN_DIRECT_PURCHASE, + ), + ( + TokenTransition::SetPriceForDirectPurchase(Default::default()), + TOKEN_SET_PRICE, + ), + ]; + // Only the operation and contract are relevant to the authorization policy; + // amounts, recipients and other action fields are validated separately. + for (transition, required) in cases { + let member = BatchedTransitionRef::Token(&transition); + let mut scope = AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([0; 32]), + document_types: None, + }], + permissions: required, + expires_at: None, + }; + assert!(AuthenticationScope::V0(scope.clone()).allows_transition(member)); + scope.permissions = ALL & !required; + assert!(!AuthenticationScope::V0(scope.clone()).allows_transition(member), + "all other permissions, including document token fees, must not authorize {transition:?}"); + scope.permissions = ALL; + scope.contracts[0].id = Identifier::from([1; 32]); + assert!( + !AuthenticationScope::V0(scope).allows_transition(member), + "no permission may escape its contract" + ); + } + } + + #[cfg(all(feature = "json-conversion", feature = "value-conversion"))] + #[test] + fn should_round_trip_scoped_bounds_in_json_and_platform_value() { + use super::super::ContractBounds; + let bounds = ContractBounds::Scoped(fixture()); + let json = bounds.to_json().unwrap(); + assert_eq!(ContractBounds::from_json(json).unwrap(), bounds); + let value = bounds.to_object().unwrap(); + assert_eq!(ContractBounds::from_object(value).unwrap(), bounds); + } + + #[cfg(feature = "state-transitions")] + #[test] + fn should_reject_scoped_keys_before_activation_and_scoped_master_keys() { + use super::super::ContractBounds; + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::state_transition::public_key_in_creation::{ + v0::IdentityPublicKeyInCreationV0, IdentityPublicKeyInCreation, + }; + let mut key = IdentityPublicKeyInCreationV0 { + id: 2, + key_type: KeyType::ECDSA_HASH160, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + data: vec![1; 20].into(), + read_only: false, + signature: Default::default(), + contract_bounds: Some(ContractBounds::Scoped(fixture())), + }; + let old = crate::version::PlatformVersion::get(13).unwrap(); + let new = crate::version::PlatformVersion::latest(); + assert!( + !IdentityPublicKeyInCreation::validate_identity_public_keys_structure( + &[key.clone().into()], + false, + old + ) + .unwrap() + .is_valid() + ); + assert!( + IdentityPublicKeyInCreation::validate_identity_public_keys_structure( + &[key.clone().into()], + false, + new + ) + .unwrap() + .is_valid() + ); + key.security_level = SecurityLevel::MASTER; + assert!( + !IdentityPublicKeyInCreation::validate_identity_public_keys_structure( + &[key.into()], + false, + new + ) + .unwrap() + .is_valid() + ); + } + + #[cfg(feature = "state-transitions")] + #[test] + fn should_bind_every_scope_field_in_versioned_shielded_preimages() { + use super::super::ContractBounds; + use crate::address_funds::PlatformAddress; + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::shielded::{ + identity_create_from_shielded_extra_sighash_data as versioned, + identity_create_from_shielded_extra_sighash_data_v0 as old, + identity_create_from_shielded_extra_sighash_data_v1 as new, + }; + use crate::state_transition::public_key_in_creation::{ + v0::IdentityPublicKeyInCreationV0, IdentityPublicKeyInCreation, + }; + let mut key = IdentityPublicKeyInCreationV0 { + id: 2, + key_type: KeyType::ECDSA_HASH160, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + data: vec![1; 20].into(), + read_only: false, + signature: Default::default(), + contract_bounds: None, + }; + let fallback = PlatformAddress::P2pkh([2; 20]); + for address in [fallback, PlatformAddress::P2sh([3; 20])] { + for bounds in [ + None, + Some(ContractBounds::SingleContract { + id: Identifier::from([4; 32]), + }), + Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from([4; 32]), + document_type_name: "legacy".into(), + }), + ] { + key.contract_bounds = bounds; + let legacy: IdentityPublicKeyInCreation = key.clone().into(); + let keys = std::slice::from_ref(&legacy); + let frozen = old(&[1; 32], 1, &address, keys).unwrap(); + assert_eq!(frozen, new(&[1; 32], 1, &address, keys).unwrap()); + for protocol in [13, 14] { + assert_eq!( + frozen, + versioned( + &[1; 32], + 1, + &address, + keys, + crate::version::PlatformVersion::get(protocol).unwrap() + ) + .unwrap(), + "legacy key preimage must remain stable under protocol {protocol}" + ); + } + } + } + key.contract_bounds = Some(ContractBounds::Scoped(fixture())); + assert!(old(&[1; 32], 1, &fallback, &[key.clone().into()]).is_err()); + let original = new(&[1; 32], 1, &fallback, &[key.clone().into()]).unwrap(); + for field in ["contract", "types", "permissions", "expiry"] { + let mut changed = key.clone(); + let Some(ContractBounds::Scoped(AuthenticationScope::V0(ref mut scope))) = + changed.contract_bounds + else { + unreachable!() + }; + match field { + "contract" => scope.contracts[0].id = Identifier::from([2; 32]), + "types" => scope.contracts[0].document_types = None, + "permissions" => scope.permissions |= permissions::DOCUMENT_DELETE, + "expiry" => scope.expires_at = Some(101), + _ => unreachable!(), + } + assert_ne!( + original, + new(&[1; 32], 1, &fallback, &[changed.into()]).unwrap(), + "must bind {field}" + ); + } + } +} diff --git a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs index 7052fea0813..29d8b974498 100644 --- a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs +++ b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs @@ -1,3 +1,4 @@ +pub mod authentication_scope; use crate::identifier::Identifier; use crate::identity::identity_public_key::contract_bounds::ContractBounds::{ SingleContract, SingleContractDocumentType, @@ -7,6 +8,7 @@ use crate::serialization::JsonConvertible; #[cfg(feature = "value-conversion")] use crate::serialization::ValueConvertible; use crate::ProtocolError; +pub use authentication_scope::{AuthenticationScope, AuthenticationScopeV0, ContractScope}; use bincode::{Decode, Encode}; use serde::{Deserialize, Serialize}; @@ -35,6 +37,8 @@ pub enum ContractBounds { id: Identifier, document_type_name: String, } = 1, + /// Application authentication permissions. Existing encryption variants retain their wire tags. + Scoped(AuthenticationScope) = 2, // /// this key can only be used within contracts owned by a specified owner // #[serde(rename = "multipleContractsOfSameOwner")] // MultipleContractsOfSameOwner { owner_id: Identifier } = 2, @@ -69,7 +73,7 @@ impl ContractBounds { match self { SingleContract { .. } => 0, SingleContractDocumentType { .. } => 1, - // MultipleContractsOfSameOwner { .. } => 2, + Self::Scoped(_) => 2, } } @@ -77,6 +81,7 @@ impl ContractBounds { match str { "singleContract" => Ok(0), "documentType" => Ok(1), + "scoped" => Ok(2), _ => Err(ProtocolError::DecodingError(String::from( "Expected type to be one of none, singleContract or singleContractDocumentType", ))), @@ -87,16 +92,39 @@ impl ContractBounds { match self { SingleContract { .. } => "singleContract", SingleContractDocumentType { .. } => "documentType", - // MultipleContractsOfSameOwner { .. } => "multipleContractsOfSameOwner", + Self::Scoped(_) => "scoped", } } + /// Every bounded contract and its optional document-type restriction. + /// Unlike the legacy singular accessors, this retains the entire delegation. + pub fn contracts(&self) -> impl Iterator)> { + let single = match self { + Self::SingleContract { id } => Some((id, None)), + Self::SingleContractDocumentType { + id, + document_type_name, + } => Some((id, Some(std::slice::from_ref(document_type_name)))), + Self::Scoped(_) => None, + }; + let scoped = match self { + Self::Scoped(scope) => Some(scope.contracts()), + _ => None, + }; + single.into_iter().chain( + scoped + .into_iter() + .flatten() + .map(|entry| (&entry.id, entry.document_types.as_deref())), + ) + } + /// Gets the identifier - pub fn identifier(&self) -> &Identifier { + pub fn identifier(&self) -> Option<&Identifier> { match self { - SingleContract { id } => id, - SingleContractDocumentType { id, .. } => id, - // MultipleContractsOfSameOwner { owner_id } => owner_id, + SingleContract { id } => Some(id), + SingleContractDocumentType { id, .. } => Some(id), + Self::Scoped(_) => None, } } @@ -108,7 +136,7 @@ impl ContractBounds { document_type_name: document_type, .. } => Some(document_type), - // MultipleContractsOfSameOwner { .. } => None, + Self::Scoped(_) => None, } } // @@ -176,7 +204,7 @@ mod core_tests { assert!(matches!(bounds, ContractBounds::SingleContract { .. })); assert_eq!(bounds.contract_bounds_type(), 0); assert_eq!(bounds.contract_bounds_type_string(), "singleContract"); - assert_eq!(bounds.identifier().as_bytes(), id_bytes.as_slice()); + assert_eq!(bounds.identifier().unwrap().as_bytes(), id_bytes.as_slice()); // document_type is None for SingleContract regardless of what we passed in. assert!(bounds.document_type().is_none()); } @@ -192,7 +220,7 @@ mod core_tests { )); assert_eq!(bounds.contract_bounds_type(), 1); assert_eq!(bounds.contract_bounds_type_string(), "documentType"); - assert_eq!(bounds.identifier().as_bytes(), id_bytes.as_slice()); + assert_eq!(bounds.identifier().unwrap().as_bytes(), id_bytes.as_slice()); assert_eq!(bounds.document_type().map(String::as_str), Some("myDoc")); } diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index edf4a0a7de2..6fb496496d1 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -24,7 +24,8 @@ pub use compute_minimum_shielded_fee::{ // re-exported (callers use the wrappers; byte-layout tests use the `_v0` impls). pub use sighash::{ compute_platform_sighash, identity_create_from_shielded_extra_sighash_data, - identity_create_from_shielded_extra_sighash_data_v0, shielded_withdrawal_extra_sighash_data, + identity_create_from_shielded_extra_sighash_data_v0, + identity_create_from_shielded_extra_sighash_data_v1, shielded_withdrawal_extra_sighash_data, shielded_withdrawal_extra_sighash_data_v0, unshield_extra_sighash_data, unshield_extra_sighash_data_v0, }; diff --git a/packages/rs-dpp/src/shielded/sighash.rs b/packages/rs-dpp/src/shielded/sighash.rs index 5856a9e5b33..05aeaa36a59 100644 --- a/packages/rs-dpp/src/shielded/sighash.rs +++ b/packages/rs-dpp/src/shielded/sighash.rs @@ -74,7 +74,7 @@ pub fn shielded_withdrawal_extra_sighash_data( platform_version: &PlatformVersion, ) -> Result, ProtocolError> { match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(shielded_withdrawal_extra_sighash_data_v0( + 0 | 1 => Ok(shielded_withdrawal_extra_sighash_data_v0( output_script, unshielding_amount, core_fee_per_byte, @@ -82,7 +82,7 @@ pub fn shielded_withdrawal_extra_sighash_data( )), version => Err(ProtocolError::UnknownVersionMismatch { method: "shielded_withdrawal_extra_sighash_data".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } @@ -117,13 +117,13 @@ pub fn unshield_extra_sighash_data( platform_version: &PlatformVersion, ) -> Result, ProtocolError> { match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(unshield_extra_sighash_data_v0( + 0 | 1 => Ok(unshield_extra_sighash_data_v0( output_address, unshielding_amount, )), version => Err(ProtocolError::UnknownVersionMismatch { method: "unshield_extra_sighash_data".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } @@ -171,15 +171,21 @@ pub fn identity_create_from_shielded_extra_sighash_data( platform_version: &PlatformVersion, ) -> Result, ProtocolError> { match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(identity_create_from_shielded_extra_sighash_data_v0( + 0 => identity_create_from_shielded_extra_sighash_data_v0( identity_id, denomination, send_to_address_on_creation_failure, public_keys, - )), + ), + 1 => identity_create_from_shielded_extra_sighash_data_v1( + identity_id, + denomination, + send_to_address_on_creation_failure, + public_keys, + ), version => Err(ProtocolError::UnknownVersionMismatch { method: "identity_create_from_shielded_extra_sighash_data".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } @@ -193,7 +199,7 @@ pub fn identity_create_from_shielded_extra_sighash_data_v0( denomination: u64, send_to_address_on_creation_failure: &PlatformAddress, public_keys: &[IdentityPublicKeyInCreation], -) -> Vec { +) -> Result, ProtocolError> { let mut data = Vec::with_capacity(32 + 8 + 21 + 2 + public_keys.len() * 44); data.extend_from_slice(identity_id); data.extend_from_slice(&denomination.to_le_bytes()); @@ -225,6 +231,13 @@ pub fn identity_create_from_shielded_extra_sighash_data_v0( // cannot flip `read_only` or alter `contract_bounds` on an observed transition. data.push(key.read_only() as u8); match key.contract_bounds() { + // This variant was not representable under v0. Reject it without + // changing a single byte of any historical preimage. + Some(ContractBounds::Scoped(_)) => { + return Err(ProtocolError::InvalidKeyContractBoundsError( + "scoped keys require shielded sighash v1".into(), + )) + } None => data.push(0u8), Some(ContractBounds::SingleContract { id }) => { data.push(1u8); @@ -242,7 +255,71 @@ pub fn identity_create_from_shielded_extra_sighash_data_v0( } } } - data + Ok(data) +} + +/// v1 adds a length-prefixed scoped delegation; legacy keys retain their preimages. +pub fn identity_create_from_shielded_extra_sighash_data_v1( + identity_id: &[u8; 32], + denomination: u64, + send_to_address_on_creation_failure: &PlatformAddress, + public_keys: &[IdentityPublicKeyInCreation], +) -> Result, ProtocolError> { + let mut data = Vec::with_capacity(32 + 8 + 21 + 2 + public_keys.len() * 44); + data.extend_from_slice(identity_id); + data.extend_from_slice(&denomination.to_le_bytes()); + // Bind the fallback address (type tag || 20-byte hash) so a relayer cannot redirect the + // failure credit. Mirrors the way `unshield`/`withdrawal` bind their output address. + match send_to_address_on_creation_failure { + PlatformAddress::P2pkh(hash) => { + data.push(0u8); + data.extend_from_slice(hash); + } + PlatformAddress::P2sh(hash) => { + data.push(1u8); + data.extend_from_slice(hash); + } + } + data.extend_from_slice(&(public_keys.len() as u16).to_le_bytes()); + for key in public_keys { + data.extend_from_slice(&key.id().to_le_bytes()); + data.push(key.purpose() as u8); + data.push(key.security_level() as u8); + data.push(key.key_type() as u8); + let key_data = key.data().as_slice(); + data.extend_from_slice(&(key_data.len() as u16).to_le_bytes()); + data.extend_from_slice(key_data); + // Also bind `read_only` and `contract_bounds`. These are state-determining key fields that + // ARE in the transition's signable_bytes, but the per-key proof-of-possession does NOT bind + // them for hash-based key types (which accept an empty signature). Committing them into the + // Orchard binding sighash makes them un-malleable for EVERY key type, so a relayer/proposer + // cannot flip `read_only` or alter `contract_bounds` on an observed transition. + data.push(key.read_only() as u8); + match key.contract_bounds() { + Some(ContractBounds::Scoped(scope)) => { + let bytes = scope.to_bytes()?; + data.push(3u8); + data.extend_from_slice(&(bytes.len() as u16).to_le_bytes()); + data.extend_from_slice(&bytes); + } + None => data.push(0u8), + Some(ContractBounds::SingleContract { id }) => { + data.push(1u8); + data.extend_from_slice(id.as_bytes()); + } + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => { + data.push(2u8); + data.extend_from_slice(id.as_bytes()); + let name = document_type_name.as_bytes(); + data.extend_from_slice(&(name.len() as u16).to_le_bytes()); + data.extend_from_slice(name); + } + } + } + Ok(data) } #[cfg(test)] @@ -306,7 +383,20 @@ mod tests { use super::*; // Pin the v0 preimage directly (see the note in the parent test module). use crate::identity::{KeyType, Purpose, SecurityLevel}; - use crate::shielded::identity_create_from_shielded_extra_sighash_data_v0 as identity_create_from_shielded_extra_sighash_data; + fn identity_create_from_shielded_extra_sighash_data( + id: &[u8; 32], + denomination: u64, + fallback: &PlatformAddress, + keys: &[IdentityPublicKeyInCreation], + ) -> Vec { + super::super::identity_create_from_shielded_extra_sighash_data_v0( + id, + denomination, + fallback, + keys, + ) + .unwrap() + } use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use platform_value::BinaryData; diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 5c048ae84ef..069b33f1fbd 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -1279,6 +1279,43 @@ impl StateTransition { call_method_identity_signed!(self, set_signature_public_key_id, public_key_id) } + /// Check the scope when the signing API receives the identity key metadata. + /// Raw signing primitives cannot check bounds without that metadata. + #[cfg(feature = "state-transition-signing")] + fn verify_identity_key_scope( + &self, + identity_public_key: &IdentityPublicKey, + ) -> Result<(), ProtocolError> { + if let Some(crate::identity::contract_bounds::ContractBounds::Scoped(scope)) = + identity_public_key.contract_bounds() + { + use crate::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + match self { + StateTransition::Batch(batch) + if batch + .transitions_iter() + .all(|transition| scope.allows_transition(transition)) => {} + StateTransition::Batch(_) => { + return Err(ProtocolError::ConsensusError(Box::new( + crate::consensus::signature::ScopedKeyOutOfScopeError::new( + identity_public_key.id(), + ) + .into(), + ))) + } + _ => { + return Err(ProtocolError::ConsensusError(Box::new( + crate::consensus::signature::ScopedKeyNonBatchError::new( + identity_public_key.id(), + ) + .into(), + ))) + } + } + } + Ok(()) + } + #[cfg(feature = "state-transition-signing")] pub async fn sign_external>( &mut self, @@ -1307,6 +1344,7 @@ impl StateTransition { >, options: StateTransitionSigningOptions, ) -> Result<(), ProtocolError> { + self.verify_identity_key_scope(identity_public_key)?; match self { StateTransition::DataContractCreate(st) => { st.verify_public_key_level_and_purpose(identity_public_key, options)?; @@ -1482,6 +1520,7 @@ impl StateTransition { bls: &impl BlsModule, options: StateTransitionSigningOptions, ) -> Result<(), ProtocolError> { + self.verify_identity_key_scope(identity_public_key)?; call_errorable_method_identity_signed!( self, verify_public_key_level_and_purpose, @@ -1973,6 +2012,81 @@ mod tests { // StateTransitionSigningOptions tests // ----------------------------------------------------------------------- + #[cfg(all(feature = "state-transition-signing", feature = "bls-signatures"))] + #[test] + fn should_enforce_scope_before_private_key_signing() { + use crate::consensus::signature::{ScopedKeyNonBatchError, ScopedKeyOutOfScopeError}; + use crate::identity::contract_bounds::authentication_scope::{ + permissions, AuthenticationScope, AuthenticationScopeV0, ContractScope, + }; + use crate::identity::contract_bounds::ContractBounds; + use crate::identity::identity_public_key::v0::IdentityPublicKeyV0; + + let private_key = [1; 32]; + let bls = crate::bls::native_bls::NativeBlsModule; + let scope = AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([2; 32]), + document_types: Some(vec!["preorder".to_string()]), + }], + permissions: permissions::DOCUMENT_DELETE, + expires_at: None, + }; + let mut key = IdentityPublicKeyV0 { + id: 7, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: KeyType::ECDSA_SECP256K1, + data: get_compressed_public_ec_key(&private_key) + .unwrap() + .to_vec() + .into(), + contract_bounds: Some(ContractBounds::Scoped(AuthenticationScope::V0( + scope.clone(), + ))), + ..Default::default() + }; + sample_batch_st_with_delete() + .sign(&key.clone().into(), &private_key, &bls) + .expect("allowed document delete must sign"); + + let err = sample_transfer_st() + .sign(&key.clone().into(), &private_key, &bls) + .unwrap_err(); + assert!(matches!(err, ProtocolError::ConsensusError(error) + if *error == ScopedKeyNonBatchError::new(key.id).into())); + + for mismatch in ["contract", "document type", "operation"] { + let mut denied = scope.clone(); + match mismatch { + "contract" => denied.contracts[0].id = Identifier::from([3; 32]), + "document type" => denied.contracts[0].document_types = Some(vec!["other".into()]), + "operation" => denied.permissions = permissions::DOCUMENT_CREATE, + _ => unreachable!(), + } + key.contract_bounds = Some(ContractBounds::Scoped(AuthenticationScope::V0(denied))); + let mut transition = sample_batch_st_with_delete(); + let original = transition.clone(); + let err = transition + .sign(&key.clone().into(), &private_key, &bls) + .unwrap_err(); + assert!( + matches!(err, ProtocolError::ConsensusError(error) + if *error == ScopedKeyOutOfScopeError::new(key.id).into()), + "{mismatch}" + ); + assert_eq!( + transition, original, + "rejection must preserve the transition" + ); + } + + key.contract_bounds = None; + sample_batch_st_with_delete() + .sign(&key.into(), &private_key, &bls) + .expect("unscoped keys must still sign"); + } + #[test] fn test_signing_options_default() { let opts = StateTransitionSigningOptions::default(); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs index e002fffb270..34b5cb00d57 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs @@ -4,6 +4,7 @@ use crate::ProtocolError; use platform_version::version::PlatformVersion; pub mod v0; +pub mod v1; impl IdentityPublicKeyInCreation { pub fn validate_identity_public_keys_structure( @@ -22,10 +23,15 @@ impl IdentityPublicKeyInCreation { in_create_identity, platform_version, ), + 1 => Self::validate_identity_public_keys_structure_v1( + identity_public_keys_with_witness, + in_create_identity, + platform_version, + ), version => Err(ProtocolError::UnknownVersionMismatch { method: "IdentityPublicKeyInCreation::validate_identity_public_keys_structure" .to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v0/mod.rs index 6b02b2e6454..fd2f3fdd29f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v0/mod.rs @@ -37,11 +37,34 @@ lazy_static! { }; } impl IdentityPublicKeyInCreation { + /// New binaries can decode Scoped even during historical replay. Reject only + /// that newly representable input; all historical validation remains unchanged. + pub(super) fn validate_identity_public_keys_structure_v0( + keys: &[IdentityPublicKeyInCreation], + in_create_identity: bool, + version: &PlatformVersion, + ) -> Result { + if keys.iter().any(|key| { + matches!( + key.contract_bounds(), + Some(crate::identity::contract_bounds::ContractBounds::Scoped(_)) + ) + }) { + return Ok(SimpleConsensusValidationResult::new_with_error( + crate::consensus::basic::identity::InvalidAuthenticationScopeError::new( + "scoped authentication keys are not activated".into(), + ) + .into(), + )); + } + Self::validate_identity_public_keys_structure_common(keys, in_create_identity, version) + } + /// This validation will validate the count of new keys, that there are no duplicates either by /// id or by data. This is done before signature and state validation to remove potential /// attack vectors. #[inline(always)] - pub(super) fn validate_identity_public_keys_structure_v0( + pub(super) fn validate_identity_public_keys_structure_common( identity_public_keys_with_witness: &[IdentityPublicKeyInCreation], in_create_identity: bool, platform_version: &PlatformVersion, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs new file mode 100644 index 00000000000..192fe3ea569 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs @@ -0,0 +1,32 @@ +use crate::consensus::basic::identity::InvalidAuthenticationScopeError; +use crate::identity::{contract_bounds::ContractBounds, Purpose, SecurityLevel}; +use crate::state_transition::public_key_in_creation::{ + accessors::IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreation, +}; +use crate::{validation::SimpleConsensusValidationResult, version::PlatformVersion, ProtocolError}; + +impl IdentityPublicKeyInCreation { + pub(super) fn validate_identity_public_keys_structure_v1( + keys: &[Self], + in_create_identity: bool, + version: &PlatformVersion, + ) -> Result { + for key in keys { + if let Some(ContractBounds::Scoped(scope)) = key.contract_bounds() { + let reason = if key.purpose() != Purpose::AUTHENTICATION + || key.security_level() == SecurityLevel::MASTER + { + Some("scoped keys must be non-MASTER authentication keys".to_owned()) + } else { + scope.validate().err().map(|error| error.to_string()) + }; + if let Some(reason) = reason { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidAuthenticationScopeError::new(reason).into(), + )); + } + } + } + Self::validate_identity_public_keys_structure_common(keys, in_create_identity, version) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs index 2711d97287c..df8da81456a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs @@ -194,6 +194,7 @@ pub(super) fn state_transition_to_execution_event_for_check_tx_v0<'a, C: CoreRPC let result = if state_transition.validates_signature_based_on_identity_info() { state_transition.validate_identity_signed_state_transition( platform.drive, + platform.state.last_block_info().time_ms, None, &mut state_transition_execution_context, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/mod.rs index 62575b8cfb4..2daf5d145d1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/mod.rs @@ -13,17 +13,20 @@ use drive::grovedb::TransactionArg; pub mod v0; pub mod v1; +pub mod v2; /// Validates the contract bounds attached to each public key in `identity_public_keys_with_witness`. /// /// `epoch` is used by v1+ to bill the underlying grovedb reads to `execution_context`; v0 /// ignores it (v0 didn't bill these reads — pre-PROTOCOL_VERSION_12 behavior is preserved /// verbatim for chain replay). +#[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. pub(crate) fn validate_identity_public_keys_contract_bounds( identity_id: Identifier, identity_public_keys_with_witness: &[IdentityPublicKeyInCreation], drive: &Drive, epoch: &Epoch, + time_ms: u64, transaction: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -55,9 +58,19 @@ pub(crate) fn validate_identity_public_keys_contract_bounds( execution_context, platform_version, ), + 2 => v2::validate_identity_public_keys_contract_bounds_v2( + identity_id, + identity_public_keys_with_witness, + drive, + epoch, + time_ms, + transaction, + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "validate_identity_public_keys_contract_bounds".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } @@ -438,14 +451,14 @@ mod tests { } /// Covers the integration this PR is wiring up — that the public dispatcher actually - /// routes to v1 under `PlatformVersion::latest()` (which sets the bounds-validator - /// version field to 1) and that the `epoch` parameter is forwarded through. If the + /// routes legacy bounds through v1 under `PlatformVersion::latest()` (which sets the bounds-validator + /// version field to 2) and that the `epoch` parameter is forwarded through. If the /// dispatcher were accidentally routing to v0 — which has the DECRYPTION-branch bug — /// the assertion below would flip from `is_valid` to invalid. #[test] - fn dispatcher_routes_to_v1_at_latest_platform_version() { + fn dispatcher_preserves_v1_encryption_rules_at_latest_platform_version() { let platform_version = PlatformVersion::latest(); - // Sanity: `latest` should select v1 of the bounds validator. + // Sanity: `latest` should select v2 of the bounds validator. assert_eq!( platform_version .drive_abci @@ -453,8 +466,8 @@ mod tests { .state_transitions .common_validation_methods .validate_identity_public_key_contract_bounds, - 1, - "test premise: latest platform version is expected to select v1; \ + 2, + "test premise: latest platform version is expected to select v2; \ update this test if the version field moves" ); @@ -487,6 +500,7 @@ mod tests { &[key], &platform.drive, &epoch, + 0, None, &mut execution_context, platform_version, @@ -511,4 +525,78 @@ mod tests { billed_count ); } + #[test] + fn should_validate_scoped_registration_against_contracts_and_executing_time() { + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractScope, + }; + use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Setters; + let version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let contract = build_contract_with_decryption_only_bounds(version); + platform + .drive + .apply_contract(&contract, BlockInfo::default(), true, None, None, version) + .unwrap(); + for case in [ + "valid", + "expired", + "missing_type", + "missing_contract", + "wrong_purpose", + "master", + ] { + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: if case == "missing_contract" { + Identifier::from([42; 32]) + } else { + contract.id() + }, + document_types: Some(vec![if case == "missing_type" { + "absent".into() + } else { + "note".into() + }]), + }], + permissions: permissions::DOCUMENT_CREATE | permissions::DOCUMENT_TOKEN_PAYMENT, + expires_at: Some(if case == "expired" { 100 } else { 101 }), + }); + let mut key = make_decryption_key_bound_to_doc_type(contract.id(), "note".into()); + key.set_contract_bounds(Some(ContractBounds::Scoped(scope))); + key.set_purpose(if case == "wrong_purpose" { + Purpose::TRANSFER + } else { + Purpose::AUTHENTICATION + }); + key.set_security_level(if case == "master" { + SecurityLevel::MASTER + } else { + SecurityLevel::HIGH + }); + let mut context = + StateTransitionExecutionContext::default_for_platform_version(version).unwrap(); + let result = validate_identity_public_keys_contract_bounds( + Identifier::from([1; 32]), + &[key], + &platform.drive, + &Epoch::new(0).unwrap(), + 100, + None, + &mut context, + version, + ) + .unwrap(); + assert_eq!( + result.is_valid(), + case == "valid", + "{case}: {:?}", + result.errors + ); + } + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v0/mod.rs index 03407cf94d0..d9206f54644 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v0/mod.rs @@ -62,6 +62,12 @@ fn validate_identity_public_key_contract_bounds_v0( let purpose = identity_public_key_in_creation.purpose(); if let Some(contract_bounds) = identity_public_key_in_creation.contract_bounds() { match contract_bounds { + ContractBounds::Scoped(_) => Ok(SimpleConsensusValidationResult::new_with_error( + dpp::consensus::basic::identity::InvalidAuthenticationScopeError::new( + "scope is not activated".into(), + ) + .into(), + )), ContractBounds::SingleContract { id: contract_id } => { // we should fetch the contract let contract = drive.get_contract_with_fetch_info( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs index 57a8c428d9d..b6306b927e3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs @@ -91,6 +91,14 @@ fn validate_identity_public_key_contract_bounds_v1( let contract_id = match contract_bounds { ContractBounds::SingleContract { id } => *id, ContractBounds::SingleContractDocumentType { id, .. } => *id, + ContractBounds::Scoped(_) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + dpp::consensus::basic::identity::InvalidAuthenticationScopeError::new( + "scope is not activated".into(), + ) + .into(), + )) + } }; let outcome = drive.get_system_or_user_contract_with_fee( contract_id.to_buffer(), @@ -110,6 +118,7 @@ fn validate_identity_public_key_contract_bounds_v1( }; match contract_bounds { + ContractBounds::Scoped(_) => unreachable!("rejected above"), ContractBounds::SingleContract { .. } => { let requirements_for_purpose = match purpose { ENCRYPTION => contract.config().requires_identity_encryption_bounded_key(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v2/mod.rs new file mode 100644 index 00000000000..a5a2375742d --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v2/mod.rs @@ -0,0 +1,85 @@ +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use dpp::block::epoch::Epoch; +use dpp::consensus::basic::{ + document::{DataContractNotPresentError, InvalidDocumentTypeError}, + identity::InvalidAuthenticationScopeError, +}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::identifier::Identifier; +use dpp::identity::{contract_bounds::ContractBounds, Purpose, SecurityLevel}; +use dpp::state_transition::public_key_in_creation::{ + accessors::IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreation, +}; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::{drive::Drive, grovedb::TransactionArg}; + +/// v2 adds authentication delegations; encryption/decryption use unchanged v1 rules. +#[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. +pub(super) fn validate_identity_public_keys_contract_bounds_v2( + identity_id: Identifier, + keys: &[IdentityPublicKeyInCreation], + drive: &Drive, + epoch: &Epoch, + time_ms: u64, + transaction: TransactionArg, + context: &mut StateTransitionExecutionContext, + version: &PlatformVersion, +) -> Result { + let mut result = SimpleConsensusValidationResult::default(); + for key in keys { + let Some(ContractBounds::Scoped(scope)) = key.contract_bounds() else { + result.add_errors( + super::v1::validate_identity_public_keys_contract_bounds_v1( + identity_id, + std::slice::from_ref(key), + drive, + epoch, + transaction, + context, + version, + )? + .errors, + ); + continue; + }; + if key.purpose() != Purpose::AUTHENTICATION + || key.security_level() == SecurityLevel::MASTER + || scope.validate().is_err() + || scope.is_expired(time_ms) + { + result.add_error(InvalidAuthenticationScopeError::new( + "scope must be valid, unexpired and attached to a non-MASTER authentication key" + .into(), + )); + continue; + } + for entry in scope.contracts() { + let outcome = drive.get_system_or_user_contract_with_fee( + entry.id.to_buffer(), + epoch, + transaction, + version, + )?; + if let Some(fee) = outcome.fee() { + context.add_operation(ValidationOperation::PrecalculatedOperation(fee.clone())); + } + let Some(contract) = outcome.contract() else { + result.add_error(DataContractNotPresentError::new(entry.id)); + continue; + }; + if let Some(names) = &entry.document_types { + for name in names { + if contract.document_type_optional_for_name(name).is_none() { + result.add_error(InvalidDocumentTypeError::new(name.clone(), entry.id)); + } + } + } + } + } + Ok(result) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs index 6e35b4361d9..7fe2ef877d9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs @@ -10,11 +10,15 @@ use crate::execution::types::state_transition_execution_context::StateTransition use crate::execution::validation::state_transition::common::validate_state_transition_identity_signed::v0::ValidateStateTransitionIdentitySignatureV0; pub mod v0; +mod v1; +use v1::ValidateStateTransitionIdentitySignatureV1; pub trait ValidateStateTransitionIdentitySignature { + #[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. fn validate_state_transition_identity_signed( &self, drive: &Drive, + time_ms: u64, request_balance: bool, request_revision: bool, transaction: TransactionArg, @@ -27,6 +31,7 @@ impl ValidateStateTransitionIdentitySignature for StateTransition { fn validate_state_transition_identity_signed( &self, drive: &Drive, + time_ms: u64, request_balance: bool, request_revision: bool, transaction: TransactionArg, @@ -48,9 +53,18 @@ impl ValidateStateTransitionIdentitySignature for StateTransition { execution_context, platform_version, ), + 1 => self.validate_state_transition_identity_signed_v1( + drive, + time_ms, + request_balance, + request_revision, + transaction, + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "StateTransition::validate_state_transition_identity_signature".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v1/mod.rs new file mode 100644 index 00000000000..c1ccfb9678e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v1/mod.rs @@ -0,0 +1,63 @@ +use dpp::identity::PartialIdentity; +use dpp::state_transition::StateTransition; +use dpp::validation::ConsensusValidationResult; +use dpp::version::{PlatformVersion}; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::common::validate_state_transition_identity_signed::v0::ValidateStateTransitionIdentitySignatureV0; + +pub(super) trait ValidateStateTransitionIdentitySignatureV1 { + #[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. + fn validate_state_transition_identity_signed_v1( + &self, + drive: &Drive, + time_ms: u64, + request_balance: bool, + request_revision: bool, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} +impl ValidateStateTransitionIdentitySignatureV1 for StateTransition { + fn validate_state_transition_identity_signed_v1( + &self, + drive: &Drive, + time_ms: u64, + request_balance: bool, + request_revision: bool, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + use dpp::identity::contract_bounds::ContractBounds; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let result = self.validate_state_transition_identity_signed_v0( + drive, + request_balance, + request_revision, + transaction, + execution_context, + platform_version, + )?; + if let Some(identity) = result.data.as_ref().filter(|_| result.is_valid()) { + for key in identity.loaded_public_keys.values() { + if let Some(ContractBounds::Scoped(scope)) = key.contract_bounds() { + if scope.is_expired(time_ms) { + return Ok(ConsensusValidationResult::new_with_error( + dpp::consensus::signature::ScopedKeyExpiredError::new(key.id()).into(), + )); + } + if !matches!(self, StateTransition::Batch(_)) { + return Ok(ConsensusValidationResult::new_with_error( + dpp::consensus::signature::ScopedKeyNonBatchError::new(key.id()).into(), + )); + } + } + } + } + Ok(result) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs index bdf5da140d3..00cf466ebda 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs @@ -30,6 +30,7 @@ pub(crate) trait StateTransitionIdentityBasedSignatureValidationV0 { fn validate_identity_signed_state_transition( &self, drive: &Drive, + time_ms: u64, tx: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -55,6 +56,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { fn validate_identity_signed_state_transition( &self, drive: &Drive, + time_ms: u64, tx: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -68,6 +70,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { //Basic signature verification Ok(self.validate_state_transition_identity_signed( drive, + time_ms, true, false, tx, @@ -79,6 +82,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { let mut consensus_validation_result = self .validate_state_transition_identity_signed( drive, + time_ms, true, false, tx, @@ -102,6 +106,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { //Basic signature verification Ok(self.validate_state_transition_identity_signed( drive, + time_ms, true, true, tx, @@ -117,6 +122,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { Ok(self.validate_state_transition_identity_signed( drive, + time_ms, false, false, tx, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index 6d881a6d248..c8d9a62df47 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -97,6 +97,7 @@ impl StateTransitionStateValidation for StateTransition { st.validate_state_for_identity_create_transition( action, platform, + block_info, execution_context, tx, ) @@ -163,6 +164,7 @@ impl StateTransitionStateValidation for StateTransition { st.validate_state_for_identity_create_from_addresses_transition( action, platform, + block_info, execution_context, tx, ) @@ -247,6 +249,7 @@ impl StateTransitionStateValidation for StateTransition { st.validate_state_for_identity_create_from_shielded_pool_transition( action, platform, + block_info, execution_context, tx, ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs index eb5ca95d0d0..02c9ec024cd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs @@ -59,6 +59,7 @@ pub(super) fn process_state_transition_v0<'a, C: CoreRPCLike>( let result = if state_transition.validates_signature_based_on_identity_info() { state_transition.validate_identity_signed_state_transition( platform.drive, + block_info.time_ms, transaction, &mut state_transition_execution_context, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs index 9a1925de7fc..008be12cc67 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs @@ -1 +1,2 @@ pub(crate) mod v0; +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs new file mode 100644 index 00000000000..63f19d80971 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs @@ -0,0 +1,292 @@ +use crate::error::Error; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::document::InvalidDocumentTransitionIdError; +use dpp::consensus::signature::{InvalidSignaturePublicKeySecurityLevelError, SignatureError}; +use dpp::dashcore::Network; +use dpp::document::Document; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::PartialIdentity; +use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; +use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::{StateTransitionHasUserFeeIncrease, StateTransitionIdentitySigned, StateTransitionOwned}; +use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; +use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; +use dpp::validation::ConsensusValidationResult; + +use dpp::version::PlatformVersion; + +use drive::state_transition_action::batch::BatchTransitionAction; +use crate::execution::validation::state_transition::state_transitions::batch::action_validation::document::document_replace_transition_action::DocumentReplaceTransitionActionValidation; +use crate::execution::validation::state_transition::state_transitions::batch::action_validation::document::document_delete_transition_action::DocumentDeleteTransitionActionValidation; +use crate::execution::validation::state_transition::state_transitions::batch::action_validation::document::document_index_only_delete_transition_action::DocumentIndexOnlyDeleteTransitionActionValidation; +use crate::execution::validation::state_transition::state_transitions::batch::action_validation::document::document_create_transition_action::DocumentCreateTransitionActionValidation; +use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; +use drive::state_transition_action::batch::batched_transition::BatchedTransitionAction; +use drive::state_transition_action::batch::batched_transition::document_transition::document_delete_transition_action::v0::DocumentDeleteTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_index_only_delete_transition_action::v0::DocumentIndexOnlyDeleteTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::DocumentPurchaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::DocumentReplaceTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::DocumentTransferTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_update_price_transition_action::DocumentUpdatePriceTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; +use drive::state_transition_action::StateTransitionAction; +use drive::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; +use crate::error::execution::ExecutionError; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0}; +use crate::execution::validation::state_transition::batch::action_validation::document::document_purchase_transition_action::DocumentPurchaseTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::document::document_transfer_transition_action::DocumentTransferTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::document::document_update_price_transition_action::DocumentUpdatePriceTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::token::token_base_transition_action::TokenBaseTransitionActionValidation; + +pub(in crate::execution::validation::state_transition::state_transitions::batch) trait DocumentsBatchStateTransitionStructureValidationV1 +{ + fn validate_advanced_structure_from_state_v1( + &self, + block_info: &BlockInfo, + network: Network, + action: &BatchTransitionAction, + identity: &PartialIdentity, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl DocumentsBatchStateTransitionStructureValidationV1 for BatchTransition { + fn validate_advanced_structure_from_state_v1( + &self, + block_info: &BlockInfo, + network: Network, + action: &BatchTransitionAction, + identity: &PartialIdentity, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let security_levels = action.combined_security_level_requirement()?; + + let signing_key = identity.loaded_public_keys.get(&self.signature_public_key_id()).ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution("the key must exist for advanced structure validation as we already fetched it during signature validation")))?; + + if !security_levels.contains(&signing_key.security_level()) { + // We only need to bump the first identity data contract nonce as that will make a replay + // attack not possible + + let first_transition = self.first_transition().ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution("There must be at least one state transition as this is already verified in basic validation")))?; + + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_batched_transition_ref( + first_transition, + self.owner_id(), + self.user_fee_increase(), + ), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + vec![SignatureError::InvalidSignaturePublicKeySecurityLevelError( + InvalidSignaturePublicKeySecurityLevelError::new( + signing_key.security_level(), + security_levels, + ), + ) + .into()], + )); + } + + if let Some(dpp::identity::contract_bounds::ContractBounds::Scoped(scope)) = + signing_key.contract_bounds() + { + use dpp::identity::contract_bounds::authentication_scope::permissions; + use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; + let unauthorized = self + .transitions_iter() + .any(|member| !scope.allows_transition(member)); + let unauthorized_payment = !scope.allows(permissions::DOCUMENT_TOKEN_PAYMENT) + && action.transitions().iter().any(|member| { + matches!(member, BatchedTransitionAction::DocumentAction(doc) + if doc.base().token_cost().is_some_and(|(_, _, amount)| amount > 0)) + }); + if unauthorized || unauthorized_payment { + let first = self.first_transition().ok_or(Error::Execution( + ExecutionError::CorruptedCodeExecution("empty validated batch"), + ))?; + let bump = BumpIdentityDataContractNonceAction::from_batched_transition_ref( + first, + self.owner_id(), + self.user_fee_increase(), + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::BumpIdentityDataContractNonceAction(bump), + vec![dpp::consensus::signature::ScopedKeyOutOfScopeError::new( + signing_key.id(), + ) + .into()], + )); + } + } + + // We should validate that all newly created documents have valid ids + for transition in self.transitions_iter() { + if let BatchedTransitionRef::Document(DocumentTransition::Create(create_transition)) = + transition + { + // Validate the ID + let generated_document_id = Document::generate_document_id_v0( + create_transition.base().data_contract_id_ref(), + &self.owner_id(), + create_transition.base().document_type_name(), + &create_transition.entropy(), + ); + + // This hash will take 2 blocks (128 bytes) + execution_context.add_operation(ValidationOperation::DoubleSha256(2)); + + let id = create_transition.base().id(); + if generated_document_id != id { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + create_transition.base(), + self.owner_id(), + self.user_fee_increase(), + ), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + vec![ + InvalidDocumentTransitionIdError::new(generated_document_id, id).into(), + ], + )); + } + } + } + + // Next we need to validate the structure of all actions (this means with the data contract) + for transition in action.transitions() { + match transition { + BatchedTransitionAction::DocumentAction(document_action) => match document_action { + DocumentTransitionAction::CreateAction(create_action) => { + let result = create_action.validate_structure( + identity.id, + block_info, + network, + platform_version, + )?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(document_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::ReplaceAction(replace_action) => { + let result = replace_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(replace_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::DeleteAction(delete_action) => { + let result = delete_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(delete_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::TransferAction(transfer_action) => { + let result = transfer_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(transfer_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::UpdatePriceAction(update_price_action) => { + let result = update_price_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(update_price_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::PurchaseAction(purchase_action) => { + let result = purchase_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(purchase_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::IndexOnlyDeleteAction(index_only_delete_action) => { + let result = + index_only_delete_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(index_only_delete_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + }, + BatchedTransitionAction::TokenAction(token_transition_action) => { + // token actions only need to do advanced structure validation on the base action + let result = token_transition_action + .base() + .validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition_action(token_transition_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + BatchedTransitionAction::BumpIdentityDataContractNonce(_) => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "we should not have a bump identity contract nonce at this stage", + ))); + } + } + } + Ok(ConsensusValidationResult::new()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs index c1fef559bd1..527ce9b6115 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs @@ -1,3 +1,4 @@ +use advanced_structure::v1::DocumentsBatchStateTransitionStructureValidationV1; mod action_validation; mod advanced_structure; mod data_triggers; @@ -175,7 +176,7 @@ impl StateTransitionStructureKnownInStateValidationV0 for BatchTransition { .batch_state_transition .advanced_structure { - 0 => { + 0 | 1 => { let identity = identity.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( "The identity must be known on advanced structure validation", @@ -186,18 +187,36 @@ impl StateTransitionStructureKnownInStateValidationV0 for BatchTransition { "action must be a documents batch transition action", ))); }; - self.validate_advanced_structure_from_state_v0( - block_info, - network, - documents_batch_transition_action, - identity, - execution_context, - platform_version, - ) + if platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .advanced_structure + == 1 + { + self.validate_advanced_structure_from_state_v1( + block_info, + network, + documents_batch_transition_action, + identity, + execution_context, + platform_version, + ) + } else { + self.validate_advanced_structure_from_state_v0( + block_info, + network, + documents_batch_transition_action, + identity, + execution_context, + platform_version, + ) + } } version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "documents batch transition: advanced structure from state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs index 41ddaad8029..812742bc8e3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs @@ -64,3 +64,5 @@ use drive::util::storage_flags::StorageFlags; use rand::prelude::StdRng; use rand::Rng; use rand::SeedableRng; + +mod scoped_auth; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/scoped_auth.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/scoped_auth.rs new file mode 100644 index 00000000000..406334e1fda --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/scoped_auth.rs @@ -0,0 +1,611 @@ +use super::*; +use crate::execution::validation::state_transition::tests::setup_identity_without_adding_it; +use dpp::consensus::codes::ErrorWithCode; +use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, ContractBounds, + ContractScope, +}; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{accessors::IdentitySettersV0, IdentityPublicKey}; +use dpp::state_transition::batch_transition::methods::v1::DocumentsBatchTransitionMethodsV1; + +/// Sign with the original unbounded key metadata to deliberately bypass SDK +/// preflight; validators must enforce the scoped key stored in Drive. +#[tokio::test] +async fn should_enforce_scoped_auth_in_execution_and_preserve_paid_failure_nonces() { + for case in [ + "allowed", + "wrong_contract", + "wrong_action", + "expired", + "disabled", + "wrong_type", + "multi_contract", + "mixed", + ] { + let version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (mut identity, signer, signing_key) = + setup_identity_without_adding_it(958, dash_to_credits!(0.1)); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let dpns = platform + .drive + .cache + .system_data_contracts + .load_dpns(version) + .unwrap(); + let mut contracts = vec![ContractScope { + id: if case == "wrong_contract" { + dpns.id() + } else { + dashpay.id() + }, + document_types: if case == "wrong_type" { + Some(vec!["contactRequest".into()]) + } else if case == "mixed" { + Some(vec!["profile".into()]) + } else { + None + }, + }]; + if case == "multi_contract" { + contracts.push(ContractScope { + id: dpns.id(), + document_types: None, + }); + contracts.sort_by_key(|c| c.id); + } + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts, + permissions: if case == "wrong_action" { + permissions::DOCUMENT_DELETE + } else { + permissions::DOCUMENT_CREATE + }, + expires_at: Some(if case == "expired" { 100 } else { 101 }), + }); + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(ContractBounds::Scoped(scope)); + if case == "disabled" { + key.disabled_at = Some(99); + } + identity.add_public_key(stored_key); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .unwrap(); + let state = platform.state.load(); + let profile = dashpay.document_type_for_name("profile").unwrap(); + let mut rng = StdRng::seed_from_u64(433); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = profile + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + version, + ) + .unwrap(); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); + let mut batch = BatchTransition::new_document_creation_transition_from_document( + document, + profile, + entropy.0, + &signing_key, + 2, + 0, + None, + &signer, + version, + None, + ) + .await + .unwrap(); + if case == "mixed" { + use dpp::state_transition::batch_transition::batched_transition::{ + document_transition::DocumentTransitionV0Methods, BatchedTransition, + }; + use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; + use dpp::state_transition::StateTransition; + let StateTransition::Batch(BatchTransition::V1(ref mut inner)) = batch else { + panic!("expected v1 batch") + }; + let mut second = inner.transitions[0].clone(); + let BatchedTransition::Document(ref mut doc) = second else { + unreachable!() + }; + doc.base_mut() + .set_document_type_name("contactRequest".into()); + doc.base_mut() + .set_id(dpp::prelude::Identifier::from([8; 32])); + inner.transitions.push(second); + batch + .sign_external( + &signing_key, + &signer, + Some(|_, _| Ok(dpp::identity::SecurityLevel::HIGH)), + ) + .await + .unwrap(); + } + let bytes = batch.serialize_to_bytes().unwrap(); + let tx = platform.drive.grove.start_transaction(); + let block_info = BlockInfo { + time_ms: 100, + ..Default::default() + }; + let result = platform + .platform + .process_raw_state_transitions( + &vec![bytes.clone()], + &state, + &block_info, + &tx, + version, + false, + None, + ) + .unwrap(); + let execution = &result.execution_results()[0]; + match case { + // Protocol 14 still limits batches to one member; mixed batches must + // fail at basic validation before any scope checks or fees. + "mixed" => { + assert!( + matches!(execution, StateTransitionExecutionResult::UnpaidConsensusError(error) if error.code() == 10412), + "{execution:?}" + ); + assert_eq!( + platform + .drive + .fetch_identity_contract_nonce( + identity.id().to_buffer(), + dashpay.id().to_buffer(), + true, + Some(&tx), + version + ) + .unwrap(), + None + ); + assert_eq!( + platform + .drive + .fetch_identity_balance(identity.id().to_buffer(), Some(&tx), version) + .unwrap(), + Some(identity.balance()) + ); + } + "allowed" | "multi_contract" => assert!( + matches!( + execution, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ), + "{case}: {execution:?}" + ), + "disabled" => assert!( + matches!( + execution, + StateTransitionExecutionResult::UnpaidConsensusError(_) + ), + "{execution:?}" + ), + "expired" => assert!( + matches!(execution, StateTransitionExecutionResult::UnpaidConsensusError(error) if error.code() == 20014), + "{execution:?}" + ), + _ => { + assert!( + matches!(execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } if error.code() == 20015), + "{case}: {execution:?}" + ); + assert_eq!( + platform + .drive + .fetch_identity_contract_nonce( + identity.id().to_buffer(), + dashpay.id().to_buffer(), + true, + Some(&tx), + version + ) + .unwrap(), + Some((1u64 << 40) | 2) + ); + let balance = platform + .drive + .fetch_identity_balance(identity.id().to_buffer(), Some(&tx), version) + .unwrap() + .unwrap(); + assert!( + balance < identity.balance(), + "invalid batch must pay validation fees" + ); + let replay = platform + .platform + .process_raw_state_transitions( + &vec![bytes], + &state, + &block_info, + &tx, + version, + false, + None, + ) + .unwrap(); + assert!( + matches!( + &replay.execution_results()[0], + StateTransitionExecutionResult::UnpaidConsensusError(_) + ), + "replay must not charge twice" + ); + } + } + } +} + +#[tokio::test] +async fn should_require_token_payment_permission_including_external_fee_tokens() { + use crate::execution::validation::state_transition::tests::{ + create_card_game_external_token_contract_with_owner_identity, + create_token_contract_with_owner_identity, + }; + use dpp::data_contract::TokenConfiguration; + use dpp::tokens::{ + gas_fees_paid_by::GasFeesPaidBy, + token_payment_info::{v0::TokenPaymentInfoV0, TokenPaymentInfo}, + }; + for allow_payment in [false, true] { + let version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (owner, _, _) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + let (token_contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + None::, + None, + None, + None, + version, + ); + let contract = create_card_game_external_token_contract_with_owner_identity( + &mut platform, + token_contract.id(), + 0, + 5, + GasFeesPaidBy::DocumentOwner, + owner.id(), + version, + ); + let (mut identity, signer, signing_key) = + setup_identity_without_adding_it(234, dash_to_credits!(0.1)); + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + // Intentionally does not include the external token's issuing contract. + contracts: vec![ContractScope { + id: contract.id(), + document_types: Some(vec!["card".into()]), + }], + permissions: permissions::DOCUMENT_CREATE + | if allow_payment { + permissions::DOCUMENT_TOKEN_PAYMENT + } else { + 0 + }, + expires_at: None, + }); + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(ContractBounds::Scoped(scope)); + identity.add_public_key(stored_key); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .unwrap(); + add_tokens_to_identity(&platform, token_id.into(), identity.id(), 15); + let card = contract.document_type_for_name("card").unwrap(); + let mut rng = StdRng::seed_from_u64(433); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = card + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + version, + ) + .unwrap(); + document.set("attack", 4.into()); + document.set("defense", 7.into()); + let batch = BatchTransition::new_document_creation_transition_from_document( + document, + card, + entropy.0, + &signing_key, + 2, + 0, + Some(TokenPaymentInfo::V0(TokenPaymentInfoV0 { + payment_token_contract_id: Some(token_contract.id()), + token_contract_position: 0, + minimum_token_cost: None, + maximum_token_cost: Some(5), + gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + })), + &signer, + version, + None, + ) + .await + .unwrap(); + let tx = platform.drive.grove.start_transaction(); + let state = platform.state.load(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![batch.serialize_to_bytes().unwrap()], + &state, + &BlockInfo::default(), + &tx, + version, + false, + None, + ) + .unwrap(); + let execution = &result.execution_results()[0]; + if allow_payment { + assert!( + matches!( + execution, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ), + "{execution:?}" + ); + } else { + assert!( + matches!(execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } if error.code() == 20015), + "{execution:?}" + ); + } + let remaining = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + Some(&tx), + version, + ) + .unwrap(); + assert_eq!(remaining, Some(if allow_payment { 10 } else { 15 })); + } +} + +#[tokio::test] +async fn should_reject_non_batch_use_even_with_all_scope_permissions() { + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::state_transition::data_contract_create_transition::{ + methods::DataContractCreateTransitionMethodsV0, DataContractCreateTransition, + }; + let version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (mut identity, signer, signing_key) = + setup_identity_without_adding_it(958, dash_to_credits!(0.1)); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(ContractBounds::Scoped(AuthenticationScope::V0( + AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: dashpay.id(), + document_types: None, + }], + permissions: permissions::ALL, + expires_at: None, + }, + ))); + identity.add_public_key(stored_key); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .unwrap(); + let mut contract = dashpay.as_ref().clone(); + contract.set_owner_id(identity.id()); + let mut signer_identity = identity.clone(); + signer_identity.add_public_key(signing_key.clone()); + let transition = DataContractCreateTransition::new_from_data_contract( + contract, + 1, + &signer_identity.into_partial_identity_info(), + signing_key.id(), + &signer, + version, + None, + ) + .await + .unwrap(); + let tx = platform.drive.grove.start_transaction(); + let state = platform.state.load(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![transition.serialize_to_bytes().unwrap()], + &state, + &BlockInfo::default(), + &tx, + version, + false, + None, + ) + .unwrap(); + assert!( + matches!(&result.execution_results()[0], StateTransitionExecutionResult::UnpaidConsensusError(error) if error.code() == 20013), + "{:?}", + result.execution_results() + ); + assert_eq!( + platform + .drive + .fetch_identity_balance(identity.id().to_buffer(), Some(&tx), version) + .unwrap(), + Some(identity.balance()) + ); +} + +#[tokio::test] +async fn should_distinguish_scoped_document_token_fees_from_standalone_token_transfers() { + use crate::execution::validation::state_transition::tests::create_token_contract_with_owner_identity; + use dpp::data_contract::TokenConfiguration; + for allow_transfer in [false, true] { + let version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (owner, _, _) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + None::, + None, + None, + None, + version, + ); + let (mut identity, signer, signing_key) = + setup_identity_without_adding_it(234, dash_to_credits!(0.1)); + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(ContractBounds::Scoped(AuthenticationScope::V0( + AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: contract.id(), + document_types: None, + }], + permissions: permissions::DOCUMENT_TOKEN_PAYMENT + | if allow_transfer { + permissions::TOKEN_TRANSFER + } else { + 0 + }, + expires_at: None, + }, + ))); + identity.add_public_key(stored_key); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .unwrap(); + add_tokens_to_identity(&platform, token_id.into(), identity.id(), 15); + let batch = BatchTransition::new_token_transfer_transition( + token_id, + identity.id(), + contract.id(), + 0, + 5, + owner.id(), + None, + None, + None, + &signing_key, + 2, + 0, + &signer, + version, + None, + ) + .await + .unwrap(); + let tx = platform.drive.grove.start_transaction(); + let state = platform.state.load(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![batch.serialize_to_bytes().unwrap()], + &state, + &BlockInfo::default(), + &tx, + version, + false, + None, + ) + .unwrap(); + let execution = &result.execution_results()[0]; + if allow_transfer { + assert!( + matches!( + execution, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ), + "{execution:?}" + ); + } else { + assert!( + matches!(execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } if error.code() == 20015), + "{execution:?}" + ); + } + assert_eq!( + platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + Some(&tx), + version + ) + .unwrap(), + Some(if allow_transfer { 10 } else { 15 }) + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs index a15e1695c74..19286a0fe9d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs @@ -10,6 +10,7 @@ use crate::error::execution::ExecutionError; use crate::execution::validation::state_transition::identity_create::basic_structure::v0::IdentityCreateStateTransitionBasicStructureValidationV0; use crate::execution::validation::state_transition::identity_create::state::v0::IdentityCreateStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::identity_create::state::v1::IdentityCreateStateTransitionStateValidationV1; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; @@ -163,6 +164,7 @@ pub trait StateTransitionStateValidationForIdentityCreateTransitionV0 { &self, action: IdentityCreateTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; @@ -173,6 +175,7 @@ impl StateTransitionStateValidationForIdentityCreateTransitionV0 for IdentityCre &self, action: IdentityCreateTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -185,9 +188,17 @@ impl StateTransitionStateValidationForIdentityCreateTransitionV0 for IdentityCre .state { 0 => self.validate_state_v0(platform, action, execution_context, tx, platform_version), + 1 => self.validate_state_v1( + platform, + block_info, + action, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity create transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -446,6 +457,159 @@ mod tests { assert_eq!(identity_balance, 99913867460); } + #[tokio::test] + async fn should_create_identity_with_scoped_authentication_key() { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let platform_version = PlatformVersion::latest(); + let platform_config = PlatformConfig { + testing_configs: PlatformTestConfig { + disable_instant_lock_signature_verification: true, + ..Default::default() + }, + ..Default::default() + }; + + let platform = TestPlatformBuilder::new() + .with_config(platform_config) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_state = platform.state.load(); + + let mut signer = SimpleSigner::default(); + + let mut rng = StdRng::seed_from_u64(567); + + let (master_key, master_private_key) = + IdentityPublicKey::random_ecdsa_master_authentication_key( + 0, + Some(58), + platform_version, + ) + .expect("expected to get key pair"); + + signer.add_identity_public_key(master_key.clone(), master_private_key); + + let (mut key, private_key) = + IdentityPublicKey::random_ecdsa_critical_level_authentication_key( + 1, + Some(999), + platform_version, + ) + .expect("expected to get key pair"); + + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractBounds, ContractScope, + }; + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(platform_version) + .unwrap(); + let bounds = ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: dashpay.id(), + document_types: Some(vec!["profile".into()]), + }], + permissions: permissions::DOCUMENT_CREATE, + expires_at: Some(100), + })); + let IdentityPublicKey::V0(ref mut key_v0) = key; + key_v0.contract_bounds = Some(bounds.clone()); + signer.add_identity_public_key(key.clone(), private_key); + + let (_, pk) = ECDSA_SECP256K1 + .random_public_and_private_key_data(&mut rng, platform_version) + .unwrap(); + + let asset_lock_proof = instant_asset_lock_proof_fixture( + Some(PrivateKey::from_byte_array(&pk, Network::Testnet).unwrap()), + None, + ); + + let identifier = asset_lock_proof + .create_identifier() + .expect("expected an identifier"); + + let identity: Identity = IdentityV0 { + id: identifier, + public_keys: BTreeMap::from([(0, master_key.clone()), (1, key.clone())]), + balance: 1000000000, + revision: 0, + } + .into(); + + let identity_create_transition: StateTransition = + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( + &identity, + asset_lock_proof, + pk.as_slice(), + &signer, + &NativeBlsModule, + 0, + platform_version, + ) + .await + .expect("expected an identity create transition"); + + let identity_create_serialized_transition = identity_create_transition + .serialize_to_bytes() + .expect("serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![identity_create_serialized_transition.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_eq!(processing_result.valid_count(), 1); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + let identity_balance = platform + .drive + .fetch_identity_balance(identity.id().into_buffer(), None, platform_version) + .expect("expected to get identity balance") + .expect("expected there to be an identity balance for this identity"); + + assert!(identity_balance > 0); + use drive::drive::identity::key::fetch::IdentityKeysRequest; + let fetched = platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest::new_specific_key_query(&identity.id().to_buffer(), 1), + None, + platform_version, + ) + .unwrap() + .unwrap(); + assert_eq!( + fetched + .loaded_public_keys + .get(&1) + .unwrap() + .contract_bounds(), + Some(&bounds) + ); + } + #[tokio::test] async fn test_identity_create_asset_lock_reuse_after_issue_first_protocol_version() { let platform_version = PlatformVersion::first(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/mod.rs index 9a1925de7fc..420eb16447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/mod.rs @@ -1 +1,3 @@ pub(crate) mod v0; + +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/v1/mod.rs new file mode 100644 index 00000000000..5eba2932946 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/v1/mod.rs @@ -0,0 +1,115 @@ +use crate::execution::validation::state_transition::common::validate_identity_public_key_contract_bounds::validate_identity_public_keys_contract_bounds; +use dpp::block::block_info::BlockInfo; +use crate::error::Error; +use crate::platform_types::platform::PlatformRef; +use crate::rpc::core::CoreRPCLike; + +use dpp::consensus::state::identity::IdentityAlreadyExistsError; + +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::identity_create_transition::accessors::IdentityCreateTransitionAccessorsV0; +use dpp::ProtocolError; + +use dpp::state_transition::identity_create_transition::IdentityCreateTransition; +use dpp::version::PlatformVersion; +use drive::state_transition_action::identity::identity_create::IdentityCreateTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::system::partially_use_asset_lock_action::PartiallyUseAssetLockAction; + +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_create) trait IdentityCreateStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityCreateStateTransitionStateValidationV1 for IdentityCreateTransition { + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + + let identity_id = self.identity_id(); + let balance = + drive.fetch_identity_balance(identity_id.to_buffer(), transaction, platform_version)?; + + // Balance is here to check if the identity does already exist + if balance.is_some() { + // Since the id comes from the state transition this should never be reachable + return Ok(ConsensusValidationResult::new_with_error( + IdentityAlreadyExistsError::new(identity_id.to_owned()).into(), + )); + } + + // Now we should check the state of added keys to make sure there aren't any that already exist + let mut key_state_validation_result = + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys(), + drive, + execution_context, + transaction, + platform_version, + )?; + + key_state_validation_result.add_errors( + validate_identity_public_keys_contract_bounds( + identity_id, + self.public_keys(), + drive, + &block_info.epoch, + block_info.time_ms, + transaction, + execution_context, + platform_version, + )? + .errors, + ); + + if key_state_validation_result.is_valid() { + // We just pass the action that was given to us + Ok(ConsensusValidationResult::new_with_data( + StateTransitionAction::IdentityCreateAction(action), + )) + } else { + // It's not valid, we need to give back the action that partially uses the asset lock + + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .unique_key_already_present; + + let used_credits = penalty + .checked_add(execution_context.fee_cost(platform_version)?.processing_fee) + .ok_or(ProtocolError::Overflow("processing fee overflow error"))?; + + let bump_action = PartiallyUseAssetLockAction::from_identity_create_transition_action( + action, + used_credits, + ); + Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action.into(), + key_state_validation_result.errors, + )) + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/mod.rs index 90d7f68499d..48b4c87f645 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/mod.rs @@ -14,6 +14,7 @@ use std::collections::BTreeMap; use crate::execution::validation::state_transition::identity_create_from_addresses::basic_structure::v0::IdentityCreateFromAddressesStateTransitionBasicStructureValidationV0; use crate::execution::validation::state_transition::identity_create_from_addresses::state::v0::IdentityCreateFromAddressesStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::identity_create_from_addresses::state::v1::IdentityCreateFromAddressesStateTransitionStateValidationV1; use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; use crate::platform_types::platform::PlatformRef; @@ -155,6 +156,7 @@ pub trait StateTransitionStateValidationForIdentityCreateFromAddressesTransition &self, action: IdentityCreateFromAddressesTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; @@ -167,6 +169,7 @@ impl StateTransitionStateValidationForIdentityCreateFromAddressesTransitionV0 &self, action: IdentityCreateFromAddressesTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -179,9 +182,17 @@ impl StateTransitionStateValidationForIdentityCreateFromAddressesTransitionV0 .state { 0 => self.validate_state_v0(platform, action, execution_context, tx, platform_version), + 1 => self.validate_state_v1( + platform, + block_info, + action, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity create from addresses transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/mod.rs index 9a1925de7fc..420eb16447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/mod.rs @@ -1 +1,3 @@ pub(crate) mod v0; + +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs new file mode 100644 index 00000000000..649dfd7ab8c --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs @@ -0,0 +1,117 @@ +use crate::execution::validation::state_transition::common::validate_identity_public_key_contract_bounds::validate_identity_public_keys_contract_bounds; +use dpp::block::block_info::BlockInfo; +use crate::error::Error; +use crate::platform_types::platform::PlatformRef; + +use dpp::consensus::state::identity::IdentityAlreadyExistsError; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::identity_create_from_addresses_transition::accessors::IdentityCreateFromAddressesTransitionAccessorsV0; +use dpp::ProtocolError; + +use dpp::state_transition::identity_create_from_addresses_transition::IdentityCreateFromAddressesTransition; +use dpp::state_transition::StateTransitionIdentityIdFromInputs; +use dpp::version::PlatformVersion; +use drive::state_transition_action::identity::identity_create_from_addresses::IdentityCreateFromAddressesTransitionAction; +use drive::state_transition_action::StateTransitionAction; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::system::bump_address_input_nonces_action::BumpAddressInputNoncesAction; +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_create_from_addresses) trait IdentityCreateFromAddressesStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateFromAddressesTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; + + +} + +impl IdentityCreateFromAddressesStateTransitionStateValidationV1 + for IdentityCreateFromAddressesTransition +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateFromAddressesTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + + let identity_id = self.identity_id_from_inputs()?; + let balance = + drive.fetch_identity_balance(identity_id.to_buffer(), transaction, platform_version)?; + + // Balance is here to check if the identity does already exist + if balance.is_some() { + // Since the id comes from the state transition this should never be reachable + return Ok(ConsensusValidationResult::new_with_error( + IdentityAlreadyExistsError::new(identity_id.to_owned()).into(), + )); + } + + // Now we should check the state of added keys to make sure there aren't any that already exist + let mut key_state_validation_result = + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys(), + drive, + execution_context, + transaction, + platform_version, + )?; + + key_state_validation_result.add_errors( + validate_identity_public_keys_contract_bounds( + identity_id, + self.public_keys(), + drive, + &block_info.epoch, + block_info.time_ms, + transaction, + execution_context, + platform_version, + )? + .errors, + ); + + if key_state_validation_result.is_valid() { + // We just pass the action that was given to us + Ok(ConsensusValidationResult::new_with_data( + StateTransitionAction::IdentityCreateFromAddressesAction(action), + )) + } else { + // It's not valid, we need to give back the action that partially uses the asset lock + + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .unique_key_already_present; + + let used_credits = penalty + .checked_add(execution_context.fee_cost(platform_version)?.processing_fee) + .ok_or(ProtocolError::Overflow("processing fee overflow error"))?; + + let bump_action = + BumpAddressInputNoncesAction::from_identity_create_from_addresses_transition_action( + action, + used_credits, + ); + Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action.into(), + key_state_validation_result.errors, + )) + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs index c295362bbdc..bb37a0084e0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs @@ -14,6 +14,7 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::identity_create_from_shielded_pool::state::v0::IdentityCreateFromShieldedPoolStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::identity_create_from_shielded_pool::state::v1::IdentityCreateFromShieldedPoolStateTransitionStateValidationV1; use crate::execution::validation::state_transition::identity_create_from_shielded_pool::transform_into_action::v0::IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0; use crate::platform_types::platform::PlatformRef; use crate::platform_types::platform_state::PlatformStateV0Methods; @@ -83,6 +84,7 @@ pub trait StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransit &self, action: IdentityCreateFromShieldedPoolTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; @@ -95,6 +97,7 @@ impl StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransitionV0 &self, action: IdentityCreateFromShieldedPoolTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -107,9 +110,17 @@ impl StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransitionV0 .state { 0 => self.validate_state_v0(platform, action, execution_context, tx, platform_version), + 1 => self.validate_state_v1( + platform, + block_info, + action, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity create from shielded pool transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs index 9a1925de7fc..420eb16447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs @@ -1 +1,3 @@ pub(crate) mod v0; + +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v1/mod.rs new file mode 100644 index 00000000000..c05fdf2ad53 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v1/mod.rs @@ -0,0 +1,149 @@ +use crate::execution::validation::state_transition::common::validate_identity_public_key_contract_bounds::validate_identity_public_keys_contract_bounds; +use dpp::block::block_info::BlockInfo; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; +use crate::platform_types::platform::PlatformRef; +use dpp::consensus::state::identity::IdentityAlreadyExistsError; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; +use drive::state_transition_action::shielded::unshield::v0::UnshieldTransitionActionV0; +use drive::state_transition_action::shielded::unshield::UnshieldTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_create_from_shielded_pool) trait IdentityCreateFromShieldedPoolStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateFromShieldedPoolTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityCreateFromShieldedPoolStateTransitionStateValidationV1 + for IdentityCreateFromShieldedPoolTransition +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateFromShieldedPoolTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + + // 1. The new identity must not already exist. The id is `double_sha256(sorted nullifiers)` — + // collision-resistant and derived from single-use spend tags — so this is practically + // unreachable, but check explicitly to return a clean consensus rejection. There is no + // chargeable fallback for this case (it cannot be triggered by a relayer choosing a + // colliding id), so a failure is a plain free rejection, mirroring the identity-exists + // check in `IdentityCreateFromAddresses`'s `validate_state`. + let identity_id = derive_identity_id_from_actions(self.actions()); + if drive + .fetch_identity_balance(identity_id.to_buffer(), transaction, platform_version)? + .is_some() + { + // Since the id comes entirely from the spend nullifiers this should never be reachable. + return Ok(ConsensusValidationResult::new_with_error( + IdentityAlreadyExistsError::new(identity_id).into(), + )); + } + + // 2. None of the new identity's public-key hashes may already be registered to another + // identity (platform enforces globally-unique key hashes for unique key types). Unlike the + // identity-exists check above, this CAN be triggered by an attacker re-using a victim's + // public-key hash, so it gets a chargeable fallback instead of a free rejection: on + // failure the spend is still final and the value is credited to + // `send_to_address_on_creation_failure` minus a penalty. This is topologically identical + // to an `Unshield` (pool -> address minus fee), so we reuse `UnshieldTransitionAction` + // wholesale (its converter, `PaidFromShieldedPool` execution event, and conservation). + let mut key_state_validation_result = + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys(), + drive, + execution_context, + transaction, + platform_version, + )?; + + key_state_validation_result.add_errors( + validate_identity_public_keys_contract_bounds( + identity_id, + self.public_keys(), + drive, + &block_info.epoch, + block_info.time_ms, + transaction, + execution_context, + platform_version, + )? + .errors, + ); + + if key_state_validation_result.is_valid() { + // We just pass the success action that was built by `transform_into_action`. + Ok(ConsensusValidationResult::new_with_data( + StateTransitionAction::IdentityCreateFromShieldedPoolAction(action), + )) + } else { + // A key-state validation failure: finalize the spend and credit the fallback address minus a + // penalty. The penalty is the flat `unique_key_already_present` amount plus the metered + // processing fee accumulated so far (like `IdentityCreateFromAddresses`'s + // `BumpAddressInputNonces` penalty) PLUS the flat shielded compute fee + // (`compute_shielded_verification_fee`): the proposer ran the same Halo 2 verification on + // the failure path that the success path charges via `additional_fixed_fee_cost`, so the + // penalty floor must cover it too (fee parity with the success / other shielded paths). We + // then CAP it at the denomination so the Unshield converter's `amount.checked_sub(fee)` + // cannot underflow (a net-zero credit is the worst case: the whole spend is consumed by + // the penalty and flows to the fee pools). + let denomination = action.denomination(); + let compute_fee = dpp::shielded::compute_shielded_verification_fee( + action.notes().len(), + platform_version, + )?; + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .unique_key_already_present + .checked_add(execution_context.fee_cost(platform_version)?.processing_fee) + .and_then(|v| v.checked_add(compute_fee)) + .ok_or(ProtocolError::Overflow( + "identity create from shielded pool failure penalty overflow", + ))? + .min(denomination); + + let failure_action = UnshieldTransitionAction::V0(UnshieldTransitionActionV0 { + output_address: *self.send_to_address_on_creation_failure(), + amount: denomination, + notes: action.notes().to_vec(), + anchor: *action.anchor(), + fee_amount: penalty, + current_total_balance: action.current_total_balance(), + // This is the chargeable failure of an identity create: the `PaidFromShieldedPool` + // execution event reads this flag to apply its ops despite the attached validation + // errors (so the apply-despite-errors path is type-enforced, not comment-enforced). + chargeable_failure: true, + }); + + Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::UnshieldAction(failure_action), + key_state_validation_result.errors, + )) + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index 8938fb57d54..3c6e08fa081 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -119,6 +119,135 @@ fn build_success_action( } } +#[test] +fn should_validate_scoped_keys_through_shielded_creation_dispatch() { + use super::StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransitionV0; + use dpp::consensus::codes::ErrorWithCode; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::identifier::Identifier; + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractBounds, ContractScope, + }; + + let version = PlatformVersion::latest(); + let platform = setup_platform(); + set_pool_total_balance(&platform, DENOMINATION * 10); + insert_anchor_into_state(&platform, &ANCHOR); + insert_dummy_encrypted_notes( + &platform, + version + .drive_abci + .validation_and_processing + .event_constants + .minimum_pool_notes_for_outgoing + .max(1), + ); + let contract = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let (valid_master, _) = + IdentityPublicKey::random_ecdsa_master_authentication_key(0, Some(31), version).unwrap(); + let state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + + for (case, id, document_type, time_ms, error_code) in [ + ("valid", contract.id(), "contactRequest", 99, None), + ("expired", contract.id(), "contactRequest", 100, Some(10535)), + ( + "unknown contract", + Identifier::from([0x71; 32]), + "contactRequest", + 99, + Some(10400), + ), + ( + "unknown document", + contract.id(), + "missing", + 99, + Some(10406), + ), + ] { + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id, + document_types: Some(vec![document_type.into()]), + }], + permissions: permissions::DOCUMENT_CREATE, + expires_at: Some(100), + }); + let key = IdentityPublicKeyInCreationV0 { + id: 1, + key_type: KeyType::ECDSA_HASH160, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: Some(ContractBounds::Scoped(scope)), + data: vec![0x72; 20].into(), + read_only: false, + signature: Default::default(), + }; + let st = transition( + vec![valid_master.clone().into(), key.into()], + vec![action(30), action(31)], + ); + let mut context = + StateTransitionExecutionContext::default_for_platform_version(version).unwrap(); + let success = build_success_action(&platform, &st, &mut context, version); + let expected_notes = success.notes().to_vec(); + let block_info = BlockInfo { + time_ms, + ..Default::default() + }; + // Exercise the public version dispatcher, not a hard-coded v0/v1 implementation. + let result = st + .validate_state_for_identity_create_from_shielded_pool_transition( + success, + &platform_ref, + &block_info, + &mut context, + None, + ) + .unwrap(); + if let Some(error_code) = error_code { + assert_eq!(result.errors.len(), 1, "{case}: {:?}", result.errors); + assert_eq!(result.errors[0].code(), error_code); + let StateTransitionAction::UnshieldAction(fallback) = result.into_data().unwrap() + else { + panic!( + "{case}: invalid bounds must finalize the spend through the charged fallback" + ); + }; + assert!(fallback.chargeable_failure(), "{case}"); + assert_eq!(fallback.output_address(), &FALLBACK_ADDRESS); + assert_eq!(fallback.amount(), DENOMINATION); + assert_eq!(fallback.notes().len(), expected_notes.len()); + for (actual, expected) in fallback.notes().iter().zip(&expected_notes) { + assert_eq!(actual.nullifier, expected.nullifier); + assert_eq!(actual.cmx, expected.cmx); + assert_eq!(actual.cv_net, expected.cv_net); + assert_eq!(actual.encrypted_note, expected.encrypted_note); + } + assert_eq!(fallback.anchor(), &ANCHOR); + assert!(fallback.fee_amount() > 0 && fallback.fee_amount() < DENOMINATION); + } else { + assert!(result.is_valid(), "{:?}", result.errors); + assert_matches!( + result.into_data().unwrap(), + StateTransitionAction::IdentityCreateFromShieldedPoolAction(_) + ); + } + } +} + #[test] fn validate_state_rejects_when_identity_already_exists_at_derived_id() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs index b07b24b12a8..701d2ec5277 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs @@ -25,6 +25,7 @@ use crate::rpc::core::CoreRPCLike; use crate::execution::validation::state_transition::identity_update::basic_structure::v0::IdentityUpdateStateTransitionStructureValidationV0; use crate::execution::validation::state_transition::identity_update::state::v0::IdentityUpdateStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::identity_update::state::v1::IdentityUpdateStateTransitionStateValidationV1; use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; use crate::execution::validation::state_transition::processor::state::StateTransitionStateValidation; use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; @@ -95,8 +96,8 @@ impl StateTransitionStateValidation for IdentityUpdateTransition { _action: Option, platform: &PlatformRef, _validation_mode: ValidationMode, - _block_info: &BlockInfo, - _execution_context: &mut StateTransitionExecutionContext, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { let platform_version = platform.state.current_platform_version()?; @@ -108,9 +109,16 @@ impl StateTransitionStateValidation for IdentityUpdateTransition { .state { 0 => self.validate_state_v0(platform, tx, platform_version), + 1 => self.validate_state_v1( + platform, + block_info, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity update transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -376,6 +384,351 @@ mod tests { }; } + #[tokio::test] + async fn should_register_scoped_authentication_key_and_preserve_proof_metadata() { + let platform_version = PlatformVersion::latest(); + + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let (identity, signer, _, key) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractScope, + }; + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(platform_version) + .unwrap(); + let bounds = ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: dashpay.id(), + document_types: None, + }], + permissions: permissions::DOCUMENT_CREATE | permissions::DOCUMENT_TOKEN_PAYMENT, + expires_at: Some(100), + })); + let platform_state = platform.state.load(); + + let secp = Secp256k1::new(); + + let mut rng = StdRng::seed_from_u64(292); + + let new_key_pair = Keypair::new(&secp, &mut rng); + + let mut new_key = IdentityPublicKeyInCreationV0 { + id: 2, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: ECDSA_SECP256K1, + read_only: false, + data: new_key_pair.public_key().serialize().to_vec().into(), + signature: Default::default(), + contract_bounds: Some(bounds.clone()), + }; + + let update_transition: IdentityUpdateTransition = IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![IdentityPublicKeyInCreation::V0(new_key.clone())], + disable_public_keys: vec![], + user_fee_increase: 0, + signature_public_key_id: key.id(), + signature: Default::default(), + } + .into(); + + let update_transition: StateTransition = update_transition.into(); + + let signable_bytes = update_transition + .signable_bytes() + .expect("expected signable bytes"); + + let secret = new_key_pair.secret_key(); + let signature = + signer::sign(&signable_bytes, &secret.secret_bytes()).expect("expected to sign"); + + new_key.signature = signature.to_vec().into(); + + let update_transition: IdentityUpdateTransition = IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![IdentityPublicKeyInCreation::V0(new_key)], + disable_public_keys: vec![], + user_fee_increase: 0, + signature_public_key_id: key.id(), + signature: Default::default(), + } + .into(); + + let mut update_transition: StateTransition = update_transition.into(); + + update_transition.set_signature( + signer + .sign(&key, signable_bytes.as_slice()) + .await + .expect("expected to sign"), + ); + + let update_transition_bytes = update_transition + .serialize_to_bytes() + .expect("expected to serialize"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![update_transition_bytes.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + true, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let proof_result = platform + .platform + .drive + .prove_state_transition(&update_transition, None, platform_version) + .map_err(|e| e.to_string()) + .expect("expected to create proof"); + + if let Some(proof_error) = proof_result.first_error() { + panic!("proof_result is not valid with error {}", proof_error); + } + + let proof_data = proof_result + .into_data() + .map_err(|e| e.to_string()) + .expect("expected to get proof data"); + + let (_, verification_result) = Drive::verify_state_transition_was_executed_with_proof( + &update_transition, + &BlockInfo::default(), + &proof_data, + &|_id: &Identifier| Ok(None), + platform_version, + ) + .map(|(root_hash, outcome)| (root_hash, outcome.into_result())) + .map_err(|e| e.to_string()) + .expect("expected to verify state transition"); + + let StateTransitionProofResult::VerifiedPartialIdentity(document) = verification_result + else { + panic!( + "verification_result expected partial identity, but got: {:?}", + verification_result + ); + }; + assert_eq!( + document + .loaded_public_keys + .get(&2) + .unwrap() + .contract_bounds(), + Some(&bounds) + ); + use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyKindRequestType, KeyRequestType, + }; + let indexed = platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type: KeyRequestType::ContractBoundKey( + dashpay.id().to_buffer(), + Purpose::AUTHENTICATION, + KeyKindRequestType::CurrentKeyOfKindRequest, + ), + limit: None, + offset: None, + }, + None, + platform_version, + ) + .unwrap() + .unwrap(); + assert_eq!( + indexed + .loaded_public_keys + .get(&2) + .unwrap() + .contract_bounds(), + Some(&bounds) + ); + } + + #[tokio::test] + async fn should_refresh_every_scoped_key_reference_after_revocation() { + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractScope, + }; + use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyKindRequestType, KeyRequestType, + }; + let version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (mut identity, mut signer, _, master) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let dpns = platform + .drive + .cache + .system_data_contracts + .load_dpns(version) + .unwrap(); + let mut contracts = vec![ + ContractScope { + id: dashpay.id(), + document_types: None, + }, + ContractScope { + id: dpns.id(), + document_types: Some(vec!["preorder".into()]), + }, + ]; + contracts.sort_by_key(|entry| entry.id); + let bounds = ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts, + permissions: permissions::DOCUMENT_CREATE, + expires_at: None, + })); + let key = setup_add_key_to_identity( + &mut platform, + &mut identity, + &mut signer, + 4, + 2, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_SECP256K1, + Some(bounds.clone()), + ); + let mut update: StateTransition = + IdentityUpdateTransition::from(IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![], + disable_public_keys: vec![key.id()], + user_fee_increase: 0, + signature_public_key_id: master.id(), + signature: Default::default(), + }) + .into(); + update.set_signature( + signer + .sign(&master, &update.signable_bytes().unwrap()) + .await + .unwrap(), + ); + let block = BlockInfo { + time_ms: 1001, + ..Default::default() + }; + let transaction = platform.drive.grove.start_transaction(); + let state = platform.state.load(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![update.serialize_to_bytes().unwrap()], + &state, + &block, + &transaction, + version, + true, + None, + ) + .unwrap(); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .unwrap(); + + // Both current-key references must resolve to the updated key, not its old hash. + for request_type in [ + KeyRequestType::ContractBoundKey( + dashpay.id().to_buffer(), + Purpose::AUTHENTICATION, + KeyKindRequestType::CurrentKeyOfKindRequest, + ), + KeyRequestType::ContractDocumentTypeBoundKey( + dpns.id().to_buffer(), + "preorder".into(), + Purpose::AUTHENTICATION, + KeyKindRequestType::CurrentKeyOfKindRequest, + ), + ] { + let fetched = platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type, + limit: None, + offset: None, + }, + None, + version, + ) + .unwrap() + .unwrap(); + let refreshed = fetched + .loaded_public_keys + .get(&key.id()) + .expect("scoped key reference"); + assert_eq!(refreshed.disabled_at(), Some(block.time_ms)); + assert_eq!(refreshed.contract_bounds(), Some(&bounds)); + } + assert!( + platform + .drive + .grove + .visualize_verify_grovedb(None, true, false, &version.drive.grove_version,) + .unwrap() + .is_empty(), + "revocation must leave no stale GroveDB references" + ); + } + #[tokio::test] async fn test_identity_update_that_disables_an_encryption_key() { let platform_config = PlatformConfig { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/mod.rs index 9a1925de7fc..420eb16447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/mod.rs @@ -1 +1,3 @@ pub(crate) mod v0; + +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs index 92f7c7216d3..fbd5029d131 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs @@ -102,6 +102,7 @@ impl IdentityUpdateStateTransitionStateValidationV0 for IdentityUpdateTransition self.public_keys_to_add(), drive, platform.state.last_committed_block_epoch_ref(), + 0, tx, &mut state_transition_execution_context, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v1/mod.rs new file mode 100644 index 00000000000..ed05f65c20f --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v1/mod.rs @@ -0,0 +1,163 @@ +use super::v0::IdentityUpdateStateTransitionStateValidationV0; +use crate::error::Error; +use dpp::block::block_info::BlockInfo; + +use crate::platform_types::platform::PlatformRef; +use crate::rpc::core::CoreRPCLike; + +use dpp::prelude::ConsensusValidationResult; + +use dpp::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; +use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; +use dpp::version::PlatformVersion; +use drive::state_transition_action::StateTransitionAction; + +use drive::grovedb::TransactionArg; +use drive::state_transition_action::system::bump_identity_nonce_action::BumpIdentityNonceAction; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::common::validate_identity_public_key_contract_bounds::validate_identity_public_keys_contract_bounds; +use crate::execution::validation::state_transition::common::validate_identity_public_key_ids_dont_exist_in_state::validate_identity_public_key_ids_dont_exist_in_state; +use crate::execution::validation::state_transition::common::validate_identity_public_key_ids_exist_in_state::validate_identity_public_key_ids_exist_in_state; +use crate::execution::validation::state_transition::common::validate_not_disabling_last_master_key::validate_master_key_uniqueness; +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_update) trait IdentityUpdateStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + state_transition_execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityUpdateStateTransitionStateValidationV1 for IdentityUpdateTransition { + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + state_transition_execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + let mut validation_result = ConsensusValidationResult::::default(); + + // Now we should check the state of added keys to make sure there aren't any that already exist + validation_result.add_errors( + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys_to_add(), + drive, + state_transition_execution_context, + tx, + platform_version, + )? + .errors, + ); + + if !validation_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result.errors, + )); + } + + validation_result.add_errors( + validate_identity_public_key_ids_dont_exist_in_state( + self.identity_id(), + self.public_keys_to_add(), + drive, + tx, + state_transition_execution_context, + platform_version, + )? + .errors, + ); + + if !validation_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result.errors, + )); + } + + // Now we should check to make sure any keys that are added are valid for the contract + // bounds they refer to + validation_result.add_errors( + validate_identity_public_keys_contract_bounds( + self.identity_id(), + self.public_keys_to_add(), + drive, + &block_info.epoch, + block_info.time_ms, + tx, + state_transition_execution_context, + platform_version, + )? + .errors, + ); + + if !validation_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result.errors, + )); + } + + if !self.public_key_ids_to_disable().is_empty() { + let validation_result_and_keys_to_disable = + validate_identity_public_key_ids_exist_in_state( + self.identity_id(), + self.public_key_ids_to_disable(), + drive, + state_transition_execution_context, + tx, + platform_version, + )?; + // We need to validate that all keys removed existed + if !validation_result_and_keys_to_disable.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result_and_keys_to_disable.errors, + )); + } + + let keys_to_disable = validation_result_and_keys_to_disable.into_data()?; + + let validation_result = validate_master_key_uniqueness( + self.public_keys_to_add(), + keys_to_disable.as_slice(), + platform_version, + )?; + if !validation_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result.errors, + )); + } + } + self.transform_into_action_v0() + } +} diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v0/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v0/mod.rs index a8b2a949f83..4abca31402b 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v0/mod.rs @@ -59,7 +59,7 @@ impl Drive { self.add_contract_info_operations_v0( identity_id, epoch, - vec![contract_apply_info], + contract_apply_info, estimated_costs_only_with_layer_info, transaction, drive_operations, @@ -245,6 +245,9 @@ impl Drive { let storage_key_requirements = contract .as_ref() .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } Purpose::ENCRYPTION => { let encryption_storage_key_requirements = contract .contract @@ -319,7 +322,17 @@ impl Drive { self.batch_insert( PathKeyElementInfo::<0>::PathKeyElement(( - identity_contract_info_group_keys_path_vec(&identity_id, &root_id), + if purpose == Purpose::AUTHENTICATION { + // Scoped authentication's current-key reference belongs beside + // its key IDs, under the purpose subtree. Keep legacy paths frozen. + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &root_id, + purpose, + ) + } else { + identity_contract_info_group_keys_path_vec(&identity_id, &root_id) + }, vec![], Element::Reference(sibling_ref_type_path, Some(2), None), )), @@ -431,6 +444,9 @@ impl Drive { let storage_key_requirements = contract .as_ref() .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } Purpose::ENCRYPTION => { let document_type = contract .contract diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs index 27df5d4a0da..bcab1161423 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs @@ -68,8 +68,41 @@ impl IdentityDataContractKeyApplyInfo { transaction: TransactionArg, drive_operations: &mut Vec, platform_version: &PlatformVersion, - ) -> Result { - let contract_id = contract_bounds.identifier().to_buffer(); + ) -> Result, Error> { + if let ContractBounds::Scoped(scope) = contract_bounds { + return Ok(scope + .contracts() + .iter() + .map(|entry| { + let contract_id = entry.id; + let document_type_keys = entry + .document_types + .as_ref() + .map(|names| { + names + .iter() + .map(|name| (name.clone(), vec![(key_id, purpose)])) + .collect() + }) + .unwrap_or_default(); + ContractBased { + contract_id, + document_type_keys, + contract_keys: if entry.document_types.is_none() { + vec![(key_id, purpose)] + } else { + vec![] + }, + } + }) + .collect()); + } + let contract_id = contract_bounds + .identifier() + .ok_or(Error::Identity(IdentityError::IdentityKeyBoundsError( + "expected single contract bounds", + )))? + .to_buffer(); // we are getting with fetch info to add the cost to the drive operations let maybe_contract_fetch_info = drive.get_contract_with_fetch_info_and_add_to_operations( contract_id, @@ -86,28 +119,26 @@ impl IdentityDataContractKeyApplyInfo { }; let contract = &contract_fetch_info.contract; match contract_bounds { - ContractBounds::SingleContract { .. } => Ok(ContractBased { + ContractBounds::SingleContract { .. } => Ok(vec![ContractBased { contract_id: contract.id(), document_type_keys: Default::default(), contract_keys: vec![(key_id, purpose)], - }), + }]), ContractBounds::SingleContractDocumentType { document_type_name: document_type, .. } => { let document_type = contract.document_type_for_name(document_type)?; - Ok(ContractBased { + Ok(vec![ContractBased { contract_id: contract.id(), document_type_keys: BTreeMap::from([( document_type.name().clone(), vec![(key_id, purpose)], )]), contract_keys: vec![], - }) - } // ContractBounds::MultipleContractsOfSameOwner { .. } => Ok(ContractFamilyBased { - // contracts_owner_id: contract.owner_id(), - // family_keys: vec![key_id], - // }), + }]) + } + ContractBounds::Scoped(_) => unreachable!("handled above"), } } } diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs index eddd0c9f029..c23fcbb62a0 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs @@ -53,7 +53,7 @@ impl Drive { self.refresh_contract_info_operations_v0( identity_id, epoch, - vec![contract_apply_info], + contract_apply_info, estimated_costs_only_with_layer_info, transaction, drive_operations, @@ -145,6 +145,9 @@ impl Drive { let storage_key_requirements = contract .as_ref() .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } Purpose::ENCRYPTION => { let encryption_storage_key_requirements = contract .contract @@ -203,7 +206,17 @@ impl Drive { let sibling_ref_type_path = SiblingReference(key_id_bytes); self.batch_refresh_reference( - identity_contract_info_group_keys_path_vec(&identity_id, &root_id), + if purpose == Purpose::AUTHENTICATION { + // Scoped authentication's current-key reference belongs beside + // its key IDs, under the purpose subtree. Keep legacy paths frozen. + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &root_id, + purpose, + ) + } else { + identity_contract_info_group_keys_path_vec(&identity_id, &root_id) + }, vec![], Element::Reference(sibling_ref_type_path, Some(2), None), true, @@ -260,6 +273,9 @@ impl Drive { let storage_key_requirements = contract .as_ref() .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } Purpose::ENCRYPTION => { let document_type = contract .contract diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs index e2cc9994d40..42ebdb98760 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs @@ -2,11 +2,12 @@ use crate::version::dpp_versions::dpp_method_versions::DPPMethodVersions; /// DPP method versions 3. Introduced in protocol v14: `daily_withdrawal_limit` 1 → 2 replaces the /// flat daily withdrawal limit with a percentage of the total credits Platform held a day ago -/// (`SystemLimits::daily_withdrawal_limit_percent`). Everything else matches V2. +/// (`SystemLimits::daily_withdrawal_limit_percent`). `shielded_extra_sighash_data` 0 → 1 +/// binds contract-scoped authentication keys in shielded identity creation. Everything else matches V2. pub const DPP_METHOD_VERSIONS_V3: DPPMethodVersions = DPPMethodVersions { epoch_core_reward_credits_for_distribution: 0, daily_withdrawal_limit: 2, deduct_fee_from_outputs_or_remaining_balance_of_inputs: 0, compute_minimum_shielded_fee: 0, - shielded_extra_sighash_data: 0, + shielded_extra_sighash_data: 1, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs index ad2a16e431e..fe703b81fd6 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs @@ -1,6 +1,7 @@ use versioned_feature_core::FeatureVersion; pub mod v1; +pub mod v2; #[derive(Clone, Debug, Default)] pub struct DPPStateTransitionMethodVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs new file mode 100644 index 00000000000..2f53cf9bfd6 --- /dev/null +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs @@ -0,0 +1,15 @@ +use crate::version::dpp_versions::dpp_state_transition_method_versions::{ + DPPStateTransitionMethodVersions, PublicKeyInCreationMethodVersions, +}; + +pub const STATE_TRANSITION_METHOD_VERSIONS_V2: DPPStateTransitionMethodVersions = + DPPStateTransitionMethodVersions { + public_key_in_creation_methods: PublicKeyInCreationMethodVersions { + from_public_key_signed_with_private_key: 0, + from_public_key_signed_external: 0, + hash: 0, + duplicated_key_ids_witness: 0, + duplicated_keys_witness: 0, + validate_identity_public_keys_structure: 1, + }, + }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 638032688c7..c818e53476d 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -22,10 +22,10 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = fetch_asset_lock_transaction_output_sync: 0, verify_asset_lock_is_not_spent_and_has_enough_balance: 0, }, - validate_identity_public_key_contract_bounds: 1, + validate_identity_public_key_contract_bounds: 2, validate_identity_public_key_ids_dont_exist_in_state: 0, validate_identity_public_key_ids_exist_in_state: 0, - validate_state_transition_identity_signed: 0, + validate_state_transition_identity_signed: 1, validate_unique_identity_public_key_hashes_in_state: 1, validate_master_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, @@ -37,7 +37,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: Some(0), identity_signatures: Some(0), nonce: None, - state: 0, + state: 1, transform_into_action: 0, }, identity_update_state_transition: DriveAbciStateTransitionValidationVersion { @@ -45,7 +45,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: Some(0), identity_signatures: Some(0), nonce: Some(0), - state: 0, + state: 1, transform_into_action: 0, }, identity_top_up_state_transition: DriveAbciStateTransitionValidationVersion { @@ -113,7 +113,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, - advanced_structure: 0, + advanced_structure: 1, state: 0, revision: 0, // PROTOCOL_VERSION_12 (v3.1 hard fork): batch state transition @@ -228,7 +228,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: Some(0), identity_signatures: Some(0), nonce: Some(0), - state: 0, + state: 1, transform_into_action: 0, }, identity_top_up_from_addresses_state_transition: @@ -310,7 +310,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: None, identity_signatures: None, nonce: None, - state: 0, + state: 1, transform_into_action: 0, }, }, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 7589c485738..bd4885043dd 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -7,7 +7,7 @@ use crate::version::dpp_versions::dpp_factory_versions::v1::DPP_FACTORY_VERSIONS use crate::version::dpp_versions::dpp_identity_versions::v1::IDENTITY_VERSIONS_V1; use crate::version::dpp_versions::dpp_method_versions::v3::DPP_METHOD_VERSIONS_V3; use crate::version::dpp_versions::dpp_state_transition_conversion_versions::v2::STATE_TRANSITION_CONVERSION_VERSIONS_V2; -use crate::version::dpp_versions::dpp_state_transition_method_versions::v1::STATE_TRANSITION_METHOD_VERSIONS_V1; +use crate::version::dpp_versions::dpp_state_transition_method_versions::v2::STATE_TRANSITION_METHOD_VERSIONS_V2; use crate::version::dpp_versions::dpp_state_transition_serialization_versions::v3::STATE_TRANSITION_SERIALIZATION_VERSIONS_V3; use crate::version::dpp_versions::dpp_state_transition_versions::v3::STATE_TRANSITION_VERSIONS_V3; use crate::version::dpp_versions::dpp_token_versions::v2::TOKEN_VERSIONS_V2; @@ -194,6 +194,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// where-clause operator enum gains `IN_TIME_RANGE = 11`, which pre-v14 /// servers reject as an unknown operator rather than misread (the v0 wire /// has no time-range operator at all). +/// Contract-scoped authentication keys activate through key-structure v1, bounds v2, +/// signature authorization v1 and batch advanced-structure v1. Identity creation +/// now validates key bounds, and identity-update state v1 retains validation fees. +/// Shielded identity creation binds scope metadata using extra-sighash-data v1. pub const PLATFORM_V14: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_14, drive: DRIVE_VERSION_V9, // changed: drive document method versions v4 — v2 index walkers (shared-prefix aggregate indexes become insertable) + the detect_ranked_mode slot @@ -210,7 +214,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { validation: DPP_VALIDATION_VERSIONS_V5, state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V3, // changed: the indexOnly delete-by-values kind (documentIndexOnlyDelete) joins the wire state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, - state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V1, + state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, state_transitions: STATE_TRANSITION_VERSIONS_V3, contract_versions: CONTRACT_VERSIONS_V6, // changed: v3 document meta-schema hosts the ranked, refersTo, requiredSince and timeRange keywords document_versions: DOCUMENT_VERSIONS_V4, // changed: document serialization format 3 — the contract version stamp that enables `requiredSince` properties diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index 2d0c526e034..14f8b8716d4 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -281,6 +281,9 @@ pub struct IdentityKeyEntryFFI { // C-string are meaningful. Doc-type string is released by // [`free_identity_key_entry_ffi`]. // + // * `contract_bounds_kind == 3` — versioned AuthenticationScope bytes + // owned by `contract_bounds_scope`, released by the same free helper. + // // Keeping the kind tag inline (vs. always nulling fields) lets // the Swift side switch on a single discriminant without // probing pointer values, matching how the rest of this struct @@ -298,6 +301,10 @@ pub struct IdentityKeyEntryFFI { pub contract_bounds_kind: u8, pub contract_bounds_id: [u8; 32], pub contract_bounds_document_type: *const c_char, + /// Versioned AuthenticationScope bincode bytes for kind 3; null otherwise. + /// Ownership matches the other buffers in this struct. + pub contract_bounds_scope: *const u8, + pub contract_bounds_scope_len: usize, } /// Composite identifier for [`IdentityKeysChangeSet::removed`] entries @@ -345,8 +352,10 @@ pub struct IdentityKeyRemovalFFI { // 169..=175 (padding to 8 for pointer alignment) // 176..=183 contract_bounds_document_type *const c_char // -// Total size = 184, alignment = 8 (from u64 / pointer). -const _: [u8; 184] = [0u8; std::mem::size_of::()]; +// 184..=191 contract_bounds_scope *const u8 +// 192..=199 contract_bounds_scope_len usize +// Total size = 200, alignment = 8 (from u64 / pointer). +const _: [u8; 200] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; // Compile-time guard for `IdentityEntryFFI`. Same rationale as the @@ -689,6 +698,19 @@ impl IdentityKeyEntryFFI { // demoting to `SingleContract` is the closest faithful // representation — the document-type qualifier is the // only thing lost, the contract id is preserved. + let scope_bytes = match entry.public_key.contract_bounds() { + Some(ContractBounds::Scoped(scope)) => { + bincode::encode_to_vec(scope, bincode::config::standard()) + .expect("AuthenticationScope encoding into a Vec is infallible") + } + _ => Vec::new(), + }; + let contract_bounds_scope_len = scope_bytes.len(); + let contract_bounds_scope = if scope_bytes.is_empty() { + ptr::null() + } else { + Box::into_raw(scope_bytes.into_boxed_slice()) as *const u8 + }; let (contract_bounds_kind, contract_bounds_id, contract_bounds_document_type) = match entry.public_key.contract_bounds() { Some(ContractBounds::SingleContract { id }) => (1u8, id.to_buffer(), ptr::null()), @@ -699,6 +721,7 @@ impl IdentityKeyEntryFFI { Ok(c) => (2u8, id.to_buffer(), c.into_raw() as *const c_char), Err(_) => (1u8, id.to_buffer(), ptr::null()), }, + Some(ContractBounds::Scoped(_)) => (3u8, [0u8; 32], ptr::null()), None => (0u8, [0u8; 32], ptr::null()), }; @@ -722,6 +745,8 @@ impl IdentityKeyEntryFFI { contract_bounds_kind, contract_bounds_id, contract_bounds_document_type, + contract_bounds_scope, + contract_bounds_scope_len, } } } @@ -868,6 +893,15 @@ unsafe fn free_optional_c_string(slot: &mut *const c_char) { /// `entry` must have been produced by /// [`IdentityKeyEntryFFI::from_entry`] and not previously freed. pub unsafe fn free_identity_key_entry_ffi(entry: &mut IdentityKeyEntryFFI) { + if !entry.contract_bounds_scope.is_null() { + let raw = std::ptr::slice_from_raw_parts_mut( + entry.contract_bounds_scope as *mut u8, + entry.contract_bounds_scope_len, + ); + drop(unsafe { Box::from_raw(raw) }); + entry.contract_bounds_scope = ptr::null(); + entry.contract_bounds_scope_len = 0; + } if !entry.public_key_data_ptr.is_null() && entry.public_key_data_len > 0 { // Reconstruct the boxed slice we created via `Box::into_raw` // on a `Box<[u8]>`. Using `Vec::from_raw_parts` would over- @@ -1240,6 +1274,75 @@ mod tests { unsafe { free_identity_key_entry_ffi(&mut ffi) }; } + #[test] + fn scoped_key_payload_survives_ffi_projection_and_registration_decode() { + use crate::identity_registration_with_signer::{decode_contract_bounds, IdentityPubkeyFFI}; + use dpp::identity::contract_bounds::{ + AuthenticationScope, AuthenticationScopeV0, ContractBounds, ContractScope, + }; + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ + ContractScope { + id: Identifier::from([1; 32]), + document_types: Some(vec!["post".into()]), + }, + ContractScope { + id: Identifier::from([2; 32]), + document_types: None, + }, + ], + permissions: 65, + expires_at: Some(1_900_000_000_000), + }); + let entry = IdentityKeyEntry { + identity_id: Identifier::from([3; 32]), + key_id: 5, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 5, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: Some(ContractBounds::Scoped(scope.clone())), + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2; 33]), + disabled_at: None, + }), + public_key_hash: [0; 20], + wallet_id: None, + derivation_indices: None, + }; + let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); + assert_eq!(ffi.contract_bounds_kind, 3); + let row = IdentityPubkeyFFI { + key_id: ffi.key_id, + key_type: ffi.key_type, + purpose: ffi.purpose, + security_level: ffi.security_level, + pubkey_bytes: ffi.public_key_data_ptr, + pubkey_len: ffi.public_key_data_len, + read_only: ffi.read_only, + contract_bounds_kind: ffi.contract_bounds_kind, + contract_bounds_id: ptr::null(), + contract_bounds_document_type: ptr::null(), + contract_bounds_scope: ffi.contract_bounds_scope, + contract_bounds_scope_len: ffi.contract_bounds_scope_len, + }; + let decoded = unsafe { decode_contract_bounds(&row, Purpose::AUTHENTICATION, 0, "keys") }; + assert!(matches!(decoded, Ok(Some(ContractBounds::Scoped(value))) if value == scope)); + let invalid_row = IdentityPubkeyFFI { + contract_bounds_scope_len: row.contract_bounds_scope_len - 1, + ..row + }; + assert!(unsafe { + decode_contract_bounds(&invalid_row, Purpose::AUTHENTICATION, 0, "keys") + } + .is_err()); + unsafe { free_identity_key_entry_ffi(&mut ffi) }; + assert!(ffi.contract_bounds_scope.is_null()); + assert_eq!(ffi.contract_bounds_scope_len, 0); + unsafe { free_identity_key_entry_ffi(&mut ffi) }; + } + #[test] fn test_identity_key_entry_ffi_contract_bounds_single_contract() { use dpp::identity::identity_public_key::contract_bounds::ContractBounds; diff --git a/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs b/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs index f6e58733d0f..9c2eb1ccce6 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_registration_with_signer.rs @@ -94,12 +94,10 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// the caller retains ownership. Compressed secp256k1 pubkeys are /// always 33 bytes (`pubkey_len == 33`); BLS would be 48; etc. /// -/// **Contract bounds** — keys may optionally carry a reference to -/// the contract (and optionally a document type) they're allowed to -/// operate within. Consensus accepts unbounded keys for every -/// purpose, including Encryption / Decryption; bounds only become -/// meaningful when the target contract or document type explicitly -/// requires a bounded key. Encoded inline as: +/// **Contract bounds** — legacy variants qualify encryption/decryption +/// keys by contract or document type. Scoped authentication grants encode +/// the contracts, document operations and expiry that consensus enforces. +/// Encoded inline as: /// - `contract_bounds_kind == 0` → no bounds. /// - `contract_bounds_kind == 1` → `SingleContract`. The first /// 32 bytes at `contract_bounds_id` are the contract id; the @@ -108,6 +106,8 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// `contract_bounds_id` is the 32-byte contract id; /// `contract_bounds_document_type` is a NUL-terminated UTF-8 /// document type name. Both must be non-null. +/// - `contract_bounds_kind == 3` → `Scoped`. The scope pointer/length +/// contain the complete versioned AuthenticationScope bincode payload. /// /// All pointers are borrowed for the call duration only — the /// FFI does not retain or free them. @@ -122,11 +122,15 @@ pub struct IdentityPubkeyFFI { pub read_only: bool, /// Discriminant for the contract-bounds union. See struct doc. pub contract_bounds_kind: u8, - /// 32-byte contract id when `contract_bounds_kind != 0`. + /// 32-byte contract id when `contract_bounds_kind` is 1 or 2. pub contract_bounds_id: *const u8, /// NUL-terminated UTF-8 document type name when /// `contract_bounds_kind == 2`. Null otherwise. pub contract_bounds_document_type: *const std::os::raw::c_char, + /// Versioned AuthenticationScope bincode bytes for kind 3; null otherwise. + /// Ownership matches the other buffers in this struct. + pub contract_bounds_scope: *const u8, + pub contract_bounds_scope_len: usize, } /// Decode the optional `contract_bounds_*` payload off an @@ -215,11 +219,33 @@ pub(crate) unsafe fn decode_contract_bounds( document_type_name: doc_type, })) } + 3 => { + if row.contract_bounds_scope.is_null() + || row.contract_bounds_scope_len == 0 + || row.contract_bounds_scope_len + > dpp::identity::contract_bounds::authentication_scope::MAX_SCOPE_BYTES + { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("{field_label}[{row_index}] has an invalid scope buffer"), + )); + } + let bytes = + slice::from_raw_parts(row.contract_bounds_scope, row.contract_bounds_scope_len); + dpp::identity::contract_bounds::AuthenticationScope::from_bytes(bytes) + .map(|scope| Some(ContractBounds::Scoped(scope))) + .map_err(|error| { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + error.to_string(), + ) + }) + } other => Err(PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorInvalidParameter, format!( "{field_label}[{row_index}].contract_bounds_kind = {other} is not a valid \ - discriminant (0=none, 1=SingleContract, 2=SingleContractDocumentType)" + discriminant (0=none, 1=SingleContract, 2=SingleContractDocumentType, 3=Scoped)" ), )), } @@ -839,6 +865,8 @@ mod tests { contract_bounds_kind: 0, contract_bounds_id: ptr::null(), contract_bounds_document_type: ptr::null(), + contract_bounds_scope: std::ptr::null(), + contract_bounds_scope_len: 0, } } diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 4f3d9cae544..5f780a735f2 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -45,10 +45,14 @@ pub struct ParsedIdentityUpdatePublicKeyFFI { pub read_only: bool, pub data_ptr: *mut u8, pub data_len: usize, - /// 0 = none, 1 = SingleContract, 2 = SingleContractDocumentType. + /// 0 = none, 1 = SingleContract, 2 = SingleContractDocumentType, 3 = Scoped. pub contract_bounds_kind: u8, pub contract_bounds_id: [u8; 32], pub contract_bounds_document_type: *mut c_char, + /// Versioned AuthenticationScope bincode bytes for kind 3; null otherwise. + /// Ownership matches the other buffers in this struct. + pub contract_bounds_scope: *const u8, + pub contract_bounds_scope_len: usize, } /// Owned C representation of the inspectable parts of a parsed @@ -119,6 +123,7 @@ fn encode_contract_bounds( ), )), }, + Some(ContractBounds::Scoped(_)) => Ok((3u8, [0u8; 32], ptr::null_mut())), None => Ok((0u8, [0u8; 32], ptr::null_mut())), } } @@ -132,6 +137,14 @@ fn encode_contract_bounds( /// pointer this module allocated and has not freed yet. unsafe fn free_parsed_public_keys(keys: &mut [ParsedIdentityUpdatePublicKeyFFI]) { for key in keys.iter_mut() { + if !key.contract_bounds_scope.is_null() { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + key.contract_bounds_scope as *mut u8, + key.contract_bounds_scope_len, + ))); + key.contract_bounds_scope = ptr::null(); + key.contract_bounds_scope_len = 0; + } if !key.data_ptr.is_null() && key.data_len > 0 { let data_slice = slice::from_raw_parts_mut(key.data_ptr, key.data_len); let _ = Box::from_raw(data_slice as *mut [u8]); @@ -169,6 +182,25 @@ pub(crate) fn project_parsed_identity_update( } }; + let scope_bytes = match public_key.contract_bounds() { + Some(ContractBounds::Scoped(scope)) => match scope.to_bytes() { + Ok(bytes) => bytes, + Err(error) => { + unsafe { free_parsed_public_keys(&mut add_public_keys_vec) }; + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + error.to_string(), + )); + } + }, + _ => Vec::new(), + }; + let contract_bounds_scope_len = scope_bytes.len(); + let contract_bounds_scope = if scope_bytes.is_empty() { + ptr::null() + } else { + Box::into_raw(scope_bytes.into_boxed_slice()) as *const u8 + }; let data = public_key.data().as_slice().to_vec().into_boxed_slice(); let data_len = data.len(); let data_ptr = Box::into_raw(data) as *mut u8; @@ -184,6 +216,8 @@ pub(crate) fn project_parsed_identity_update( contract_bounds_kind, contract_bounds_id, contract_bounds_document_type, + contract_bounds_scope, + contract_bounds_scope_len, }); } diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index 721f0910b10..f5a0ab9264b 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -765,6 +765,8 @@ mod tests { contract_bounds_kind: 0, contract_bounds_id: std::ptr::null(), contract_bounds_document_type: std::ptr::null(), + contract_bounds_scope: std::ptr::null(), + contract_bounds_scope_len: 0, }; let rows = [ffi_row(&pk_a), ffi_row(&pk_b)]; let dummy_signer = std::ptr::dangling_mut::(); diff --git a/packages/rs-platform-wallet-ffi/src/managed_identity.rs b/packages/rs-platform-wallet-ffi/src/managed_identity.rs index 71e7c7544ee..9a5e0a29732 100644 --- a/packages/rs-platform-wallet-ffi/src/managed_identity.rs +++ b/packages/rs-platform-wallet-ffi/src/managed_identity.rs @@ -186,6 +186,8 @@ pub struct IdentityPublicKeyFFI { pub disabled_at: u64, pub data_ptr: *mut u8, pub data_len: usize, + /// Complete DPP bounds JSON, null for an unrestricted key. Rust-owned. + pub contract_bounds_json: *mut std::os::raw::c_char, } /// Snapshot every `IdentityPublicKey` on the identity into a flat @@ -221,6 +223,14 @@ pub unsafe extern "C" fn managed_identity_get_public_keys( None => (false, 0u64), }; + let contract_bounds_json = + pk.contract_bounds().map_or(std::ptr::null_mut(), |bounds| { + let json = serde_json::to_string(bounds) + .expect("ContractBounds JSON serialization is infallible"); + std::ffi::CString::new(json) + .expect("JSON escapes NUL bytes") + .into_raw() + }); buf.push(IdentityPublicKeyFFI { key_id, purpose: pk.purpose() as u8, @@ -231,6 +241,7 @@ pub unsafe extern "C" fn managed_identity_get_public_keys( disabled_at: disabled_val, data_ptr, data_len, + contract_bounds_json, }); } buf @@ -263,6 +274,10 @@ pub unsafe extern "C" fn managed_identity_free_public_keys( } let slice = unsafe { std::slice::from_raw_parts_mut(keys, count) }; for entry in slice.iter_mut() { + if !entry.contract_bounds_json.is_null() { + drop(unsafe { std::ffi::CString::from_raw(entry.contract_bounds_json) }); + entry.contract_bounds_json = std::ptr::null_mut(); + } if !entry.data_ptr.is_null() && entry.data_len > 0 { let data_slice = unsafe { std::slice::from_raw_parts_mut(entry.data_ptr, entry.data_len) }; @@ -326,6 +341,40 @@ mod tests { Identity::V0(identity_v0) } + #[test] + fn scoped_public_key_snapshot_keeps_bounds_json() { + use dpp::identity::contract_bounds::{ + AuthenticationScope, AuthenticationScopeV0, ContractBounds, ContractScope, + }; + let scope = ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([7; 32]), + document_types: None, + }], + permissions: 65, + expires_at: Some(1_900_000_000_000), + })); + let mut identity = create_test_identity(); + let Identity::V0(inner) = &mut identity; + let IdentityPublicKey::V0(key) = inner.public_keys.get_mut(&0).unwrap(); + key.security_level = SecurityLevel::HIGH; + key.contract_bounds = Some(scope.clone()); + let handle = MANAGED_IDENTITY_STORAGE.insert(ManagedIdentity::new(identity, 0)); + let mut keys = std::ptr::null_mut(); + let mut count = 0; + unsafe { + let result = managed_identity_get_public_keys(handle, &mut keys, &mut count); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(count, 1); + let json = std::ffi::CStr::from_ptr((*keys).contract_bounds_json) + .to_str() + .unwrap(); + assert_eq!(serde_json::from_str::(json).unwrap(), scope); + managed_identity_free_public_keys(keys, count); + managed_identity_destroy(handle); + } + } + #[test] fn test_get_and_set_label_stub_returns_null() { unsafe { diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 2ba409b0eb8..5f2bb17748c 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -5947,8 +5947,8 @@ unsafe fn build_identity_public_keys( // inconsistency (the writer is supposed to demote to // kind=1 in that case — see identity_persistence.rs); we // demote it here too rather than fabricating an empty doc- - // type name. Invalid kind tags load as unbounded so a - // forward-compatible writer doesn't lock us out. + // type name. Unknown kinds and corrupt scoped payloads are skipped + // with a warning; they must never become unbounded keys. let contract_bounds: Option = match row.contract_bounds_kind { 0 => None, 1 => Some(ContractBounds::SingleContract { @@ -5971,7 +5971,35 @@ unsafe fn build_identity_public_keys( } } } - _ => None, + 3 => { + if row.contract_bounds_scope.is_null() + || row.contract_bounds_scope_len == 0 + || row.contract_bounds_scope_len + > dpp::identity::contract_bounds::authentication_scope::MAX_SCOPE_BYTES + { + tracing::warn!( + key_id = row.key_id, + "Skipping key with invalid persisted scope buffer" + ); + continue; + } + match dpp::identity::contract_bounds::AuthenticationScope::from_bytes( + slice::from_raw_parts(row.contract_bounds_scope, row.contract_bounds_scope_len), + ) { + Ok(scope) => Some(ContractBounds::Scoped(scope)), + Err(error) => { + tracing::warn!(key_id = row.key_id, %error, "Skipping key with corrupt persisted scope"); + continue; + } + } + } + _ => { + tracing::warn!( + key_id = row.key_id, + "Skipping key with unknown persisted bounds kind" + ); + continue; + } }; let pk = IdentityPublicKey::V0(IdentityPublicKeyV0 { @@ -9309,3 +9337,56 @@ mod tests { ); } } + +#[cfg(test)] +mod scoped_key_restore_tests { + use super::*; + use dpp::identity::contract_bounds::{ + AuthenticationScope, AuthenticationScopeV0, ContractBounds, ContractScope, + }; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use std::ptr; + + #[test] + fn restore_retains_scope_and_never_widens_corrupt_scope() { + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([7; 32]), + document_types: None, + }], + permissions: 65, + expires_at: Some(1_900_000_000_000), + }); + let bytes = scope.to_bytes().unwrap(); + let key_data = [2; 33]; + let mut key = IdentityKeyRestoreFFI { + key_id: 5, + key_type: 0, + purpose: 0, + security_level: 2, + read_only: false, + data: key_data.as_ptr(), + data_len: key_data.len(), + contract_bounds_kind: 3, + contract_bounds_id: [0; 32], + contract_bounds_document_type: ptr::null(), + contract_bounds_scope: bytes.as_ptr(), + contract_bounds_scope_len: bytes.len(), + }; + // The repr(C) restore envelope consists exclusively of integer and raw-pointer fields. + let mut spec: IdentityRestoreEntryFFI = unsafe { std::mem::zeroed() }; + spec.keys = &key; + spec.keys_count = 1; + let restored = unsafe { build_identity_public_keys(&spec) }; + assert_eq!( + restored[&5].contract_bounds(), + Some(&ContractBounds::Scoped(scope)) + ); + key.contract_bounds_scope_len -= 1; + spec.keys = &key; + assert!(unsafe { build_identity_public_keys(&spec) }.is_empty()); + key.contract_bounds_kind = 255; + spec.keys = &key; + assert!(unsafe { build_identity_public_keys(&spec) }.is_empty()); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index c49be7de1b7..51d1abdac45 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -192,7 +192,7 @@ pub struct AccountSpecFFI { /// /// `contract_bounds_*` mirror the [`IdentityKeyEntryFFI`] /// projection of DPP's `ContractBounds` enum (kind tag: 0=none, -/// 1=SingleContract, 2=SingleContractDocumentType). Including them +/// 1=SingleContract, 2=SingleContractDocumentType, 3=Scoped). Including them /// here closes the persist↔restore round-trip — without it, scoped /// DashPay keys (registered with `SingleContractDocumentType`) come /// back as unbounded on cold restart. @@ -212,11 +212,11 @@ pub struct IdentityKeyRestoreFFI { pub data: *const u8, pub data_len: usize, /// ContractBounds discriminant: 0=none, 1=SingleContract, - /// 2=SingleContractDocumentType. Mirrors the encoding in + /// 2=SingleContractDocumentType, 3=Scoped. Mirrors the encoding in /// [`crate::identity_persistence::IdentityKeyEntryFFI`]. pub contract_bounds_kind: u8, /// 32-byte contract identifier. Zeroed when - /// `contract_bounds_kind == 0`; otherwise the contract id the + /// `contract_bounds_kind` is 0 or 3; otherwise the contract id the /// key is bound to. pub contract_bounds_id: [u8; 32], /// NUL-terminated UTF-8 doc-type name. Non-null iff @@ -224,6 +224,10 @@ pub struct IdentityKeyRestoreFFI { /// same load-callback allocation arena that frees the public- /// key data buffer). pub contract_bounds_document_type: *const c_char, + /// Versioned AuthenticationScope bincode bytes for kind 3; null otherwise. + /// Ownership matches the other buffers in this struct. + pub contract_bounds_scope: *const u8, + pub contract_bounds_scope_len: usize, } /// Per-identity entry attached to a [`WalletRestoreEntryFFI`]. diff --git a/packages/rs-sdk-ffi/src/identity/mod.rs b/packages/rs-sdk-ffi/src/identity/mod.rs index aae14eab159..8d956023c14 100644 --- a/packages/rs-sdk-ffi/src/identity/mod.rs +++ b/packages/rs-sdk-ffi/src/identity/mod.rs @@ -31,7 +31,7 @@ pub use keys::{ dash_sdk_identity_public_key_destroy, dash_sdk_identity_public_key_get_id, StateTransitionType, }; pub use names::dash_sdk_identity_register_name; -pub use parse::dash_sdk_identity_parse_json; +pub use parse::{dash_sdk_contract_bounds_parse_json, dash_sdk_identity_parse_json}; pub use put::{ dash_sdk_identity_put_to_platform_with_chain_lock, dash_sdk_identity_put_to_platform_with_chain_lock_and_wait, diff --git a/packages/rs-sdk-ffi/src/identity/parse.rs b/packages/rs-sdk-ffi/src/identity/parse.rs index 342c29b6d18..1e9aefd2f20 100644 --- a/packages/rs-sdk-ffi/src/identity/parse.rs +++ b/packages/rs-sdk-ffi/src/identity/parse.rs @@ -81,3 +81,86 @@ pub unsafe extern "C" fn dash_sdk_identity_parse_json(json_str: *const c_char) - )), } } + +/// Normalize DPP contract-bounds JSON for native persistence. Scope encoding +/// stays in Rust so host clients never reconstruct the consensus wire format. +/// +/// # Safety +/// `json_str` must point to a valid NUL-terminated UTF-8 string for this call. +/// Release the returned string with `dash_sdk_string_free`. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_contract_bounds_parse_json( + json_str: *const c_char, +) -> DashSDKResult { + use dash_sdk::dpp::identity::contract_bounds::ContractBounds; + let convert = || -> Result { + if json_str.is_null() { + return Err("Contract bounds JSON is null".into()); + } + let json = CStr::from_ptr(json_str) + .to_str() + .map_err(|e| e.to_string())?; + let bounds: ContractBounds = serde_json::from_str(json).map_err(|e| e.to_string())?; + let value = match bounds { + ContractBounds::SingleContract { id } => { + serde_json::json!({"kind": 1, "id": id.to_buffer().to_vec()}) + } + ContractBounds::SingleContractDocumentType { + id, + document_type_name, + } => { + serde_json::json!({"kind": 2, "id": id.to_buffer().to_vec(), "documentType": document_type_name}) + } + ContractBounds::Scoped(scope) => { + serde_json::json!({"kind": 3, "scope": scope.to_bytes().map_err(|e| e.to_string())?}) + } + }; + Ok(value.to_string()) + }; + match convert() { + Ok(json) => DashSDKResult::success_string( + std::ffi::CString::new(json) + .expect("JSON has no NUL bytes") + .into_raw(), + ), + Err(error) => DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::SerializationError, + error, + )), + } +} + +#[cfg(test)] +mod scoped_bounds_tests { + use super::*; + use dash_sdk::dpp::identity::contract_bounds::{ + AuthenticationScope, AuthenticationScopeV0, ContractBounds, ContractScope, + }; + use dash_sdk::dpp::prelude::Identifier; + + #[test] + fn scoped_json_normalization_preserves_complete_wire_payload() { + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([1; 32]), + document_types: Some(vec!["post".into()]), + }], + permissions: 65, + expires_at: Some(1_900_000_000_000), + }); + let json = std::ffi::CString::new( + serde_json::to_string(&ContractBounds::Scoped(scope.clone())).unwrap(), + ) + .unwrap(); + let mut result = unsafe { dash_sdk_contract_bounds_parse_json(json.as_ptr()) }; + assert!(result.error.is_null()); + let output = unsafe { CStr::from_ptr(result.data as *const c_char) } + .to_str() + .unwrap(); + let normalized: serde_json::Value = serde_json::from_str(output).unwrap(); + assert_eq!(normalized["kind"], 3); + let bytes: Vec = serde_json::from_value(normalized["scope"].clone()).unwrap(); + assert_eq!(AuthenticationScope::from_bytes(&bytes).unwrap(), scope); + unsafe { crate::types::dash_sdk_result_free(&mut result) }; + } +} diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 917d26094df..48c92f11795 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -1088,6 +1088,9 @@ unsafe extern "C" fn tramp_persist_identity_keys( }) } +// Shared with descriptor verification so the smoke check resolves the actual call signature. +const IDENTITY_KEY_UPSERT_DESCRIPTOR: &str = "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;[B)I"; + unsafe fn persist_identity_key_upsert( env: &mut JNIEnv, bridge: &JObject, @@ -1100,10 +1103,11 @@ unsafe fn persist_identity_key_upsert( let key_wallet_id = env.byte_array_from_slice(&e.wallet_id)?; let cb_id = env.byte_array_from_slice(&e.contract_bounds_id)?; let cb_doctype = cstr_opt(env, e.contract_bounds_document_type)?; + let cb_scope = bytes(env, e.contract_bounds_scope, e.contract_bounds_scope_len)?; env.call_method( bridge, "onPersistIdentityKeyUpsert", - "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;)I", + IDENTITY_KEY_UPSERT_DESCRIPTOR, &[ wid.into(), (&identity_id).into(), @@ -1124,6 +1128,7 @@ unsafe fn persist_identity_key_upsert( JValue::Byte(e.contract_bounds_kind as i8), (&cb_id).into(), (&cb_doctype).into(), + (&cb_scope).into(), ], )? .i() @@ -1923,6 +1928,7 @@ struct IdentityKeyRestoreStaged { key: IdentityKeyRestoreFFI, data: Vec, doc_type: Option, + scope: Vec, } /// Mint the raw FFI pointers for a fully staged wallet list. Infallible: @@ -2087,8 +2093,13 @@ fn seal_wallet_entries(staged: Vec) -> Vec, pub(crate) contract_bounds_id: Option<[u8; 32]>, pub(crate) contract_bounds_document_type: Option, + pub(crate) contract_bounds_scope: Vec, } impl DecodedPubkeyRow { @@ -87,6 +88,8 @@ impl DecodedPubkeyRow { pubkey_len: self.pubkey_bytes.len(), read_only: self.read_only, contract_bounds_kind: self.contract_bounds_kind, + contract_bounds_scope: self.contract_bounds_scope.as_ptr(), + contract_bounds_scope_len: self.contract_bounds_scope.len(), contract_bounds_id: self .contract_bounds_id .as_ref() @@ -110,13 +113,15 @@ impl DecodedPubkeyRow { /// u8 purpose (DPP Purpose discriminant, 0 = AUTHENTICATION) /// u8 security_level (DPP SecurityLevel discriminant, 0 = MASTER) /// u8 read_only (0 / 1 — any other byte is rejected) -/// u8 contract_bounds_kind (0 none, 1 SingleContract, 2 SingleContractDocumentType) +/// u8 contract_bounds_kind (0 none, 1 SingleContract, 2 SingleContractDocumentType, 3 Scoped) /// u16 pubkey_len /// u8[pubkey_len] pubkey_bytes (compressed pubkey, or 20-byte HASH160) -/// if contract_bounds_kind != 0: +/// if contract_bounds_kind == 1 or contract_bounds_kind == 2: /// u8[32] contract_bounds_id /// if contract_bounds_kind == 2: /// u16 doc_type_len, u8[doc_type_len] doc_type (UTF-8) +/// if contract_bounds_kind == 3: +/// u16 scope_len, u8[scope_len] versioned DPP scope bytes /// ``` /// /// Strict: returns `Err` on truncation, trailing bytes, a negative key ID @@ -180,9 +185,9 @@ pub(crate) fn parse_pubkey_rows(bytes: &[u8]) -> Result, S } }; let contract_bounds_kind = fixed[8]; - if contract_bounds_kind > 2 { + if contract_bounds_kind > 3 { return Err(format!( - "pubkey blob row {i} contractBoundsKind must be 0, 1 or 2, got {contract_bounds_kind}" + "pubkey blob row {i} contractBoundsKind must be 0, 1, 2 or 3, got {contract_bounds_kind}" )); } let pubkey_len = u16::from_be_bytes([fixed[9], fixed[10]]) as usize; @@ -192,7 +197,7 @@ pub(crate) fn parse_pubkey_rows(bytes: &[u8]) -> Result, S let mut contract_bounds_id: Option<[u8; 32]> = None; let mut contract_bounds_document_type: Option = None; - if contract_bounds_kind != 0 { + if matches!(contract_bounds_kind, 1 | 2) { let id_bytes = read(&mut cursor, 32) .ok_or_else(|| format!("pubkey blob truncated at row {i} contractBoundsId"))?; let mut id = [0u8; 32]; @@ -212,6 +217,19 @@ pub(crate) fn parse_pubkey_rows(bytes: &[u8]) -> Result, S } } + let contract_bounds_scope = if contract_bounds_kind == 3 { + let length = read(&mut cursor, 2) + .ok_or_else(|| format!("pubkey blob truncated at row {i} scope length"))?; + let length = u16::from_be_bytes([length[0], length[1]]) as usize; + if length == 0 || length > 2048 { + return Err(format!("pubkey blob row {i} invalid scope length")); + } + read(&mut cursor, length) + .ok_or_else(|| format!("pubkey blob truncated at row {i} scope"))? + .to_vec() + } else { + Vec::new() + }; rows.push(DecodedPubkeyRow { key_id, key_type, @@ -222,6 +240,7 @@ pub(crate) fn parse_pubkey_rows(bytes: &[u8]) -> Result, S pubkey_bytes, contract_bounds_id, contract_bounds_document_type, + contract_bounds_scope, }); } @@ -460,6 +479,20 @@ mod tests { ] } + #[test] + fn scoped_payload_has_independent_length_prefixed_framing() { + let mut bytes = vec![0, 0, 0, 1, 0, 0, 0, 7, 0, 0, 2, 0, 3, 0, 1, 2]; + bytes.extend_from_slice(&[0, 4, 0, 9, 0, 8]); + let decoded = parse_pubkey_rows(&bytes).unwrap(); + assert_eq!(decoded[0].contract_bounds_scope, vec![0, 9, 0, 8]); + assert!(decoded[0].contract_bounds_id.is_none()); + let ffi = decoded[0].to_ffi(); + assert_eq!(ffi.contract_bounds_kind, 3); + assert_eq!(ffi.contract_bounds_scope_len, 4); + bytes.pop(); + assert!(parse_pubkey_rows(&bytes).is_err()); + } + #[test] fn round_trips_the_full_six_key_policy() { let dashpay_id = [7u8; 32]; @@ -539,8 +572,8 @@ mod tests { #[test] fn rejects_invalid_bounds_kind() { let mut rows = vec![base_master()]; - rows[0].bounds = Some((3, [1u8; 32], None)); - // encode() writes kind byte from bounds.0 = 3, then a 32-byte id. + rows[0].bounds = Some((4, [1u8; 32], None)); + // encode() writes kind byte from bounds.0 = 4, then a 32-byte id. let err = parse_pubkey_rows(&encode(&rows)).unwrap_err(); assert!(err.contains("contractBoundsKind"), "{err}"); } diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 04552c1e9ea..d731a99b7a8 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -151,13 +151,15 @@ fn read_cstring(env: &mut JNIEnv, s: &JString, field: &str) -> Option { /// u8 purpose (DPP Purpose discriminant, 0 = AUTHENTICATION) /// u8 security_level (DPP SecurityLevel discriminant, 0 = MASTER) /// u8 read_only (0 / 1) -/// u8 contract_bounds_kind (0 none, 1 SingleContract, 2 SingleContractDocumentType) +/// u8 contract_bounds_kind (0 none, 1 SingleContract, 2 SingleContractDocumentType, 3 Scoped) /// u16 pubkey_len /// u8[pubkey_len] pubkey_bytes (compressed pubkey, or 20-byte HASH160) -/// if contract_bounds_kind != 0: +/// if contract_bounds_kind == 1 or contract_bounds_kind == 2: /// u8[32] contract_bounds_id /// if contract_bounds_kind == 2: /// u16 doc_type_len, u8[doc_type_len] doc_type (UTF-8) +/// if contract_bounds_kind == 3: +/// u16 scope_len, u8[scope_len] versioned DPP scope bytes /// ``` /// /// `disablePublicKeyIds` is a JVM `int[]` of key ids to disable (may be diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift index 9271358c6a7..d214e0cf1aa 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift @@ -1,4 +1,5 @@ import Foundation +import DashSDKFFI // MARK: - Key Type @@ -167,6 +168,7 @@ public struct IdentityPublicKey: Codable, Equatable, Sendable { // MARK: - Contract Bounds public enum ContractBounds: Codable, Equatable, Sendable, CustomStringConvertible { + case scoped(encodedScope: Data) case singleContract(id: Identifier) case singleContractDocumentType(id: Identifier, documentTypeName: String) @@ -174,9 +176,11 @@ public enum ContractBounds: Codable, Equatable, Sendable, CustomStringConvertibl case type case id case documentType + case encodedScope } private enum BoundType: String, Codable { + case scoped case singleContract case singleContractDocumentType } @@ -186,6 +190,8 @@ public enum ContractBounds: Codable, Equatable, Sendable, CustomStringConvertibl let type = try container.decode(BoundType.self, forKey: .type) switch type { + case .scoped: + self = .scoped(encodedScope: try container.decode(Data.self, forKey: .encodedScope)) case .singleContract: let id = try container.decode(Identifier.self, forKey: .id) self = .singleContract(id: id) @@ -200,6 +206,9 @@ public enum ContractBounds: Codable, Equatable, Sendable, CustomStringConvertibl var container = encoder.container(keyedBy: CodingKeys.self) switch self { + case .scoped(let encodedScope): + try container.encode(BoundType.scoped, forKey: .type) + try container.encode(encodedScope, forKey: .encodedScope) case .singleContract(let id): try container.encode(BoundType.singleContract, forKey: .type) try container.encode(id, forKey: .id) @@ -212,6 +221,8 @@ public enum ContractBounds: Codable, Equatable, Sendable, CustomStringConvertibl public var description: String { switch self { + case .scoped: + return "Scoped application authentication" case .singleContract(let id): return "Limited to contract: \(id.toBase58String())" case .singleContractDocumentType(let id, let docType): @@ -219,8 +230,10 @@ public enum ContractBounds: Codable, Equatable, Sendable, CustomStringConvertibl } } - public var contractId: Identifier { + public var contractId: Identifier? { switch self { + case .scoped: + return nil case .singleContract(let id): return id case .singleContractDocumentType(let id, _): @@ -319,3 +332,38 @@ extension DPPIdentity { ) } } + +extension ContractBounds { + /// Decode bounds returned by Platform without implementing DPP's wire encoding in Swift. + public static func fromPlatformJSON(_ value: Any?) throws -> ContractBounds? { + guard let value, !(value is NSNull) else { return nil } + let input = try JSONSerialization.data(withJSONObject: value) + guard let json = String(data: input, encoding: .utf8) else { + throw SDKError.serializationError("Invalid contract bounds JSON") + } + let result = json.withCString { dash_sdk_contract_bounds_parse_json($0) } + if let error = result.error { + let message = error.pointee.message.map { String(cString: $0) } ?? "Invalid contract bounds" + dash_sdk_error_free(error) + throw SDKError.serializationError(message) + } + guard let raw = result.data else { throw SDKError.serializationError("Missing contract bounds") } + defer { dash_sdk_string_free(raw.assumingMemoryBound(to: CChar.self)) } + let output = Data(String(cString: raw.assumingMemoryBound(to: CChar.self)).utf8) + guard let fields = try JSONSerialization.jsonObject(with: output) as? [String: Any], + let kind = fields["kind"] as? Int else { + throw SDKError.serializationError("Invalid normalized contract bounds") + } + if kind == 3, let bytes = fields["scope"] as? [UInt8] { + return .scoped(encodedScope: Data(bytes)) + } + guard let bytes = fields["id"] as? [UInt8], bytes.count == 32 else { + throw SDKError.serializationError("Missing contract identifier") + } + if kind == 1 { return .singleContract(id: Data(bytes)) } + if kind == 2, let name = fields["documentType"] as? String { + return .singleContractDocumentType(id: Data(bytes), documentTypeName: name) + } + throw SDKError.serializationError("Unknown contract bounds kind") + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift index 5ed049b04fb..cdf339c9d8f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift @@ -34,6 +34,9 @@ public final class PersistentPublicKey { /// `.singleContract(id:)`. Optional so old stores load cleanly. public var contractBoundsDocumentTypeName: String? + /// Versioned DPP scope bytes. Optional for lightweight migration of existing stores. + public var contractBoundsScope: Data? + // MARK: - Private Key Reference (optional) public var privateKeyKeychainIdentifier: String? @@ -74,6 +77,7 @@ public final class PersistentPublicKey { disabledAt: Int64? = nil, contractBounds: [Data]? = nil, contractBoundsDocumentTypeName: String? = nil, + contractBoundsScope: Data? = nil, identityId: String ) { self.keyId = keyId @@ -89,6 +93,7 @@ public final class PersistentPublicKey { self.contractBoundsData = nil } self.contractBoundsDocumentTypeName = contractBoundsDocumentTypeName + self.contractBoundsScope = contractBoundsScope self.identityId = identityId self.createdAt = Date() } @@ -116,6 +121,7 @@ public final class PersistentPublicKey { // `PersistentPublicKey.from(IdentityPublicKey, identityId:)` // which sets both columns atomically. contractBoundsDocumentTypeName = nil + contractBoundsScope = nil if let newValue = newValue { contractBoundsData = try? JSONSerialization.data(withJSONObject: newValue.map { $0.base64EncodedString() }) } else { @@ -178,7 +184,9 @@ extension PersistentPublicKey { // rejected here. Drop the bounds projection on length // mismatch — the rest of the key is still recoverable. let bounds: ContractBounds? - if let id = contractBounds?.first, id.count == 32 { + if let encodedScope = contractBoundsScope { + bounds = .scoped(encodedScope: encodedScope) + } else if let id = contractBounds?.first, id.count == 32 { if let docTypeName = contractBoundsDocumentTypeName, !docTypeName.isEmpty { bounds = .singleContractDocumentType(id: id, documentTypeName: docTypeName) } else { @@ -205,7 +213,12 @@ extension PersistentPublicKey { public static func from(_ publicKey: IdentityPublicKey, identityId: String) -> PersistentPublicKey? { let boundsIds: [Data]? let docTypeName: String? + var scope: Data? switch publicKey.contractBounds { + case .scoped(let encodedScope): + boundsIds = nil + docTypeName = nil + scope = encodedScope case .singleContract(let id): boundsIds = [id] docTypeName = nil @@ -226,6 +239,7 @@ extension PersistentPublicKey { disabledAt: publicKey.disabledAt.map { Int64($0) }, contractBounds: boundsIds, contractBoundsDocumentTypeName: docTypeName, + contractBoundsScope: scope, identityId: identityId ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift index 182e49b380c..cfa3a69b766 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift @@ -84,8 +84,7 @@ public final class ManagedIdentity: @unchecked Sendable { } /// Snapshot of an identity's registered public keys. Mirrors the - /// DPP `IdentityPublicKeyV0` shape. Contract bounds aren't - /// included yet — see the FFI docstring. + /// DPP `IdentityPublicKeyV0` shape, including the complete contract bounds. public struct IdentityPublicKeyInfo: Sendable { public let keyId: Int32 public let purpose: KeyPurpose @@ -99,6 +98,7 @@ public final class ManagedIdentity: @unchecked Sendable { /// (compressed secp256k1 pubkey for ECDSA, hash160 for /// HASH160 variants, etc.). public let data: Data + public let contractBounds: ContractBounds? } /// Return every `IdentityPublicKey` registered on this identity. @@ -143,6 +143,13 @@ public final class ManagedIdentity: @unchecked Sendable { data = Data() } + let bounds: ContractBounds? + if let json = ffi.contract_bounds_json { + let value = try JSONSerialization.jsonObject(with: Data(String(cString: json).utf8)) + bounds = try ContractBounds.fromPlatformJSON(value) + } else { + bounds = nil + } keys.append( IdentityPublicKeyInfo( keyId: Int32(bitPattern: ffi.key_id), @@ -153,7 +160,8 @@ public final class ManagedIdentity: @unchecked Sendable { disabledAt: ffi.disabled_at_is_some ? Int64(bitPattern: ffi.disabled_at) : nil, - data: data + data: data, + contractBounds: bounds ) ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 8664e1dfb90..74e180e2862 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -200,9 +200,10 @@ public final class ManagedPlatformWallet: @unchecked Sendable { } /// Swift mirror of `dpp::identity::identity_public_key::contract_bounds::ContractBounds`. - /// Pinned to two variants (no `MultipleContractsOfSameOwner`) - /// to match the Rust enum's currently-supported shape. + /// Scoped authentication grants retain their versioned DPP encoding. public enum ContractBounds: Sendable, Equatable { + /// Versioned scope bytes produced by DPP; validated by Rust on registration. + case scoped(encodedScope: Data) /// Key may be used within a specific contract (any /// document type). Maps to `kind == 1` on the FFI side. case singleContract(id: Data) @@ -669,7 +670,7 @@ public final class ManagedPlatformWallet: @unchecked Sendable { let pk = pubkeys[index] return buffers[index].withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> R in let basePtr = raw.bindMemory(to: UInt8.self).baseAddress - return pinContractBounds(pk.contractBounds) { kind, idPtr, docTypePtr in + return pinContractBounds(pk.contractBounds) { kind, idPtr, docTypePtr, scopePtr, scopeLen in rows.append( IdentityPubkeyFFI( key_id: pk.keyId, @@ -681,7 +682,9 @@ public final class ManagedPlatformWallet: @unchecked Sendable { read_only: pk.readOnly, contract_bounds_kind: kind, contract_bounds_id: idPtr, - contract_bounds_document_type: docTypePtr + contract_bounds_document_type: docTypePtr, + contract_bounds_scope: scopePtr, + contract_bounds_scope_len: scopeLen ) ) return pinNext(index + 1, &rows, pubkeys, buffers, body) @@ -696,11 +699,15 @@ public final class ManagedPlatformWallet: @unchecked Sendable { /// inside `pinNext`. private static func pinContractBounds( _ bounds: ContractBounds?, - _ body: (UInt8, UnsafePointer?, UnsafePointer?) -> R + _ body: (UInt8, UnsafePointer?, UnsafePointer?, UnsafePointer?, UInt) -> R ) -> R { switch bounds { case .none: - return body(0, nil, nil) + return body(0, nil, nil, nil, 0) + case .scoped(let encodedScope): + return encodedScope.withUnsafeBytes { raw in + body(3, nil, nil, raw.bindMemory(to: UInt8.self).baseAddress, UInt(raw.count)) + } case .singleContract(let id): // The Rust side reads exactly 32 bytes off // `contract_bounds_id`. A short or empty `Data` would @@ -714,7 +721,7 @@ public final class ManagedPlatformWallet: @unchecked Sendable { ) return id.withUnsafeBytes { raw -> R in let idPtr = raw.bindMemory(to: UInt8.self).baseAddress - return body(1, idPtr, nil) + return body(1, idPtr, nil, nil, 0) } case .singleContractDocumentType(let id, let documentTypeName): precondition( @@ -724,7 +731,7 @@ public final class ManagedPlatformWallet: @unchecked Sendable { return id.withUnsafeBytes { raw -> R in let idPtr = raw.bindMemory(to: UInt8.self).baseAddress return documentTypeName.withCString { docTypePtr in - body(2, idPtr, docTypePtr) + body(2, idPtr, docTypePtr, nil, 0) } } } @@ -3453,6 +3460,11 @@ extension ManagedPlatformWallet { id: Swift.withUnsafeBytes(of: &idTuple) { Data($0) }, documentTypeName: documentTypeName ) + case 3: + guard let scope = entry.contract_bounds_scope, entry.contract_bounds_scope_len > 0 else { + throw PlatformWalletError.deserialization("Missing authentication scope at key \(index)") + } + return .scoped(encodedScope: Data(bytes: scope, count: Int(entry.contract_bounds_scope_len))) default: throw PlatformWalletError.deserialization( "Unknown IdentityUpdateTransition contract-bounds kind \(entry.contract_bounds_kind) at key \(index)" diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 4c0baa95899..ff6489ebe54 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2464,7 +2464,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // and reconstruct as `.singleContract`. let snapshotBoundsIds: [Data]? let snapshotBoundsDocType: String? + var snapshotScope: Data? switch entry.contractBounds { + case .some(.scoped(let encodedScope)): + snapshotBoundsIds = nil + snapshotBoundsDocType = nil + snapshotScope = encodedScope case .some(.singleContract(let id)): snapshotBoundsIds = [id] snapshotBoundsDocType = nil @@ -2520,6 +2525,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // scope) must overwrite any stale value here. row.contractBounds = snapshotBoundsIds row.contractBoundsDocumentTypeName = snapshotBoundsDocType + row.contractBoundsScope = snapshotScope // Private-key handling: no secret crosses the FFI. A // wallet-derivable key whose private bytes were materialized by @@ -6479,7 +6485,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // wrong-length id falls back to "no bounds" // rather than crashing FFI marshalling on the // Rust side. - if let id = pk.contractBounds?.first, id.count == 32 { + if let scope = pk.contractBoundsScope { + row.contract_bounds_kind = 3 + if !scope.isEmpty { + let scopeBuf = UnsafeMutablePointer.allocate(capacity: scope.count) + scope.copyBytes(to: scopeBuf, count: scope.count) + allocation.scalarBuffers.append((scopeBuf, scope.count)) + row.contract_bounds_scope = UnsafePointer(scopeBuf) + row.contract_bounds_scope_len = UInt(scope.count) + } + } else if let id = pk.contractBounds?.first, id.count == 32 { withUnsafeMutableBytes(of: &row.contract_bounds_id) { dst in id.copyBytes(to: dst.bindMemory(to: UInt8.self).baseAddress!, count: 32) } @@ -8090,8 +8105,15 @@ private func persistIdentityKeysCallback( } else { bounds = nil } - default: + case 3: + guard let scope = e.contract_bounds_scope, e.contract_bounds_scope_len > 0 else { + return -1 + } + bounds = .scoped(encodedScope: Data(bytes: scope, count: Int(e.contract_bounds_scope_len))) + case 0: bounds = nil + default: + return -1 } upserts.append(.init( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityKeyRefresher.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityKeyRefresher.swift index 7282187529d..2ba42dc4e94 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityKeyRefresher.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityKeyRefresher.swift @@ -59,32 +59,37 @@ enum IdentityKeyRefresher { } // Public keys — parse the freshly-fetched set. - var parsedPublicKeys: [IdentityPublicKey] = [] - if let publicKeysArray = fetchedIdentity["publicKeys"] as? [[String: Any]] { - parsedPublicKeys = publicKeysArray.compactMap { keyData -> IdentityPublicKey? in - guard let id = keyData["id"] as? Int, - let purpose = keyData["purpose"] as? Int, - let securityLevel = keyData["securityLevel"] as? Int, - let keyType = keyData["type"] as? Int, - let dataStr = keyData["data"] as? String, - let data = Data(base64Encoded: dataStr) else { - return nil - } + let publicKeysArray: [[String: Any]] + if let rows = fetchedIdentity["publicKeys"] as? [[String: Any]] { + publicKeysArray = rows + } else if let rows = fetchedIdentity["publicKeys"] as? [String: [String: Any]] { + publicKeysArray = Array(rows.values) + } else { + throw SDKError.serializationError("Identity response is missing public keys") + } + let parsedPublicKeys = try publicKeysArray.compactMap { keyData -> IdentityPublicKey? in + guard let id = keyData["id"] as? Int, + let purpose = keyData["purpose"] as? Int, + let securityLevel = keyData["securityLevel"] as? Int, + let keyType = keyData["type"] as? Int, + let dataStr = keyData["data"] as? String, + let data = Data(base64Encoded: dataStr) else { + return nil + } - let readOnly = keyData["readOnly"] as? Bool ?? false - let disabledAt = keyData["disabledAt"] as? UInt64 + let readOnly = keyData["readOnly"] as? Bool ?? false + let disabledAt = keyData["disabledAt"] as? UInt64 - return IdentityPublicKey( - id: UInt32(id), - purpose: KeyPurpose(rawValue: UInt8(purpose)) ?? .authentication, - securityLevel: SecurityLevel(rawValue: UInt8(securityLevel)) ?? .high, - contractBounds: nil, - keyType: KeyType(rawValue: UInt8(keyType)) ?? .ecdsaSecp256k1, - readOnly: readOnly, - data: data, - disabledAt: disabledAt - ) - } + return IdentityPublicKey( + id: UInt32(id), + purpose: KeyPurpose(rawValue: UInt8(purpose)) ?? .authentication, + securityLevel: SecurityLevel(rawValue: UInt8(securityLevel)) ?? .high, + contractBounds: try ContractBounds.fromPlatformJSON(keyData["contractBounds"]), + keyType: KeyType(rawValue: UInt8(keyType)) ?? .ecdsaSecp256k1, + readOnly: readOnly, + data: data, + disabledAt: disabledAt + ) } // Replace the PersistentIdentity's public key rows with the diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift index 432cd0cd4d7..893f455eaac 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift @@ -332,7 +332,7 @@ struct LoadIdentityView: View { // The publicKeys might be a dictionary with key IDs as keys if let publicKeysDict = identityData["publicKeys"] as? [String: Any] { print("🔵 Public keys are in dictionary format") - parsedPublicKeys = publicKeysDict.compactMap { (keyIdStr, keyData) -> IdentityPublicKey? in + parsedPublicKeys = try publicKeysDict.compactMap { (keyIdStr, keyData) -> IdentityPublicKey? in guard let keyData = keyData as? [String: Any], let id = Int(keyIdStr) ?? keyData["id"] as? Int, let purpose = keyData["purpose"] as? Int, @@ -356,7 +356,7 @@ struct LoadIdentityView: View { id: UInt32(id), purpose: KeyPurpose(rawValue: UInt8(purpose)) ?? .authentication, securityLevel: SecurityLevel(rawValue: UInt8(securityLevel)) ?? .high, - contractBounds: nil, + contractBounds: try ContractBounds.fromPlatformJSON(keyData["contractBounds"]), keyType: KeyType(rawValue: UInt8(keyType)) ?? .ecdsaSecp256k1, readOnly: readOnly, data: data, @@ -365,7 +365,7 @@ struct LoadIdentityView: View { } } else if let publicKeysArray = identityData["publicKeys"] as? [[String: Any]] { print("🔵 Public keys are in array format") - parsedPublicKeys = publicKeysArray.compactMap { keyData -> IdentityPublicKey? in + parsedPublicKeys = try publicKeysArray.compactMap { keyData -> IdentityPublicKey? in guard let id = keyData["id"] as? Int, let purpose = keyData["purpose"] as? Int, let securityLevel = keyData["securityLevel"] as? Int, @@ -388,7 +388,7 @@ struct LoadIdentityView: View { id: UInt32(id), purpose: KeyPurpose(rawValue: UInt8(purpose)) ?? .authentication, securityLevel: SecurityLevel(rawValue: UInt8(securityLevel)) ?? .high, - contractBounds: nil, + contractBounds: try ContractBounds.fromPlatformJSON(keyData["contractBounds"]), keyType: KeyType(rawValue: UInt8(keyType)) ?? .ecdsaSecp256k1, readOnly: readOnly, data: data, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index d5394d9436a..ddf5e4c3291 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -765,7 +765,9 @@ struct PublicKeyStorageDetailView: View { } Section("Data") { FieldRow(label: "Public Key", value: hexString(record.publicKeyData)) - if let bounds = record.contractBounds, !bounds.isEmpty { + if record.contractBoundsScope != nil { + FieldRow(label: "Contract Bounds", value: "Scoped authentication") + } else if let bounds = record.contractBounds, !bounds.isEmpty { FieldRow(label: "Contract Bounds", value: "\(bounds.count)") ForEach(Array(bounds.enumerated()), id: \.offset) { _, contractId in FieldRow(label: "Contract", value: contractId.toBase58String()) diff --git a/packages/wasm-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs b/packages/wasm-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs new file mode 100644 index 00000000000..75656eb1f44 --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::basic::identity::InvalidAuthenticationScopeError; +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=InvalidAuthenticationScopeError)] +pub struct InvalidAuthenticationScopeErrorWasm { + inner: InvalidAuthenticationScopeError, +} + +impl From<&InvalidAuthenticationScopeError> for InvalidAuthenticationScopeErrorWasm { + fn from(e: &InvalidAuthenticationScopeError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=InvalidAuthenticationScopeError)] +impl InvalidAuthenticationScopeErrorWasm { + #[wasm_bindgen(js_name=getCode)] + pub fn get_code(&self) -> u32 { + ConsensusError::from(self.inner.clone()).code() + } + + #[wasm_bindgen(getter)] + pub fn message(&self) -> String { + self.inner.to_string() + } +} diff --git a/packages/wasm-dpp/src/errors/consensus/basic/identity/mod.rs b/packages/wasm-dpp/src/errors/consensus/basic/identity/mod.rs index 95db5d69870..1e0eb0b2f82 100644 --- a/packages/wasm-dpp/src/errors/consensus/basic/identity/mod.rs +++ b/packages/wasm-dpp/src/errors/consensus/basic/identity/mod.rs @@ -57,3 +57,6 @@ pub use invalid_instant_asset_lock_proof_signature_error::*; pub use missing_master_public_key_error::*; pub use missing_public_key_error::*; pub use not_implemented_credit_withdrawal_transition_pooling_error::*; + +mod invalid_authentication_scope_error; +pub use invalid_authentication_scope_error::InvalidAuthenticationScopeErrorWasm; diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 5c76989dc95..80ec2415ec7 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -1,3 +1,7 @@ +use super::basic::identity::InvalidAuthenticationScopeErrorWasm; +use super::signature::ScopedKeyExpiredErrorWasm; +use super::signature::ScopedKeyNonBatchErrorWasm; +use super::signature::ScopedKeyOutOfScopeErrorWasm; use crate::errors::consensus::basic::{ IncompatibleProtocolVersionErrorWasm, InvalidIdentifierErrorWasm, InvalidSignaturePublicKeyPurposeErrorWasm, JsonSchemaErrorWasm, @@ -656,6 +660,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { InvalidIdentityAssetLockTransactionError(e) => { InvalidIdentityAssetLockTransactionErrorWasm::from(e).into() } + dpp::consensus::basic::BasicError::InvalidAuthenticationScopeError(e) => { + InvalidAuthenticationScopeErrorWasm::from(e).into() + } IdentityAssetLockTransactionTooManyInputsError(e) => { IdentityAssetLockTransactionTooManyInputsErrorWasm::from(e).into() } @@ -1049,6 +1056,11 @@ fn from_signature_error(signature_error: &SignatureError) -> JsValue { SignatureError::InvalidSignaturePublicKeyPurposeError(err) => { InvalidSignaturePublicKeyPurposeErrorWasm::from(err).into() } + SignatureError::ScopedKeyNonBatchError(err) => ScopedKeyNonBatchErrorWasm::from(err).into(), + SignatureError::ScopedKeyExpiredError(err) => ScopedKeyExpiredErrorWasm::from(err).into(), + SignatureError::ScopedKeyOutOfScopeError(err) => { + ScopedKeyOutOfScopeErrorWasm::from(err).into() + } SignatureError::UncompressedPublicKeyNotAllowedError(err) => { UncompressedPublicKeyNotAllowedErrorWasm::from(err).into() } diff --git a/packages/wasm-dpp/src/errors/consensus/signature/mod.rs b/packages/wasm-dpp/src/errors/consensus/signature/mod.rs index 8b5b27b3f10..b1ad818a97e 100644 --- a/packages/wasm-dpp/src/errors/consensus/signature/mod.rs +++ b/packages/wasm-dpp/src/errors/consensus/signature/mod.rs @@ -9,3 +9,12 @@ pub use basic_ecdsa_error::*; pub use identity_not_found_error::*; pub use signature_should_not_be_present_error::*; pub use uncompressed_public_key_not_allowed_error::*; + +mod scoped_key_non_batch_error; +pub use scoped_key_non_batch_error::ScopedKeyNonBatchErrorWasm; + +mod scoped_key_expired_error; +pub use scoped_key_expired_error::ScopedKeyExpiredErrorWasm; + +mod scoped_key_out_of_scope_error; +pub use scoped_key_out_of_scope_error::ScopedKeyOutOfScopeErrorWasm; diff --git a/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs new file mode 100644 index 00000000000..2ad0c9aa498 --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::signature::ScopedKeyExpiredError; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=ScopedKeyExpiredError)] +pub struct ScopedKeyExpiredErrorWasm { + inner: ScopedKeyExpiredError, +} + +impl From<&ScopedKeyExpiredError> for ScopedKeyExpiredErrorWasm { + fn from(e: &ScopedKeyExpiredError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=ScopedKeyExpiredError)] +impl ScopedKeyExpiredErrorWasm { + #[wasm_bindgen(js_name=getCode)] + pub fn get_code(&self) -> u32 { + ConsensusError::from(self.inner.clone()).code() + } + + #[wasm_bindgen(getter)] + pub fn message(&self) -> String { + self.inner.to_string() + } +} diff --git a/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs new file mode 100644 index 00000000000..d19206280a5 --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::signature::ScopedKeyNonBatchError; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=ScopedKeyNonBatchError)] +pub struct ScopedKeyNonBatchErrorWasm { + inner: ScopedKeyNonBatchError, +} + +impl From<&ScopedKeyNonBatchError> for ScopedKeyNonBatchErrorWasm { + fn from(e: &ScopedKeyNonBatchError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=ScopedKeyNonBatchError)] +impl ScopedKeyNonBatchErrorWasm { + #[wasm_bindgen(js_name=getCode)] + pub fn get_code(&self) -> u32 { + ConsensusError::from(self.inner.clone()).code() + } + + #[wasm_bindgen(getter)] + pub fn message(&self) -> String { + self.inner.to_string() + } +} diff --git a/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs new file mode 100644 index 00000000000..0ac94a61331 --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::signature::ScopedKeyOutOfScopeError; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=ScopedKeyOutOfScopeError)] +pub struct ScopedKeyOutOfScopeErrorWasm { + inner: ScopedKeyOutOfScopeError, +} + +impl From<&ScopedKeyOutOfScopeError> for ScopedKeyOutOfScopeErrorWasm { + fn from(e: &ScopedKeyOutOfScopeError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=ScopedKeyOutOfScopeError)] +impl ScopedKeyOutOfScopeErrorWasm { + #[wasm_bindgen(js_name=getCode)] + pub fn get_code(&self) -> u32 { + ConsensusError::from(self.inner.clone()).code() + } + + #[wasm_bindgen(getter)] + pub fn message(&self) -> String { + self.inner.to_string() + } +} diff --git a/packages/wasm-dpp2/src/data_contract/contract_bounds.rs b/packages/wasm-dpp2/src/data_contract/contract_bounds.rs index f8081c78d43..20b950e5d88 100644 --- a/packages/wasm-dpp2/src/data_contract/contract_bounds.rs +++ b/packages/wasm-dpp2/src/data_contract/contract_bounds.rs @@ -1,31 +1,98 @@ +use crate::error::WasmDppError; use crate::error::WasmDppResult; use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; use crate::impl_try_from_js_value; use crate::impl_wasm_conversions_inner; use crate::impl_wasm_type_info; -use dpp::identity::contract_bounds::ContractBounds; +use dpp::identity::contract_bounds::{ + AuthenticationScope, ContractBounds, authentication_scope::permissions, +}; use dpp::prelude::Identifier; +use dpp::serialization::JsonConvertible; +use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; +/// Combine explicitly granted actions with bitwise OR. +#[wasm_bindgen] +#[derive(Clone, Copy, Debug)] +pub enum AuthenticationPermission { + DocumentCreate = 1, + DocumentReplace = 2, + DocumentDelete = 4, + DocumentTransfer = 8, + DocumentUpdatePrice = 16, + DocumentPurchase = 32, + DocumentTokenPayment = 64, + TokenBurn = 128, + TokenMint = 256, + TokenTransfer = 512, + TokenFreeze = 1024, + TokenUnfreeze = 2048, + TokenDestroyFrozenFunds = 4096, + TokenClaim = 8192, + TokenEmergencyAction = 16384, + TokenConfigUpdate = 32768, + TokenDirectPurchase = 65536, + TokenSetPrice = 131072, +} +// wasm-bindgen requires literal discriminants; keep them tied to consensus bits. +const _: () = { + assert!(AuthenticationPermission::DocumentCreate as u32 == permissions::DOCUMENT_CREATE); + assert!(AuthenticationPermission::DocumentReplace as u32 == permissions::DOCUMENT_REPLACE); + assert!(AuthenticationPermission::DocumentDelete as u32 == permissions::DOCUMENT_DELETE); + assert!(AuthenticationPermission::DocumentTransfer as u32 == permissions::DOCUMENT_TRANSFER); + assert!( + AuthenticationPermission::DocumentUpdatePrice as u32 == permissions::DOCUMENT_UPDATE_PRICE + ); + assert!(AuthenticationPermission::DocumentPurchase as u32 == permissions::DOCUMENT_PURCHASE); + assert!( + AuthenticationPermission::DocumentTokenPayment as u32 + == permissions::DOCUMENT_TOKEN_PAYMENT + ); + assert!(AuthenticationPermission::TokenBurn as u32 == permissions::TOKEN_BURN); + assert!(AuthenticationPermission::TokenMint as u32 == permissions::TOKEN_MINT); + assert!(AuthenticationPermission::TokenTransfer as u32 == permissions::TOKEN_TRANSFER); + assert!(AuthenticationPermission::TokenFreeze as u32 == permissions::TOKEN_FREEZE); + assert!(AuthenticationPermission::TokenUnfreeze as u32 == permissions::TOKEN_UNFREEZE); + assert!( + AuthenticationPermission::TokenDestroyFrozenFunds as u32 + == permissions::TOKEN_DESTROY_FROZEN_FUNDS + ); + assert!(AuthenticationPermission::TokenClaim as u32 == permissions::TOKEN_CLAIM); + assert!( + AuthenticationPermission::TokenEmergencyAction as u32 + == permissions::TOKEN_EMERGENCY_ACTION + ); + assert!(AuthenticationPermission::TokenConfigUpdate as u32 == permissions::TOKEN_CONFIG_UPDATE); + assert!( + AuthenticationPermission::TokenDirectPurchase as u32 == permissions::TOKEN_DIRECT_PURCHASE + ); + assert!(AuthenticationPermission::TokenSetPrice as u32 == permissions::TOKEN_SET_PRICE); +}; + #[wasm_bindgen(typescript_custom_section)] const TS_TYPES: &str = r#" -/** - * ContractBounds serialized as a plain object. - */ -export interface ContractBoundsObject { - identifier: Uint8Array; - documentTypeName?: string; - contractBoundsType: "SingleContract" | "SingleContractDocumentType"; +export interface ContractScopeInput { id: string; documentTypes?: string[] | null; } +export interface AuthenticationScopeJSON { + $formatVersion: "0"; + contracts: ContractScopeInput[]; + permissions: number; + expiresAt: number | string | null; } /** - * ContractBounds serialized as JSON. + * ContractBounds serialized as a plain object. */ -export interface ContractBoundsJSON { - identifier: string; - documentTypeName?: string; - contractBoundsType: "SingleContract" | "SingleContractDocumentType"; -} +export type ContractBoundsObject = + | { $type: "singleContract"; id: Uint8Array } + | { $type: "documentType"; id: Uint8Array; documentTypeName: string } + | { $type: "scoped"; $formatVersion: "0"; contracts: { id: Uint8Array; documentTypes: string[] | null }[]; permissions: number; expiresAt: bigint | null }; + +/** ContractBounds serialized as JSON. */ +export type ContractBoundsJSON = + | { $type: "singleContract"; id: string } + | { $type: "documentType"; id: string; documentTypeName: string } + | ({ $type: "scoped" } & AuthenticationScopeJSON); "#; #[wasm_bindgen] @@ -98,9 +165,44 @@ impl ContractBoundsWasm { )) } + /// Creates an application delegation. Sort order is canonicalized by the + /// constructor; duplicates/empty restrictions remain errors. + #[wasm_bindgen(js_name = "Scoped")] + pub fn scoped( + contracts: JsValue, + permissions: u32, + expires_at: Option, + ) -> WasmDppResult { + let contracts_json: serde_json::Value = serde_wasm_bindgen::from_value(contracts) + .map_err(|e| WasmDppError::invalid_argument(e.to_string()))?; + let mut scope = AuthenticationScope::from_json(serde_json::json!({ + "$formatVersion": "0", "contracts": contracts_json, + "permissions": permissions, "expiresAt": expires_at, + }))?; + let AuthenticationScope::V0(ref mut inner) = scope; + inner.contracts.sort_by_key(|entry| entry.id); + for entry in &mut inner.contracts { + if let Some(names) = &mut entry.document_types { + names.sort(); + } + } + scope.validate()?; + Ok(ContractBoundsWasm(ContractBounds::Scoped(scope))) + } + + #[wasm_bindgen(getter)] + pub fn scope(&self) -> WasmDppResult { + match &self.0 { + ContractBounds::Scoped(scope) => { + crate::serialization::conversions::json_to_js_value(&scope.to_json()?) + } + _ => Ok(JsValue::UNDEFINED), + } + } + #[wasm_bindgen(getter = "identifier")] - pub fn id(&self) -> IdentifierWasm { - (*self.0.identifier()).into() + pub fn id(&self) -> Option { + self.0.identifier().copied().map(Into::into) } #[wasm_bindgen(getter = "documentTypeName")] @@ -126,6 +228,11 @@ impl ContractBoundsWasm { let contract_id: Identifier = contract_id.try_into()?; self.0 = match self.clone().0 { + ContractBounds::Scoped(_) => { + return Err(WasmDppError::invalid_argument( + "replace the complete scope to change scoped bounds", + )); + } ContractBounds::SingleContract { .. } => { ContractBounds::SingleContract { id: contract_id } } @@ -144,8 +251,13 @@ impl ContractBoundsWasm { pub fn set_document_type_name( &mut self, #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, - ) { + ) -> WasmDppResult<()> { self.0 = match self.clone().0 { + ContractBounds::Scoped(_) => { + return Err(WasmDppError::invalid_argument( + "replace the complete scope to change scoped bounds", + )); + } ContractBounds::SingleContract { .. } => self.clone().0, ContractBounds::SingleContractDocumentType { id, .. } => { ContractBounds::SingleContractDocumentType { @@ -153,7 +265,8 @@ impl ContractBoundsWasm { document_type_name, } } - } + }; + Ok(()) } } diff --git a/packages/wasm-dpp2/src/lib.rs b/packages/wasm-dpp2/src/lib.rs index fa7a7772367..a4d0d336197 100644 --- a/packages/wasm-dpp2/src/lib.rs +++ b/packages/wasm-dpp2/src/lib.rs @@ -40,6 +40,7 @@ pub use core::pro_tx_hash::{ pub use identity::signer::IdentitySignerWasm; pub use identity::transitions::pooling::PoolingWasm; +pub use data_contract::contract_bounds::AuthenticationPermission; pub use data_contract::{ ContractBoundsWasm, DataContractCreateTransitionWasm, DataContractUpdateTransitionWasm, DataContractWasm, DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, diff --git a/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs b/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs index c8d3e38fbdc..89c3da84eb2 100644 --- a/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs +++ b/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs @@ -204,6 +204,11 @@ fn serialize_identity_public_key(key: &IdentityPublicKey) -> Result { let bounds_obj = Object::new(); match bounds { + dpp::identity::contract_bounds::ContractBounds::Scoped(scope) => { + Reflect::set(&bounds_obj, &JsValue::from_str("type"), &JsValue::from_str("Scoped"))?; + let value = serde_wasm_bindgen::to_value(scope).map_err(|e| JsValue::from_str(&e.to_string()))?; + Reflect::set(&bounds_obj, &JsValue::from_str("scope"), &value)?; + } dpp::identity::identity_public_key::contract_bounds::ContractBounds::SingleContract { id } => { Reflect::set(&bounds_obj, &JsValue::from_str("type"), &JsValue::from_str("SingleContract")) .map_err(|_| JsValue::from_str("Failed to set bounds type"))?; diff --git a/packages/wasm-sdk/tests/smoke/scoped-authentication.cjs b/packages/wasm-sdk/tests/smoke/scoped-authentication.cjs new file mode 100644 index 00000000000..554067fd41c --- /dev/null +++ b/packages/wasm-sdk/tests/smoke/scoped-authentication.cjs @@ -0,0 +1,47 @@ +/* Run against a real wasm-bindgen --target nodejs build: + * node tests/smoke/scoped-authentication.cjs /absolute/path/to/wasm_sdk.js + */ +const assert = require('node:assert/strict'); +const path = require('node:path'); + +if (!process.argv[2]) throw new Error('Pass the generated wasm_sdk.js module path'); +const wasm = require(path.resolve(process.argv[2])); +const id = '11111111111111111111111111111111'; +const P = wasm.AuthenticationPermission; +const bounds = wasm.ContractBounds.Scoped( + [{ id, documentTypes: ['post', 'like'] }], + P.DocumentCreate | P.DocumentTokenPayment, + 100n, +); +assert.deepEqual(bounds.scope.contracts[0].documentTypes, ['like', 'post']); +assert.deepEqual(wasm.ContractBounds.fromJSON(bounds.toJSON()).toJSON(), bounds.toJSON()); +assert.deepEqual(wasm.ContractBounds.fromObject(bounds.toObject()).toJSON(), bounds.toJSON()); +assert.equal(bounds.identifier, undefined); +assert.throws(() => { bounds.documentTypeName = 'profile'; }); + +for (const [contracts, mask] of [ + [[{ id, documentTypes: [] }], 65], + [[{ id }, { id }], 65], + [[{ id }], 0], + [[{ id }], 1 << 30], + [[{ id, documentTypes: ['a'.repeat(2048)] }], 65], +]) { + assert.throws(() => wasm.ContractBounds.Scoped(contracts, mask)); +} + +const key = new wasm.IdentityPublicKeyInCreation({ + keyId: 2, + purpose: 'authentication', + securityLevel: 'high', + keyType: 'ecdsa_hash160', + isReadOnly: false, + data: new Uint8Array(20).fill(1), + signature: new Uint8Array(), + contractBounds: bounds, +}); +assert.deepEqual(key.contractBounds.toJSON(), bounds.toJSON()); +assert.deepEqual( + wasm.IdentityPublicKeyInCreation.fromJSON(key.toJSON()).contractBounds.toJSON(), + bounds.toJSON(), +); +console.log('Scoped authentication WASM smoke checks passed');