Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -505,13 +505,67 @@ class DashDatabaseMigrationTest {
db.close()
}

/**
* v12 → v13 adds the nullable `public_keys.contractBoundsKind` column
* (additive). Pre-existing keys, bounded or not, must survive with a
* NULL kind (the restore path infers a legacy row's kind), and new rows
* must accept an explicit kind, including 3 (ContractGroup).
*/
@Test
fun migrate12To13AddsContractBoundsKindColumn() {
helper.createDatabase(dbName, 12).apply {
execSQL(
"INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " +
"syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " +
"VALUES (x'01', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)",
)
execSQL(
"INSERT INTO identities (identityId, balance, revision, isLocal, identityType, " +
"createdAt, lastUpdated, networkRaw, identityIndex, walletId) " +
"VALUES (x'0A', 0, 0, 1, 'User', 0, 0, 1, 0, x'01')",
)
execSQL(
"INSERT INTO public_keys (keyId, purpose, securityLevel, keyType, readOnly, " +
"publicKeyData, contractBoundsData, contractBoundsDocumentTypeName, " +
"identityId, createdAt, identityIdData) " +
"VALUES (0, '1', '3', '0', 0, x'02AB', x'5B5D', 'contactRequest', " +
"'id-base58', 0, x'0A')",
)
close()
}

val db = helper.runMigrationsAndValidate(dbName, 13, true, DashDatabase.MIGRATION_12_13)

// Pre-existing rows survive with a NULL kind and their bounds intact.
db.query(
"SELECT contractBoundsKind, contractBoundsDocumentTypeName FROM public_keys " +
"WHERE keyId = 0",
).use { c ->
assertTrue(c.moveToFirst())
assertTrue(c.isNull(0))
assertEquals("contactRequest", c.getString(1))
}
// New rows accept an explicit kind.
db.execSQL(
"INSERT INTO public_keys (keyId, purpose, securityLevel, keyType, readOnly, " +
"publicKeyData, contractBoundsData, contractBoundsKind, identityId, " +
"createdAt, identityIdData) " +
"VALUES (1, '0', '2', '0', 0, x'02CD', x'5B5D', 3, 'id-base58', 0, x'0A')",
)
db.query("SELECT contractBoundsKind FROM public_keys WHERE keyId = 1").use { c ->
assertTrue(c.moveToFirst())
assertEquals(3, c.getInt(0))
}
db.close()
}

/** The requested contiguous path from the pre-u64 v4 schema to latest. */
@Test
fun migrate4ToLatest() {
helper.createDatabase(dbName, 4).close()
helper.runMigrationsAndValidate(
dbName,
12,
13,
true,
DashDatabase.MIGRATION_4_5,
DashDatabase.MIGRATION_5_6,
Expand All @@ -521,16 +575,17 @@ class DashDatabaseMigrationTest {
DashDatabase.MIGRATION_9_10,
DashDatabase.MIGRATION_10_11,
DashDatabase.MIGRATION_11_12,
DashDatabase.MIGRATION_12_13,
).close()
}

/** The full chain from v1 must also land on a valid v12 schema. */
/** The full chain from v1 must also land on a valid v13 schema. */
@Test
fun migrateAllTheWayFrom1() {
helper.createDatabase(dbName, 1).close()
helper.runMigrationsAndValidate(
dbName,
12,
13,
true,
DashDatabase.MIGRATION_1_2,
DashDatabase.MIGRATION_2_3,
Expand All @@ -543,6 +598,7 @@ class DashDatabaseMigrationTest {
DashDatabase.MIGRATION_9_10,
DashDatabase.MIGRATION_10_11,
DashDatabase.MIGRATION_11_12,
DashDatabase.MIGRATION_12_13,
).close()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -504,10 +504,13 @@ abstract class NativePersistenceBridge {

/**
* One `IdentityKeyEntryFFI` upsert. Descriptor
* `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ)I`. The last four
* arguments are the key's usage limits (protocol version 14): the credits it
* may spend over its lifetime when [totalBudgetIsSome], and the block time in
* milliseconds from which it can no longer sign when [expiresAtIsSome].
* `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ)I`.
* `contractBoundsKind`: 0 none, 1 SingleContract, 2 SingleContractDocumentType,
* 3 ContractGroup (`contractBoundsId` is then the contract group id and
* `contractBoundsDocumentType` is null). The last four arguments are the
* key's usage limits (protocol version 14): the credits it may spend over
* its lifetime when [totalBudgetIsSome], and the block time in milliseconds
* from which it can no longer sign when [expiresAtIsSome].
*/
@Suppress("LongParameterList")
open fun onPersistIdentityKeyUpsert(
Expand Down Expand Up @@ -1263,8 +1266,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 ContractGroup;
* `contractBoundsId` is 32 bytes (or empty for kind 0): the contract id, or
* the contract group id for kind 3;
* `contractBoundsDocumentType` is non-null only for kind 2.
* `totalBudget` / `expiresAt` are the key's usage limits (protocol version 14),
* meaningful only when the matching `*IsSome` flag is set; a limited key must
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ internal object TransactionsNative {
* 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`, then `u8 limitsFlags`
* followed by `u64 totalBudget` (bit 0) and `u64 expiresAt` (bit 1).
* May be empty.
* (the contract group id for kind 3, ContractGroup) and (when `== 2`)
* `u16 docTypeLen, docType`, then `u8 limitsFlags` followed by
* `u64 totalBudget` (bit 0) and `u64 expiresAt` (bit 1). May be empty.
* @param disablePublicKeyIds key ids to disable; may be empty. At least
* one of add / disable must be non-empty.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ 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 ContractGroup)
* u16 pubkeyLen
* u8[pubkeyLen] pubkeyBytes (compressed pubkey, or 20-byte HASH160)
* if contractBoundsKind != 0:
* u8[32] contractBoundsId
* u8[32] contractBoundsId (contract id, or contract group id for kind 3)
* if contractBoundsKind == 2:
* u16 docTypeLen, u8[docTypeLen] docType (UTF-8)
* u8 limitsFlags (bit 0: totalBudget follows, bit 1: expiresAt follows)
Expand All @@ -41,6 +42,7 @@ import java.io.DataOutputStream
* if limitsFlags & 2:
* u64 expiresAt (block time in ms from which the key can no longer sign)
* ```
* Kind 3 carries the id only, never a document type.
*/
object IdentityPubkeyCodec {

Expand Down Expand Up @@ -71,6 +73,7 @@ object IdentityPubkeyCodec {
dos.writeShort(dt.size)
dos.write(dt)
}
is ContractBounds.ContractGroup -> dos.write(bounds.contractGroupId)
}
// Usage limits (protocol version 14): flags first, then only the values set.
val flags = (if (k.totalBudget != null) 1 else 0) or (if (k.expiresAt != null) 2 else 0)
Expand All @@ -81,10 +84,14 @@ 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 ContractGroup.
*/
internal fun contractBoundsKind(bounds: ContractBounds?): Int = when (bounds) {
null -> 0
is ContractBounds.SingleContract -> 1
is ContractBounds.SingleContractDocumentType -> 2
is ContractBounds.ContractGroup -> 3
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@ 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.
* Contract bounds of an identity public key: Kotlin mirror of the rs-dpp
* `ContractBounds` enum (and of Swift's `ManagedPlatformWallet.ContractBounds`).
* Drive requires them on ENCRYPTION / DECRYPTION keys. AUTHENTICATION keys
* may carry them since protocol version 14, where they limit what the key
* can sign. Null means unbounded.
*/
sealed class ContractBounds {
/** Bind the key to a single contract (any of its document types). */
Expand Down Expand Up @@ -85,6 +87,24 @@ sealed class ContractBounds {
override fun hashCode(): Int =
31 * contractId.contentHashCode() + documentTypeName.hashCode()
}

/**
* Bind an AUTHENTICATION key to a contract group: the key signs only
* within the group's members. [contractGroupId] is the 32-byte contract
* group id; there is never a document type.
*/
data class ContractGroup(val contractGroupId: ByteArray) : ContractBounds() {
Comment thread
QuantumExplorer marked this conversation as resolved.
init {
require(contractGroupId.size == 32) {
"contractGroupId must be 32 bytes, got ${contractGroupId.size}"
}
}

override fun equals(other: Any?): Boolean =
other is ContractGroup && contractGroupId.contentEquals(other.contractGroupId)

override fun hashCode(): Int = contractGroupId.contentHashCode()
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,22 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti
* pre-migration row reads back as an ordinary, unstamped, non-tombstone
* entry, and a wallet with no recorded chainlock height has no boundary
* at all (nothing collects).
*
* Version 12 (identity key usage limits, protocol version 14): adds the
* nullable `public_keys.totalBudget` and `public_keys.expiresAt` columns,
* so a key registered with a lifetime budget or an expiry restores as the
* limited key it is instead of an unlimited one.
*
* Version 13 (contract group key bounds): adds the nullable
* `public_keys.contractBoundsKind` column. The id and document type name
* alone cannot tell a ContractGroup bound (kind 3) from a SingleContract
* bound (kind 1), so a group-bound AUTHENTICATION key used to restore as
* SingleContract on the group id. The persist callback now records the
* kind the native row carries; a NULL kind (legacy row) keeps the old
* inference on restore.
*/
@Database(
version = 12,
version = 13,
exportSchema = true,
entities = [
WalletEntity::class,
Expand Down Expand Up @@ -632,6 +645,21 @@ abstract class DashDatabase : RoomDatabase() {
}
}

/**
* v12 -> v13: additive nullable `public_keys.contractBoundsKind`, see
* the version-13 class doc above. NULL for every pre-existing row:
* the restore path infers a legacy row's kind as before, and the
* persist callback records the real kind on the next upsert of each
* key.
*/
val MIGRATION_12_13: Migration = object : Migration(12, 13) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE `public_keys` ADD COLUMN `contractBoundsKind` INTEGER",
)
}
}

/**
* Build the on-disk database. WAL is Room's default journal mode on
* API 16+; writes go through the persistence handler inside
Expand All @@ -652,6 +680,7 @@ abstract class DashDatabase : RoomDatabase() {
MIGRATION_9_10,
MIGRATION_10_11,
MIGRATION_11_12,
MIGRATION_12_13,
)
.build()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1981,10 +1981,13 @@ class PlatformWalletPersistenceHandler(
val identityBase58 = identityId.toBase58String()
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)
// doc-type name (Swift stores `[base64(contractId)]` JSON), plus
// the kind itself: the blob holds the contract group id for kind
// 3, which the other two columns cannot tell from kind 1.
val boundsKind = contractBoundsKind.toInt() and 0xFF
val boundsData = if (boundsKind != 0)
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,
Expand All @@ -2001,6 +2004,7 @@ class PlatformWalletPersistenceHandler(
publicKeyData = publicKeyData,
contractBoundsData = boundsData,
contractBoundsDocumentTypeName = docTypeName,
contractBoundsKind = boundsKind,
// 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
Expand Down Expand Up @@ -2857,17 +2861,35 @@ class PlatformWalletPersistenceHandler(
.sortedBy { it.keyId }
.map { pk ->
// ContractBounds → (kind, 32-byte id, doc-type). Inverse
// of `contractBoundsIdToJson` on the persist side:
// * no blob → kind 0 (unbounded)
// * blob + docType → kind 2 (SingleContractDocumentType)
// * blob, no docType → kind 1 (SingleContract)
// of `contractBoundsIdToJson` on the persist side. The
// stored kind decides:
// * no blob → kind 0 (unbounded)
// * stored kind 3 → kind 3 (ContractGroup, no docType)
// * stored kind 2 → kind 2 with its docType; without
// one it demotes to kind 1, as the
// Rust loader does
// * stored kind 1 → kind 1 (SingleContract)
// * stored kind 0, or a kind this build does not know
// → kind 0, the same fallback the
// Swift restore path applies,
// rather than asserting a
// SingleContract bound the key
// never had
// A NULL stored kind is a legacy row (schema < 13) and
// keeps the inference those rows have always used:
// blob + docType → kind 2, blob alone → kind 1.
// A blob that fails to decode to 32 bytes degrades to
// kind 0 rather than crashing FFI marshalling.
val boundsId = pk.contractBoundsData?.let { contractBoundsJsonToId(it) }
val hasDocType = !pk.contractBoundsDocumentTypeName.isNullOrEmpty()
val storedKind = pk.contractBoundsKind
val (kind, id) = when {
boundsId == null -> 0.toByte() to ByteArray(0)
pk.contractBoundsDocumentTypeName != null -> 2.toByte() to boundsId
else -> 1.toByte() to boundsId
storedKind == 3 -> 3.toByte() to boundsId
storedKind == 1 -> 1.toByte() to boundsId
storedKind == 2 || storedKind == null ->
(if (hasDocType) 2 else 1).toByte() to boundsId
else -> 0.toByte() to ByteArray(0)
}
Comment thread
QuantumExplorer marked this conversation as resolved.
IdentityKeyRestoreData(
keyId = pk.keyId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ data class PublicKeyEntity(
val contractBoundsData: ByteArray? = null,
/** Document-type qualifier for `.singleContractDocumentType` bounds. */
val contractBoundsDocumentTypeName: String? = null,
/**
* Contract-bounds kind as the native row carried it: 0 none, 1
* SingleContract, 2 SingleContractDocumentType, 3 ContractGroup (then
* [contractBoundsData] holds the contract group id). Null on rows
* written before schema 13, whose kind is inferred on restore.
*/
val contractBoundsKind: Int? = null,
val privateKeyKeychainIdentifier: String? = null,
/**
* Derivation breadcrumb (DIP-9 identity index) captured from the
Expand Down
Loading
Loading