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
6 changes: 4 additions & 2 deletions book/src/data-model/key-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Five facts define a limited key:

1. **Limits are opt-in per key and live on a new key version.** `IdentityPublicKey::V1` is the version 0 key followed by `total_budget` and `expires_at`. Every key that existed before, and every key without limits registered after, is still a version 0 key with the same bytes as ever.
2. **Only AUTHENTICATION keys below MASTER may carry them.** The master key is what registers a replacement when a key runs out, so it must never run out itself. TRANSFER, ENCRYPTION and DECRYPTION keys cannot be limited.
3. **Limits are signed and immutable.** They are part of the signable bytes of the transition that registers the key, and there is no transition that changes them. To give an application more, register another key.
3. **Limits are signed, and only ever loosened.** They are part of the signable bytes of the transition that registers the key. The one transition that changes them, `IdentityKeyLimitsUpdate` (see Raising Limits below), raises a budget or moves an expiry later; to give an application less, disable the key and register another.
4. **A budget caps what leaves the identity, and only goes down.** Fees, and credits the transition moves out (a document purchase, a prefunded voting balance), count against it. Storage refunds do not top it up. What is left is tracked by Drive next to the key, because the key itself never changes.
5. **An expiry is a block time.** `expires_at` is an absolute timestamp in milliseconds, the same unit and clock as `disabled_at`. The key signs at `expires_at - 1` and not at `expires_at`.

Expand Down Expand Up @@ -216,7 +216,9 @@ The last check is the one with a twist. An expired key may be revived by moving

**The proof.** The proof is the rewritten key, nothing more. The verifier requires the key present and holding exactly the total budget and the expiry the transition asked for. That authenticates the state the update aimed at, not this exact transition: the nonce and the fee increase are signed but not stored, so any later state of the key with those limits would produce the same proof. The outcome is therefore classified as affected state, like a credit transfer, and the SDKs wait for it with the affected-state wait.

In the SDKs: `Identity::update_key_limits`, `top_up_key_budget` and `extend_key_expiry` (Rust, `UpdateIdentityKeyLimits`), `identityUpdateKeyLimits({ identity, keyId, addBudget, expiresAt, signer })` (wasm-sdk), `sdk.identities.updateKeyLimits` (js-evo-sdk). All resolve to the key as stored after the update.
In the SDKs: `Identity::update_key_limits`, `top_up_key_budget` and `extend_key_expiry` (Rust, `UpdateIdentityKeyLimits`), `identityUpdateKeyLimits({ identity, keyId, addBudget, expiresAt, signer })` (wasm-sdk), `sdk.identities.updateKeyLimits` (js-evo-sdk). All resolve to the key as stored after the update. A limited key is registered the ordinary way: an `IdentityPublicKeyInCreation` built with `totalBudget` or `expiresAt` (wasm-dpp2) passed to `identityUpdate` or `sdk.identities.update`, or an `IdentityPublicKey::with_limits(..)` key passed to the Rust identity update builder.

In the wallet and on mobile: `IdentityWallet::update_identity_key_limits_with_external_signer` (platform-wallet) raises the limits and lays the key as stored over the cached identity, so the client's key row follows through the persister; the FFI exposes it as `platform_wallet_update_identity_key_limits_with_signer` and the query as `dash_sdk_identity_fetch_keys_remaining_budgets`. Every key row that crosses the FFI (registration, update, the persisted key entry, the cold-restore row and the managed identity's key snapshot) carries `total_budget` and `expires_at`, so a limited key persists and restores as limited. Kotlin: `IdentityUpdates.updateKeyLimits` and `Identities.fetchKeysRemainingBudgets`, with `IdentityPubkey.totalBudget` / `expiresAt` on the rows it registers (Room schema 12). Swift: `ManagedPlatformWallet.updateIdentityKeyLimits(identityId:keyId:addBudget:expiresAt:signer:)` and `SDK.fetchKeysRemainingBudgets(identityId:keyIds:)`, with the two limits on `IdentityPublicKey`, `IdentityPublicKeyInfo`, the `IdentityPubkey` row and `PersistentPublicKey` (SwiftData schema V5). The wallet's own signers prefer a key without limits and skip an expired one; what is left of a budget is only known through the query, so a spent key is refused by Platform.

## The Budget Rule

Expand Down
18 changes: 18 additions & 0 deletions book/src/evo-sdk/state-transitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,24 @@ await sdk.identities.creditTransfer({
});
```

### Register a key with a budget or an expiry

A key added with `totalBudget` or `expiresAt` is registered with those limits (protocol version 14): an application key that can spend at most so many credits, or that stops signing at a block time. Only AUTHENTICATION keys below MASTER may carry them. `identityUpdate` assigns the key id; the signer holds the identity's MASTER key and the new key's private key, since a new key signs its own registration.

```typescript
const appKey = new IdentityPublicKeyInCreation({
keyId: 0, // reassigned to the next free id
purpose: 'AUTHENTICATION',
securityLevel: 'CRITICAL',
keyType: 'ECDSA_SECP256K1',
data: appKeyPublicKeyBytes,
totalBudget: 500000000n, // credits this key may take from the identity over its lifetime
expiresAt: 1800000000000n, // optional: block time in milliseconds from which it stops signing
});

await sdk.identities.update({ identity, addPublicKeys: [appKey], signer });
```

### Raise a key's limits

A key registered with a budget or an expiry can be topped up, or have its expiry moved later, without being replaced. The signer holds a MASTER key, or a CRITICAL authentication key without limits and without contract bounds; the budget is added to the total the passed identity's key shows.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -470,13 +470,48 @@ class DashDatabaseMigrationTest {
db.close()
}

/**
* v11 -> v12 adds the identity key usage limits (protocol version 14):
* `totalBudget` and `expiresAt` on `public_keys`, both nullable. A key
* persisted before the migration reads back without limits, and a limited
* key written afterwards keeps both values, so a restored key keeps the
* limits it was registered with.
*/
@Test
fun migrate11To12AddsKeyLimitColumns() {
val legacy = helper.createDatabase(dbName, 11)
legacy.execSQL(
"INSERT INTO public_keys (keyId, purpose, securityLevel, keyType, readOnly, " +
"publicKeyData, identityId, createdAt) " +
"VALUES (5, '0', '1', '0', 0, x'02', 'GL2Rq8L3VuBEQfCAZykmUaiXXrsd1Bwub2gcaMmtNbn3', 0)",
)
legacy.close()

val db = helper.runMigrationsAndValidate(dbName, 12, true, DashDatabase.MIGRATION_11_12)
db.query("SELECT totalBudget, expiresAt FROM public_keys WHERE keyId = 5").use { c ->
assertTrue(c.moveToFirst())
assertTrue(c.isNull(0))
assertTrue(c.isNull(1))
}
db.execSQL(
"UPDATE public_keys SET totalBudget = 500000000, expiresAt = 1800000000000 " +
"WHERE keyId = 5",
)
db.query("SELECT totalBudget, expiresAt FROM public_keys WHERE keyId = 5").use { c ->
assertTrue(c.moveToFirst())
assertEquals(500_000_000L, c.getLong(0))
assertEquals(1_800_000_000_000L, c.getLong(1))
}
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,
11,
12,
true,
DashDatabase.MIGRATION_4_5,
DashDatabase.MIGRATION_5_6,
Expand All @@ -485,16 +520,17 @@ class DashDatabaseMigrationTest {
DashDatabase.MIGRATION_8_9,
DashDatabase.MIGRATION_9_10,
DashDatabase.MIGRATION_10_11,
DashDatabase.MIGRATION_11_12,
).close()
}

/** The full chain from v1 must also land on a valid v11 schema. */
/** The full chain from v1 must also land on a valid v12 schema. */
@Test
fun migrateAllTheWayFrom1() {
helper.createDatabase(dbName, 1).close()
helper.runMigrationsAndValidate(
dbName,
11,
12,
true,
DashDatabase.MIGRATION_1_2,
DashDatabase.MIGRATION_2_3,
Expand All @@ -506,6 +542,7 @@ class DashDatabaseMigrationTest {
DashDatabase.MIGRATION_8_9,
DashDatabase.MIGRATION_9_10,
DashDatabase.MIGRATION_10_11,
DashDatabase.MIGRATION_11_12,
).close()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,13 @@ 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;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].
*/
@Suppress("LongParameterList")
open fun onPersistIdentityKeyUpsert(
walletId: ByteArray,
Expand All @@ -524,6 +530,10 @@ abstract class NativePersistenceBridge {
contractBoundsKind: Byte,
contractBoundsId: ByteArray,
contractBoundsDocumentType: String?,
totalBudgetIsSome: Boolean = false,
totalBudget: Long = 0L,
expiresAtIsSome: Boolean = false,
expiresAt: Long = 0L,
): Int = 0

/** One `(identityId, keyId)` removal. Descriptor `([B[BI)I`. */
Expand Down Expand Up @@ -1256,6 +1266,9 @@ class ContactRequestRestoreData(
* `contractBoundsKind`: 0 none, 1 SingleContract, 2 SingleContractDocumentType;
* `contractBoundsId` is 32 bytes (or empty for kind 0);
* `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
* restore as limited, or it would come back unlimited on cold restart.
*/
class IdentityKeyRestoreData(
@JvmField val keyId: Int,
Expand All @@ -1267,6 +1280,10 @@ class IdentityKeyRestoreData(
@JvmField val contractBoundsKind: Byte,
@JvmField val contractBoundsId: ByteArray,
@JvmField val contractBoundsDocumentType: String?,
@JvmField val totalBudgetIsSome: Boolean = false,
@JvmField val totalBudget: Long = 0L,
@JvmField val expiresAtIsSome: Boolean = false,
@JvmField val expiresAt: Long = 0L,
)

/** Mirror of `ShieldedNoteRestoreFFI`. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,19 @@ internal object QueriesNative {
/** Identity balance + revision as a JSON object, or null. */
external fun identityFetchBalanceAndRevision(sdk: Long, identityId: String): String?

/**
* What is left of the budgets of [keyIds] of [identityId] (protocol version 14),
* as a JSON object keyed by key id: `{"5": "1000", "6": null}`. A budgeted key
* maps to the credits left as a decimal string; a key without a budget, or that
* the identity does not have, maps to null. Bridges
* `dash_sdk_identity_fetch_keys_remaining_budgets`.
*/
external fun identityFetchKeysRemainingBudgets(
sdk: Long,
identityId: String,
keyIds: IntArray,
): String?

/** Identity owning a unique public-key hash (hex) as JSON, or null. */
external fun identityFetchByPublicKeyHash(sdk: Long, publicKeyHash: String): String?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +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`. May be empty.
* 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 All @@ -40,6 +42,27 @@ internal object TransactionsNative {
signerHandle: Long,
)

/**
* Raise the limits of key [keyId] of [identityId] (protocol version 14):
* add [addBudget] credits to its total budget when [hasAddBudget], and
* move its expiry to [expiresAt] (block time in milliseconds) when
* [hasExpiresAt]. Bridges
* `platform_wallet_update_identity_key_limits_with_signer`; [signerHandle]
* holds the identity's MASTER key or a CRITICAL authentication key
* without limits and without contract bounds. Room learns of the raised
* limits through the persistence changeset.
*/
external fun updateIdentityKeyLimits(
walletHandle: Long,
identityId: ByteArray,
keyId: Int,
hasAddBudget: Boolean,
addBudget: Long,
hasExpiresAt: Boolean,
expiresAt: Long,
signerHandle: Long,
)

/**
* Purchase for-sale [documentId] on [contractId]'s [documentType] for
* [price] credits, with [purchaserId] as the buyer — signed via
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import java.io.DataOutputStream
* u8[32] contractBoundsId
* if contractBoundsKind == 2:
* u16 docTypeLen, u8[docTypeLen] docType (UTF-8)
* u8 limitsFlags (bit 0: totalBudget follows, bit 1: expiresAt follows)
* if limitsFlags & 1:
* u64 totalBudget (credits the key may spend over its lifetime)
* if limitsFlags & 2:
* u64 expiresAt (block time in ms from which the key can no longer sign)
* ```
*/
object IdentityPubkeyCodec {
Expand Down Expand Up @@ -67,6 +72,11 @@ object IdentityPubkeyCodec {
dos.write(dt)
}
}
// 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)
dos.writeByte(flags)
k.totalBudget?.let { dos.writeLong(it) }
k.expiresAt?.let { dos.writeLong(it) }
}
return out.toByteArray()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ sealed class ContractBounds {
*
* @property pubkeyBytes the on-chain public payload — the 33-byte compressed
* pubkey, or the 20-byte HASH160 for an [KeyType.ECDSA_HASH160] key.
* @property totalBudget usage limit (protocol version 14): the credits the key may
* take from the identity over its lifetime. Only AUTHENTICATION keys below
* MASTER may carry limits; either limit registers a version 1 key.
* @property expiresAt usage limit (protocol version 14): the block time in
* milliseconds from which the key can no longer sign.
*/
data class IdentityPubkey(
val keyId: Int,
Expand All @@ -106,11 +111,22 @@ data class IdentityPubkey(
val pubkeyBytes: ByteArray,
val readOnly: Boolean = false,
val contractBounds: ContractBounds? = null,
val totalBudget: Long? = null,
val expiresAt: Long? = null,
) {
init {
require(keyId >= 0) { "keyId must be non-negative, got $keyId" }
require(totalBudget == null || totalBudget >= 0) {
"totalBudget must be non-negative, got $totalBudget"
}
require(expiresAt == null || expiresAt >= 0) {
"expiresAt must be non-negative, got $expiresAt"
}
}

/** Whether the key carries a budget or an expiry (a version 1 key). */
val hasLimits: Boolean get() = totalBudget != null || expiresAt != null

override fun equals(other: Any?): Boolean =
other is IdentityPubkey &&
keyId == other.keyId &&
Expand All @@ -119,7 +135,9 @@ data class IdentityPubkey(
securityLevel == other.securityLevel &&
pubkeyBytes.contentEquals(other.pubkeyBytes) &&
readOnly == other.readOnly &&
contractBounds == other.contractBounds
contractBounds == other.contractBounds &&
totalBudget == other.totalBudget &&
expiresAt == other.expiresAt

override fun hashCode(): Int {
var result = keyId
Expand All @@ -129,6 +147,8 @@ data class IdentityPubkey(
result = 31 * result + pubkeyBytes.contentHashCode()
result = 31 * result + readOnly.hashCode()
result = 31 * result + (contractBounds?.hashCode() ?: 0)
result = 31 * result + (totalBudget?.hashCode() ?: 0)
result = 31 * result + (expiresAt?.hashCode() ?: 0)
return result
}
}
Expand Down Expand Up @@ -197,4 +217,51 @@ class IdentityUpdates internal constructor(
)
}
}

/**
* Raise the limits of key [keyId] of [identityId] (protocol version 14):
* add [addBudget] credits to its total budget (and to what is left of
* it), and/or move its expiry to [expiresAt] (block time in
* milliseconds). At least one must be given. Bridges
* `platform_wallet_update_identity_key_limits_with_signer`, signed via
* [signerHandle] with the identity's MASTER key or a CRITICAL
* authentication key without limits and without contract bounds. A
* limit the key does not have, a zero top-up and an expiry that is not
* later are refused before anything is signed. Room learns of the raised
* limits through the persistence changeset, as it does for an added key.
*/
suspend fun updateKeyLimits(
walletHandle: Long,
identityId: ByteArray,
keyId: Int,
addBudget: Long? = null,
expiresAt: Long? = null,
signerHandle: Long,
) = gate.op {
require(identityId.size == 32) {
"identityId must be 32 bytes, got ${identityId.size}"
}
require(keyId >= 0) { "keyId must be non-negative, got $keyId" }
require(addBudget != null || expiresAt != null) {
"updateKeyLimits needs a budget to add or a new expiry"
}
require(addBudget == null || addBudget > 0) {
"addBudget must be positive, got $addBudget"
}
require(expiresAt == null || expiresAt >= 0) {
"expiresAt must be non-negative, got $expiresAt"
}
mapNativeErrors {
TransactionsNative.updateIdentityKeyLimits(
walletHandle,
identityId,
keyId,
addBudget != null,
addBudget ?: 0L,
expiresAt != null,
expiresAt ?: 0L,
signerHandle,
)
}
}
}
Loading
Loading