From c5ac0a31d3671d94ad801b418921a8a415e5cfd7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 18 Sep 2026 18:03:12 +0700 Subject: [PATCH 1/7] feat(sdk): register a key with a budget or an expiry from wasm-dpp2 and the JS SDKs `IdentityPublicKeyInCreation` and `IdentityPublicKey` in wasm-dpp2 take `totalBudget` and `expiresAt` (bigint) in their options, expose them as getters and setters, and build a version 1 key when either is given; a key without limits stays a version 0 key with the same bytes as ever. The in-creation to key conversion goes through the dpp enum so the limits follow the key into `identityUpdate`, which previously rebuilt a version 0 key and dropped them. The TS Object and JSON shapes list `$formatVersion` and the two limits. Specs cover the constructor, the getters and setters, Object and JSON round trips for both key versions, and the conversion into an identity public key. The evo-sdk guide shows registering an app key with a budget, and the key limits chapter no longer calls limits immutable. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/key-limits.md | 4 +- book/src/evo-sdk/state-transitions.md | 18 +++ packages/wasm-dpp2/src/identity/public_key.rs | 79 +++++++++-- .../transitions/public_key_in_creation.rs | 133 ++++++++++++++---- .../tests/unit/IdentityPublicKey.spec.ts | 53 +++++++ .../tests/unit/PublicKeyInCreation.spec.ts | 94 +++++++++++++ .../src/state_transitions/identity.rs | 4 +- 7 files changed, 341 insertions(+), 44 deletions(-) diff --git a/book/src/data-model/key-limits.md b/book/src/data-model/key-limits.md index fab3bb99f53..0225f0f06e3 100644 --- a/book/src/data-model/key-limits.md +++ b/book/src/data-model/key-limits.md @@ -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`. @@ -216,7 +216,7 @@ 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. ## The Budget Rule diff --git a/book/src/evo-sdk/state-transitions.md b/book/src/evo-sdk/state-transitions.md index bc73cfd01d3..a97e1988dcb 100644 --- a/book/src/evo-sdk/state-transitions.md +++ b/book/src/evo-sdk/state-transitions.md @@ -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. diff --git a/packages/wasm-dpp2/src/identity/public_key.rs b/packages/wasm-dpp2/src/identity/public_key.rs index cbab9db7b31..8e2c1b42fe8 100644 --- a/packages/wasm-dpp2/src/identity/public_key.rs +++ b/packages/wasm-dpp2/src/identity/public_key.rs @@ -15,6 +15,7 @@ use crate::utils::{ use crate::version::PlatformVersionLikeJs; use dpp::dashcore::Network; use dpp::dashcore::secp256k1::hashes::hex::{Case, DisplayHex}; +use dpp::fee::Credits; use dpp::identity::contract_bounds::ContractBounds; use dpp::identity::hash::IdentityPublicKeyHashMethodsV0; use dpp::identity::identity_public_key::accessors::v0::{ @@ -43,6 +44,10 @@ struct IdentityPublicKeyOptions { is_read_only: bool, #[serde(default)] disabled_at: Option, + #[serde(default)] + total_budget: Option, + #[serde(default)] + expires_at: Option, } #[wasm_bindgen(typescript_custom_section)] @@ -60,10 +65,15 @@ export interface IdentityPublicKeyOptions { data: Uint8Array; disabledAt?: number; contractBounds?: ContractBounds; + /** Credits the key may spend over its lifetime (protocol version 14); makes it a version 1 key */ + totalBudget?: bigint; + /** Block time in milliseconds from which the key can no longer sign; makes it a version 1 key */ + expiresAt?: bigint; } /** - * IdentityPublicKey serialized as a plain object. + * IdentityPublicKey serialized as a plain object. `$formatVersion` is "0" for a key without + * limits and "1" for a key that may carry them. */ export interface IdentityPublicKeyObject { $formatVersion: string; @@ -75,6 +85,8 @@ export interface IdentityPublicKeyObject { readOnly: boolean; data: Uint8Array; disabledAt?: bigint; + totalBudget?: bigint; + expiresAt?: bigint; } /** @@ -90,6 +102,8 @@ export interface IdentityPublicKeyJSON { readOnly: boolean; data: string; disabledAt?: number; + totalBudget?: number | string; + expiresAt?: number | string; } /** @@ -174,18 +188,25 @@ impl IdentityPublicKeyWasm { let opts: IdentityPublicKeyOptions = serde_wasm_bindgen::from_value(options.into()) .map_err(|e| WasmDppError::invalid_argument(e.to_string()))?; - Ok(IdentityPublicKeyWasm(IdentityPublicKey::from( - IdentityPublicKeyV0 { - id: opts.key_id, - purpose: Purpose::from(purpose), - security_level: SecurityLevel::from(security_level), - contract_bounds, - key_type: KeyType::from(key_type), - read_only: opts.is_read_only, - data: BinaryData::new(opts.data), - disabled_at: opts.disabled_at, - }, - ))) + let key = IdentityPublicKey::from(IdentityPublicKeyV0 { + id: opts.key_id, + purpose: Purpose::from(purpose), + security_level: SecurityLevel::from(security_level), + contract_bounds, + key_type: KeyType::from(key_type), + read_only: opts.is_read_only, + data: BinaryData::new(opts.data), + disabled_at: opts.disabled_at, + }); + + // A key without limits stays a version 0 key, the same bytes as ever + let key = if opts.total_budget.is_some() || opts.expires_at.is_some() { + key.with_limits(opts.total_budget, opts.expires_at) + } else { + key + }; + + Ok(IdentityPublicKeyWasm(key)) } } @@ -342,6 +363,38 @@ impl IdentityPublicKeyWasm { Ok(()) } + /// Setting a budget on a version 0 key makes it a version 1 key + #[wasm_bindgen(setter = totalBudget)] + pub fn set_total_budget( + &mut self, + #[wasm_bindgen(js_name = "totalBudget")] total_budget: Option, + ) -> WasmDppResult<()> { + let total_budget = total_budget + .map(|value| try_to_u64(&value, "totalBudget")) + .transpose()?; + self.0 = self + .0 + .clone() + .with_limits(total_budget, self.0.expires_at()); + Ok(()) + } + + /// Setting an expiry on a version 0 key makes it a version 1 key + #[wasm_bindgen(setter = expiresAt)] + pub fn set_expires_at( + &mut self, + #[wasm_bindgen(js_name = "expiresAt")] expires_at: Option, + ) -> WasmDppResult<()> { + let expires_at = expires_at + .map(|value| try_to_u64(&value, "expiresAt")) + .transpose()?; + self.0 = self + .0 + .clone() + .with_limits(self.0.total_budget(), expires_at); + Ok(()) + } + #[wasm_bindgen(js_name = "getPublicKeyHash")] pub fn public_key_hash(&self) -> WasmDppResult { let hash = self diff --git a/packages/wasm-dpp2/src/identity/transitions/public_key_in_creation.rs b/packages/wasm-dpp2/src/identity/transitions/public_key_in_creation.rs index 3a5564cd310..2b7d6080fe8 100644 --- a/packages/wasm-dpp2/src/identity/transitions/public_key_in_creation.rs +++ b/packages/wasm-dpp2/src/identity/transitions/public_key_in_creation.rs @@ -7,15 +7,17 @@ use crate::identity::public_key::IdentityPublicKeyWasm; use crate::impl_try_from_js_value; use crate::impl_wasm_conversions_inner; use crate::impl_wasm_type_info; -use crate::utils::{try_from_options, try_from_options_optional, try_to_u32}; -use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; -use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use crate::utils::{try_from_options, try_from_options_optional, try_to_u32, try_to_u64}; +use dpp::fee::Credits; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel, TimestampMillis}; use dpp::platform_value::BinaryData; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::state_transition::public_key_in_creation::accessors::{ IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreationV0Setters, + IdentityPublicKeyInCreationV1Getters, }; use dpp::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; +use dpp::state_transition::public_key_in_creation::v1::IdentityPublicKeyInCreationV1; use serde::Deserialize; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; @@ -29,10 +31,20 @@ struct IdentityPublicKeyInCreationOptions { is_read_only: bool, #[serde(default)] signature: Option>, + #[serde(default)] + total_budget: Option, + #[serde(default)] + expires_at: Option, } #[wasm_bindgen(typescript_custom_section)] const TS_TYPES: &str = r#" +/** + * A key registered with `totalBudget` or `expiresAt` carries usage limits (protocol version + * 14): the credits its transitions may take from the identity over its lifetime, and the block + * time in milliseconds from which it can no longer sign. Only AUTHENTICATION keys below MASTER + * may carry them. The limits are part of what the identity signs when the key is registered. + */ export interface IdentityPublicKeyInCreationOptions { keyId: number; purpose: PurposeLike; @@ -42,12 +54,16 @@ export interface IdentityPublicKeyInCreationOptions { data: Uint8Array; signature?: Uint8Array; contractBounds?: ContractBounds; + totalBudget?: bigint; + expiresAt?: bigint; } /** - * IdentityPublicKeyInCreation serialized as a plain object. + * IdentityPublicKeyInCreation serialized as a plain object. `$formatVersion` is "0" for a key + * without limits and "1" for a key that may carry them. */ export interface IdentityPublicKeyInCreationObject { + $formatVersion: string; keyId: number; purpose: Purpose; securityLevel: SecurityLevel; @@ -56,12 +72,15 @@ export interface IdentityPublicKeyInCreationObject { data: Uint8Array; signature?: Uint8Array; contractBounds?: ContractBoundsObject; + totalBudget?: bigint; + expiresAt?: bigint; } /** * IdentityPublicKeyInCreation serialized as JSON. */ export interface IdentityPublicKeyInCreationJSON { + $formatVersion: string; keyId: number; purpose: string; securityLevel: string; @@ -70,6 +89,8 @@ export interface IdentityPublicKeyInCreationJSON { data: string; signature?: string; contractBounds?: ContractBoundsJSON; + totalBudget?: number | string; + expiresAt?: number | string; } "#; @@ -103,19 +124,9 @@ impl From for IdentityPublicKeyInCreation { } impl From for IdentityPublicKey { + /// Goes through the enum so the limits of a version 1 key follow it fn from(value: IdentityPublicKeyInCreationWasm) -> Self { - let contract_bounds = value.0.contract_bounds().cloned(); - - IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: value.0.id(), - purpose: value.0.purpose(), - security_level: value.0.security_level(), - contract_bounds, - key_type: value.0.key_type(), - read_only: value.0.read_only(), - data: value.0.data().clone(), - disabled_at: None, - }) + IdentityPublicKey::from(value.0) } } @@ -143,18 +154,27 @@ impl IdentityPublicKeyInCreationWasm { serde_wasm_bindgen::from_value(options.into()) .map_err(|e| WasmDppError::invalid_argument(e.to_string()))?; - Ok(IdentityPublicKeyInCreationWasm( - IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { - id: opts.key_id, - key_type: KeyType::from(key_type), - purpose: Purpose::from(purpose), - security_level: SecurityLevel::from(security_level), - contract_bounds: contract_bounds.map(Into::into), - read_only: opts.is_read_only, - data: BinaryData::new(opts.data), - signature: BinaryData::from(opts.signature.unwrap_or_default()), - }), - )) + let key = IdentityPublicKeyInCreationV0 { + id: opts.key_id, + key_type: KeyType::from(key_type), + purpose: Purpose::from(purpose), + security_level: SecurityLevel::from(security_level), + contract_bounds: contract_bounds.map(Into::into), + read_only: opts.is_read_only, + data: BinaryData::new(opts.data), + signature: BinaryData::from(opts.signature.unwrap_or_default()), + }; + + // A key without limits stays a version 0 key, the same bytes as ever + let key = match (opts.total_budget, opts.expires_at) { + (None, None) => key.into(), + (total_budget, expires_at) => { + IdentityPublicKeyInCreationV1::from_v0_with_limits(key, total_budget, expires_at) + .into() + } + }; + + Ok(IdentityPublicKeyInCreationWasm(key)) } #[wasm_bindgen(js_name = "toIdentityPublicKey")] @@ -211,6 +231,19 @@ impl IdentityPublicKeyInCreationWasm { self.0.signature().to_vec() } + /// The credits the key may spend over its lifetime, `undefined` when it has no budget + #[wasm_bindgen(getter = "totalBudget")] + pub fn total_budget(&self) -> Option { + self.0.total_budget() + } + + /// The block time in milliseconds from which the key can no longer sign, `undefined` when + /// it does not expire + #[wasm_bindgen(getter = "expiresAt")] + pub fn expires_at(&self) -> Option { + self.0.expires_at() + } + #[wasm_bindgen(setter = keyId)] pub fn set_key_id( &mut self, @@ -271,6 +304,50 @@ impl IdentityPublicKeyInCreationWasm { pub fn set_contract_bounds(&mut self, bounds: Option) { self.0.set_contract_bounds(bounds.map(|b| b.into())); } + + /// Setting a budget on a version 0 key makes it a version 1 key. The key's own signature + /// no longer covers it afterwards: sign again. + #[wasm_bindgen(setter = "totalBudget")] + pub fn set_total_budget( + &mut self, + #[wasm_bindgen(js_name = "totalBudget")] total_budget: Option, + ) -> WasmDppResult<()> { + let total_budget = total_budget + .map(|value| try_to_u64(&value, "totalBudget")) + .transpose()?; + self.set_limits(total_budget, self.0.expires_at()); + Ok(()) + } + + /// Setting an expiry on a version 0 key makes it a version 1 key. The key's own signature + /// no longer covers it afterwards: sign again. + #[wasm_bindgen(setter = "expiresAt")] + pub fn set_expires_at( + &mut self, + #[wasm_bindgen(js_name = "expiresAt")] expires_at: Option, + ) -> WasmDppResult<()> { + let expires_at = expires_at + .map(|value| try_to_u64(&value, "expiresAt")) + .transpose()?; + self.set_limits(self.0.total_budget(), expires_at); + Ok(()) + } +} + +impl IdentityPublicKeyInCreationWasm { + fn set_limits(&mut self, total_budget: Option, expires_at: Option) { + self.0 = match self.0.clone() { + IdentityPublicKeyInCreation::V0(v0) => { + IdentityPublicKeyInCreationV1::from_v0_with_limits(v0, total_budget, expires_at) + .into() + } + IdentityPublicKeyInCreation::V1(mut v1) => { + v1.total_budget = total_budget; + v1.expires_at = expires_at; + v1.into() + } + }; + } } impl IdentityPublicKeyInCreationWasm { diff --git a/packages/wasm-dpp2/tests/unit/IdentityPublicKey.spec.ts b/packages/wasm-dpp2/tests/unit/IdentityPublicKey.spec.ts index b23e3d35247..1282fd3f0c3 100644 --- a/packages/wasm-dpp2/tests/unit/IdentityPublicKey.spec.ts +++ b/packages/wasm-dpp2/tests/unit/IdentityPublicKey.spec.ts @@ -283,6 +283,59 @@ describe('IdentityPublicKey', () => { }); }); + describe('limits', () => { + it('should build a version 1 key from a budget and an expiry', () => { + const pubKey = new wasm.IdentityPublicKey({ + keyId, + purpose, + securityLevel, + keyType, + isReadOnly: false, + data: binaryData, + totalBudget: BigInt(500000000), + expiresAt: BigInt(1800000000000), + }); + + expect(pubKey.totalBudget).to.equal(BigInt(500000000)); + expect(pubKey.expiresAt).to.equal(BigInt(1800000000000)); + + const obj = pubKey.toObject(); + expect(obj.$formatVersion).to.equal('1'); + expect(obj.totalBudget).to.equal(BigInt(500000000)); + expect(obj.expiresAt).to.equal(BigInt(1800000000000)); + + const json = pubKey.toJSON(); + expect(json.$formatVersion).to.equal('1'); + expect(json.totalBudget).to.equal(500000000); + expect(json.expiresAt).to.equal(1800000000000); + + const restored = wasm.IdentityPublicKey.fromJSON(json); + expect(restored.totalBudget).to.equal(BigInt(500000000)); + expect(restored.expiresAt).to.equal(BigInt(1800000000000)); + expect(wasm.IdentityPublicKey.fromObject(obj).toBytes()).to.deep.equal(pubKey.toBytes()); + }); + + it('should keep a key without limits at version 0 and add them through the setters', () => { + const pubKey = new wasm.IdentityPublicKey({ + keyId, + purpose, + securityLevel, + keyType, + isReadOnly: false, + data: binaryData, + }); + + expect(pubKey.totalBudget).to.equal(undefined); + expect(pubKey.expiresAt).to.equal(undefined); + expect(pubKey.toObject().$formatVersion).to.equal('0'); + + pubKey.totalBudget = BigInt(10); + expect(pubKey.totalBudget).to.equal(BigInt(10)); + expect(pubKey.toObject().$formatVersion).to.equal('1'); + expect(pubKey.toJSON()).to.not.have.property('expiresAt'); + }); + }); + describe('toJSON()', () => { it('should serialize to JSON with expected fixture values', () => { const pubKey = new wasm.IdentityPublicKey({ diff --git a/packages/wasm-dpp2/tests/unit/PublicKeyInCreation.spec.ts b/packages/wasm-dpp2/tests/unit/PublicKeyInCreation.spec.ts index 3a85f325b64..4dc2a05ce00 100644 --- a/packages/wasm-dpp2/tests/unit/PublicKeyInCreation.spec.ts +++ b/packages/wasm-dpp2/tests/unit/PublicKeyInCreation.spec.ts @@ -13,6 +13,8 @@ interface PublicKeyInCreationOptions { isReadOnly?: boolean; data?: Uint8Array | Buffer; signature?: number[]; + totalBudget?: bigint; + expiresAt?: bigint; } describe('IdentityPublicKeyInCreation', () => { @@ -26,6 +28,8 @@ describe('IdentityPublicKeyInCreation', () => { isReadOnly: options.isReadOnly ?? false, data: options.data ?? Buffer.from('0333d5cf3674001d2f64c55617b7b11a2e8fc62aab09708b49355e30c7205bdb2e', 'hex'), signature: options.signature ?? [], + totalBudget: options.totalBudget, + expiresAt: options.expiresAt, }); } @@ -160,6 +164,96 @@ describe('IdentityPublicKeyInCreation', () => { }); }); + describe('limits', () => { + it('should register a key without limits as a version 0 key', () => { + const publicKeyInCreation = createPublicKeyInCreation(); + + expect(publicKeyInCreation.totalBudget).to.equal(undefined); + expect(publicKeyInCreation.expiresAt).to.equal(undefined); + expect(publicKeyInCreation.toObject().$formatVersion).to.equal('0'); + }); + + it('should carry a budget and an expiry on a version 1 key', () => { + const publicKeyInCreation = createPublicKeyInCreation({ + securityLevel: 'critical', + totalBudget: BigInt(500000000), + expiresAt: BigInt(1800000000000), + }); + + expect(publicKeyInCreation.totalBudget).to.equal(BigInt(500000000)); + expect(publicKeyInCreation.expiresAt).to.equal(BigInt(1800000000000)); + + const obj = publicKeyInCreation.toObject(); + expect(obj.$formatVersion).to.equal('1'); + expect(obj.totalBudget).to.equal(BigInt(500000000)); + expect(obj.expiresAt).to.equal(BigInt(1800000000000)); + expect(obj.data).to.be.instanceOf(Uint8Array); + expect(obj.data.length).to.equal(33); + + const json = publicKeyInCreation.toJSON(); + expect(json.$formatVersion).to.equal('1'); + expect(json.totalBudget).to.equal(500000000); + expect(json.expiresAt).to.equal(1800000000000); + expect(json.securityLevel).to.equal(1); // CRITICAL + }); + + it('should carry one limit and leave the other out', () => { + const budgetOnly = createPublicKeyInCreation({ totalBudget: BigInt(10) }); + expect(budgetOnly.totalBudget).to.equal(BigInt(10)); + expect(budgetOnly.expiresAt).to.equal(undefined); + expect(budgetOnly.toJSON()).to.not.have.property('expiresAt'); + + const expiryOnly = createPublicKeyInCreation({ expiresAt: BigInt(30) }); + expect(expiryOnly.totalBudget).to.equal(undefined); + expect(expiryOnly.expiresAt).to.equal(BigInt(30)); + expect(expiryOnly.toJSON()).to.not.have.property('totalBudget'); + }); + + it('should round trip a limited key through fromObject() and fromJSON()', () => { + const publicKeyInCreation = createPublicKeyInCreation({ + totalBudget: BigInt(500000000), + expiresAt: BigInt(1800000000000), + }); + + const fromObject = wasm.IdentityPublicKeyInCreation.fromObject(publicKeyInCreation.toObject()); + expect(fromObject.totalBudget).to.equal(BigInt(500000000)); + expect(fromObject.expiresAt).to.equal(BigInt(1800000000000)); + expect(fromObject.keyId).to.equal(publicKeyInCreation.keyId); + + const fromJson = wasm.IdentityPublicKeyInCreation.fromJSON(publicKeyInCreation.toJSON()); + expect(fromJson.totalBudget).to.equal(BigInt(500000000)); + expect(fromJson.expiresAt).to.equal(BigInt(1800000000000)); + expect(Buffer.from(fromJson.data)).to.deep.equal(Buffer.from(publicKeyInCreation.data)); + }); + + it('should turn a version 0 key into a version 1 key when a limit is set', () => { + const publicKeyInCreation = createPublicKeyInCreation(); + + publicKeyInCreation.totalBudget = BigInt(20); + expect(publicKeyInCreation.totalBudget).to.equal(BigInt(20)); + expect(publicKeyInCreation.expiresAt).to.equal(undefined); + expect(publicKeyInCreation.toObject().$formatVersion).to.equal('1'); + + publicKeyInCreation.expiresAt = BigInt(40); + expect(publicKeyInCreation.totalBudget).to.equal(BigInt(20)); + expect(publicKeyInCreation.expiresAt).to.equal(BigInt(40)); + }); + + it('should carry the limits into the identity public key', () => { + const publicKeyInCreation = createPublicKeyInCreation({ + totalBudget: BigInt(500000000), + expiresAt: BigInt(1800000000000), + }); + + const publicKey = publicKeyInCreation.toIdentityPublicKey(); + + expect(publicKey.totalBudget).to.equal(BigInt(500000000)); + expect(publicKey.expiresAt).to.equal(BigInt(1800000000000)); + expect(publicKey.toObject().$formatVersion).to.equal('1'); + expect(createPublicKeyInCreation().toIdentityPublicKey().totalBudget).to.equal(undefined); + }); + }); + describe('toJSON()', () => { it('should convert to JSON and back via fromJSON()', () => { const publicKeyInCreation = createPublicKeyInCreation(); diff --git a/packages/wasm-sdk/src/state_transitions/identity.rs b/packages/wasm-sdk/src/state_transitions/identity.rs index 82a515df7d7..d222c497e63 100644 --- a/packages/wasm-sdk/src/state_transitions/identity.rs +++ b/packages/wasm-sdk/src/state_transitions/identity.rs @@ -520,7 +520,9 @@ export interface IdentityUpdateOptions { /** * Array of public keys to add to the identity. - * Use IdentityPublicKeyInCreation to create new keys. + * Use IdentityPublicKeyInCreation to create new keys. A key built with `totalBudget` or + * `expiresAt` is registered with those limits (protocol version 14); the signer must hold + * its private key as well as the master key, since a new key signs its own registration. */ addPublicKeys?: IdentityPublicKeyInCreation[]; From 7d52ca63f80ad330558d01cbb7fbce9a5ead3288 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 18 Sep 2026 18:33:29 +0700 Subject: [PATCH 2/7] feat(platform-wallet)!: carry key limits through the wallet, the FFI and the JNI The wallet raises the limits of one of an identity's keys: `update_identity_key_limits_with_external_signer` computes the absolute values from the key it holds, refuses what Platform would charge for, signs through the external signer, and lays the key as stored over the cached identity with a new `ManagedIdentity::replace_key`, so the client's key row follows through the persister. Its signers prefer a key without limits and skip an expired one. Every key row that crosses the FFI carries `total_budget` and `expires_at`: the registration and update rows (`IdentityPubkeyFFI`, registering a version 1 key when set), the managed identity's key snapshot, the persisted key entry (`IdentityKeyEntryFFI`, now 216 bytes) and the cold-restore row, so a limited key persists and restores as limited. New exports: `platform_wallet_update_identity_key_limits_with_signer` and `dash_sdk_identity_fetch_keys_remaining_budgets`; the FFI signing-key picker skips expired keys and prefers unlimited ones. The JNI row blob ends every row with a limits flags byte followed by the limits that are set (the golden registration fixture is regenerated as v2), the persisted key upsert passes the four limit fields, restored rows read them, and `updateIdentityKeyLimits` and `identityFetchKeysRemainingBudgets` natives are added. The unused `DocumentPropertyType` import in rs-drive's query module is gated like its only user, so the verify-only build of drive, which the wallet crates use, lints clean. Co-Authored-By: Claude Fable 5.1 --- packages/rs-drive/src/query/mod.rs | 4 +- .../src/identity_key_limits.rs | 69 ++++++++ .../src/identity_persistence.rs | 34 +++- .../src/identity_registration_with_signer.rs | 58 +++++-- .../src/identity_update.rs | 27 +-- .../rs-platform-wallet-ffi/src/invitation.rs | 4 + packages/rs-platform-wallet-ffi/src/lib.rs | 1 + .../src/managed_identity.rs | 21 +++ .../rs-platform-wallet-ffi/src/persistence.rs | 8 + .../src/wallet_restore_types.rs | 10 ++ .../wallet/identity/network/contact_info.rs | 18 +- .../src/wallet/identity/network/key_limits.rs | 100 ++++++++++++ .../wallet/identity/network/key_selection.rs | 111 +++++++++++++ .../src/wallet/identity/network/mod.rs | 3 + .../src/wallet/identity/network/profile.rs | 13 +- .../src/wallet/identity/network/update.rs | 2 +- .../state/managed_identity/identity_ops.rs | 57 +++++++ packages/rs-sdk-ffi/src/identity/keys.rs | 19 ++- packages/rs-sdk-ffi/src/identity/mod.rs | 3 +- .../queries/keys_remaining_budgets.rs | 113 +++++++++++++ .../rs-sdk-ffi/src/identity/queries/mod.rs | 2 + .../rs-unified-sdk-jni/src/persistence.rs | 15 +- .../rs-unified-sdk-jni/src/pubkey_rows.rs | 154 +++++++++++++++++- packages/rs-unified-sdk-jni/src/queries.rs | 59 ++++++- .../rs-unified-sdk-jni/src/transactions.rs | 73 ++++++++- 25 files changed, 919 insertions(+), 59 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/identity_key_limits.rs create mode 100644 packages/rs-platform-wallet/src/wallet/identity/network/key_limits.rs create mode 100644 packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs create mode 100644 packages/rs-sdk-ffi/src/identity/queries/keys_remaining_budgets.rs diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 6f24cd20a22..659d768668f 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -1,4 +1,6 @@ -use dpp::data_contract::document_type::{DocumentPropertyType, TimeRangeTransform}; +#[cfg(feature = "server")] +use dpp::data_contract::document_type::DocumentPropertyType; +use dpp::data_contract::document_type::TimeRangeTransform; use std::sync::Arc; #[cfg(any(feature = "server", feature = "verify"))] diff --git a/packages/rs-platform-wallet-ffi/src/identity_key_limits.rs b/packages/rs-platform-wallet-ffi/src/identity_key_limits.rs new file mode 100644 index 00000000000..f57cb6f5d8d --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/identity_key_limits.rs @@ -0,0 +1,69 @@ +//! FFI binding for raising the limits of one of an identity's keys (protocol +//! version 14), driven by an external `SignerHandle`. +//! +//! The identity's MASTER key, or a CRITICAL authentication key without limits +//! and without contract bounds, signs the `IdentityKeyLimitsUpdate` transition +//! via the supplied `signer_handle` (typically the iOS-side `KeychainSigner`). + +use rs_sdk_ffi::{SignerHandle, VTableSigner}; + +use crate::check_ptr; +use crate::error::*; +use crate::handle::*; +use crate::runtime::block_on_worker; +use crate::types::*; +use crate::{unwrap_option_or_return, unwrap_result_or_return}; + +/// Raise the limits of the key `key_id` of the identity: add `add_budget` +/// credits to its total budget (and to what is left of it) when +/// `has_add_budget`, and move its expiry to `expires_at` (block time in +/// milliseconds) when `has_expires_at`. At least one must be set; a limit +/// the key does not have, a zero top-up and an expiry that is not later +/// are refused before anything is signed, since Platform would refuse them +/// and charge for it. +/// +/// The cached identity carries the key as stored after the update, and the +/// client's key row follows through the persistence changeset, as an +/// identity update does for an added key. No identity revision is claimed. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_update_identity_key_limits_with_signer( + wallet_handle: Handle, + identity_id: *const u8, + key_id: u32, + has_add_budget: bool, + add_budget: u64, + has_expires_at: bool, + expires_at: u64, + signer_handle: *mut SignerHandle, +) -> PlatformWalletFFIResult { + check_ptr!(signer_handle); + + let id = unwrap_result_or_return!(read_identifier(identity_id)); + + if !has_add_budget && !has_expires_at { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "a budget to add or a new expiry must be given".to_string(), + ); + } + let add_budget = has_add_budget.then_some(add_budget); + let expires_at = has_expires_at.then_some(expires_at); + + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity_wallet + .update_identity_key_limits_with_external_signer( + &id, key_id, add_budget, expires_at, signer, None, + ) + .await + }) + }); + let result = unwrap_option_or_return!(option); + unwrap_result_or_return!(result); + PlatformWalletFFIResult::ok() +} diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index 90de62284b2..e04c82a2ff0 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -302,6 +302,17 @@ pub struct IdentityKeyEntryFFI { pub contract_bounds_kind: u8, pub contract_bounds_id: [u8; 32], pub contract_bounds_document_type: *const c_char, + + // Usage limits (protocol version 14). `total_budget` is the credits + // the key may take from the identity over its lifetime when + // `total_budget_is_some`; `expires_at` is the block time in + // milliseconds from which it can no longer sign when + // `expires_at_is_some`. A version 0 key has neither, and the client + // must persist both so a limited key restores as limited. + pub total_budget_is_some: bool, + pub total_budget: u64, + pub expires_at_is_some: bool, + pub expires_at: u64, } /// Composite identifier for [`IdentityKeysChangeSet::removed`] entries @@ -348,9 +359,15 @@ pub struct IdentityKeyRemovalFFI { // 137..=168 contract_bounds_id [u8; 32] // 169..=175 (padding to 8 for pointer alignment) // 176..=183 contract_bounds_document_type *const c_char +// 184 total_budget_is_some bool +// 185..=191 (padding to 8) +// 192..=199 total_budget u64 +// 200 expires_at_is_some bool +// 201..=207 (padding to 8) +// 208..=215 expires_at u64 // -// Total size = 184, alignment = 8 (from u64 / pointer). -const _: [u8; 184] = [0u8; std::mem::size_of::()]; +// Total size = 216, alignment = 8 (from u64 / pointer). +const _: [u8; 216] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; // Compile-time guard for `IdentityEntryFFI`. Same rationale as the @@ -659,6 +676,7 @@ impl IdentityKeyEntryFFI { /// [`free_identity_key_entry_ffi`]. pub fn from_entry(entry: &IdentityKeyEntry) -> Self { use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; let pk_bytes = entry.public_key.data().as_slice().to_vec(); @@ -670,6 +688,14 @@ impl IdentityKeyEntryFFI { Some(ts) => (true, ts), None => (false, 0u64), }; + let (total_budget_is_some, total_budget) = match entry.public_key.total_budget() { + Some(credits) => (true, credits), + None => (false, 0u64), + }; + let (expires_at_is_some, expires_at) = match entry.public_key.expires_at() { + Some(ts) => (true, ts), + None => (false, 0u64), + }; let (wallet_id_is_some, wallet_id) = match entry.wallet_id { Some(id) => (true, id), @@ -727,6 +753,10 @@ impl IdentityKeyEntryFFI { contract_bounds_kind, contract_bounds_id, contract_bounds_document_type, + total_budget_is_some, + total_budget, + expires_at_is_some, + expires_at, } } } 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 22e301b5513..7dbda21ff29 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 @@ -112,6 +112,14 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; /// keys only). `contract_bounds_id` is the 32-byte contract group /// id; the `contract_bounds_document_type` pointer is ignored. /// +/// A key may carry usage limits (protocol version 14): `total_budget`, +/// the credits its transitions may take from the identity over its +/// lifetime, when `has_total_budget`; `expires_at`, the block time in +/// milliseconds from which it can no longer sign, when +/// `has_expires_at`. Only AUTHENTICATION keys below MASTER may carry +/// them; a row with neither flag registers a version 0 key, the same +/// bytes as ever. +/// /// All pointers are borrowed for the call duration only — the /// FFI does not retain or free them. #[repr(C)] @@ -130,6 +138,29 @@ pub struct IdentityPubkeyFFI { /// 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, + /// Whether `total_budget` is set. + pub has_total_budget: bool, + /// The key's total budget in credits when `has_total_budget`. + pub total_budget: u64, + /// Whether `expires_at` is set. + pub has_expires_at: bool, + /// The key's expiry in block time milliseconds when `has_expires_at`. + pub expires_at: u64, +} + +/// Attach the usage limits of `row` to `key`. A row with neither limit +/// leaves the key a version 0 key; either limit makes it a version 1 key. +pub(crate) fn key_with_row_limits( + key: IdentityPublicKey, + row: &IdentityPubkeyFFI, +) -> IdentityPublicKey { + match ( + row.has_total_budget.then_some(row.total_budget), + row.has_expires_at.then_some(row.expires_at), + ) { + (None, None) => key, + (total_budget, expires_at) => key.with_limits(total_budget, expires_at), + } } /// Decode the optional `contract_bounds_*` payload off an @@ -338,16 +369,19 @@ pub(crate) unsafe fn decode_identity_pubkeys( } keys_map.insert( row.key_id, - IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: row.key_id, - purpose, - security_level, - contract_bounds, - key_type, - read_only: row.read_only, - data: BinaryData::new(pubkey_bytes), - disabled_at: None, - }), + key_with_row_limits( + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: row.key_id, + purpose, + security_level, + contract_bounds, + key_type, + read_only: row.read_only, + data: BinaryData::new(pubkey_bytes), + disabled_at: None, + }), + row, + ), ); } Ok(keys_map) @@ -862,6 +896,10 @@ mod tests { contract_bounds_kind: 0, contract_bounds_id: ptr::null(), contract_bounds_document_type: ptr::null(), + has_total_budget: false, + total_budget: 0, + has_expires_at: false, + expires_at: 0, } } diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index f989323beb7..666263532c1 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -22,7 +22,9 @@ use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; use crate::error::*; use crate::handle::*; -use crate::identity_registration_with_signer::{decode_contract_bounds, IdentityPubkeyFFI}; +use crate::identity_registration_with_signer::{ + decode_contract_bounds, key_with_row_limits, IdentityPubkeyFFI, +}; use crate::runtime::block_on_worker; use crate::types::*; use crate::{unwrap_option_or_return, unwrap_result_or_return}; @@ -315,16 +317,19 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( "add_public_keys" )); - keys.push(IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: row.key_id, - purpose, - security_level, - contract_bounds, - key_type, - read_only: row.read_only, - data: BinaryData::new(pubkey_bytes), - disabled_at: None, - })); + keys.push(key_with_row_limits( + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: row.key_id, + purpose, + security_level, + contract_bounds, + key_type, + read_only: row.read_only, + data: BinaryData::new(pubkey_bytes), + disabled_at: None, + }), + row, + )); } keys }; diff --git a/packages/rs-platform-wallet-ffi/src/invitation.rs b/packages/rs-platform-wallet-ffi/src/invitation.rs index 721f0910b10..089e95bc6c5 100644 --- a/packages/rs-platform-wallet-ffi/src/invitation.rs +++ b/packages/rs-platform-wallet-ffi/src/invitation.rs @@ -765,6 +765,10 @@ mod tests { contract_bounds_kind: 0, contract_bounds_id: std::ptr::null(), contract_bounds_document_type: std::ptr::null(), + has_total_budget: false, + total_budget: 0, + has_expires_at: false, + expires_at: 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/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 3dc26554a46..dc32a5957b6 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -38,6 +38,7 @@ pub mod event_handler; pub mod handle; pub mod identity_derive_and_persist; pub mod identity_discovery; +pub mod identity_key_limits; pub mod identity_key_preview; pub mod identity_keys_from_mnemonic; pub mod identity_loading; diff --git a/packages/rs-platform-wallet-ffi/src/managed_identity.rs b/packages/rs-platform-wallet-ffi/src/managed_identity.rs index fb76678e508..d2b99febc81 100644 --- a/packages/rs-platform-wallet-ffi/src/managed_identity.rs +++ b/packages/rs-platform-wallet-ffi/src/managed_identity.rs @@ -4,6 +4,7 @@ use crate::types::*; use crate::{check_ptr, deref_ptr, unwrap_option_or_return, unwrap_result_or_return}; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; use dpp::serialization::PlatformDeserializableUntrusted; use platform_wallet::ManagedIdentity; use std::os::raw::c_char; @@ -186,6 +187,14 @@ pub struct IdentityPublicKeyFFI { pub disabled_at: u64, pub data_ptr: *mut u8, pub data_len: usize, + /// Usage limits (protocol version 14): the credits the key may spend + /// over its lifetime when `total_budget_is_some`, and the block time in + /// milliseconds from which it can no longer sign when + /// `expires_at_is_some`. A version 0 key has neither. + pub total_budget_is_some: bool, + pub total_budget: u64, + pub expires_at_is_some: bool, + pub expires_at: u64, } /// Snapshot every `IdentityPublicKey` on the identity into a flat @@ -220,6 +229,14 @@ pub unsafe extern "C" fn managed_identity_get_public_keys( Some(ts) => (true, ts), None => (false, 0u64), }; + let (total_budget_is_some, total_budget) = match pk.total_budget() { + Some(credits) => (true, credits), + None => (false, 0u64), + }; + let (expires_at_is_some, expires_at) = match pk.expires_at() { + Some(ts) => (true, ts), + None => (false, 0u64), + }; buf.push(IdentityPublicKeyFFI { key_id, @@ -231,6 +248,10 @@ pub unsafe extern "C" fn managed_identity_get_public_keys( disabled_at: disabled_val, data_ptr, data_len, + total_budget_is_some, + total_budget, + expires_at_is_some, + expires_at, }); } buf diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index fcec3cbe6d9..dd049a67b83 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -6445,6 +6445,14 @@ unsafe fn build_identity_public_keys( data: BinaryData::new(bytes), disabled_at: None, }); + // A limited key restores as limited: either limit makes it a version 1 key + let pk = match ( + row.total_budget_is_some.then_some(row.total_budget), + row.expires_at_is_some.then_some(row.expires_at), + ) { + (None, None) => pk, + (total_budget, expires_at) => pk.with_limits(total_budget, expires_at), + }; map.insert(row.key_id, pk); } map 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 b3b07d6560d..22a71ade25b 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -224,6 +224,16 @@ pub struct IdentityKeyRestoreFFI { /// same load-callback allocation arena that frees the public- /// key data buffer). pub contract_bounds_document_type: *const c_char, + /// Usage limits (protocol version 14), mirroring + /// [`crate::identity_persistence::IdentityKeyEntryFFI`]: the credits + /// the key may spend over its lifetime when `total_budget_is_some`, + /// and the block time in milliseconds from which it can no longer + /// sign when `expires_at_is_some`. Without them a limited key would + /// come back unlimited on cold restart. + pub total_budget_is_some: bool, + pub total_budget: u64, + pub expires_at_is_some: bool, + pub expires_at: u64, } /// Per-identity entry attached to a [`WalletRestoreEntryFFI`]. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs index 68ca514d66c..11d8e418145 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs @@ -16,10 +16,9 @@ //! everything from chain. use dpp::document::{Document, DocumentV0}; -use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::signer::Signer; -use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::identity::{IdentityPublicKey, KeyType, SecurityLevel}; use dpp::platform_value::Value; use dpp::prelude::Identifier; @@ -563,15 +562,12 @@ impl DashPayView<'_, B> { } let established_count = managed.dashpay().established_contacts().len(); let identity_index = managed.identity_index; - let signing_key = managed - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), - [KeyType::ECDSA_SECP256K1].into(), - false, - ) - .cloned(); + let signing_key = super::usable_authentication_key( + &managed.identity, + &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + &[KeyType::ECDSA_SECP256K1], + ) + .cloned(); // Shared own-ECDH-root selector (same policy as the // contact-request send path); `Option` preserved — a missing // key defers the publish rather than erroring here. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/key_limits.rs b/packages/rs-platform-wallet/src/wallet/identity/network/key_limits.rs new file mode 100644 index 00000000000..003b81803ba --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/key_limits.rs @@ -0,0 +1,100 @@ +//! Raising the limits of one of an identity's authentication keys (protocol version 14). +//! +//! A key registered with a budget or an expiry is topped up, or has its expiry moved later, +//! with an `IdentityKeyLimitsUpdate` transition rather than replaced. The wallet computes the +//! absolute values from the key it holds, signs with the identity's MASTER key (or a CRITICAL +//! authentication key without limits and without contract bounds) through the supplied +//! signer, and lays the key as stored after the update over its cache so the client's key +//! row follows through the persister. + +use dash_sdk::platform::transition::put_settings::PutSettings; +use dash_sdk::platform::transition::update_identity_key_limits::{ + raised_key_limits, UpdateIdentityKeyLimits, +}; +use dpp::fee::Credits; +use dpp::identity::signer::Signer; +use dpp::identity::{IdentityPublicKey, KeyID, TimestampMillis}; +use dpp::prelude::Identifier; + +use super::update::SignerRef; +use super::*; +use crate::error::PlatformWalletError; + +impl IdentityWallet { + /// Raise the limits of the key `key_id` of `identity_id`: add `add_budget` credits to its + /// total budget (and to what is left of it), and move its expiry to `expires_at`. At least + /// one must be given. What consensus would refuse and charge for is refused here first: a + /// limit the key does not have, a zero top-up, an expiry that is not later. + /// + /// Signing is routed through the supplied `&S: Signer`, which must hold + /// the identity's MASTER key or a CRITICAL authentication key without limits and without + /// contract bounds. No identity revision is claimed or bumped, so the cached identity only + /// needs to hold the signing key. + /// + /// Resolves to the key as stored after the update, which is also laid over the cached + /// identity and upserted through the persister, as + /// [`Self::update_identity_with_external_signer`] does for an added key. + pub async fn update_identity_key_limits_with_external_signer( + &self, + identity_id: &Identifier, + key_id: KeyID, + add_budget: Option, + expires_at: Option, + signer: &S, + settings: Option, + ) -> Result + where + S: Signer + Send + Sync, + { + let identity = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + info.identity_manager + .identity(identity_id) + .map(|m| m.identity.clone()) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? + }; + + let (total_budget, expires_at) = + raised_key_limits(&identity, key_id, add_budget, expires_at) + .map_err(|e| PlatformWalletError::InvalidIdentityData(e.to_string()))?; + + let updated_key = identity + .update_key_limits( + &self.sdk, + key_id, + total_budget, + expires_at, + None, + SignerRef(signer), + settings, + ) + .await?; + + // Post-broadcast local apply: the cached key carries the raised limits and the + // client's key row follows through the persister. + { + let mut wm = self.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { + managed + .replace_key(updated_key.clone(), &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity key not persisted after the key limits update: {e}" + )) + })?; + } + } + + Ok(updated_key) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs b/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs new file mode 100644 index 00000000000..b72ea4833f1 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs @@ -0,0 +1,111 @@ +//! Choosing an authentication key to sign with, now that a key may carry limits. + +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; +use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + +use crate::util::now_ms; + +/// The first AUTHENTICATION key of `identity` at one of `security_levels`, of one of +/// `key_types`, that can sign now. +/// +/// A key without limits is preferred: a key with a budget or an expiry is an application +/// key, taken only when no unlimited key qualifies. A disabled key is skipped, and so is a +/// key whose expiry has passed the wall clock (the block time trails it by seconds at most). +/// What is left of a budget is not known offline; a spent key is refused by Platform. +pub(crate) fn usable_authentication_key<'a>( + identity: &'a Identity, + security_levels: &[SecurityLevel], + key_types: &[KeyType], +) -> Option<&'a IdentityPublicKey> { + let now = now_ms(); + let qualifies = |key: &IdentityPublicKey| { + key.purpose() == Purpose::AUTHENTICATION + && security_levels.contains(&key.security_level()) + && key_types.contains(&key.key_type()) + && !key.is_disabled() + && !key.is_expired_at(now) + }; + let keys = identity.public_keys(); + keys.values() + .find(|key| qualifies(key) && !key.has_limits()) + .or_else(|| keys.values().find(|key| qualifies(key))) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::KeyID; + use dpp::platform_value::{BinaryData, Identifier}; + use std::collections::BTreeMap; + + fn key(id: KeyID, security_level: SecurityLevel) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![id as u8; 33]), + disabled_at: None, + }) + } + + fn identity(keys: Vec) -> Identity { + IdentityV0 { + id: Identifier::from([1; 32]), + public_keys: keys + .into_iter() + .map(|key| (key.id(), key)) + .collect::>(), + balance: 0, + revision: 0, + } + .into() + } + + const LEVELS: [SecurityLevel; 2] = [SecurityLevel::HIGH, SecurityLevel::CRITICAL]; + const TYPES: [KeyType; 1] = [KeyType::ECDSA_SECP256K1]; + + #[test] + fn prefers_a_key_without_limits_over_an_earlier_limited_one() { + let identity = identity(vec![ + key(0, SecurityLevel::MASTER), + key(1, SecurityLevel::CRITICAL).with_limits(Some(1_000), None), + key(2, SecurityLevel::HIGH), + ]); + let chosen = usable_authentication_key(&identity, &LEVELS, &TYPES).expect("a key"); + assert_eq!(chosen.id(), 2); + } + + #[test] + fn falls_back_to_a_limited_key_that_has_not_expired() { + let far_future = now_ms() + 1_000_000; + let identity = identity(vec![ + key(0, SecurityLevel::MASTER), + key(1, SecurityLevel::CRITICAL).with_limits(None, Some(1)), + key(2, SecurityLevel::CRITICAL).with_limits(Some(1_000), Some(far_future)), + ]); + let chosen = usable_authentication_key(&identity, &LEVELS, &TYPES).expect("a key"); + assert_eq!(chosen.id(), 2, "the expired key 1 is skipped"); + } + + #[test] + fn answers_none_when_every_candidate_is_disabled_or_expired() { + let mut disabled = key(1, SecurityLevel::HIGH); + { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + disabled.set_disabled_at(5); + } + let identity = identity(vec![ + key(0, SecurityLevel::MASTER), + disabled, + key(2, SecurityLevel::CRITICAL).with_limits(None, Some(1)), + ]); + assert!(usable_authentication_key(&identity, &LEVELS, &TYPES).is_none()); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index d5bc97c6616..ec766a7d64e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -26,6 +26,8 @@ mod document; mod dpns; mod dpns_marketplace; mod identity_handle; +mod key_limits; +mod key_selection; mod loading; mod register_from_addresses; mod registration; @@ -34,6 +36,7 @@ mod top_up_from_addresses; mod transfer; mod transfer_to_addresses; mod update; +pub(crate) use key_selection::usable_authentication_key; mod withdrawal; pub(crate) use withdrawal::{select_owner_withdrawal_key, select_transfer_withdrawal_key}; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index 1cce9634674..78cc63b539a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use dpp::document::DocumentV0Getters; use dpp::identity::accessors::IdentityGettersV0; +#[cfg(test)] use dpp::identity::identity_public_key::Purpose; use dpp::identity::signer::Signer; use dpp::identity::KeyType; @@ -18,13 +19,13 @@ use crate::error::PlatformWalletError; use crate::wallet::identity::{ContactProfileEntry, DashPayProfile}; // Profile documents require HIGH or CRITICAL authentication; MASTER is reserved -// for identity operations and cannot authorize an ordinary document write. +// for identity operations and cannot authorize an ordinary document write. A key +// without limits is preferred and an expired one is skipped. fn profile_signing_key(identity: &Identity) -> Option<&IdentityPublicKey> { - identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), - [KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160].into(), - false, + super::usable_authentication_key( + identity, + &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + &[KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160], ) } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 6ad873bb1aa..475a12eb2e3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -24,7 +24,7 @@ use crate::error::PlatformWalletError; use super::*; // Borrowed-signer adapter — see `dpns.rs` for the same pattern. -struct SignerRef<'a, S: ?Sized>(&'a S); +pub(super) struct SignerRef<'a, S: ?Sized>(pub(super) &'a S); impl<'a, S: ?Sized> std::fmt::Debug for SignerRef<'a, S> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 0ecb4fe2c81..2436b75e9f1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -399,6 +399,63 @@ impl ManagedIdentity { Ok(()) } + /// Replace one public key of the identity with `public_key`, the key as it is stored + /// after a key limits update, and emit a single-key [`IdentityKeysChangeSet`] upsert for + /// it: the limits-side counterpart to [`Self::add_key`] and [`Self::disable_keys`]. + /// + /// The key is layered by id, so a key the identity did not hold is added. The entry reuses + /// the `(wallet_id, identity_index, key_index)` derivation breadcrumb `add_key` carries, so + /// the client keeps the key's private-key linkage across the upsert; an out-of-wallet + /// identity emits a breadcrumb-less entry, matching its watch-only state. + /// + /// Does **not** touch the identity revision: a key limits update claims none. + pub fn replace_key( + &mut self, + public_key: dpp::identity::IdentityPublicKey, + persister: &WalletPersister, + ) -> Result<(), crate::changeset::PersistenceError> { + use dpp::identity::accessors::IdentitySettersV0; + + let identity_id = self.id(); + let key_id = public_key.id(); + let public_key_hash = pubkey_hash_of(&public_key); + + let (wallet_id, derivation_indices) = match (self.wallet_id, self.identity_index) { + (Some(wallet_id), Some(identity_index)) => ( + Some(wallet_id), + Some(crate::changeset::IdentityKeyDerivationIndices { + identity_index, + key_index: key_id, + }), + ), + _ => (None, None), + }; + + let mut keys = self.identity.public_keys().clone(); + keys.insert(key_id, public_key.clone()); + self.identity.set_public_keys(keys); + + let mut keys_cs = IdentityKeysChangeSet::default(); + keys_cs.upserts.insert( + (identity_id, key_id), + IdentityKeyEntry { + identity_id, + key_id, + public_key, + public_key_hash, + wallet_id, + derivation_indices, + }, + ); + let cs = crate::changeset::PlatformWalletChangeSet { + identities: Some(self.snapshot_changeset()), + identity_keys: Some(keys_cs), + ..Default::default() + }; + persister.store(cs)?; + Ok(()) + } + /// Stamp `disabled_at` on the public keys named by `key_ids` and /// emit a single-key [`IdentityKeysChangeSet`] upsert per affected /// key — the disable-side counterpart to [`Self::add_key`]. diff --git a/packages/rs-sdk-ffi/src/identity/keys.rs b/packages/rs-sdk-ffi/src/identity/keys.rs index 9adc81d75f3..eab9181a053 100644 --- a/packages/rs-sdk-ffi/src/identity/keys.rs +++ b/packages/rs-sdk-ffi/src/identity/keys.rs @@ -4,6 +4,7 @@ use crate::types::{IdentityHandle, IdentityPublicKeyHandle}; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; use dash_sdk::dpp::identity::{IdentityPublicKey, Purpose, SecurityLevel}; use dash_sdk::dpp::prelude::Identity; @@ -68,6 +69,13 @@ pub unsafe extern "C" fn dash_sdk_identity_get_signing_key_for_transition( } }; + // A key whose expiry has passed the wall clock cannot sign any more (the block time + // trails the wall clock by seconds at most). + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or_default(); + // Search for keys matching the requirements, preferring lower security levels for security_level in required_security_levels.iter() { for purpose in required_purposes.iter() { @@ -78,12 +86,19 @@ pub unsafe extern "C" fn dash_sdk_identity_get_signing_key_for_transition( key.purpose() == *purpose && key.security_level() == *security_level && key.disabled_at().is_none() // Only consider enabled keys + && !key.is_expired_at(now_ms) }) .collect(); if !matching_keys.is_empty() { - // Return the first matching key found - let key = matching_keys[0].clone(); + // A key without limits first: a key with a budget or an expiry is an + // application key, taken only when nothing else qualifies. + let key = matching_keys + .iter() + .find(|key| !key.has_limits()) + .copied() + .unwrap_or(matching_keys[0]) + .clone(); let handle = Box::into_raw(Box::new(key)) as *mut IdentityPublicKeyHandle; return DashSDKResult::success(handle as *mut std::os::raw::c_void); } diff --git a/packages/rs-sdk-ffi/src/identity/mod.rs b/packages/rs-sdk-ffi/src/identity/mod.rs index aae14eab159..bdfd364378d 100644 --- a/packages/rs-sdk-ffi/src/identity/mod.rs +++ b/packages/rs-sdk-ffi/src/identity/mod.rs @@ -62,7 +62,8 @@ pub use queries::{ dash_sdk_identity_fetch_balance_and_revision, dash_sdk_identity_fetch_by_non_unique_public_key_hash, dash_sdk_identity_fetch_by_public_key_hash, dash_sdk_identity_fetch_handle, - dash_sdk_identity_fetch_public_keys, dash_sdk_identity_resolve_name, + dash_sdk_identity_fetch_keys_remaining_budgets, dash_sdk_identity_fetch_public_keys, + dash_sdk_identity_resolve_name, }; // Re-export helper functions for use by submodules diff --git a/packages/rs-sdk-ffi/src/identity/queries/keys_remaining_budgets.rs b/packages/rs-sdk-ffi/src/identity/queries/keys_remaining_budgets.rs new file mode 100644 index 00000000000..2503a324cc8 --- /dev/null +++ b/packages/rs-sdk-ffi/src/identity/queries/keys_remaining_budgets.rs @@ -0,0 +1,113 @@ +//! What is left of the budgets of identity keys (protocol version 14) + +use dash_sdk::dpp::identity::KeyID; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::prelude::Identifier; +use dash_sdk::platform::identity_keys_remaining_budgets::{ + IdentityKeysRemainingBudgets, IdentityKeysRemainingBudgetsQuery, +}; +use dash_sdk::platform::Fetch; +use serde_json::{Map, Value}; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::slice; + +use crate::sdk::SDKWrapper; +use crate::types::SDKHandle; +use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, FFIError}; + +/// Fetch what is left of the budgets of the given keys of an identity. +/// +/// An authentication key registered with a total budget spends it as its transitions run; +/// Platform tracks what remains next to the key. Raising the budget with a key limits update +/// raises what remains by the same amount. +/// +/// # Parameters +/// - `sdk_handle`: SDK handle +/// - `identity_id`: Base58-encoded identity ID +/// - `key_ids`: the ids of the keys to look up, at least one, none repeated +/// - `key_ids_len`: how many ids `key_ids` points at +/// +/// # Returns +/// A JSON object keyed by key id: `{"5": "1000", "6": null}`. A budgeted key maps to what is +/// left of its budget in credits, as a decimal string; a key that has no budget, or that the +/// identity does not have, maps to null. +/// +/// # Safety +/// - `sdk_handle`, `identity_id` and `key_ids` must be valid, non-null pointers. +/// - `identity_id` must point to a NUL-terminated C string valid for the duration of the call. +/// - `key_ids` must point to `key_ids_len` readable `u32` values. +/// - On success, returns a C string pointer inside `DashSDKResult`; caller must free it using SDK routines. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_identity_fetch_keys_remaining_budgets( + sdk_handle: *const SDKHandle, + identity_id: *const c_char, + key_ids: *const u32, + key_ids_len: usize, +) -> DashSDKResult { + if sdk_handle.is_null() || identity_id.is_null() || key_ids.is_null() { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "SDK handle, identity ID or key ids is null".to_string(), + )); + } + if key_ids_len == 0 { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + "At least one key id is required".to_string(), + )); + } + + let wrapper = &*(sdk_handle as *const SDKWrapper); + + let id_str = match CStr::from_ptr(identity_id).to_str() { + Ok(s) => s, + Err(e) => return DashSDKResult::error(FFIError::from(e).into()), + }; + + let id = match Identifier::from_string(id_str, Encoding::Base58) { + Ok(id) => id, + Err(e) => { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + format!("Invalid identity ID: {}", e), + )) + } + }; + + let key_ids: Vec = slice::from_raw_parts(key_ids, key_ids_len).to_vec(); + + let result: Result, FFIError> = wrapper.runtime.block_on(async { + let budgets = IdentityKeysRemainingBudgets::fetch( + &wrapper.sdk, + IdentityKeysRemainingBudgetsQuery { + identity_id: id, + key_ids: key_ids.clone(), + }, + ) + .await + .map_err(FFIError::from)? + .unwrap_or_default(); + + Ok(key_ids + .iter() + .map(|key_id| { + let remaining = match budgets.get(key_id) { + Some(Some(credits)) => Value::String(credits.to_string()), + _ => Value::Null, + }; + (key_id.to_string(), remaining) + }) + .collect()) + }); + + match result { + Ok(map) => match CString::new(Value::Object(map).to_string()) { + Ok(s) => DashSDKResult::success_string(s.into_raw()), + Err(e) => DashSDKResult::error( + FFIError::InternalError(format!("Failed to create CString: {}", e)).into(), + ), + }, + Err(e) => DashSDKResult::error(e.into()), + } +} diff --git a/packages/rs-sdk-ffi/src/identity/queries/mod.rs b/packages/rs-sdk-ffi/src/identity/queries/mod.rs index 41b9cce48bf..e858437e64e 100644 --- a/packages/rs-sdk-ffi/src/identity/queries/mod.rs +++ b/packages/rs-sdk-ffi/src/identity/queries/mod.rs @@ -9,6 +9,7 @@ pub mod fetch; pub mod fetch_handle; pub mod identities_balances; pub mod identities_contract_keys; +pub mod keys_remaining_budgets; pub mod nonce; pub mod public_keys; pub mod resolve; @@ -24,5 +25,6 @@ pub use by_public_key_hash::dash_sdk_identity_fetch_by_public_key_hash; pub use fetch::dash_sdk_identity_fetch; pub use fetch_handle::dash_sdk_identity_fetch_handle; pub use identities_balances::dash_sdk_identities_fetch_balances; +pub use keys_remaining_budgets::dash_sdk_identity_fetch_keys_remaining_budgets; pub use public_keys::dash_sdk_identity_fetch_public_keys; pub use resolve::dash_sdk_identity_resolve_name; diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index b840b6cd164..a87afbe5f4c 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -1317,7 +1317,7 @@ unsafe fn persist_identity_key_upsert( env.call_method( bridge, "onPersistIdentityKeyUpsert", - "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;)I", + "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ)I", &[ wid.into(), (&identity_id).into(), @@ -1338,6 +1338,10 @@ unsafe fn persist_identity_key_upsert( JValue::Byte(e.contract_bounds_kind as i8), (&cb_id).into(), (&cb_doctype).into(), + JValue::Bool(e.total_budget_is_some as u8), + JValue::Long(e.total_budget as i64), + JValue::Bool(e.expires_at_is_some as u8), + JValue::Long(e.expires_at as i64), ], )? .i() @@ -3370,6 +3374,11 @@ fn build_identity_key_restore( // Java String. Interior NULs (impossible for a DPP document-type name) // would fail `CString::new` — degrade to `None` rather than fail the load. let doc_type = read_opt_cstring_field(env, holder, "contractBoundsDocumentType")?; + // Usage limits (protocol version 14): a limited key must restore as limited. + let total_budget_is_some = env.get_field(holder, "totalBudgetIsSome", "Z")?.z()?; + let total_budget = env.get_field(holder, "totalBudget", "J")?.j()? as u64; + let expires_at_is_some = env.get_field(holder, "expiresAtIsSome", "Z")?.z()?; + let expires_at = env.get_field(holder, "expiresAt", "J")?.j()? as u64; let key = IdentityKeyRestoreFFI { key_id, @@ -3382,6 +3391,10 @@ fn build_identity_key_restore( contract_bounds_kind, contract_bounds_id, contract_bounds_document_type: ptr::null(), + total_budget_is_some, + total_budget, + expires_at_is_some, + expires_at, }; Ok(IdentityKeyRestoreStaged { key, diff --git a/packages/rs-unified-sdk-jni/src/pubkey_rows.rs b/packages/rs-unified-sdk-jni/src/pubkey_rows.rs index f3f6dfcb7d8..4079715a9af 100644 --- a/packages/rs-unified-sdk-jni/src/pubkey_rows.rs +++ b/packages/rs-unified-sdk-jni/src/pubkey_rows.rs @@ -69,6 +69,10 @@ pub(crate) struct DecodedPubkeyRow { pub(crate) pubkey_bytes: Vec, pub(crate) contract_bounds_id: Option<[u8; 32]>, pub(crate) contract_bounds_document_type: Option, + /// Usage limits (protocol version 14): the credits the key may spend over its lifetime, + /// and the block time in ms from which it can no longer sign. + pub(crate) total_budget: Option, + pub(crate) expires_at: Option, } impl DecodedPubkeyRow { @@ -95,6 +99,10 @@ impl DecodedPubkeyRow { .contract_bounds_document_type .as_ref() .map_or(ptr::null(), |c| c.as_ptr()), + has_total_budget: self.total_budget.is_some(), + total_budget: self.total_budget.unwrap_or(0), + has_expires_at: self.expires_at.is_some(), + expires_at: self.expires_at.unwrap_or(0), } } } @@ -118,12 +126,18 @@ impl DecodedPubkeyRow { /// u8[32] contract_bounds_id /// if contract_bounds_kind == 2: /// u16 doc_type_len, u8[doc_type_len] doc_type (UTF-8) +/// u8 limits_flags (bit 0: total_budget follows, bit 1: expires_at follows; +/// any other bit is rejected) +/// if limits_flags & 1: +/// u64 total_budget (credits the key may spend over its lifetime) +/// if limits_flags & 2: +/// u64 expires_at (block time in ms from which the key can no longer sign) /// ``` /// /// Strict: returns `Err` on truncation, trailing bytes, a negative key ID /// (`writeInt` is signed on the Kotlin side, so a set sign bit is a bug), an -/// invalid `read_only` or `contract_bounds_kind` byte, or an interior NUL in a -/// document type. It does **not** validate the DPP role bytes against DPP's +/// invalid `read_only`, `contract_bounds_kind` or `limits_flags` byte, or an +/// interior NUL in a document type. It does **not** validate the DPP role bytes against DPP's /// structural rules — that stays server-side — nor does it apply any /// registration-only invariant; the registration seam layers those on top. pub(crate) fn parse_pubkey_rows(bytes: &[u8]) -> Result, String> { @@ -146,11 +160,11 @@ pub(crate) fn parse_pubkey_rows(bytes: &[u8]) -> Result, S count_bytes[3], ]) as usize; // Length-before-allocation guard: each row is at least an 11-byte fixed - // header, so a header claiming more rows than the remaining payload can - // possibly hold is malformed — prevents a huge `with_capacity` abort from a - // raw-JNI blob. + // header plus the limits flags byte, so a header claiming more rows than + // the remaining payload can possibly hold is malformed — prevents a huge + // `with_capacity` abort from a raw-JNI blob. if count - .checked_mul(11) + .checked_mul(12) .is_none_or(|need| bytes.len() - cursor < need) { return Err(format!( @@ -213,6 +227,31 @@ pub(crate) fn parse_pubkey_rows(bytes: &[u8]) -> Result, S } } + let limits_flags = read(&mut cursor, 1) + .ok_or_else(|| format!("pubkey blob truncated at row {i} limitsFlags"))?[0]; + if limits_flags & !0b11 != 0 { + return Err(format!( + "pubkey blob row {i} limitsFlags must only set bits 0 and 1, got {limits_flags:#b}" + )); + } + let read_u64 = |cursor: &mut usize, what: &str| -> Result { + let bytes = read(cursor, 8) + .ok_or_else(|| format!("pubkey blob truncated at row {i} {what}"))?; + let mut buf = [0u8; 8]; + buf.copy_from_slice(bytes); + Ok(u64::from_be_bytes(buf)) + }; + let total_budget = if limits_flags & 0b01 != 0 { + Some(read_u64(&mut cursor, "totalBudget")?) + } else { + None + }; + let expires_at = if limits_flags & 0b10 != 0 { + Some(read_u64(&mut cursor, "expiresAt")?) + } else { + None + }; + rows.push(DecodedPubkeyRow { key_id, key_type, @@ -223,6 +262,8 @@ pub(crate) fn parse_pubkey_rows(bytes: &[u8]) -> Result, S pubkey_bytes, contract_bounds_id, contract_bounds_document_type, + total_budget, + expires_at, }); } @@ -367,6 +408,8 @@ mod tests { read_only: u8, pubkey: Vec, bounds: Option<(u8, [u8; 32], Option)>, + total_budget: Option, + expires_at: Option, } fn encode(rows: &[Row]) -> Vec { @@ -390,6 +433,15 @@ mod tests { out.extend_from_slice(dt); } } + let flags = + u8::from(r.total_budget.is_some()) | (u8::from(r.expires_at.is_some()) << 1); + out.push(flags); + if let Some(total_budget) = r.total_budget { + out.extend_from_slice(&total_budget.to_be_bytes()); + } + if let Some(expires_at) = r.expires_at { + out.extend_from_slice(&expires_at.to_be_bytes()); + } } out } @@ -403,6 +455,8 @@ mod tests { read_only: 0, pubkey: vec![2u8; 33], bounds: None, + total_budget: None, + expires_at: None, } } @@ -421,6 +475,8 @@ mod tests { read_only: 0, pubkey: vec![3u8; 33], bounds: None, + total_budget: None, + expires_at: None, }, Row { key_id: 2, @@ -430,6 +486,8 @@ mod tests { read_only: 0, pubkey: vec![4u8; 33], bounds: None, + total_budget: None, + expires_at: None, }, Row { key_id: 3, @@ -439,6 +497,8 @@ mod tests { read_only: 0, pubkey: vec![5u8; 33], bounds: None, + total_budget: None, + expires_at: None, }, Row { key_id: 4, @@ -448,6 +508,8 @@ mod tests { read_only: 0, pubkey: vec![6u8; 33], bounds: Some((2, dashpay_id, Some("contactRequest".to_string()))), + total_budget: None, + expires_at: None, }, Row { key_id: 5, @@ -457,6 +519,8 @@ mod tests { read_only: 0, pubkey: vec![7u8; 33], bounds: Some((2, dashpay_id, Some("contactRequest".to_string()))), + total_budget: None, + expires_at: None, }, ] } @@ -508,6 +572,8 @@ mod tests { read_only: 1, pubkey: vec![2u8; 33], bounds: Some((1, id, None)), + total_budget: None, + expires_at: None, }]; let decoded = parse_pubkey_rows(&encode(&rows)).expect("parse"); assert_eq!(decoded[0].contract_bounds_kind, 1); @@ -558,6 +624,8 @@ mod tests { read_only: 0, pubkey: vec![4u8; 33], bounds: Some((3, contract_group_id, None)), + total_budget: None, + expires_at: None, }]; let decoded = parse_pubkey_rows(&encode(&rows)).expect("parse"); assert_eq!(decoded[0].contract_bounds_kind, 3); @@ -597,6 +665,8 @@ mod tests { read_only: 0, pubkey: vec![2u8; 33], bounds: Some((2, [1u8; 32], Some("con\0tact".to_string()))), + total_budget: None, + expires_at: None, }]; let err = parse_pubkey_rows(&encode(&rows)).unwrap_err(); assert!(err.contains("interior NUL"), "{err}"); @@ -692,7 +762,7 @@ mod tests { /// copy in the Kotlin SDK's test resources so the two can never drift. const GOLDEN: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../kotlin-sdk/sdk/src/test/resources/golden/registration_pubkeys_v1.bin" + "/../kotlin-sdk/sdk/src/test/resources/golden/registration_pubkeys_v2.bin" )); /// Decode the golden blob and assert the full 6-key DashPay policy — role @@ -760,6 +830,8 @@ mod tests { read_only: 0, pubkey: vec![2u8; 33], bounds: None, + total_budget: None, + expires_at: None, }, Row { key_id: 5, @@ -769,10 +841,78 @@ mod tests { read_only: 0, pubkey: vec![3u8; 33], bounds: None, + total_budget: None, + expires_at: None, }, ]; let rows = parse_pubkey_rows(&encode(&update_rows)).expect("parse"); assert_eq!(rows.len(), 2); assert!(rows.iter().all(|r| r.key_id != 0)); } + + /// A row with limits, one with a budget only and one with an expiry only round trip, + /// and the FFI view flags exactly what was set. + #[test] + fn round_trips_usage_limits() { + let limited = |key_id: u32, total_budget: Option, expires_at: Option| Row { + key_id, + key_type: KEY_TYPE_ECDSA, + purpose: PURPOSE_AUTH, + security_level: SEC_CRITICAL, + read_only: 0, + pubkey: vec![key_id as u8; 33], + bounds: None, + total_budget, + expires_at, + }; + let rows = vec![ + limited(1, Some(500_000_000), Some(1_800_000_000_000)), + limited(2, Some(7), None), + limited(3, None, Some(9)), + limited(4, None, None), + ]; + + let decoded = parse_pubkey_rows(&encode(&rows)).expect("parse"); + + assert_eq!(decoded.len(), 4); + assert_eq!(decoded[0].total_budget, Some(500_000_000)); + assert_eq!(decoded[0].expires_at, Some(1_800_000_000_000)); + assert_eq!(decoded[1].total_budget, Some(7)); + assert_eq!(decoded[1].expires_at, None); + assert_eq!(decoded[2].total_budget, None); + assert_eq!(decoded[2].expires_at, Some(9)); + assert_eq!(decoded[3].total_budget, None); + assert_eq!(decoded[3].expires_at, None); + + let ffi = decoded[0].to_ffi(); + assert!(ffi.has_total_budget && ffi.total_budget == 500_000_000); + assert!(ffi.has_expires_at && ffi.expires_at == 1_800_000_000_000); + let ffi = decoded[1].to_ffi(); + assert!(ffi.has_total_budget && ffi.total_budget == 7); + assert!(!ffi.has_expires_at && ffi.expires_at == 0); + let ffi = decoded[3].to_ffi(); + assert!(!ffi.has_total_budget && !ffi.has_expires_at); + } + + #[test] + fn rejects_unknown_limits_flags() { + let mut bytes = encode(&[base_master()]); + // The flags byte is the last byte of a row without limits. + let last = bytes.len() - 1; + bytes[last] = 0b100; + + let err = parse_pubkey_rows(&bytes).expect_err("unknown flag bit"); + assert!(err.contains("limitsFlags"), "{err}"); + } + + #[test] + fn rejects_a_truncated_limit() { + let mut row = base_master(); + row.total_budget = Some(1); + let mut bytes = encode(&[row]); + bytes.truncate(bytes.len() - 1); + + let err = parse_pubkey_rows(&bytes).expect_err("truncated budget"); + assert!(err.contains("truncated"), "{err}"); + } } diff --git a/packages/rs-unified-sdk-jni/src/queries.rs b/packages/rs-unified-sdk-jni/src/queries.rs index 752ab91d918..bf2e0bf9d7e 100644 --- a/packages/rs-unified-sdk-jni/src/queries.rs +++ b/packages/rs-unified-sdk-jni/src/queries.rs @@ -29,11 +29,12 @@ use rs_sdk_ffi::{ dash_sdk_identity_fetch_balance, dash_sdk_identity_fetch_balance_and_revision, dash_sdk_identity_fetch_by_non_unique_public_key_hash, dash_sdk_identity_fetch_by_public_key_hash, dash_sdk_identity_fetch_contract_nonce, - dash_sdk_identity_fetch_nonce, dash_sdk_identity_fetch_public_keys, - dash_sdk_identity_fetch_token_balances, dash_sdk_protocol_version_get_upgrade_state, - dash_sdk_protocol_version_get_upgrade_vote_status, dash_sdk_refresh_protocol_version, - dash_sdk_system_get_current_quorums_info, dash_sdk_system_get_epochs_info, - dash_sdk_system_get_path_elements, dash_sdk_system_get_prefunded_specialized_balance, + dash_sdk_identity_fetch_keys_remaining_budgets, dash_sdk_identity_fetch_nonce, + dash_sdk_identity_fetch_public_keys, dash_sdk_identity_fetch_token_balances, + dash_sdk_protocol_version_get_upgrade_state, dash_sdk_protocol_version_get_upgrade_vote_status, + dash_sdk_refresh_protocol_version, dash_sdk_system_get_current_quorums_info, + dash_sdk_system_get_epochs_info, dash_sdk_system_get_path_elements, + dash_sdk_system_get_prefunded_specialized_balance, dash_sdk_system_get_total_credits_in_platform, dash_sdk_token_get_contract_info, dash_sdk_token_get_direct_purchase_prices, dash_sdk_token_get_perpetual_distribution_last_claim, @@ -106,6 +107,54 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_QueriesNative_identit }) } +/// What is left of the budgets of the given keys of an identity (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. `keyIds` is a JVM `int[]` of at least one id. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_QueriesNative_identityFetchKeysRemainingBudgets( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + identity_id: JString, + key_ids: jni::objects::JIntArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let id = require_cstr!(env, identity_id); + let key_ids: Vec = match env.get_array_length(&key_ids) { + Ok(len) if len > 0 => { + let mut buf = vec![0i32; len as usize]; + if env.get_int_array_region(&key_ids, 0, &mut buf).is_err() { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "keyIds could not be read"); + return ptr::null_mut(); + } + if buf.iter().any(|&i| i < 0) { + throw_sdk_exception(env, 1, "keyIds must be non-negative"); + return ptr::null_mut(); + } + buf.into_iter().map(|i| i as u32).collect() + } + _ => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "keyIds must hold at least one key id"); + return ptr::null_mut(); + } + }; + let result = unsafe { + dash_sdk_identity_fetch_keys_remaining_budgets( + sdk as *const SDKHandle, + id.as_ptr(), + key_ids.as_ptr(), + key_ids.len(), + ) + }; + unsafe { unwrap_string(env, result) } + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + /// Resolve a DPNS name to its identity record (JSON), or null if unregistered. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_QueriesNative_dpnsResolve( diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index e8375b84683..ea20309ab9a 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -41,7 +41,7 @@ use crate::pubkey_rows::decode_update_pubkeys_blob; use crate::support::{guard, net_from_ord, take_pwffi_error, throw_sdk_exception}; use jni::objects::{JByteArray, JClass, JString}; -use jni::sys::{jint, jlong, jstring}; +use jni::sys::{jboolean, jint, jlong, jstring}; use jni::JNIEnv; use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::identity_registration_with_signer::IdentityPubkeyFFI; @@ -159,6 +159,11 @@ fn read_cstring(env: &mut JNIEnv, s: &JString, field: &str) -> Option { /// u8[32] contract_bounds_id /// if contract_bounds_kind == 2: /// u16 doc_type_len, u8[doc_type_len] doc_type (UTF-8) +/// u8 limits_flags (bit 0: total_budget follows, bit 1: expires_at follows) +/// if limits_flags & 1: +/// u64 total_budget (credits the key may spend over its lifetime) +/// if limits_flags & 2: +/// u64 expires_at (block time in ms from which the key can no longer sign) /// ``` /// /// `disablePublicKeyIds` is a JVM `int[]` of key ids to disable (may be @@ -257,6 +262,72 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up }) } +/// Raise the limits of one of the identity's keys through +/// `platform_wallet_update_identity_key_limits_with_signer`: add +/// `addBudget` credits to its total budget (and to what is left of it) when +/// `hasAddBudget`, and move its expiry to `expiresAt` (block time in +/// milliseconds) when `hasExpiresAt`. At least one must be set. The signer +/// 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, as it does for an added key. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_updateIdentityKeyLimits( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, + key_id: jint, + has_add_budget: jboolean, + add_budget: jlong, + has_expires_at: jboolean, + expires_at: jlong, + signer_handle: jlong, +) { + guard(&mut env, (), |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return; + }; + if key_id < 0 { + throw_sdk_exception(env, 1, "keyId must be non-negative"); + return; + } + let has_add_budget = has_add_budget != 0; + let has_expires_at = has_expires_at != 0; + if !has_add_budget && !has_expires_at { + throw_sdk_exception( + env, + 1, + "updateIdentityKeyLimits needs a budget to add or a new expiry", + ); + return; + } + // `writeLong` is signed on the Kotlin side: a set sign bit is a bug, not a huge value. + if has_add_budget && add_budget < 0 { + throw_sdk_exception(env, 1, "addBudget must be non-negative"); + return; + } + if has_expires_at && expires_at < 0 { + throw_sdk_exception(env, 1, "expiresAt must be non-negative"); + return; + } + + let result = unsafe { + platform_wallet_ffi::identity_key_limits::platform_wallet_update_identity_key_limits_with_signer( + wallet_handle as Handle, + id.as_ptr(), + key_id as u32, + has_add_budget, + add_budget as u64, + has_expires_at, + expires_at as u64, + signer_handle as *mut SignerHandle, + ) + }; + take_pwffi_error(env, result); + }) +} + // ── Document purchase / set-price ───────────────────────────────────── /// Shared purchase/set-price marshaling. `purchase == true` routes to From 241a86892319302bd08309d54ed19e0dec752697 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 18 Sep 2026 18:33:29 +0700 Subject: [PATCH 3/7] feat(kotlin-sdk): key limits on Android: register, persist, restore, top up, read what is left `IdentityPubkey` carries `totalBudget` and `expiresAt`, and the row codec ends every row with a limits flags byte followed by the limits that are set, matching the JNI parser; the golden registration fixture moves to v2 with that byte. `PublicKeyEntity` gains the two nullable columns (Room schema 12, migration 11 to 12), the persistence handler stores them from the upsert callback and hands them back on cold restore, so a limited key persists and restores as limited. `IdentityUpdates.updateKeyLimits` adds credits to a key's budget or moves its expiry later through the new native, and `Identities.fetchKeysRemainingBudgets` reads what is left of the budgets as JSON. Unit tests cover the codec's limits section; the migration test covers 11 to 12. The Room schema export for version 12 is produced by the next Gradle build. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/key-limits.md | 2 + .../persistence/DashDatabaseMigrationTest.kt | 43 +++++++++- .../dashsdk/ffi/NativePersistenceBridge.kt | 19 +++- .../dashsdk/ffi/QueriesNative.kt | 13 +++ .../dashsdk/ffi/TransactionsNative.kt | 25 +++++- .../dashsdk/identity/IdentityPubkeyCodec.kt | 10 +++ .../dashsdk/identity/IdentityUpdates.kt | 69 ++++++++++++++- .../dashsdk/persistence/DashDatabase.kt | 19 +++- .../PlatformWalletPersistenceHandler.kt | 12 +++ .../persistence/entities/PublicKeyEntity.kt | 10 +++ .../dashsdk/queries/PlatformQueries.kt | 20 +++++ .../identity/IdentityPubkeyCodecTest.kt | 81 ++++++++++++++++++ .../dashsdk/identity/RegistrationKeysTest.kt | 2 +- .../golden/registration_pubkeys_v1.bin | Bin 364 -> 0 bytes .../golden/registration_pubkeys_v2.bin | Bin 0 -> 370 bytes 15 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/IdentityPubkeyCodecTest.kt delete mode 100644 packages/kotlin-sdk/sdk/src/test/resources/golden/registration_pubkeys_v1.bin create mode 100644 packages/kotlin-sdk/sdk/src/test/resources/golden/registration_pubkeys_v2.bin diff --git a/book/src/data-model/key-limits.md b/book/src/data-model/key-limits.md index 0225f0f06e3..8838b5e6a7a 100644 --- a/book/src/data-model/key-limits.md +++ b/book/src/data-model/key-limits.md @@ -218,6 +218,8 @@ The last check is the one with a twist. An expired key may be revived by moving 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:)` and `fetchKeysRemainingBudgets(identityId:keyIds:)`, with the two limits on `IdentityPublicKey`, `IdentityPublicKeyInfo`, the `IdentityPubkey` row and `PersistentPublicKey` (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 A budget has one subtlety, and it is the same one identity balances have. Most of what a transition costs is known before it runs, but the metered processing fee is only known afterwards. A rule that demanded the whole fee fit up front would have to use the worst-case estimate, which can be fifty times the real processing cost, and would strand the tail of every budget. A rule that checked nothing up front would let a key spend without limit. 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 ef90b3804a1..6f5064c5c50 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 @@ -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, @@ -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, @@ -506,6 +542,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_8_9, DashDatabase.MIGRATION_9_10, DashDatabase.MIGRATION_10_11, + DashDatabase.MIGRATION_11_12, ).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 1426fc60dea..11ff284d1c6 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 @@ -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, @@ -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`. */ @@ -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, @@ -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`. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt index 0fbdc0052e4..55676165e54 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt @@ -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? 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..4911b70b18b 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 @@ -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. */ @@ -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 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..4a5684fdde9 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 @@ -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 { @@ -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() } 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..8b20689c945 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 @@ -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, @@ -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 && @@ -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 @@ -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 } } @@ -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, + ) + } + } } 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 822c08a242a..35b08d11f42 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 @@ -144,7 +144,7 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * at all (nothing collects). */ @Database( - version = 11, + version = 12, exportSchema = true, entities = [ WalletEntity::class, @@ -616,6 +616,22 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v11 -> v12: identity key usage limits (protocol version 14). Two + * nullable columns on `public_keys`, `totalBudget` (credits the key may + * spend over its lifetime) and `expiresAt` (block time in ms from which + * it can no longer sign), so a limited key persisted from the + * identity-keys changeset restores as limited. Additive: every + * pre-migration row reads back as a key without limits, which is what + * every key registered before protocol version 14 is. + */ + val MIGRATION_11_12: Migration = object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `public_keys` ADD COLUMN `totalBudget` INTEGER") + db.execSQL("ALTER TABLE `public_keys` ADD COLUMN `expiresAt` INTEGER") + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -635,6 +651,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, + MIGRATION_11_12, ) .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 88a080308a0..a424d425e29 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 @@ -1863,6 +1863,10 @@ class PlatformWalletPersistenceHandler( contractBoundsKind: Byte, contractBoundsId: ByteArray, contractBoundsDocumentType: String?, + totalBudgetIsSome: Boolean, + totalBudget: Long, + expiresAtIsSome: Boolean, + expiresAt: Long, ): Int = guarded { // Item 1 — private-key persistence (the CLAUDE.md "one allowed // exception" shape). The `IdentityKeyEntryFFI` payload carries only @@ -1990,6 +1994,10 @@ class PlatformWalletPersistenceHandler( keyType = (keyType.toInt() and 0xFF).toString(), readOnly = readOnly, disabledAt = if (disabledAtIsSome) disabledAt else null, + // Usage limits (protocol version 14); a key limits update + // upserts the same key id with the raised values. + totalBudget = if (totalBudgetIsSome) totalBudget else null, + expiresAt = if (expiresAtIsSome) expiresAt else null, publicKeyData = publicKeyData, contractBoundsData = boundsData, contractBoundsDocumentTypeName = docTypeName, @@ -2872,6 +2880,10 @@ class PlatformWalletPersistenceHandler( contractBoundsId = id, contractBoundsDocumentType = if (kind.toInt() == 2) pk.contractBoundsDocumentTypeName else null, + totalBudgetIsSome = pk.totalBudget != null, + totalBudget = pk.totalBudget ?: 0L, + expiresAtIsSome = pk.expiresAt != null, + expiresAt = pk.expiresAt ?: 0L, ) }.toTypedArray() // DashPay contact rows — pending + established requests with 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..c4f8ddf88c1 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 @@ -48,6 +48,16 @@ data class PublicKeyEntity( val keyType: String, val readOnly: Boolean = false, val disabledAt: Long? = null, + /** + * Usage limit (protocol version 14): the credits the key may take from the + * identity over its lifetime, null for a key without a budget. + */ + val totalBudget: Long? = null, + /** + * Usage limit (protocol version 14): the block time in milliseconds from which + * the key can no longer sign, null for a key that does not expire. + */ + val expiresAt: Long? = null, val publicKeyData: ByteArray, /** * JSON-encoded `[base64(contractId)]` blob — legacy shape kept verbatim diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt index e88af084bde..e019bd7323c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt @@ -47,6 +47,26 @@ class Identities internal constructor(private val sdk: Sdk) { mapNativeErrors { QueriesNative.identityFetchBalanceAndRevision(sdk.handle, identityId) } } + /** + * What is left of the budgets of [keyIds] (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. Raising a budget with + * `IdentityUpdates.updateKeyLimits` raises what is left by the same amount. + */ + suspend fun fetchKeysRemainingBudgets(identityId: String, keyIds: List): String? = + sdk.queryGate.op { + require(keyIds.isNotEmpty()) { "keyIds must hold at least one key id" } + require(keyIds.all { it >= 0 }) { "every key id must be non-negative, got $keyIds" } + mapNativeErrors { + QueriesNative.identityFetchKeysRemainingBudgets( + sdk.handle, + identityId, + keyIds.toIntArray(), + ) + } + } + /** * Fetch the identity that owns a unique public-key [hashHex] (hex), as * JSON, or null if none. diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/IdentityPubkeyCodecTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/IdentityPubkeyCodecTest.kt new file mode 100644 index 00000000000..2e9aadbfa03 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/IdentityPubkeyCodecTest.kt @@ -0,0 +1,81 @@ +package org.dashfoundation.dashsdk.identity + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer + +/** + * The usage-limits section of the add/register public-key blob (protocol version + * 14): every row ends with a flags byte, followed only by the limits that are set, + * each a big-endian u64. The Rust parser (`rs-unified-sdk-jni::pubkey_rows`) reads + * exactly this layout. + */ +class IdentityPubkeyCodecTest { + + private fun key( + keyId: Int, + totalBudget: Long? = null, + expiresAt: Long? = null, + ) = IdentityPubkey( + keyId = keyId, + keyType = KeyType.ECDSA_SECP256K1, + purpose = KeyPurpose.AUTHENTICATION, + securityLevel = SecurityLevel.CRITICAL, + pubkeyBytes = ByteArray(33) { keyId.toByte() }, + totalBudget = totalBudget, + expiresAt = expiresAt, + ) + + /** The fixed header, the pubkey and the flags byte of a row without bounds. */ + private fun rowWithoutLimitsSize() = 4 + 1 + 1 + 1 + 1 + 1 + 2 + 33 + 1 + + @Test + fun `a key without limits ends with a zero flags byte`() { + val encoded = IdentityPubkeyCodec.encode(listOf(key(1))) + + assertEquals(4 + rowWithoutLimitsSize(), encoded.size) + assertEquals(0, encoded.last().toInt()) + } + + @Test + fun `a budget and an expiry follow the flags byte as big-endian u64s`() { + val encoded = IdentityPubkeyCodec.encode( + listOf(key(1, totalBudget = 500_000_000L, expiresAt = 1_800_000_000_000L)), + ) + + assertEquals(4 + rowWithoutLimitsSize() + 16, encoded.size) + val tail = ByteBuffer.wrap(encoded, 4 + rowWithoutLimitsSize() - 1, 17) + assertEquals(0b11, tail.get().toInt()) + assertEquals(500_000_000L, tail.getLong()) + assertEquals(1_800_000_000_000L, tail.getLong()) + } + + @Test + fun `one limit sets one flag bit and writes one value`() { + val budgetOnly = IdentityPubkeyCodec.encode(listOf(key(1, totalBudget = 7L))) + val expiryOnly = IdentityPubkeyCodec.encode(listOf(key(1, expiresAt = 9L))) + + assertEquals(4 + rowWithoutLimitsSize() + 8, budgetOnly.size) + assertEquals(4 + rowWithoutLimitsSize() + 8, expiryOnly.size) + assertEquals(0b01, budgetOnly[4 + rowWithoutLimitsSize() - 1].toInt()) + assertEquals(0b10, expiryOnly[4 + rowWithoutLimitsSize() - 1].toInt()) + assertArrayEquals( + ByteBuffer.allocate(8).putLong(7L).array(), + budgetOnly.copyOfRange(4 + rowWithoutLimitsSize(), budgetOnly.size), + ) + assertArrayEquals( + ByteBuffer.allocate(8).putLong(9L).array(), + expiryOnly.copyOfRange(4 + rowWithoutLimitsSize(), expiryOnly.size), + ) + } + + @Test + fun `hasLimits reports either limit`() { + assertFalse(key(1).hasLimits) + assertTrue(key(1, totalBudget = 1L).hasLimits) + assertTrue(key(1, expiresAt = 1L).hasLimits) + } +} diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/RegistrationKeysTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/RegistrationKeysTest.kt index 8d3ec44436f..d20ba28ae44 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/RegistrationKeysTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/RegistrationKeysTest.kt @@ -81,7 +81,7 @@ class RegistrationKeysTest { */ @Test fun `encoder output matches the cross-language golden fixture`() { - val golden = javaClass.getResourceAsStream("/golden/registration_pubkeys_v1.bin") + val golden = javaClass.getResourceAsStream("/golden/registration_pubkeys_v2.bin") .use { requireNotNull(it) { "golden fixture resource missing" }.readBytes() } val rows = RegistrationKeys.buildRegistrationRows(fixturePubkeys(6), includeDashPayKeys = true) diff --git a/packages/kotlin-sdk/sdk/src/test/resources/golden/registration_pubkeys_v1.bin b/packages/kotlin-sdk/sdk/src/test/resources/golden/registration_pubkeys_v1.bin deleted file mode 100644 index 82bbb3c3bf14eb1406cd67234b39ba4d52677dd5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 364 zcmZQzU|?f_14Sl60H~A^j2IM|2?3y5CNN@9WFZ8AYMB|BfkrbZvJwJ7MJx=A%s|5x z*$9C}3%9Jvf3NgPODf}qkeSQo@b>s@M;!@)NsR~Ho^LAL!@!rEpI4HYToRO8SejZ~ R0yLYIfeCCjJ0U=-*#Q64E-L^4 diff --git a/packages/kotlin-sdk/sdk/src/test/resources/golden/registration_pubkeys_v2.bin b/packages/kotlin-sdk/sdk/src/test/resources/golden/registration_pubkeys_v2.bin new file mode 100644 index 0000000000000000000000000000000000000000..9e8d5302ea2420ff35d8c6ddbc3a31e98ee39c82 GIT binary patch literal 370 zcmZQzU|?f_14Sl60Hl-=j2IM|2?3B|CNN@9WFZ7VikTUhfrc|EvJwIy#Vibr%s}H6 z*$9C}3%9Jvf3NgPODf}qkeSQo@b>s@M;!@)NsR~Ho^LAL!@!rEpI4HYToRO8SejZ~ R0y3PHfeCClJ0U=_;Q&jXE-L^4 literal 0 HcmV?d00001 From 6e0ddeeb11e6fb5d8157e8bdeba1ae96a1c9667f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 18 Sep 2026 18:47:59 +0700 Subject: [PATCH 4/7] feat(swift-sdk): key limits on iOS: models, SwiftData schema V5, top up, read what is left `IdentityPublicKey`, `IdentityPublicKeyInfo` and the `IdentityPubkey` registration row carry `totalBudget` and `expiresAt`, and the one place that builds `IdentityPubkeyFFI` sets the four new C fields, so a key registered from Swift may carry limits. `PersistentPublicKey` stores both (SwiftData schema V5 with a lightweight V4 to V5 stage; V4's model graph is frozen, 36 files, since its relationships need the whole graph), the persist-identity-keys callback reads them off `IdentityKeyEntryFFI` and writes them on every upsert, and the cold-restore builder sets them on `IdentityKeyRestoreFFI`, so a limited key persists and restores as limited. `ManagedPlatformWallet.updateIdentityKeyLimits(identityId:keyId:addBudget:expiresAt:signer:)` raises a key's limits through the wallet FFI and `SDK.fetchKeysRemainingBudgets(identityId:keyIds:)` reads what is left of the budgets. The example app's hand-rolled JSON key builders read the two limits too, so a fetched version 1 key is no longer persisted as unlimited. Tests: persistence of both limits, a version 0 key staying unlimited, a raise overwriting, the Codable round trip, and the V4 to V5 migration with its fixture store. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/key-limits.md | 2 +- .../SwiftDashSDK/DPP/DPPIdentity.swift | 40 +- .../FFI/PlatformQueryExtensions.swift | 65 +++ .../Persistence/DashModelContainer.swift | 91 ++++- .../DashSchemaV4+PersistentAccount.swift | 78 ++++ .../DashSchemaV4+PersistentAssetLock.swift | 102 +++++ .../DashSchemaV4+PersistentCoreAddress.swift | 68 ++++ .../DashSchemaV4+PersistentDPNSName.swift | 132 ++++++ ...maV4+PersistentDashpayContactProfile.swift | 97 +++++ ...maV4+PersistentDashpayContactRequest.swift | 118 ++++++ ...emaV4+PersistentDashpayIgnoredSender.swift | 67 ++++ ...ashSchemaV4+PersistentDashpayPayment.swift | 99 +++++ ...ashSchemaV4+PersistentDashpayProfile.swift | 70 ++++ .../DashSchemaV4+PersistentDataContract.swift | 286 +++++++++++++ .../DashSchemaV4+PersistentDocument.swift | 181 +++++++++ .../DashSchemaV4+PersistentDocumentType.swift | 101 +++++ .../DashSchemaV4+PersistentIdentity.swift | 274 +++++++++++++ .../DashSchemaV4+PersistentIndex.swift | 86 ++++ .../DashSchemaV4+PersistentInvitation.swift | 73 ++++ .../DashSchemaV4+PersistentKeyword.swift | 40 ++ .../DashSchemaV4+PersistentMasternode.swift | 185 +++++++++ .../DashSchemaV4+PersistentPendingInput.swift | 46 +++ ...shSchemaV4+PersistentPlatformAddress.swift | 78 ++++ ...PersistentPlatformAddressesSyncState.swift | 41 ++ .../DashSchemaV4+PersistentProperty.swift | 55 +++ .../DashSchemaV4+PersistentPublicKey.swift | 171 ++++++++ ...hSchemaV4+PersistentShieldedActivity.swift | 89 +++++ .../DashSchemaV4+PersistentShieldedNote.swift | 66 +++ ...emaV4+PersistentShieldedOutgoingNote.swift | 49 +++ ...SchemaV4+PersistentShieldedSyncState.swift | 34 ++ ...chemaV4+PersistentShieldedViewingKey.swift | 34 ++ .../DashSchemaV4+PersistentToken.swift | 376 ++++++++++++++++++ .../DashSchemaV4+PersistentTokenBalance.swift | 198 +++++++++ ...SchemaV4+PersistentTokenHistoryEvent.swift | 112 ++++++ ...SchemaV4+PersistentTrackedMasternode.swift | 42 ++ .../DashSchemaV4+PersistentTransaction.swift | 167 ++++++++ .../DashSchemaV4+PersistentTxo.swift | 99 +++++ .../DashSchemaV4+PersistentWallet.swift | 95 +++++ ...maV4+PersistentWalletManagerMetadata.swift | 34 ++ .../DashSchemaV4+TokenTypes.swift | 153 +++++++ .../Models/PersistentPublicKey.swift | 45 ++- .../PlatformWallet/ManagedIdentity.swift | 14 +- .../ManagedPlatformWallet.swift | 76 +++- .../PlatformWalletPersistenceHandler.swift | 39 ++ .../SwiftDashSDK/PlatformWallet/README.md | 55 +++ .../Services/IdentityKeyRefresher.swift | 11 +- .../Views/LoadIdentityView.swift | 22 +- .../DashModelMigrationTests.swift | 153 ++++++- .../Fixtures/SchemaStores/dash-v5.store | Bin 0 -> 663552 bytes .../IdentityKeyLimitsPersistenceTests.swift | 212 ++++++++++ .../swift-sdk/scripts/freeze_schema_models.py | 29 ++ .../scripts/test_freeze_schema_models.py | 2 +- 52 files changed, 4817 insertions(+), 35 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAccount.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTransaction.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v5.store create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyLimitsPersistenceTests.swift diff --git a/book/src/data-model/key-limits.md b/book/src/data-model/key-limits.md index 8838b5e6a7a..ab5a2d820c7 100644 --- a/book/src/data-model/key-limits.md +++ b/book/src/data-model/key-limits.md @@ -218,7 +218,7 @@ The last check is the one with a twist. An expired key may be revived by moving 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:)` and `fetchKeysRemainingBudgets(identityId:keyIds:)`, with the two limits on `IdentityPublicKey`, `IdentityPublicKeyInfo`, the `IdentityPubkey` row and `PersistentPublicKey` (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. +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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift index 9271358c6a7..de71b5cfc22 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift @@ -127,6 +127,40 @@ public struct IdentityPublicKey: Codable, Equatable, Sendable { public let data: BinaryData public let disabledAt: TimestampMillis? + /// Usage limit (protocol version 14): the credits this key may take + /// from the identity over its whole lifetime. `nil` for a key + /// registered without a budget, which may spend without a ceiling. + /// + /// A key carrying either limit is an `IdentityPublicKey::V1` on the + /// wire (tagged `"$formatVersion": "1"`); a key with neither stays a + /// version 0 key with the same bytes as ever. + public let totalBudget: Credits? + + /// Usage limit (protocol version 14): the block time in milliseconds + /// from which this key can no longer sign. `nil` for a key registered + /// without an expiry, which never expires. + public let expiresAt: TimestampMillis? + + /// Whether the key carries either usage limit, which is what makes it + /// a version 1 key. + public var hasLimits: Bool { + totalBudget != nil || expiresAt != nil + } + + /// Check if the key has expired at a deterministic block time + /// (milliseconds). A key without an expiry never expires. + public func isExpired(at timestamp: TimestampMillis) -> Bool { + guard let expiresAt = expiresAt else { return false } + return expiresAt <= timestamp + } + + /// Check if the key has expired at a deterministic date. Convenience + /// over ``isExpired(at:)-(TimestampMillis)`` for wall-clock callers; + /// consensus compares against block time, so treat this as an estimate. + public func isExpired(at date: Date = Date()) -> Bool { + isExpired(at: TimestampMillis(date.timeIntervalSince1970 * 1000)) + } + /// Check if the key is disabled at a deterministic timestamp. public func isDisabled(at timestamp: TimestampMillis) -> Bool { guard let disabledAt = disabledAt else { return false } @@ -151,7 +185,9 @@ public struct IdentityPublicKey: Codable, Equatable, Sendable { keyType: KeyType, readOnly: Bool, data: BinaryData, - disabledAt: TimestampMillis? = nil + disabledAt: TimestampMillis? = nil, + totalBudget: Credits? = nil, + expiresAt: TimestampMillis? = nil ) { self.id = id self.purpose = purpose @@ -161,6 +197,8 @@ public struct IdentityPublicKey: Codable, Equatable, Sendable { self.readOnly = readOnly self.data = data self.disabledAt = disabledAt + self.totalBudget = totalBudget + self.expiresAt = expiresAt } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift index d419d5d1348..4a7ca694e5f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/PlatformQueryExtensions.swift @@ -354,6 +354,71 @@ extension SDK { return try processJSONResult(result) } + /// What is left of the budgets of the given keys of an identity + /// (protocol version 14). + /// + /// An authentication key registered with a total budget spends it as its + /// transitions run, and Platform tracks what remains next to the key; + /// raising the budget through + /// ``ManagedPlatformWallet/updateIdentityKeyLimits(identityId:keyId:addBudget:expiresAt:signer:)`` + /// raises what remains by the same amount. + /// + /// - Parameters: + /// - identityId: base58-encoded identity id. + /// - keyIds: the key ids to look up. At least one, none repeated. + /// - Returns: one entry per key id the node answered for. The value is + /// what is left of that key's budget in CREDITS, or `nil` for a key + /// that carries no budget, or that the identity does not have. + public func fetchKeysRemainingBudgets( + identityId: String, + keyIds: [UInt32] + ) async throws -> [UInt32: UInt64?] { + guard let handle = handle else { + throw SDKError.invalidState("SDK not initialized") + } + guard !keyIds.isEmpty else { + throw SDKError.invalidParameter("At least one key id is required") + } + + // The FFI answers with a JSON object keyed by key id, whose values + // are decimal STRINGS (credits are `u64`, which JSON numbers cannot + // carry losslessly) or null. + let json = try keyIds.withUnsafeBufferPointer { buffer in + try processJSONResult( + dash_sdk_identity_fetch_keys_remaining_budgets( + handle, + identityId, + buffer.baseAddress, + UInt(buffer.count) + ) + ) + } + + var budgets: [UInt32: UInt64?] = [:] + budgets.reserveCapacity(json.count) + for (rawKeyId, value) in json { + guard let keyId = UInt32(rawKeyId) else { + throw SDKError.serializationError( + "Unparseable key id in remaining-budgets response: \(rawKeyId)" + ) + } + if value is NSNull { + // `updateValue`, not the subscript: assigning `nil` through + // the subscript of a dictionary whose value type is itself + // optional REMOVES the entry instead of storing "no budget". + budgets.updateValue(nil, forKey: keyId) + continue + } + guard let text = value as? String, let credits = UInt64(text) else { + throw SDKError.serializationError( + "Unparseable remaining budget for key \(keyId): \(value)" + ) + } + budgets.updateValue(credits, forKey: keyId) + } + return budgets + } + /// Get identity by public key hash public func identityGetByPublicKeyHash(publicKeyHash: String) async throws -> [String: Any] { guard let handle = handle else { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 2d66c4b8ea3..8684f588bd5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -85,7 +85,55 @@ public enum DashModelContainer { + [DashSchemaV2.PersistentTrackedMasternode.self] } - /// All persistent model types in the current Dash SDK schema (V4). + /// The exact model set registered as schema V4: the same entities as + /// V3, with the wallet transaction models V4 widened. Frozen as its own + /// whole graph rather than as a row for the three models that changed: + /// `PersistentTxo`, `PersistentPendingInput` and `PersistentWallet` all + /// carry relationships, and a frozen model naming a relationship target + /// that its own schema does not declare binds that bare name to the live + /// type (the earlier partial rows get away with it because the models + /// they freeze are relationship-isolated). + fileprivate static var v4ModelTypes: [any PersistentModel.Type] { + [ + DashSchemaV4.PersistentIdentity.self, + DashSchemaV4.PersistentDPNSName.self, + DashSchemaV4.PersistentDashpayProfile.self, + DashSchemaV4.PersistentDashpayContactProfile.self, + DashSchemaV4.PersistentDashpayContactRequest.self, + DashSchemaV4.PersistentDashpayPayment.self, + DashSchemaV4.PersistentDashpayIgnoredSender.self, + DashSchemaV4.PersistentDocument.self, + DashSchemaV4.PersistentDataContract.self, + DashSchemaV4.PersistentPublicKey.self, + DashSchemaV4.PersistentTokenBalance.self, + DashSchemaV4.PersistentKeyword.self, + DashSchemaV4.PersistentToken.self, + DashSchemaV4.PersistentDocumentType.self, + DashSchemaV4.PersistentIndex.self, + DashSchemaV4.PersistentProperty.self, + DashSchemaV4.PersistentTokenHistoryEvent.self, + DashSchemaV4.PersistentPlatformAddress.self, + DashSchemaV4.PersistentPlatformAddressesSyncState.self, + DashSchemaV4.PersistentWallet.self, + DashSchemaV4.PersistentAccount.self, + DashSchemaV4.PersistentCoreAddress.self, + DashSchemaV4.PersistentTransaction.self, + DashSchemaV4.PersistentTxo.self, + DashSchemaV4.PersistentPendingInput.self, + DashSchemaV4.PersistentWalletManagerMetadata.self, + DashSchemaV4.PersistentShieldedNote.self, + DashSchemaV4.PersistentShieldedOutgoingNote.self, + DashSchemaV4.PersistentShieldedSyncState.self, + DashSchemaV4.PersistentShieldedActivity.self, + DashSchemaV4.PersistentShieldedViewingKey.self, + DashSchemaV4.PersistentAssetLock.self, + DashSchemaV4.PersistentInvitation.self, + DashSchemaV4.PersistentMasternode.self, + DashSchemaV4.PersistentTrackedMasternode.self + ] + } + + /// All persistent model types in the current Dash SDK schema (V5). /// Unlike the released versions above this list tracks the LIVE models, /// so it moves whenever a model gains a property — which is exactly why /// the released versions must not. When the next property lands: freeze @@ -139,7 +187,7 @@ public enum DashModelContainer { /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV4.self) + Schema(versionedSchema: DashSchemaV5.self) } /// Create a persistent model container for storing data @@ -206,14 +254,18 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] + [ + DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self, + DashSchemaV5.self + ] } public static var stages: [MigrationStage] { [ .lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self), .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self) ] } } @@ -416,6 +468,37 @@ public enum DashSchemaV4: VersionedSchema { Schema.Version(4, 0, 0) } + public static var models: [any PersistentModel.Type] { + DashModelContainer.v4ModelTypes + } +} + +/// Version 5 adds the key usage-limit columns (protocol version 14) to +/// `PersistentPublicKey`, on the same entity set as V4: +/// - `totalBudget` (optional): the credits an authentication key may take +/// from its identity over its whole lifetime, `nil` for a key registered +/// without a budget. Signed carrier for the protocol's unsigned +/// `Credits`, read through `totalBudgetCredits`. +/// - `expiresAt` (optional): the block time in milliseconds from which the +/// key can no longer sign, `nil` for a key registered without an expiry. +/// Read through `expiresAtMillis`. +/// Both are what make a key an `IdentityPublicKey::V1`; without the columns a +/// limited key would come back unlimited on cold restart and the wallet would +/// offer it for signing work consensus refuses. Existing rows migrate with +/// both `NULL`, which is exactly "a version 0 key, no limits". +/// +/// Both columns are additive and optional, so a lightweight migration +/// preserves every existing row. +/// +/// Registering it required freezing every model V4 registers: the generated +/// copies under `FrozenSchemas/`, see `scripts/freeze_schema_models.py`. +/// V4 needed the whole graph rather than a row for `PersistentPublicKey` +/// alone: see `DashModelContainer.v4ModelTypes`. +public enum DashSchemaV5: VersionedSchema { + public static var versionIdentifier: Schema.Version { + Schema.Version(5, 0, 0) + } + public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAccount.swift new file mode 100644 index 00000000000..e5c5757dfca --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAccount.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData + +// `PersistentAccount` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentAccount { + #Unique([ + \.wallet, + \.accountType, + \.accountIndex, + \.standardTag, + \.registrationIndex, + \.keyClass, + \.userIdentityId, + \.friendIdentityId, + ]) + + var accountType: UInt32 + var accountIndex: UInt32 + var accountTypeName: String + var balanceConfirmed: UInt64 + var balanceUnconfirmed: UInt64 + var externalHighestUsed: Int32 + var internalHighestUsed: Int32 + var standardTag: UInt8 + var registrationIndex: UInt32 + var keyClass: UInt32 + var userIdentityId: Data + var friendIdentityId: Data + @Attribute(.unique) var accountExtendedPubKeyBytes: Data? + var createdAt: Date + var lastUpdated: Date + + var wallet: PersistentWallet + + @Relationship(deleteRule: .cascade, inverse: \PersistentCoreAddress.account) + var coreAddresses: [PersistentCoreAddress] + + @Relationship(deleteRule: .cascade, inverse: \PersistentPlatformAddress.account) + var platformAddresses: [PersistentPlatformAddress] + + var involvedTransactions: [PersistentTransaction] = [] + + init( + wallet: PersistentWallet, + accountType: UInt32, + accountIndex: UInt32, + accountTypeName: String + ) { + self.wallet = wallet + self.accountType = accountType + self.accountIndex = accountIndex + self.accountTypeName = accountTypeName + self.balanceConfirmed = 0 + self.balanceUnconfirmed = 0 + self.externalHighestUsed = -1 + self.internalHighestUsed = -1 + self.standardTag = 0 + self.registrationIndex = 0 + self.keyClass = 0 + self.userIdentityId = Data() + self.friendIdentityId = Data() + self.accountExtendedPubKeyBytes = nil + self.createdAt = Date() + self.lastUpdated = Date() + self.coreAddresses = [] + self.platformAddresses = [] + self.involvedTransactions = [] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift new file mode 100644 index 00000000000..f693fa47678 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift @@ -0,0 +1,102 @@ +import Foundation +import SwiftData + +// `PersistentAssetLock` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentAssetLock { + #Index([\.walletId]) + + @Attribute(.unique) var outPointHex: String + + var walletId: Data + + var transactionBytes: Data + + var fundingTypeRaw: Int + + var identityIndexRaw: Int32 + + var accountIndexRaw: Int32 = 0 + + var amountDuffs: Int64 + + var statusRaw: Int + + var proofBytes: Data? + + var recipientPlatformAddressHash: Data? + + var recipientPlatformAddressType: UInt8? + + var recipientIsExternal: Bool? + + var createdAt: Date + var updatedAt: Date + + init( + outPointHex: String, + walletId: Data, + transactionBytes: Data, + fundingTypeRaw: Int, + identityIndexRaw: Int32, + accountIndexRaw: Int32 = 0, + amountDuffs: Int64, + statusRaw: Int, + proofBytes: Data? = nil + ) { + self.outPointHex = outPointHex + self.walletId = walletId + self.transactionBytes = transactionBytes + self.fundingTypeRaw = fundingTypeRaw + self.identityIndexRaw = identityIndexRaw + self.accountIndexRaw = accountIndexRaw + self.amountDuffs = amountDuffs + self.statusRaw = statusRaw + self.proofBytes = proofBytes + self.createdAt = Date() + self.updatedAt = Date() + } + } +} + +extension DashSchemaV4.PersistentAssetLock { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static func predicate( + walletId: Data, + identityIndex: UInt32 + ) -> Predicate { + let identityIndexRaw = Int32(bitPattern: identityIndex) + return #Predicate { entry in + entry.walletId == walletId && entry.identityIndexRaw == identityIndexRaw + } + } +} + +extension DashSchemaV4.PersistentAssetLock { + static func encodeOutPoint(rawBytes: Data) -> String { + precondition(rawBytes.count == 36, "outpoint must be 36 bytes") + let txid = rawBytes.prefix(32) + let voutBytes = rawBytes.suffix(4) + let vout = voutBytes.withUnsafeBytes { raw -> UInt32 in + var value: UInt32 = 0 + withUnsafeMutableBytes(of: &value) { dst in + dst.copyBytes(from: raw.prefix(MemoryLayout.size)) + } + return UInt32(littleEndian: value) + } + let txidHex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(txidHex):\(vout)" + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift new file mode 100644 index 00000000000..f2949819ba4 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift @@ -0,0 +1,68 @@ +import Foundation +import SwiftData + +// `PersistentCoreAddress` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentCoreAddress { + @Attribute(.unique) var address: String + var publicKey: Data + var keyType: UInt8 = 0 + var poolTypeTag: UInt8 + var addressIndex: UInt32 + var derivationPath: String + var isUsed: Bool + var firstSeenHeight: UInt32 + var lastSeenHeight: UInt32 + var balance: UInt64 + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.coreAddress) + var txos: [PersistentTxo] = [] + + init( + address: String, + publicKey: Data = Data(), + keyType: UInt8 = 0, + poolTypeTag: UInt8, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0 + ) { + self.address = address + self.publicKey = publicKey + self.keyType = keyType + self.poolTypeTag = poolTypeTag + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.balance = balance + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV4.PersistentCoreAddress { + var poolTypeName: String { + switch poolTypeTag { + case 0: return "External" + case 1: return "Internal" + case 2: return "Additional" + case 3: return "Additional (Hardened)" + default: return "Unknown(\(poolTypeTag))" + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift new file mode 100644 index 00000000000..893dc47285e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift @@ -0,0 +1,132 @@ +import Foundation +import SwiftData + +// `PersistentDPNSName` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDPNSName { + #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var label: String + + var normalizedLabel: String + + var parentDomainName: String + + var normalizedParentDomainName: String + + var acquiredAt: UInt64 + + var isOwned: Bool = true + + var documentIdBase58: String? + + var priceCredits: Int64? + + var saleStatusRaw: Int16 = 0 + + var counterpartyIdBase58: String? + + var documentCreatedAtMs: UInt64? + + var documentUpdatedAtMs: UInt64? + + var documentTransferredAtMs: UInt64? + + var marketplaceUpdatedAt: UInt64 = 0 + + var identity: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + identity: PersistentIdentity, + label: String, + parentDomainName: String = "dash", + acquiredAt: UInt64 = 0, + isOwned: Bool = true + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.label = label + self.normalizedLabel = Self.normalize(label) + self.parentDomainName = parentDomainName + self.normalizedParentDomainName = Self.normalize(parentDomainName) + self.acquiredAt = acquiredAt + self.isOwned = isOwned + self.documentIdBase58 = nil + self.priceCredits = nil + self.saleStatusRaw = 0 + self.counterpartyIdBase58 = nil + self.documentCreatedAtMs = nil + self.documentUpdatedAtMs = nil + self.documentTransferredAtMs = nil + self.marketplaceUpdatedAt = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV4.PersistentDPNSName { + var saleStatus: DpnsNameSaleStatus? { + guard documentIdBase58 != nil else { return nil } + switch saleStatusRaw { + case 0: + return .owned + case 1: + guard let to = counterpartyId else { return nil } + return .sold(to: to) + case 2: + guard let to = counterpartyId else { return nil } + return .transferred(to: to) + default: + return nil + } + } + + var counterpartyId: Data? { + counterpartyIdBase58.flatMap { Data.identifier(fromBase58: $0) } + } + + var listedPriceCredits: UInt64? { + guard documentIdBase58 != nil, let priceCredits else { return nil } + return UInt64(bitPattern: priceCredits) + } +} + +extension DashSchemaV4.PersistentDPNSName { + static func normalize(_ input: String) -> String { + String(input.map { c -> Character in + switch c { + case "o", "O": return "0" + case "i", "I": return "1" + case "l", "L": return "1" + default: return Character(c.lowercased()) + } + }) + } +} + +extension DashSchemaV4.PersistentDPNSName { + static func predicate(identityId: Data) -> Predicate { + let target = identityId + return #Predicate { name in + name.identity.identityId == target && name.isOwned == true + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift new file mode 100644 index 00000000000..5053e0e2a28 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData + +// `PersistentDashpayContactProfile` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDashpayContactProfile { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var contactIdentityId: Data + + var displayName: String? + + var publicMessage: String? + + var bio: String? + + var avatarUrl: String? + + var avatarHash: Data? + + var avatarFingerprint: Data? + + var checkedAtMs: UInt64 + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + checkedAtMs: UInt64, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.checkedAtMs = checkedAtMs + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV4.PersistentDashpayContactProfile { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + contactIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let contact = contactIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.contactIdentityId == contact + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift new file mode 100644 index 00000000000..744c5572ddf --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift @@ -0,0 +1,118 @@ +import Foundation +import SwiftData + +// `PersistentDashpayContactRequest` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDashpayContactRequest { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId, \.isOutgoing + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var contactIdentityId: Data + + var isOutgoing: Bool + + var senderKeyIndex: UInt32 + + var recipientKeyIndex: UInt32 + + var accountReference: UInt32 + + var encryptedPublicKey: Data + + var encryptedAccountLabel: Data? + + var autoAcceptProof: Data? + + var coreHeightCreatedAt: UInt32 + + var createdAtMillis: UInt64 + + var paymentChannelBroken: Bool = false + + var contactAlias: String? + + var contactNote: String? + + var contactHidden: Bool = false + + var contactAccountLabel: String? + + var contactAcceptedAccounts: [UInt32] = [] + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + isOutgoing: Bool, + senderKeyIndex: UInt32, + recipientKeyIndex: UInt32, + accountReference: UInt32, + encryptedPublicKey: Data, + encryptedAccountLabel: Data? = nil, + autoAcceptProof: Data? = nil, + coreHeightCreatedAt: UInt32, + createdAtMillis: UInt64, + paymentChannelBroken: Bool = false + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.isOutgoing = isOutgoing + self.senderKeyIndex = senderKeyIndex + self.recipientKeyIndex = recipientKeyIndex + self.accountReference = accountReference + self.encryptedPublicKey = encryptedPublicKey + self.encryptedAccountLabel = encryptedAccountLabel + self.autoAcceptProof = autoAcceptProof + self.coreHeightCreatedAt = coreHeightCreatedAt + self.createdAtMillis = createdAtMillis + self.paymentChannelBroken = paymentChannelBroken + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV4.PersistentDashpayContactRequest { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + isOutgoing: Bool + ) -> Predicate { + let target = ownerIdentityId + let direction = isOutgoing + return #Predicate { row in + row.ownerIdentityId == target && row.isOutgoing == direction + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift new file mode 100644 index 00000000000..d332a6ffa47 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftData + +// `PersistentDashpayIgnoredSender` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDashpayIgnoredSender { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.ignoredSenderId + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var ignoredSenderId: Data + + var owner: PersistentIdentity + + var ignoredAt: Date + + init( + owner: PersistentIdentity, + ignoredSenderId: Data + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.ignoredSenderId = ignoredSenderId + self.ignoredAt = Date() + } + } +} + +extension DashSchemaV4.PersistentDashpayIgnoredSender { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + ignoredSenderId: Data + ) -> Predicate { + let target = ownerIdentityId + let sender = ignoredSenderId + return #Predicate { row in + row.ownerIdentityId == target + && row.ignoredSenderId == sender + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift new file mode 100644 index 00000000000..7dc4b69c6f1 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftData + +// `PersistentDashpayPayment` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDashpayPayment { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.txid + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var counterpartyIdentityId: Data + + var amountDuffs: UInt64 + + var directionRaw: UInt8 + + var direction: DashPayPaymentDirection { + get { DashPayPaymentDirection(rawValue: directionRaw) ?? .sent } + set { directionRaw = newValue.rawValue } + } + + var statusRaw: UInt8 + + var status: DashPayPaymentStatus { + get { DashPayPaymentStatus(rawValue: statusRaw) ?? .pending } + set { statusRaw = newValue.rawValue } + } + + var txid: String + + var memo: String? + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + owner: PersistentIdentity, + counterpartyIdentityId: Data, + amountDuffs: UInt64, + direction: DashPayPaymentDirection, + status: DashPayPaymentStatus, + txid: String, + memo: String? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.counterpartyIdentityId = counterpartyIdentityId + self.amountDuffs = amountDuffs + self.directionRaw = direction.rawValue + self.statusRaw = status.rawValue + self.txid = txid + self.memo = memo + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV4.PersistentDashpayPayment { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + counterpartyIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let counterparty = counterpartyIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.counterpartyIdentityId == counterparty + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift new file mode 100644 index 00000000000..49ecf016725 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftData + +// `PersistentDashpayProfile` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDashpayProfile { + #Unique([\.networkRaw, \.identity]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var displayName: String? + + var publicMessage: String? + + var bio: String? + + var avatarUrl: String? + + var avatarHash: Data? + + var avatarFingerprint: Data? + + var identity: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + identity: PersistentIdentity, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV4.PersistentDashpayProfile { + static func predicate(identityId: Data) -> Predicate { + let target = identityId + return #Predicate { profile in + profile.identity.identityId == target + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift new file mode 100644 index 00000000000..58068875996 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift @@ -0,0 +1,286 @@ +import Foundation +import SwiftData + +// `PersistentDataContract` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDataContract { + #Index([\.networkRaw]) + + @Attribute(.unique) var id: Data + var name: String + var serializedContract: Data + var createdAt: Date + var lastAccessedAt: Date + + var binarySerialization: Data? + + var version: Int? + var ownerId: Data? + + @Relationship(deleteRule: .cascade, inverse: \PersistentKeyword.dataContract) + var keywordRelations: [PersistentKeyword] + var contractDescription: String? + + var schemaData: Data + var documentTypesData: Data + + var groupsData: Data? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var lastUpdated: Date + var lastSyncedAt: Date? + + var canBeDeleted: Bool + var readonly: Bool + var keepsHistory: Bool + var schemaDefs: Int? + + var documentsKeepHistoryContractDefault: Bool + var documentsMutableContractDefault: Bool + var documentsCanBeDeletedContractDefault: Bool + + @Relationship(deleteRule: .cascade, inverse: \PersistentToken.dataContract) + var tokens: [PersistentToken]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocumentType.dataContract) + var documentTypes: [PersistentDocumentType]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.dataContract) + var documents: [PersistentDocument] + + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.ownedDataContracts) + var ownerIdentity: PersistentIdentity? + + var hasTokens: Bool + var tokensData: Data? + + var idBase58: String { + id.toBase58String() + } + + var ownerIdBase58: String? { + ownerId?.toBase58String() + } + + var parsedContract: [String: Any]? { + try? JSONSerialization.jsonObject(with: serializedContract, options: []) as? [String: Any] + } + + var binarySerializationHex: String? { + binarySerialization?.toHexString() + } + + var keywords: [String] { + keywordRelations.map { $0.keyword } + } + + var schema: [String: Any] { + get { + guard let json = try? JSONSerialization.jsonObject(with: schemaData), + let dict = json as? [String: Any] else { + return [:] + } + return dict + } + set { + schemaData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var documentTypesList: [String] { + get { + guard let json = try? JSONSerialization.jsonObject(with: documentTypesData), + let array = json as? [String] else { + return [] + } + return array + } + set { + documentTypesData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var tokenConfigurations: [String: Any]? { + get { + guard let data = tokensData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + tokensData = try? JSONSerialization.data(withJSONObject: newValue) + hasTokens = true + } else { + tokensData = nil + hasTokens = false + } + lastUpdated = Date() + } + } + + var groups: [String: Any]? { + get { + guard let data = groupsData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + groupsData = try? JSONSerialization.data(withJSONObject: newValue) + } else { + groupsData = nil + } + lastUpdated = Date() + } + } + + init( + id: Data, + name: String, + serializedContract: Data, + version: Int? = 1, + ownerId: Data? = nil, + schema: [String: Any] = [:], + documentTypesList: [String] = [], + keywords: [String] = [], + description: String? = nil, + hasTokens: Bool = false, + network: Network + ) { + self.id = id + self.name = name + self.serializedContract = serializedContract + self.createdAt = Date() + self.lastAccessedAt = Date() + self.version = version + self.ownerId = ownerId + + self.schemaData = (try? JSONSerialization.data(withJSONObject: schema)) ?? Data() + self.documentTypesData = (try? JSONSerialization.data(withJSONObject: documentTypesList)) ?? Data() + + self.keywordRelations = keywords.map { PersistentKeyword(keyword: $0, contractId: id.toBase58String()) } + self.contractDescription = description + + self.hasTokens = hasTokens + self.tokensData = nil + + self.groupsData = nil + + self.documents = [] + + self.ownerIdentity = nil + + self.networkRaw = network.rawValue + self.lastUpdated = Date() + self.lastSyncedAt = nil + + self.canBeDeleted = false + self.readonly = false + self.keepsHistory = false + self.documentsKeepHistoryContractDefault = false + self.documentsMutableContractDefault = true + self.documentsCanBeDeletedContractDefault = true + } + + func updateLastAccessed() { + self.lastAccessedAt = Date() + } + + func updateVersion(_ newVersion: Int) { + self.version = newVersion + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func addDocument(_ document: PersistentDocument) { + documents.append(document) + lastUpdated = Date() + } + + func removeDocument(withId documentId: String) { + if let docIdData = Data.identifier(fromBase58: documentId) { + documents.removeAll { $0.id == docIdData } + } + lastUpdated = Date() + } + } +} + +extension DashSchemaV4.PersistentDataContract { + static func predicate(contractId: String) -> Predicate { + guard let idData = Data.identifier(fromBase58: contractId) else { + return #Predicate { _ in false } + } + return #Predicate { contract in + contract.id == idData + } + } + + static func predicate(ownerId: Data) -> Predicate { + #Predicate { contract in + contract.ownerId == ownerId + } + } + + static func predicate(name: String) -> Predicate { + #Predicate { contract in + contract.name.localizedStandardContains(name) + } + } + + static var contractsWithTokensPredicate: Predicate { + #Predicate { contract in + contract.hasTokens == true + } + } + + static func predicate(keyword: String) -> Predicate { + #Predicate { contract in + contract.keywordRelations.contains { $0.keyword == keyword } + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { contract in + contract.lastSyncedAt == nil || contract.lastSyncedAt! < date + } + } + + static func predicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { contract in + contract.networkRaw == target + } + } + + static func contractsWithTokensPredicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { contract in + contract.hasTokens == true && contract.networkRaw == target + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift new file mode 100644 index 00000000000..896b6010580 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift @@ -0,0 +1,181 @@ +import Foundation +import SwiftData + +// `PersistentDocument` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDocument { + #Index([\.networkRaw]) + + @Attribute(.unique) var documentId: String + + var documentType: String + var revision: Int32 + var data: Data + + var contractId: String + var ownerId: String + + var contractIdData: Data + var ownerIdData: Data + + var createdAt: Date + var updatedAt: Date + var transferredAt: Date? + + var createdAtBlockHeight: Int64? + var updatedAtBlockHeight: Int64? + var transferredAtBlockHeight: Int64? + + var createdAtCoreBlockHeight: Int64? + var updatedAtCoreBlockHeight: Int64? + var transferredAtCoreBlockHeight: Int64? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var isDeleted: Bool = false + + var localCreatedAt: Date + var localUpdatedAt: Date + + var documentType_relation: PersistentDocumentType? + var dataContract: PersistentDataContract? + + var ownerIdentity: PersistentIdentity? + + var id: Data { + Data.identifier(fromBase58: documentId) ?? Data() + } + + var idBase58: String { + documentId + } + + var ownerIdBase58: String { + ownerId + } + + var contractIdBase58: String { + contractId + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var displayTitle: String { + guard let props = properties else { return "Document" } + + if let title = props["title"] as? String { return title } + if let name = props["name"] as? String { return name } + if let label = props["label"] as? String { return label } + if let normalizedLabel = props["normalizedLabel"] as? String { return normalizedLabel } + + return documentType + } + + var summary: String { + var parts: [String] = [] + + parts.append("Type: \(documentType)") + parts.append("Rev: \(revision)") + + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateStyle = .short + parts.append("Created: \(formatter.string(from: createdAt))") + + return parts.joined(separator: " • ") + } + + init( + documentId: String, + documentType: String, + revision: Int32, + data: Data, + contractId: String, + ownerId: String, + network: Network + ) { + self.documentId = documentId + self.documentType = documentType + self.revision = revision + self.data = data + self.contractId = contractId + self.ownerId = ownerId + self.contractIdData = Data.identifier(fromBase58: contractId) ?? Data() + self.ownerIdData = Data.identifier(fromBase58: ownerId) ?? Data() + self.networkRaw = network.rawValue + self.createdAt = Date() + self.updatedAt = Date() + self.localCreatedAt = Date() + self.localUpdatedAt = Date() + } + + func updateProperties(_ newData: Data) { + self.data = newData + self.updatedAt = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = Int32(newRevision) + self.updatedAt = Date() + } + + func markAsDeleted() { + self.isDeleted = true + self.updatedAt = Date() + } + + static func predicate(documentId: String) -> Predicate { + #Predicate { doc in + doc.documentId == documentId && doc.isDeleted == false + } + } + + static func predicate(contractId: String, network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { doc in + doc.contractId == contractId && doc.networkRaw == target && doc.isDeleted == false + } + } + + static func predicate(ownerId: Data) -> Predicate { + let ownerIdString = ownerId.toBase58String() + return #Predicate { doc in + doc.ownerId == ownerIdString && doc.isDeleted == false + } + } + + func linkToLocalIdentityIfNeeded(in modelContext: ModelContext) { + guard ownerIdentity == nil else { return } + + let ownerIdToMatch = self.ownerIdData + let identityPredicate = #Predicate { identity in + identity.identityId == ownerIdToMatch && identity.isLocal == true + } + + let descriptor = FetchDescriptor(predicate: identityPredicate) + + do { + if let localIdentity = try modelContext.fetch(descriptor).first { + self.ownerIdentity = localIdentity + self.localUpdatedAt = Date() + } + } catch { + print("Failed to link document to local identity: \(error)") + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift new file mode 100644 index 00000000000..d6cbbf62bfd --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift @@ -0,0 +1,101 @@ +import Foundation +import SwiftData + +// `PersistentDocumentType` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentDocumentType { + @Attribute(.unique) var id: Data + var contractId: Data + var name: String + + var schemaJSON: Data + var propertiesJSON: Data + + var documentsKeepHistory: Bool + var documentsMutable: Bool + var documentsCanBeDeleted: Bool + var documentsTransferable: Bool + + var indexOnly: Bool = false + + var requiredFieldsJSON: Data? + + var securityLevel: Int + + var tradeMode: Int + var creationRestrictionMode: Int + + var requiresIdentityEncryptionBoundedKey: Bool + var requiresIdentityDecryptionBoundedKey: Bool + + var createdAt: Date + var lastAccessedAt: Date + + var dataContract: PersistentDataContract? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.documentType_relation) + var documents: [PersistentDocument]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentIndex.documentType) + var indices: [PersistentIndex]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentProperty.documentType) + var propertiesList: [PersistentProperty]? + + init(contractId: Data, name: String, schemaJSON: Data, propertiesJSON: Data) { + var idData = contractId + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.name = name + self.schemaJSON = schemaJSON + self.propertiesJSON = propertiesJSON + self.documentsKeepHistory = false + self.documentsMutable = true + self.documentsCanBeDeleted = true + self.documentsTransferable = false + self.securityLevel = 0 + self.tradeMode = 0 + self.creationRestrictionMode = 0 + self.requiresIdentityEncryptionBoundedKey = false + self.requiresIdentityDecryptionBoundedKey = false + self.createdAt = Date() + self.lastAccessedAt = Date() + } + } +} + +extension DashSchemaV4.PersistentDocumentType { + var contractIdBase58: String { + contractId.toBase58String() + } + + var schema: [String: Any]? { + try? JSONSerialization.jsonObject(with: schemaJSON, options: []) as? [String: Any] + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String: Any] + } + + var persistentProperties: [DashSchemaV4.PersistentProperty]? { + return propertiesList + } + + var requiredFields: [String]? { + guard let data = requiredFieldsJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String] + } + + var documentCount: Int { + documents?.count ?? 0 + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift new file mode 100644 index 00000000000..9cf09acdedb --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift @@ -0,0 +1,274 @@ +import Foundation +import SwiftData + +// `PersistentIdentity` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentIdentity { + #Index([\.networkRaw]) + + @Attribute(.unique) var identityId: Data + var balance: Int64 + var revision: Int64 + var isLocal: Bool + var alias: String? + var dpnsName: String? + var mainDpnsName: String? + var identityType: String + + var votingPrivateKeyIdentifier: String? + var ownerPrivateKeyIdentifier: String? + var payoutPrivateKeyIdentifier: String? + + @Relationship(deleteRule: .cascade) var publicKeys: [PersistentPublicKey] + + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var wallet: PersistentWallet? + var identityIndex: UInt32 = 0 + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.ownerIdentity) var documents: [PersistentDocument] + @Relationship(deleteRule: .nullify) var tokenBalances: [PersistentTokenBalance] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDPNSName.identity) + var dpnsNames: [PersistentDPNSName] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayProfile.identity) + var dashpayProfile: PersistentDashpayProfile? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactRequest.owner) + var contactRequests: [PersistentDashpayContactRequest] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayPayment.owner) + var dashpayPayments: [PersistentDashpayPayment] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayIgnoredSender.owner) + var dashpayIgnoredSenders: [PersistentDashpayIgnoredSender] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactProfile.owner) + var contactProfiles: [PersistentDashpayContactProfile] = [] + + var ownedDataContracts: [PersistentDataContract] + + init( + identityId: Data, + balance: Int64 = 0, + revision: Int64 = 0, + isLocal: Bool = true, + alias: String? = nil, + dpnsName: String? = nil, + mainDpnsName: String? = nil, + identityType: IdentityType = .user, + votingPrivateKeyIdentifier: String? = nil, + ownerPrivateKeyIdentifier: String? = nil, + payoutPrivateKeyIdentifier: String? = nil, + network: Network, + identityIndex: UInt32 = 0 + ) { + self.identityId = identityId + self.balance = balance + self.revision = revision + self.isLocal = isLocal + self.alias = alias + self.dpnsName = dpnsName + self.mainDpnsName = mainDpnsName + self.identityType = identityType.rawValue + self.votingPrivateKeyIdentifier = votingPrivateKeyIdentifier + self.ownerPrivateKeyIdentifier = ownerPrivateKeyIdentifier + self.payoutPrivateKeyIdentifier = payoutPrivateKeyIdentifier + self.networkRaw = network.rawValue + self.identityIndex = identityIndex + self.publicKeys = [] + self.documents = [] + self.tokenBalances = [] + self.dpnsNames = [] + self.dashpayProfile = nil + self.contactRequests = [] + self.dashpayPayments = [] + self.dashpayIgnoredSenders = [] + self.contactProfiles = [] + self.ownedDataContracts = [] + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + } + + var identityIdString: String { + identityId.toHexString() + } + + var identityIdBase58: String { + identityId.toBase58String() + } + + var formattedBalance: String { + let dashAmount = Double(balance) / 100_000_000_000 + return String(format: "%.8f DASH", dashAmount) + } + + var identityPublicKeys: [IdentityPublicKey] { + publicKeys.compactMap { $0.toIdentityPublicKey() } + } + + var displayName: String { + if let alias = alias, !alias.isEmpty { + return alias + } + if let mainDpnsName = mainDpnsName, !mainDpnsName.isEmpty { + return mainDpnsName + } + if let dpnsName = dpnsName, !dpnsName.isEmpty { + return dpnsName + } + return String(identityIdString.prefix(12)) + "..." + } + + var identityTypeEnum: IdentityType { + IdentityType(rawValue: identityType) ?? .user + } + + func updateBalance(_ newBalance: Int64) { + self.balance = newBalance + self.lastUpdated = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = newRevision + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func updateDPNSName(_ name: String?) { + self.dpnsName = name + self.lastUpdated = Date() + } + + func addPublicKey(_ key: PersistentPublicKey) { + publicKeys.append(key) + lastUpdated = Date() + } + + func removePublicKey(withId keyId: Int32) { + publicKeys.removeAll { $0.keyId == keyId } + lastUpdated = Date() + } + } +} + +extension DashSchemaV4.PersistentIdentity { + static func predicate(identityId: Data) -> Predicate { + #Predicate { identity in + identity.identityId == identityId + } + } + + static var walletOwnedIdentitiesPredicate: Predicate { + #Predicate { identity in + identity.wallet != nil + } + } + + static func predicate(type: IdentityType) -> Predicate { + let typeString = type.rawValue + return #Predicate { identity in + identity.identityType == typeString + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { identity in + identity.lastSyncedAt == nil || identity.lastSyncedAt! < date + } + } + + static func predicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { identity in + identity.networkRaw == target + } + } + + static func walletOwnedIdentitiesPredicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { identity in + identity.wallet != nil && identity.networkRaw == target + } + } + + static func fetch( + in context: ModelContext, + identityId: Data + ) -> DashSchemaV4.PersistentIdentity? { + let target = identityId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == target } + ) + return try? context.fetch(descriptor).first + } +} + +extension DashSchemaV4.PersistentIdentity { + @discardableResult + static func updateBalance( + in context: ModelContext, + identityId: Data, + balance: UInt64 + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.balance = Int64(bitPattern: balance) + row.lastUpdated = Date() + return true + } + + @discardableResult + static func updateDpnsName( + in context: ModelContext, + identityId: Data, + dpnsName: String? + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.dpnsName = dpnsName + row.lastUpdated = Date() + return true + } + + @discardableResult + static func updateMainDpnsName( + in context: ModelContext, + identityId: Data, + mainDpnsName: String? + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.mainDpnsName = mainDpnsName + row.lastUpdated = Date() + return true + } + + @discardableResult + static func remove( + in context: ModelContext, + identityId: Data + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + context.delete(row) + return true + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift new file mode 100644 index 00000000000..9b93607a0d4 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift @@ -0,0 +1,86 @@ +import Foundation +import SwiftData + +// `PersistentIndex` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentIndex { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + var unique: Bool + var nullSearchable: Bool + var contested: Bool + + var countable: String? + var rangeCountable: Bool = false + var summable: String? + var rangeSummable: Bool = false + var averageable: String? + var rangeAverageable: Bool = false + + var rankedCountable: Bool = false + var rankedSummable: Bool = false + var rankedAverageable: Bool = false + + var terminal: String? + + var preallocated: Bool = false + + var timeRangeJSON: Data? + + var propertiesJSON: Data + + var contestedDetailsJSON: Data? + + var createdAt: Date + + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, properties: [String]) { + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.unique = false + self.nullSearchable = false + self.contested = false + + if let jsonData = try? JSONSerialization.data(withJSONObject: properties, options: []) { + self.propertiesJSON = jsonData + } else { + self.propertiesJSON = Data() + } + + self.createdAt = Date() + } + } +} + +extension DashSchemaV4.PersistentIndex { + var properties: [String]? { + try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String] + } + + var contestedDetails: [String: Any]? { + guard let data = contestedDetailsJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var timeRange: [String: Any]? { + guard let data = timeRangeJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift new file mode 100644 index 00000000000..71cc5bb3f33 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftData + +// `PersistentInvitation` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentInvitation { + #Index([\.walletId]) + + @Attribute(.unique) var outPointHex: String + + var rawOutPoint: Data + + var walletId: Data + + var fundingIndexRaw: Int + + var amountDuffs: Int64 + + var expiryUnix: Int + + var createdAtSecs: Int + + var hasInviter: Bool + + var statusRaw: Int + + var reclaimInFlight: Bool = false + + var createdAt: Date + var updatedAt: Date + + init( + outPointHex: String, + rawOutPoint: Data, + walletId: Data, + fundingIndexRaw: Int, + amountDuffs: Int64, + expiryUnix: Int, + createdAtSecs: Int, + hasInviter: Bool, + statusRaw: Int, + reclaimInFlight: Bool = false + ) { + self.outPointHex = outPointHex + self.rawOutPoint = rawOutPoint + self.walletId = walletId + self.fundingIndexRaw = fundingIndexRaw + self.amountDuffs = amountDuffs + self.expiryUnix = expiryUnix + self.createdAtSecs = createdAtSecs + self.hasInviter = hasInviter + self.statusRaw = statusRaw + self.reclaimInFlight = reclaimInFlight + self.createdAt = Date() + self.updatedAt = Date() + } + } +} + +extension DashSchemaV4.PersistentInvitation { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift new file mode 100644 index 00000000000..b42f37707f7 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftData + +// `PersistentKeyword` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentKeyword { + @Attribute(.unique) var id: String + var keyword: String + var contractId: String + + var dataContract: PersistentDataContract? + + init(keyword: String, contractId: String) { + self.id = "\(contractId)_\(keyword)" + self.keyword = keyword + self.contractId = contractId + } + } +} + +extension DashSchemaV4.PersistentKeyword { + static func predicate(keyword: String) -> Predicate { + #Predicate { item in + item.keyword.localizedStandardContains(keyword) + } + } + + static func predicate(contractId: String) -> Predicate { + #Predicate { item in + item.contractId == contractId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift new file mode 100644 index 00000000000..bd028d65783 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift @@ -0,0 +1,185 @@ +import Foundation +import SwiftData + +// `PersistentMasternode` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentMasternode { + #Unique([\.walletId, \.proTxHash]) + + var walletId: Data + var proTxHash: Data + var registrationTxid: Data + + var serviceAddress: String? + var isEvonode: Bool + + var ownerKeyHash: Data? + var votingKeyHash: Data? + var ownerAddress: String? + var votingAddress: String? + var operatorPublicKey: Data? + var platformNodeId: Data? + var payoutAddress: String? + var operatorPseudoAddress: String? + var platformNodeAddress: String? + + var ownerInWallet: Bool = false + var ownerAccountType: UInt8 = 0 + var ownerKeyIndex: UInt32 = 0 + var votingInWallet: Bool = false + var votingAccountType: UInt8 = 0 + var votingKeyIndex: UInt32 = 0 + var operatorInWallet: Bool = false + var operatorAccountType: UInt8 = 0 + var operatorKeyIndex: UInt32 = 0 + var platformInWallet: Bool = false + var platformAccountType: UInt8 = 0 + var platformKeyIndex: UInt32 = 0 + + var collateralTxid: Data? + var collateralVout: UInt32 + + var revoked: Bool + var revocationReason: UInt16 + var statusRaw: UInt8 = 3 + + var registrationHeight: UInt32 + var hasRegistration: Bool + var txCount: UInt32 + + var orderIndex: UInt32 + var typeIndex: UInt32 = 0 + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + proTxHash: Data, + registrationTxid: Data, + serviceAddress: String? = nil, + isEvonode: Bool = false, + ownerKeyHash: Data? = nil, + votingKeyHash: Data? = nil, + ownerAddress: String? = nil, + votingAddress: String? = nil, + operatorPublicKey: Data? = nil, + platformNodeId: Data? = nil, + payoutAddress: String? = nil, + collateralTxid: Data? = nil, + collateralVout: UInt32 = 0, + revoked: Bool = false, + revocationReason: UInt16 = 0, + statusRaw: UInt8 = 3, + registrationHeight: UInt32 = 0, + hasRegistration: Bool = false, + txCount: UInt32 = 0, + orderIndex: UInt32 = 0, + typeIndex: UInt32 = 0 + ) { + self.walletId = walletId + self.proTxHash = proTxHash + self.registrationTxid = registrationTxid + self.serviceAddress = serviceAddress + self.isEvonode = isEvonode + self.ownerKeyHash = ownerKeyHash + self.votingKeyHash = votingKeyHash + self.ownerAddress = ownerAddress + self.votingAddress = votingAddress + self.operatorPublicKey = operatorPublicKey + self.platformNodeId = platformNodeId + self.payoutAddress = payoutAddress + self.collateralTxid = collateralTxid + self.collateralVout = collateralVout + self.revoked = revoked + self.revocationReason = revocationReason + self.statusRaw = statusRaw + self.registrationHeight = registrationHeight + self.hasRegistration = hasRegistration + self.txCount = txCount + self.orderIndex = orderIndex + self.typeIndex = typeIndex + self.createdAt = Date() + self.lastUpdated = Date() + } + + var proTxHashHex: String { + proTxHash.reversed().map { String(format: "%02x", $0) }.joined() + } + + var proTxHashShort: String { + let hex = proTxHashHex + guard hex.count >= 12 else { return hex } + return "\(String(hex.prefix(6)))…\(String(hex.suffix(6)))" + } + + var ownerKeyHashHex: String? { + ownerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var votingKeyHashHex: String? { + votingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + static func providerAccountTypeName(_ tag: UInt8) -> String { + switch tag { + case 8: return "ProviderVotingKeys" + case 9: return "ProviderOwnerKeys" + case 10: return "ProviderOperatorKeys" + case 11: return "ProviderPlatformKeys" + default: return "Unknown(\(tag))" + } + } + + static func keyOwnershipLabel( + inWallet: Bool, + accountType: UInt8, + index: UInt32 + ) -> String { + inWallet + ? "\(providerAccountTypeName(accountType)) #\(index)" + : "not in this wallet" + } + + var operatorPublicKeyHex: String? { + operatorPublicKey.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var platformNodeIdHex: String? { + platformNodeId.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var collateralDisplay: String? { + guard let txid = collateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(collateralVout)" + } + + var displayNumber: Int { + Int(typeIndex) + } + + var typeName: String { + isEvonode ? "Evonode" : "Masternode" + } + + var displayTitle: String { + "\(typeName) \(displayNumber)" + } + + var status: MasternodeStatus { + MasternodeStatus(rawValue: statusRaw) ?? .unknown + } + + var statusName: String { + status.displayName + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift new file mode 100644 index 00000000000..725f19910fd --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift @@ -0,0 +1,46 @@ +import Foundation +import SwiftData + +// `PersistentPendingInput` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentPendingInput { + #Index([\.outpoint], [\.walletId], [\.walletId, \.isSweptTombstone]) + var outpoint: Data + + var inputIndex: UInt32 + + var spendingTxid: Data + + var spendingTransaction: PersistentTransaction? + + var walletId: Data + + var createdAt: Date + + var isSweptTombstone: Bool = false + + var winnerMinedHeight: UInt32? + + init( + outpoint: Data, + inputIndex: UInt32, + spendingTxid: Data, + spendingTransaction: PersistentTransaction?, + walletId: Data + ) { + self.outpoint = outpoint + self.inputIndex = inputIndex + self.spendingTxid = spendingTxid + self.spendingTransaction = spendingTransaction + self.walletId = walletId + self.createdAt = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift new file mode 100644 index 00000000000..90998081e7b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData + +// `PersistentPlatformAddress` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentPlatformAddress { + #Index([\.walletId]) + + @Attribute(.unique) var address: String + var addressType: UInt8 + @Attribute(.unique) var addressHash: Data + var publicKey: Data + var accountIndex: UInt32 + var addressIndex: UInt32 + var derivationPath: String + var isUsed: Bool + var balance: UInt64 + var nonce: UInt32 + var firstSeenHeight: UInt32 + var lastSeenHeight: UInt64 + var walletId: Data + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + + init( + address: String, + addressType: UInt8, + addressHash: Data, + publicKey: Data = Data(), + accountIndex: UInt32, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0, + nonce: UInt32 = 0, + walletId: Data + ) { + self.address = address + self.addressType = addressType + self.addressHash = addressHash + self.publicKey = publicKey + self.accountIndex = accountIndex + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.balance = balance + self.nonce = nonce + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.walletId = walletId + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaV4.PersistentPlatformAddress { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static var nonZeroBalancesPredicate: Predicate { + #Predicate { entry in + entry.balance > 0 + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift new file mode 100644 index 00000000000..fbc04ff24cf --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftData + +// `PersistentPlatformAddressesSyncState` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentPlatformAddressesSyncState { + @Attribute(.unique) var walletId: Data + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + var syncHeight: UInt64 + var syncTimestamp: UInt64 + var lastKnownRecentBlock: UInt64 + var lastUpdated: Date + + init( + walletId: Data, + network: Network, + syncHeight: UInt64, + syncTimestamp: UInt64, + lastKnownRecentBlock: UInt64 + ) { + self.walletId = walletId + self.networkRaw = network.rawValue + self.syncHeight = syncHeight + self.syncTimestamp = syncTimestamp + self.lastKnownRecentBlock = lastKnownRecentBlock + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift new file mode 100644 index 00000000000..233efafe6bd --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift @@ -0,0 +1,55 @@ +import Foundation +import SwiftData + +// `PersistentProperty` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentProperty { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + var type: String + var format: String? + var contentMediaType: String? + var byteArray: Bool + var minItems: Int? + var maxItems: Int? + var pattern: String? + var minLength: Int? + var maxLength: Int? + var minValue: Int? + var maxValue: Int? + var fieldDescription: String? + + var transient: Bool + var isRequired: Bool + + var createdAt: Date + + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, type: String) { + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.type = type + self.byteArray = false + self.transient = false + self.isRequired = false + self.createdAt = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift new file mode 100644 index 00000000000..68c9d28cb32 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift @@ -0,0 +1,171 @@ +import Foundation +import SwiftData + +// `PersistentPublicKey` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentPublicKey { + var keyId: Int32 + var purpose: String + var securityLevel: String + var keyType: String + var readOnly: Bool + var disabledAt: Int64? + + var publicKeyData: Data + + var contractBoundsData: Data? + + var contractBoundsDocumentTypeName: String? + + var privateKeyKeychainIdentifier: String? + + var walletId: Data? + + var identityDerivationPath: String? + + var identityId: String + var createdAt: Date + var lastAccessed: Date? + + @Relationship(inverse: \PersistentIdentity.publicKeys) + var identity: PersistentIdentity? + + init( + keyId: Int32, + purpose: KeyPurpose, + securityLevel: SecurityLevel, + keyType: KeyType, + publicKeyData: Data, + readOnly: Bool = false, + disabledAt: Int64? = nil, + contractBounds: [Data]? = nil, + contractBoundsDocumentTypeName: String? = nil, + identityId: String + ) { + self.keyId = keyId + self.purpose = String(purpose.rawValue) + self.securityLevel = String(securityLevel.rawValue) + self.keyType = String(keyType.rawValue) + self.publicKeyData = publicKeyData + self.readOnly = readOnly + self.disabledAt = disabledAt + if let contractBounds = contractBounds { + self.contractBoundsData = try? JSONSerialization.data(withJSONObject: contractBounds.map { $0.base64EncodedString() }) + } else { + self.contractBoundsData = nil + } + self.contractBoundsDocumentTypeName = contractBoundsDocumentTypeName + self.identityId = identityId + self.createdAt = Date() + } + + var contractBounds: [Data]? { + get { + guard let data = contractBoundsData, + let json = try? JSONSerialization.jsonObject(with: data), + let strings = json as? [String] else { + return nil + } + return strings.compactMap { Data(base64Encoded: $0) } + } + set { + contractBoundsDocumentTypeName = nil + if let newValue = newValue { + contractBoundsData = try? JSONSerialization.data(withJSONObject: newValue.map { $0.base64EncodedString() }) + } else { + contractBoundsData = nil + } + } + } + + var purposeEnum: KeyPurpose? { + guard let purposeInt = UInt8(purpose) else { return nil } + return KeyPurpose(rawValue: purposeInt) + } + + var securityLevelEnum: SecurityLevel? { + guard let levelInt = UInt8(securityLevel) else { return nil } + return SecurityLevel(rawValue: levelInt) + } + + var keyTypeEnum: KeyType? { + guard let typeInt = UInt8(keyType) else { return nil } + return KeyType(rawValue: typeInt) + } + + var isDisabled: Bool { + disabledAt != nil + } + + var hasPrivateKeyIdentifier: Bool { + privateKeyKeychainIdentifier != nil + } + } +} + +extension DashSchemaV4.PersistentPublicKey { + func toIdentityPublicKey() -> IdentityPublicKey? { + guard let purpose = purposeEnum, + let securityLevel = securityLevelEnum, + let keyType = keyTypeEnum else { + return nil + } + + let bounds: ContractBounds? + if let id = contractBounds?.first, id.count == 32 { + if let docTypeName = contractBoundsDocumentTypeName, !docTypeName.isEmpty { + bounds = .singleContractDocumentType(id: id, documentTypeName: docTypeName) + } else { + bounds = .singleContract(id: id) + } + } else { + bounds = nil + } + + return IdentityPublicKey( + id: KeyID(keyId), + purpose: purpose, + securityLevel: securityLevel, + contractBounds: bounds, + keyType: keyType, + readOnly: readOnly, + data: publicKeyData, + disabledAt: disabledAt.map { TimestampMillis($0) } + ) + } + + static func from(_ publicKey: IdentityPublicKey, identityId: String) -> DashSchemaV4.PersistentPublicKey? { + let boundsIds: [Data]? + let docTypeName: String? + switch publicKey.contractBounds { + case .singleContract(let id): + boundsIds = [id] + docTypeName = nil + case .singleContractDocumentType(let id, let name): + boundsIds = [id] + docTypeName = name + case .none: + boundsIds = nil + docTypeName = nil + } + return DashSchemaV4.PersistentPublicKey( + keyId: Int32(publicKey.id), + purpose: publicKey.purpose, + securityLevel: publicKey.securityLevel, + keyType: publicKey.keyType, + publicKeyData: publicKey.data, + readOnly: publicKey.readOnly, + disabledAt: publicKey.disabledAt.map { Int64($0) }, + contractBounds: boundsIds, + contractBoundsDocumentTypeName: docTypeName, + identityId: identityId + ) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift new file mode 100644 index 00000000000..e916eb9174a --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift @@ -0,0 +1,89 @@ +import Foundation +import SwiftData + +// `PersistentShieldedActivity` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentShieldedActivity { + #Unique([\.walletId, \.accountIndex, \.entryId]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var entryId: Data + + var kindTag: Int + var direction: Int + var status: Int + + var amount: UInt64 + var fee: UInt64 + var hasFee: Bool + var blockHeight: UInt64 + var hasBlockHeight: Bool + var createdAtMs: UInt64 + + var minNotePosition: UInt64 = 0 + var hasMinNotePosition: Bool = false + + var identityId: Data + var counterparty: Data + var memo: Data + var noteCmxs: Data + var spentNullifiers: Data + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + entryId: Data, + kindTag: Int, + direction: Int, + status: Int, + amount: UInt64, + fee: UInt64, + hasFee: Bool, + blockHeight: UInt64, + hasBlockHeight: Bool, + createdAtMs: UInt64, + minNotePosition: UInt64 = 0, + hasMinNotePosition: Bool = false, + identityId: Data, + counterparty: Data, + memo: Data, + noteCmxs: Data, + spentNullifiers: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.entryId = entryId + self.kindTag = kindTag + self.direction = direction + self.status = status + self.amount = amount + self.fee = fee + self.hasFee = hasFee + self.blockHeight = blockHeight + self.hasBlockHeight = hasBlockHeight + self.createdAtMs = createdAtMs + self.minNotePosition = minNotePosition + self.hasMinNotePosition = hasMinNotePosition + self.identityId = identityId + self.counterparty = counterparty + self.memo = memo + self.noteCmxs = noteCmxs + self.spentNullifiers = spentNullifiers + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift new file mode 100644 index 00000000000..2646b312466 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift @@ -0,0 +1,66 @@ +import Foundation +import SwiftData + +// `PersistentShieldedNote` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentShieldedNote { + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var position: UInt64 + var cmx: Data + @Attribute(.unique) var nullifier: Data + var blockHeight: UInt64 + var isSpent: Bool + var value: UInt64 + var noteData: Data + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + position: UInt64, + cmx: Data, + nullifier: Data, + blockHeight: UInt64, + isSpent: Bool, + value: UInt64, + noteData: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.position = position + self.cmx = cmx + self.nullifier = nullifier + self.blockHeight = blockHeight + self.isSpent = isSpent + self.value = value + self.noteData = noteData + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} + +extension DashSchemaV4.PersistentShieldedNote { + static func unspentPredicate(walletId: Data) -> Predicate { + #Predicate { + $0.walletId == walletId && $0.isSpent == false + } + } + + static var unspentPredicate: Predicate { + #Predicate { $0.isSpent == false } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift new file mode 100644 index 00000000000..5dd20e6b091 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftData + +// `PersistentShieldedOutgoingNote` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentShieldedOutgoingNote { + #Unique([\.walletId, \.accountIndex, \.cmx]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var cmx: Data + var recipient: Data + var value: UInt64 + var memo: Data + var blockHeight: UInt64 + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + cmx: Data, + recipient: Data, + value: UInt64, + memo: Data, + blockHeight: UInt64 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.cmx = cmx + self.recipient = recipient + self.value = value + self.memo = memo + self.blockHeight = blockHeight + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift new file mode 100644 index 00000000000..6c520c0f965 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentShieldedSyncState` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentShieldedSyncState { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + var lastSyncedIndex: UInt64 + + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + lastSyncedIndex: UInt64 = 0 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.lastSyncedIndex = lastSyncedIndex + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift new file mode 100644 index 00000000000..a51d1e46f9f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentShieldedViewingKey` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentShieldedViewingKey { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + var fvkBytes: Data + + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + fvkBytes: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.fvkBytes = fvkBytes + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift new file mode 100644 index 00000000000..f06de9f8f3b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift @@ -0,0 +1,376 @@ +import Foundation +import SwiftData + +// `PersistentToken` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentToken { + @Attribute(.unique) var id: Data + var contractId: Data + var position: Int + var name: String + + var baseSupply: String + var maxSupply: String? + var decimals: Int + + var localizations: [String: TokenLocalization]? + + var isPaused: Bool + var allowTransferToFrozenBalance: Bool + + var keepsTransferHistory: Bool + var keepsFreezingHistory: Bool + var keepsMintingHistory: Bool + var keepsBurningHistory: Bool + var keepsDirectPricingHistory: Bool + var keepsDirectPurchaseHistory: Bool + + var conventionsChangeRules: ChangeControlRules? + var maxSupplyChangeRules: ChangeControlRules? + var manualMintingRules: ChangeControlRules? + var manualBurningRules: ChangeControlRules? + var freezeRules: ChangeControlRules? + var unfreezeRules: ChangeControlRules? + var destroyFrozenFundsRules: ChangeControlRules? + var emergencyActionRules: ChangeControlRules? + + var perpetualDistribution: TokenPerpetualDistribution? + var preProgrammedDistribution: TokenPreProgrammedDistribution? + var newTokensDestinationIdentity: Data? + var mintingAllowChoosingDestination: Bool + var distributionChangeRules: TokenDistributionChangeRules? + + var tradeMode: TokenTradeMode + var tradeModeChangeRules: ChangeControlRules? + + var mainControlGroupPosition: Int? + var mainControlGroupCanBeModified: String? + + var tokenDescription: String? + + var createdAt: Date + var lastUpdatedAt: Date + + var dataContract: PersistentDataContract? + + @Relationship(deleteRule: .cascade) + var balances: [PersistentTokenBalance]? + + @Relationship(deleteRule: .cascade) + var historyEvents: [PersistentTokenHistoryEvent]? + + init(contractId: Data, position: Int, name: String, baseSupply: String, decimals: Int = 8) { + var idData = contractId + withUnsafeBytes(of: position.bigEndian) { bytes in + idData.append(contentsOf: bytes) + } + self.id = idData + + self.contractId = contractId + self.position = position + self.name = name + self.baseSupply = baseSupply + self.decimals = decimals + + self.isPaused = false + self.allowTransferToFrozenBalance = true + self.keepsTransferHistory = true + self.keepsFreezingHistory = true + self.keepsMintingHistory = true + self.keepsBurningHistory = true + self.keepsDirectPricingHistory = true + self.keepsDirectPurchaseHistory = true + self.mintingAllowChoosingDestination = true + self.tradeMode = TokenTradeMode.notTradeable + + self.createdAt = Date() + self.lastUpdatedAt = Date() + } + } +} + +extension DashSchemaV4.PersistentToken { + var displayName: String { + if let desc = tokenDescription, !desc.isEmpty { + return desc + } + return getSingularForm() ?? name + } + + var formattedBaseSupply: String { + Self.formatSupply(baseSupply, decimals: decimals) + } + + static func formatSupply(_ raw: String, decimals: Int) -> String { + guard !raw.isEmpty, raw.allSatisfy({ $0.isASCII && $0.isNumber }) else { + return raw + } + let normalized = String(raw.drop(while: { $0 == "0" })) + let digits = normalized.isEmpty ? "0" : normalized + let scale = max(0, decimals) + let integer: String + var fraction = "" + if scale == 0 { + integer = digits + } else if digits.count <= scale { + integer = "0" + fraction = String(repeating: "0", count: scale - digits.count) + digits + } else { + let split = digits.index(digits.endIndex, offsetBy: -scale) + integer = String(digits[.. 0 && offset.isMultiple(of: 3) { grouped.append(",") } + grouped.append(character) + } + grouped = String(grouped.reversed()) + return fraction.isEmpty ? grouped : "\(grouped).\(fraction)" + } + + var contractIdBase58: String { + contractId.toBase58String() + } + + var canManuallyMint: Bool { + manualMintingRules != nil + } + + var canManuallyBurn: Bool { + manualBurningRules != nil + } + + var canFreeze: Bool { + freezeRules != nil + } + + var canUnfreeze: Bool { + unfreezeRules != nil + } + + var canDestroyFrozenFunds: Bool { + destroyFrozenFundsRules != nil + } + + var hasEmergencyActions: Bool { + emergencyActionRules != nil + } + + var canChangeMaxSupply: Bool { + maxSupplyChangeRules != nil + } + + var canChangeConventions: Bool { + conventionsChangeRules != nil + } + + var hasDistribution: Bool { + perpetualDistribution != nil || preProgrammedDistribution != nil + } + + var canChangeTradeMode: Bool { + tradeModeChangeRules != nil + } + + var keepsAnyHistory: Bool { + keepsTransferHistory || + keepsFreezingHistory || + keepsMintingHistory || + keepsBurningHistory || + keepsDirectPricingHistory || + keepsDirectPurchaseHistory + } + + var totalSupply: String { + guard let balances = balances, !balances.isEmpty else { return baseSupply } + return Self.sumUnsignedBalances(balances.map(\.unsignedBalance)) + } + + var totalFrozenBalance: String { + guard let balances = balances else { return "0" } + return Self.sumUnsignedBalances( + balances.lazy.filter(\.frozen).map(\.unsignedBalance) + ) + } + + var activeHolders: Int { + balances?.filter { $0.unsignedBalance > 0 }.count ?? 0 + } + + private static func sumUnsignedBalances(_ values: S) -> String + where S.Element == UInt64 { + var digits: [UInt8] = [0] // little-endian decimal digits + + for value in values { + var carry = 0 + let addend = String(value).utf8.reversed().map { Int($0 - 48) } + let width = max(digits.count, addend.count) + if digits.count < width { + digits.append(contentsOf: repeatElement(0, count: width - digits.count)) + } + + for index in 0.. 0 { + digits.append(UInt8(carry % 10)) + carry /= 10 + } + } + + return String(digits.reversed().map { Character(String($0)) }) + } + + var hasMaxSupply: Bool { + maxSupply != nil + } + + var isTradeable: Bool { + tradeMode != .notTradeable + } + + var newTokensDestinationIdentityBase58: String? { + newTokensDestinationIdentity?.toBase58String() + } +} + +extension DashSchemaV4.PersistentToken { + func setLocalization(languageCode: String, singularForm: String, pluralForm: String, description: String? = nil) { + if localizations == nil { + localizations = [:] + } + localizations?[languageCode] = DashSchemaV4.TokenLocalization( + singularForm: singularForm, + pluralForm: pluralForm, + description: description + ) + lastUpdatedAt = Date() + } + + func getSingularForm(languageCode: String = "en") -> String? { + return localizations?[languageCode]?.singularForm ?? localizations?["en"]?.singularForm + } + + func getPluralForm(languageCode: String = "en") -> String? { + return localizations?[languageCode]?.pluralForm ?? localizations?["en"]?.pluralForm + } +} + +extension DashSchemaV4.PersistentToken { + func getChangeControlRules(for type: ChangeControlRuleType) -> DashSchemaV4.ChangeControlRules? { + switch type { + case .conventions: return conventionsChangeRules + case .maxSupply: return maxSupplyChangeRules + case .manualMinting: return manualMintingRules + case .manualBurning: return manualBurningRules + case .freeze: return freezeRules + case .unfreeze: return unfreezeRules + case .destroyFrozenFunds: return destroyFrozenFundsRules + case .emergencyAction: return emergencyActionRules + case .tradeMode: return tradeModeChangeRules + } + } + + func setChangeControlRules(_ rules: DashSchemaV4.ChangeControlRules, for type: ChangeControlRuleType) { + switch type { + case .conventions: conventionsChangeRules = rules + case .maxSupply: maxSupplyChangeRules = rules + case .manualMinting: manualMintingRules = rules + case .manualBurning: manualBurningRules = rules + case .freeze: freezeRules = rules + case .unfreeze: unfreezeRules = rules + case .destroyFrozenFunds: destroyFrozenFundsRules = rules + case .emergencyAction: emergencyActionRules = rules + case .tradeMode: tradeModeChangeRules = rules + } + + lastUpdatedAt = Date() + } +} + +extension DashSchemaV4.PersistentToken { + static func mintableTokensPredicate() -> Predicate { + #Predicate { token in + token.manualMintingRules != nil + } + } + + static func burnableTokensPredicate() -> Predicate { + #Predicate { token in + token.manualBurningRules != nil + } + } + + static func freezableTokensPredicate() -> Predicate { + #Predicate { token in + token.freezeRules != nil + } + } + + static func distributionTokensPredicate() -> Predicate { + #Predicate { token in + token.perpetualDistribution != nil || token.preProgrammedDistribution != nil + } + } + + static func pausedTokensPredicate() -> Predicate { + #Predicate { token in + token.isPaused == true + } + } + + static func tokensByContractPredicate(contractId: Data) -> Predicate { + #Predicate { token in + token.contractId == contractId + } + } + + static func tokensWithControlRulePredicate(rule: ControlRuleType) -> Predicate { + switch rule { + case .manualMinting: + return #Predicate { token in + token.manualMintingRules != nil + } + case .manualBurning: + return #Predicate { token in + token.manualBurningRules != nil + } + case .freeze: + return #Predicate { token in + token.freezeRules != nil + } + case .unfreeze: + return #Predicate { token in + token.unfreezeRules != nil + } + case .destroyFrozenFunds: + return #Predicate { token in + token.destroyFrozenFundsRules != nil + } + case .emergencyAction: + return #Predicate { token in + token.emergencyActionRules != nil + } + case .conventions: + return #Predicate { token in + token.conventionsChangeRules != nil + } + case .maxSupply: + return #Predicate { token in + token.maxSupplyChangeRules != nil + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift new file mode 100644 index 00000000000..31d7c67c1bc --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift @@ -0,0 +1,198 @@ +import Foundation +import SwiftData + +// `PersistentTokenBalance` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentTokenBalance { + #Index([\.networkRaw]) + + var tokenId: String + var identityId: Data + var balance: Int64 + var frozen: Bool + + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + var tokenName: String? + var tokenSymbol: String? + var tokenDecimals: Int32? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + @Relationship(deleteRule: .nullify) var identity: PersistentIdentity? + @Relationship(inverse: \PersistentToken.balances) var token: PersistentToken? + + init( + tokenId: String, + identityId: Data, + balance: Int64 = 0, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.tokenId = tokenId + self.identityId = identityId + self.balance = balance + self.frozen = frozen + self.tokenName = tokenName + self.tokenSymbol = tokenSymbol + self.tokenDecimals = tokenDecimals + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + self.networkRaw = network.rawValue + } + + convenience init( + tokenId: String, + identityId: Data, + unsignedBalance: UInt64, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.init( + tokenId: tokenId, + identityId: identityId, + balance: Int64(bitPattern: unsignedBalance), + frozen: frozen, + tokenName: tokenName, + tokenSymbol: tokenSymbol, + tokenDecimals: tokenDecimals, + network: network + ) + } + + var unsignedBalance: UInt64 { + get { UInt64(bitPattern: balance) } + set { balance = Int64(bitPattern: newValue) } + } + + var formattedBalance: String { + let decimals: Int + if let tokenDecimals { + decimals = Int(tokenDecimals) + } else if let tokenDecimals = token?.decimals { + decimals = tokenDecimals + } else { + return "\(unsignedBalance)" + } + + guard decimals > 0 else { return String(unsignedBalance) } + + let digits = String(unsignedBalance) + let scale = decimals + if digits.count <= scale { + return "0." + String(repeating: "0", count: scale - digits.count) + digits + } + let split = digits.index(digits.endIndex, offsetBy: -scale) + return String(digits[.. (tokenId: String, balance: UInt64, frozen: Bool) { + return (tokenId: tokenId, balance: unsignedBalance, frozen: frozen) + } +} + +extension DashSchemaV4.PersistentTokenBalance { + static func predicate(tokenId: String, identityId: Data) -> Predicate { + #Predicate { balance in + balance.tokenId == tokenId && balance.identityId == identityId + } + } + + static func predicate(identityId: Data) -> Predicate { + #Predicate { balance in + balance.identityId == identityId + } + } + + static func predicate(tokenId: String) -> Predicate { + #Predicate { balance in + balance.tokenId == tokenId + } + } + + static var nonZeroBalancesPredicate: Predicate { + #Predicate { balance in + balance.balance != 0 + } + } + + static var frozenBalancesPredicate: Predicate { + #Predicate { balance in + balance.frozen == true + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { balance in + balance.lastSyncedAt == nil || balance.lastSyncedAt! < date + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift new file mode 100644 index 00000000000..5972942e7fb --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift @@ -0,0 +1,112 @@ +import Foundation +import SwiftData + +// `PersistentTokenHistoryEvent` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentTokenHistoryEvent { + @Attribute(.unique) var id: UUID + + var eventType: String + var transactionId: Data? + var blockHeight: Int64? + var coreBlockHeight: Int64? + + var fromIdentity: Data? + var toIdentity: Data? + var performedByIdentity: Data + + var amount: String? + var balanceBefore: String? + var balanceAfter: String? + + var additionalDataJSON: Data? + + var eventDescription: String? + + var createdAt: Date + var eventTimestamp: Date + + @Relationship(inverse: \PersistentToken.historyEvents) + var token: PersistentToken? + + init( + eventType: TokenEventType, + performedByIdentity: Data, + eventTimestamp: Date = Date() + ) { + self.id = UUID() + self.eventType = eventType.rawValue + self.performedByIdentity = performedByIdentity + self.eventTimestamp = eventTimestamp + self.createdAt = Date() + } + + var eventTypeEnum: TokenEventType { + TokenEventType(rawValue: eventType) ?? .unknown + } + + var fromIdentityBase58: String? { + fromIdentity?.toBase58String() + } + + var toIdentityBase58: String? { + toIdentity?.toBase58String() + } + + var performedByIdentityBase58: String { + performedByIdentity.toBase58String() + } + + var displayTitle: String { + switch eventTypeEnum { + case .mint: + return "Minted \(formattedAmount)" + case .burn: + return "Burned \(formattedAmount)" + case .transfer: + return "Transfer \(formattedAmount)" + case .freeze: + return "Frozen \(formattedAmount)" + case .unfreeze: + return "Unfrozen \(formattedAmount)" + case .destroyFrozenFunds: + return "Destroyed Frozen Funds \(formattedAmount)" + case .configUpdate: + return "Configuration Updated" + case .emergencyAction: + return "Emergency Action" + case .perpetualDistribution: + return "Perpetual Distribution \(formattedAmount)" + case .preProgrammedRelease: + return "Pre-programmed Release \(formattedAmount)" + case .directPricing: + return "Direct Pricing Updated" + case .directPurchase: + return "Direct Purchase \(formattedAmount)" + case .unknown: + return "Unknown Event" + } + } + + private var formattedAmount: String { + guard let amount = amount else { return "" } + return amount + } + + func setAdditionalData(_ data: [String: Any]) { + additionalDataJSON = try? JSONSerialization.data(withJSONObject: data) + } + + func getAdditionalData() -> [String: Any]? { + guard let data = additionalDataJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift new file mode 100644 index 00000000000..4a2e857dbd9 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftData + +// `PersistentTrackedMasternode` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentTrackedMasternode { + #Unique([\.networkRaw, \.proTxHash]) + #Index([\.networkRaw]) + + var networkRaw: UInt32 + var proTxHash: Data + var label: String? + var addedAt: UInt64 + var snapshotJSON: String + + var network: Network? { + get { Network(rawValue: networkRaw) } + set { networkRaw = newValue?.rawValue ?? networkRaw } + } + + init( + networkRaw: UInt32, + proTxHash: Data, + label: String?, + addedAt: UInt64, + snapshotJSON: String + ) { + self.networkRaw = networkRaw + self.proTxHash = proTxHash + self.label = label + self.addedAt = addedAt + self.snapshotJSON = snapshotJSON + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTransaction.swift new file mode 100644 index 00000000000..9550581b615 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTransaction.swift @@ -0,0 +1,167 @@ +import Foundation +import SwiftData + +// `PersistentTransaction` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentTransaction { + #Index([\.firstSeen]) + + @Attribute(.unique) var txid: Data + var transactionData: Data + var context: UInt32 + var blockHeight: UInt32 + var blockHash: Data? + var blockTimestamp: UInt32 + var blockPosition: UInt32 = 0 + var hasBlockPosition: Bool = false + var direction: UInt32 + var transactionType: String + var transactionTypeKind: UInt8 = 0xFF + var netAmount: Int64 + var fee: UInt64? + var label: String + var firstSeen: UInt64 + + var providerServiceAddress: String? = nil + var providerProTxHash: Data? = nil + var providerCollateralTxid: Data? = nil + var providerCollateralVout: UInt32 = 0 + var providerOwnerKeyHash: Data? = nil + var providerVotingKeyHash: Data? = nil + + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.transaction) + var outputs: [PersistentTxo] = [] + + @Relationship(inverse: \PersistentTxo.spendingTransaction) + var inputs: [PersistentTxo] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentPendingInput.spendingTransaction) + var pendingInputs: [PersistentPendingInput] = [] + + @Relationship(inverse: \PersistentAccount.involvedTransactions) + var involvedAccounts: [PersistentAccount] = [] + + init( + txid: Data, + transactionData: Data, + context: UInt32 = 0, + blockHeight: UInt32 = 0, + direction: UInt32 = 0, + transactionType: String = "Standard", + netAmount: Int64 = 0, + firstSeen: UInt64 = 0 + ) { + self.txid = txid + self.transactionData = transactionData + self.context = context + self.blockHeight = blockHeight + self.blockTimestamp = 0 + self.direction = direction + self.transactionType = transactionType + self.netAmount = netAmount + self.firstSeen = firstSeen + self.label = "" + self.createdAt = Date() + self.lastUpdated = Date() + } + + var txidHex: String { + txid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var contextName: String { + switch context { + case 0: return "Mempool" + case 1: return "InstantSend" + case 2: return "In Block" + case 3: return "Chain Locked" + default: return "Unknown" + } + } + + var directionName: String { + switch direction { + case 0: return "Incoming" + case 1: return "Outgoing" + case 2: return "Internal" + case 3: return "CoinJoin" + default: return "Unknown" + } + } + + var typedKind: TransactionTypeKind? { + TransactionTypeKind(rawValue: transactionTypeKind) + } + + var isAssetLock: Bool { + typedKind == .assetLock + } + + var isAssetUnlock: Bool { + typedKind == .assetUnlock + } + + var isProviderRegistration: Bool { + typedKind == .providerRegistration + } + + var isProviderUpdateService: Bool { + typedKind == .providerUpdateService + } + + var providerProTxHashHex: String? { + providerProTxHash.map { $0.reversed().map { String(format: "%02x", $0) }.joined() } + } + + var providerCollateralDisplay: String? { + guard let txid = providerCollateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(providerCollateralVout)" + } + + var providerOwnerKeyHashHex: String? { + providerOwnerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var providerVotingKeyHashHex: String? { + providerVotingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var isProviderSpecial: Bool { + providerSpecialName != nil + } + + var providerSpecialName: String? { + switch typedKind { + case .providerRegistration: return "Provider Registration" + case .providerUpdateRegistrar: return "Provider Update Registrar" + case .providerUpdateService: return "Provider Update Service" + case .providerUpdateRevocation: return "Provider Update Revocation" + default: return nil + } + } + + var displayDirection: String { + if isAssetLock { return "Asset Lock" } + if isAssetUnlock { return "Asset Unlock" } + if let name = providerSpecialName { return name } + return directionName + } + + var formattedAmount: String { + let dash = Double(abs(netAmount)) / 100_000_000.0 + let sign = netAmount >= 0 ? "+" : "-" + return String(format: "%@%.8f DASH", sign, dash) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift new file mode 100644 index 00000000000..548d513f9b2 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftData + +// `PersistentTxo` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentTxo { + #Index([\.walletId]) + + @Attribute(.unique) var outpoint: Data + var vout: UInt32 + var amount: UInt64 + var address: String + var scriptPubKey: Data + var height: UInt32 + var isCoinbase: Bool + var isConfirmed: Bool + var isInstantLocked: Bool + var isLocked: Bool + var isSpent: Bool + var createdAt: Date + var lastUpdated: Date + + var walletId: Data = Data() + + var transaction: PersistentTransaction? + + var spendingTransaction: PersistentTransaction? + + var supersededByTxid: Data? + + var spendingInputIndex: UInt32? = nil + + var account: PersistentAccount? + + var coreAddress: PersistentCoreAddress? + + init( + transaction: PersistentTransaction, + vout: UInt32, + amount: UInt64, + address: String, + scriptPubKey: Data = Data(), + height: UInt32 = 0 + ) { + self.outpoint = Self.makeOutpoint(txid: transaction.txid, vout: vout) + self.vout = vout + self.amount = amount + self.address = address + self.scriptPubKey = scriptPubKey + self.height = height + self.isCoinbase = false + self.isConfirmed = false + self.isInstantLocked = false + self.isLocked = false + self.isSpent = false + self.createdAt = Date() + self.lastUpdated = Date() + self.transaction = transaction + } + + static func makeOutpoint(txid: Data, vout: UInt32) -> Data { + var data = Data(capacity: 36) + data.append(txid) + var v = vout.littleEndian + withUnsafeBytes(of: &v) { data.append(contentsOf: $0) } + return data + } + + var txid: Data { + if let transaction { + return transaction.txid + } + return outpoint.count >= 32 ? Data(outpoint.prefix(32)) : Data() + } + + var txidHex: String { + let rawTxid = txid + guard rawTxid.count == 32 else { return "" } + return rawTxid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var outpointHex: String { + let hex = txidHex + return hex.isEmpty ? "" : "\(hex):\(vout)" + } + + var formattedAmount: String { + let dash = Double(amount) / 100_000_000.0 + return String(format: "%.8f DASH", dash) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift new file mode 100644 index 00000000000..8301f4bc246 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift @@ -0,0 +1,95 @@ +import Foundation +import SwiftData + +// `PersistentWallet` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentWallet { + #Index([\.networkRaw], [\.walletGroupId]) + #Unique([\.walletId]) + + var walletId: Data + var walletGroupId: Data = Data() + var networkRaw: UInt32? + + var network: Network? { + get { + guard let raw = networkRaw else { return nil } + return Network(rawValue: raw) ?? .testnet + } + set { networkRaw = newValue?.rawValue } + } + var name: String? + var walletDescription: String? + var birthHeight: UInt32 + var syncedHeight: UInt32 + var lastSynced: UInt64 + var lastAppliedChainLockBytes: Data? + var lastAppliedChainLockHeight: UInt32? + var isImported: Bool = false + var seedBindingVerifiedMarker: String? + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentAccount.wallet) + var accounts: [PersistentAccount] + + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.wallet) + var identities: [PersistentIdentity] + + init( + walletId: Data, + walletGroupId: Data = Data(), + network: Network? = nil, + name: String? = nil, + walletDescription: String? = nil, + birthHeight: UInt32 = 0, + syncedHeight: UInt32 = 0, + isImported: Bool = false + ) { + self.walletId = walletId + self.walletGroupId = walletGroupId + self.networkRaw = network?.rawValue + self.name = name + self.walletDescription = walletDescription + self.birthHeight = birthHeight + self.syncedHeight = syncedHeight + self.lastSynced = 0 + self.isImported = isImported + self.createdAt = Date() + self.lastUpdated = Date() + self.accounts = [] + self.identities = [] + } + } +} + +extension DashSchemaV4.PersistentWallet { + var label: String { + if let name = name, !name.isEmpty { + return name + } + let hex = walletId.prefix(4) + .map { String(format: "%02x", $0) } + .joined() + return hex.isEmpty ? "Wallet" : "Wallet \(hex)…" + } +} + +extension DashSchemaV4.PersistentWallet { + static func predicate(walletId: Data) -> Predicate { + #Predicate { $0.walletId == walletId } + } + + static func predicate( + walletGroupId: Data + ) -> Predicate { + #Predicate { $0.walletGroupId == walletGroupId } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift new file mode 100644 index 00000000000..66eeeabc41f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentWalletManagerMetadata` exactly as schema DashSchemaV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaV4 { + @Model + final class PersistentWalletManagerMetadata { + @Attribute(.unique) var networkRaw: UInt32 + var combinedSyncHeight: UInt32 + var combinedSyncBlockHash: Data? + var walletCount: Int + var createdAt: Date + var lastUpdated: Date + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + init(network: Network) { + self.networkRaw = network.rawValue + self.combinedSyncHeight = 0 + self.walletCount = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift new file mode 100644 index 00000000000..7e6f1bb0c13 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift @@ -0,0 +1,153 @@ +import Foundation +import SwiftData + +// Inline value types exactly as schema DashSchemaV4 stored them, generated +// by scripts/freeze_schema_models.py from TokenTypes.swift +// at commit 787cac09e7. SwiftData expands a stored Codable struct into composite +// attributes of the owning entity, so these shapes are inputs to that +// version's checksum just like the model's own properties. Do not edit. +extension DashSchemaV4 { + struct ChangeControlRules: Codable, Equatable, Sendable { + var authorizedToMakeChange: String + var adminActionTakers: String + var changingAuthorizedActionTakersToNoOneAllowed: Bool + var changingAdminActionTakersToNoOneAllowed: Bool + var selfChangingAdminActionTakersAllowed: Bool + + init( + authorizedToMakeChange: String = AuthorizedActionTakers.noOne.rawValue, + adminActionTakers: String = AuthorizedActionTakers.noOne.rawValue, + changingAuthorizedActionTakersToNoOneAllowed: Bool = false, + changingAdminActionTakersToNoOneAllowed: Bool = false, + selfChangingAdminActionTakersAllowed: Bool = false + ) { + self.authorizedToMakeChange = authorizedToMakeChange + self.adminActionTakers = adminActionTakers + self.changingAuthorizedActionTakersToNoOneAllowed = changingAuthorizedActionTakersToNoOneAllowed + self.changingAdminActionTakersToNoOneAllowed = changingAdminActionTakersToNoOneAllowed + self.selfChangingAdminActionTakersAllowed = selfChangingAdminActionTakersAllowed + } + + static func mostRestrictive() -> ChangeControlRules { + return ChangeControlRules() + } + + static func contractOwnerControlled() -> ChangeControlRules { + return ChangeControlRules( + authorizedToMakeChange: AuthorizedActionTakers.contractOwner.rawValue, + adminActionTakers: AuthorizedActionTakers.noOne.rawValue, + selfChangingAdminActionTakersAllowed: true + ) + } + } + + enum AuthorizedActionTakers: String, CaseIterable, Codable, Sendable { + case noOne = "NoOne" + case contractOwner = "ContractOwner" + case mainGroup = "MainGroup" + + static func identity(_ id: Data) -> String { + return "Identity:\(id.toBase58String())" + } + + static func group(_ position: Int) -> String { + return "Group:\(position)" + } + } + + struct TokenPerpetualDistribution: Codable, Equatable, Sendable { + var distributionType: String + var distributionRecipient: String + var enabled: Bool + var lastDistributionTime: Date? + var nextDistributionTime: Date? + + init(distributionRecipient: String = "AllEqualShare", enabled: Bool = true) { + self.distributionType = "{}" + self.distributionRecipient = distributionRecipient + self.enabled = enabled + } + } + + struct TokenPreProgrammedDistribution: Codable, Equatable, Sendable { + var distributionSchedule: [DistributionEvent] + var currentEventIndex: Int + var totalDistributed: String + var remainingToDistribute: String + var isActive: Bool + var isPaused: Bool + var isCompleted: Bool + + init() { + self.distributionSchedule = [] + self.currentEventIndex = 0 + self.totalDistributed = "0" + self.remainingToDistribute = "0" + self.isActive = true + self.isPaused = false + self.isCompleted = false + } + } + + struct DistributionEvent: Codable, Equatable, Sendable { + var id: UUID + var triggerType: String + var triggerTime: Date? + var triggerBlock: Int64? + var triggerCondition: String? + var amount: String + var recipient: String + var description: String? + + init(triggerTime: Date, amount: String, recipient: String = "AllHolders", description: String? = nil) { + self.id = UUID() + self.triggerType = "Time" + self.triggerTime = triggerTime + self.amount = amount + self.recipient = recipient + self.description = description + } + } + + struct TokenDistributionChangeRules: Codable, Equatable, Sendable { + var perpetualDistributionRules: ChangeControlRules? + var newTokensDestinationIdentityRules: ChangeControlRules? + var mintingAllowChoosingDestinationRules: ChangeControlRules? + var changeDirectPurchasePricingRules: ChangeControlRules? + + init( + perpetualDistributionRules: ChangeControlRules? = nil, + newTokensDestinationIdentityRules: ChangeControlRules? = nil, + mintingAllowChoosingDestinationRules: ChangeControlRules? = nil, + changeDirectPurchasePricingRules: ChangeControlRules? = nil + ) { + self.perpetualDistributionRules = perpetualDistributionRules + self.newTokensDestinationIdentityRules = newTokensDestinationIdentityRules + self.mintingAllowChoosingDestinationRules = mintingAllowChoosingDestinationRules + self.changeDirectPurchasePricingRules = changeDirectPurchasePricingRules + } + } + + enum TokenTradeMode: String, CaseIterable, Codable, Sendable { + case notTradeable = "NotTradeable" + + var displayName: String { + switch self { + case .notTradeable: + return "Not Tradeable" + } + } + } + + struct TokenLocalization: Codable, Equatable, Sendable { + let singularForm: String + let pluralForm: String + let description: String? + + init(singularForm: String, pluralForm: String, description: String? = nil) { + self.singularForm = singularForm + self.pluralForm = pluralForm + self.description = description + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift index 5ed049b04fb..a3a62495552 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift @@ -12,6 +12,21 @@ public final class PersistentPublicKey { public var readOnly: Bool public var disabledAt: Int64? + // MARK: - Usage limits (protocol version 14) + /// The credits this key may take from the identity over its whole + /// lifetime, or `nil` for a key registered without a budget. Schema-stable + /// signed carrier for the protocol's unsigned `Credits`: read it through + /// `totalBudgetCredits` rather than the column, the same way + /// `PersistentTokenBalance.balance` carries an unsigned balance. + /// Additive optional column => SwiftData lightweight migration. + public var totalBudget: Int64? + + /// The block time in milliseconds from which this key can no longer sign, + /// or `nil` for a key registered without an expiry. Signed carrier for an + /// unsigned protocol value, like `totalBudget`; read it through + /// `expiresAtMillis`. Additive optional column => lightweight migration. + public var expiresAt: Int64? + // MARK: - Key Data public var publicKeyData: Data @@ -74,6 +89,8 @@ public final class PersistentPublicKey { disabledAt: Int64? = nil, contractBounds: [Data]? = nil, contractBoundsDocumentTypeName: String? = nil, + totalBudget: Int64? = nil, + expiresAt: Int64? = nil, identityId: String ) { self.keyId = keyId @@ -83,6 +100,8 @@ public final class PersistentPublicKey { self.publicKeyData = publicKeyData self.readOnly = readOnly self.disabledAt = disabledAt + self.totalBudget = totalBudget + self.expiresAt = expiresAt if let contractBounds = contractBounds { self.contractBoundsData = try? JSONSerialization.data(withJSONObject: contractBounds.map { $0.base64EncodedString() }) } else { @@ -143,6 +162,26 @@ public final class PersistentPublicKey { disabledAt != nil } + /// The key's lifetime budget in credits, read through the signed + /// `totalBudget` column's raw bits. Setter writes through. + public var totalBudgetCredits: UInt64? { + get { totalBudget.map { UInt64(bitPattern: $0) } } + set { totalBudget = newValue.map { Int64(bitPattern: $0) } } + } + + /// The key's expiry as block time in milliseconds, read through the + /// signed `expiresAt` column's raw bits. Setter writes through. + public var expiresAtMillis: UInt64? { + get { expiresAt.map { UInt64(bitPattern: $0) } } + set { expiresAt = newValue.map { Int64(bitPattern: $0) } } + } + + /// Whether the key carries either usage limit, which is what makes it + /// a version 1 key on the wire. + public var hasLimits: Bool { + totalBudget != nil || expiresAt != nil + } + /// Check if this public key has an associated private key identifier public var hasPrivateKeyIdentifier: Bool { privateKeyKeychainIdentifier != nil @@ -196,7 +235,9 @@ extension PersistentPublicKey { keyType: keyType, readOnly: readOnly, data: publicKeyData, - disabledAt: disabledAt.map { TimestampMillis($0) } + disabledAt: disabledAt.map { TimestampMillis($0) }, + totalBudget: totalBudgetCredits, + expiresAt: expiresAtMillis ) } @@ -226,6 +267,8 @@ extension PersistentPublicKey { disabledAt: publicKey.disabledAt.map { Int64($0) }, contractBounds: boundsIds, contractBoundsDocumentTypeName: docTypeName, + totalBudget: publicKey.totalBudget.map { Int64(bitPattern: $0) }, + expiresAt: publicKey.expiresAt.map { Int64(bitPattern: $0) }, identityId: identityId ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift index 182e49b380c..85b115aaec0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedIdentity.swift @@ -99,6 +99,14 @@ public final class ManagedIdentity: @unchecked Sendable { /// (compressed secp256k1 pubkey for ECDSA, hash160 for /// HASH160 variants, etc.). public let data: Data + /// Usage limit (protocol version 14): the credits this key may + /// take from the identity over its whole lifetime, or `nil` for + /// a key registered without a budget. + public let totalBudget: UInt64? + /// Usage limit (protocol version 14): the block time in + /// milliseconds from which this key can no longer sign, or `nil` + /// for a key registered without an expiry. + public let expiresAt: Int64? } /// Return every `IdentityPublicKey` registered on this identity. @@ -153,7 +161,11 @@ public final class ManagedIdentity: @unchecked Sendable { disabledAt: ffi.disabled_at_is_some ? Int64(bitPattern: ffi.disabled_at) : nil, - data: data + data: data, + totalBudget: ffi.total_budget_is_some ? ffi.total_budget : nil, + expiresAt: ffi.expires_at_is_some + ? Int64(bitPattern: ffi.expires_at) + : nil ) ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 8664e1dfb90..810298d336d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -179,6 +179,15 @@ public final class ManagedPlatformWallet: @unchecked Sendable { /// `nil` is valid for every purpose, including Encryption / /// Decryption keys. public let contractBounds: ContractBounds? + /// Optional usage limit (protocol version 14): the credits this + /// key may take from the identity over its whole lifetime. Only + /// AUTHENTICATION keys below MASTER may carry one. `nil` leaves + /// the key unbudgeted. + public let totalBudget: UInt64? + /// Optional usage limit (protocol version 14): the block time in + /// milliseconds from which this key can no longer sign. `nil` + /// leaves the key without an expiry. + public let expiresAt: UInt64? public init( keyId: UInt32, @@ -187,7 +196,9 @@ public final class ManagedPlatformWallet: @unchecked Sendable { securityLevel: SecurityLevel, pubkeyBytes: Data, readOnly: Bool = false, - contractBounds: ContractBounds? = nil + contractBounds: ContractBounds? = nil, + totalBudget: UInt64? = nil, + expiresAt: UInt64? = nil ) { self.keyId = keyId self.keyType = keyType @@ -196,6 +207,8 @@ public final class ManagedPlatformWallet: @unchecked Sendable { self.pubkeyBytes = pubkeyBytes self.readOnly = readOnly self.contractBounds = contractBounds + self.totalBudget = totalBudget + self.expiresAt = expiresAt } } @@ -681,7 +694,11 @@ 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, + has_total_budget: pk.totalBudget != nil, + total_budget: pk.totalBudget ?? 0, + has_expires_at: pk.expiresAt != nil, + expires_at: pk.expiresAt ?? 0 ) ) return pinNext(index + 1, &rows, pubkeys, buffers, body) @@ -3256,6 +3273,61 @@ extension ManagedPlatformWallet { }.value } + /// Raise the usage limits of one of an identity's keys (protocol + /// version 14), signing the resulting `IdentityKeyLimitsUpdate` with + /// the identity's MASTER key (or a CRITICAL authentication key that + /// carries no limits and no contract bounds) via the supplied + /// `KeychainSigner`. + /// + /// Limits only ever go up. `addBudget` is added to the key's total + /// budget AND to what is left of it, in credits; `expiresAt` moves the + /// key's expiry to that block time in milliseconds and must be later + /// than the one the key carries. At least one of the two must be given. + /// A limit the key does not already have, a zero top-up and an expiry + /// that is not later are refused before anything is signed, because + /// Platform would refuse them and charge for it. + /// + /// No identity revision is claimed or bumped. The cached key and its + /// `PersistentPublicKey` row follow through the persist-identity-keys + /// callback, the same way an added key does. + public func updateIdentityKeyLimits( + identityId: Identifier, + keyId: UInt32, + addBudget: UInt64? = nil, + expiresAt: UInt64? = nil, + signer: KeychainSigner + ) async throws { + guard addBudget != nil || expiresAt != nil else { + throw PlatformWalletError.walletOperation( + "updateIdentityKeyLimits needs a budget to add or a new expiry" + ) + } + let handle = self.handle + let signerHandle = signer.handle + let idBytes: [UInt8] = identityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let budgetToAdd = addBudget + let newExpiresAt = expiresAt + try await Task.detached(priority: .userInitiated) { + _ = signer + let result = idBytes.withUnsafeBufferPointer { + idBp -> PlatformWalletFFIResult in + platform_wallet_update_identity_key_limits_with_signer( + handle, + idBp.baseAddress!, + keyId, + budgetToAdd != nil, + budgetToAdd ?? 0, + newExpiresAt != nil, + newExpiresAt ?? 0, + signerHandle + ) + } + try result.check() + }.value + } + /// Parse a raw `IdentityUpdateTransition` from DPP bytes without /// signing or broadcasting it. Accepts both standard tagged bytes /// and Yappr's tagless `dash-st:` framing. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index e2dc2234eb1..5d3caaa5e6f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -4006,6 +4006,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { row.contractBounds = snapshotBoundsIds row.contractBoundsDocumentTypeName = snapshotBoundsDocType + // Usage limits (protocol version 14). Rust is the source of + // truth on every callback, and a key limits update raises a + // budget / moves an expiry in place, so the row follows the + // snapshot rather than keeping whatever it held: a key whose + // budget was just raised must not read back at the old value. + row.totalBudgetCredits = entry.totalBudget + row.expiresAtMillis = entry.expiresAt + // Private-key handling: no secret crosses the FFI. A // wallet-derivable key whose private bytes were materialized by // another path (e.g. identity registration writes its keychain @@ -5000,6 +5008,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let keyType: UInt8 let readOnly: Bool let disabledAt: UInt64? + /// Usage limit (protocol version 14): the credits the key may take + /// from the identity over its whole lifetime. `nil` for a key + /// without a budget. + let totalBudget: UInt64? + /// Usage limit (protocol version 14): the block time in + /// milliseconds from which the key can no longer sign. `nil` for a + /// key without an expiry. + let expiresAt: UInt64? let publicKeyData: Data let publicKeyHash: Data /// Owning wallet if this key is derivable from one we control. @@ -8135,6 +8151,27 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { row.contract_bounds_document_type = nil } + // Usage limits (protocol version 14). Without them a + // key registered with a budget or an expiry would come + // back unlimited on cold restart, and the restored + // identity would offer it for signing work consensus + // rejects. `total_budget` is credits; `expires_at` is + // block time in milliseconds. + if let totalBudget = pk.totalBudgetCredits { + row.total_budget_is_some = true + row.total_budget = totalBudget + } else { + row.total_budget_is_some = false + row.total_budget = 0 + } + if let expiresAt = pk.expiresAtMillis { + row.expires_at_is_some = true + row.expires_at = expiresAt + } else { + row.expires_at_is_some = false + row.expires_at = 0 + } + keyBuf[k] = row } entry.keys = UnsafePointer(keyBuf) @@ -9834,6 +9871,8 @@ private func persistIdentityKeysCallback( keyType: e.key_type, readOnly: e.read_only, disabledAt: e.disabled_at_is_some ? e.disabled_at : nil, + totalBudget: e.total_budget_is_some ? e.total_budget : nil, + expiresAt: e.expires_at_is_some ? e.expires_at : nil, publicKeyData: pubKey, publicKeyHash: dataFromTuple20(e.public_key_hash), walletId: walletId, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md index 4dd0c8f1dcd..7c467d4c1c8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/README.md @@ -637,6 +637,61 @@ Failed merely because retry was abandoned. damaged ciphertext can produce the same symptom. Keep the record for diagnosis instead of blindly submitting a replacement payment. +## Identity key usage limits + +Protocol version 14 lets an authentication key carry usage limits: a +`totalBudget`, the credits the key may take from its identity over its whole +lifetime, and an `expiresAt`, the block time in milliseconds from which it can +no longer sign. A key with either is registered as an `IdentityPublicKey::V1`; +a key with neither stays a version 0 key with the same bytes as ever. + +Register a limited key by setting the two optional fields on the +`ManagedPlatformWallet.IdentityPubkey` rows passed to identity registration, +invitation claim or `updateIdentity(identityId:addPublicKeys:...)`: + +```swift +let key = ManagedPlatformWallet.IdentityPubkey( + keyId: 3, + keyType: .ecdsaSecp256k1, + purpose: .authentication, + securityLevel: .high, + pubkeyBytes: pubkey, + totalBudget: 100_000_000, // credits, for the key's whole lifetime + expiresAt: 1_800_000_000_000 // block time in milliseconds +) +``` + +Read a key's limits back from `ManagedIdentity.getPublicKeys()` +(`totalBudget` / `expiresAt` on each `IdentityPublicKeyInfo`), or from the +persisted `PersistentPublicKey` row (`totalBudgetCredits` / `expiresAtMillis`). + +**Raise a key's limits:** +```swift +func updateIdentityKeyLimits( + identityId: Identifier, + keyId: UInt32, + addBudget: UInt64? = nil, // credits ADDED to the total budget + expiresAt: UInt64? = nil, // new expiry, block time in milliseconds + signer: KeychainSigner +) async throws +``` + +Limits only ever go up, and at least one of the two must be given. The +transition is signed by the identity's MASTER key or by a CRITICAL +authentication key that carries no limits and no contract bounds. No identity +revision is claimed. + +**Read what is left of a key's budget:** +```swift +func fetchKeysRemainingBudgets( + identityId: String, + keyIds: [UInt32] +) async throws -> [UInt32: UInt64?] +``` + +Defined on `SDK`. Each value is the credits left to that key, or `nil` for a +key that carries no budget or that the identity does not have. + ## See Also - [SwiftExampleApp Integration](../../../SwiftExampleApp/SwiftExampleApp/Services/DashPayService.swift) - Real-world usage example diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityKeyRefresher.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityKeyRefresher.swift index 7282187529d..9c67cfa6fe1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityKeyRefresher.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityKeyRefresher.swift @@ -73,6 +73,13 @@ enum IdentityKeyRefresher { let readOnly = keyData["readOnly"] as? Bool ?? false let disabledAt = keyData["disabledAt"] as? UInt64 + // Usage limits (protocol version 14). Present only on a + // version 1 key: `totalBudget` is credits for the key's + // whole lifetime, `expiresAt` is block time in + // milliseconds. Dropping them here would persist a + // limited key as unlimited. + let totalBudget = keyData["totalBudget"] as? UInt64 + let expiresAt = keyData["expiresAt"] as? UInt64 return IdentityPublicKey( id: UInt32(id), @@ -82,7 +89,9 @@ enum IdentityKeyRefresher { keyType: KeyType(rawValue: UInt8(keyType)) ?? .ecdsaSecp256k1, readOnly: readOnly, data: data, - disabledAt: disabledAt + disabledAt: disabledAt, + totalBudget: totalBudget, + expiresAt: expiresAt ) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift index 432cd0cd4d7..ea3fdffa2e7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift @@ -351,6 +351,13 @@ struct LoadIdentityView: View { let readOnly = keyData["readOnly"] as? Bool ?? false let disabledAt = keyData["disabledAt"] as? UInt64 + // Usage limits (protocol version 14). Present only on a + // version 1 key: `totalBudget` is credits for the key's + // whole lifetime, `expiresAt` is block time in + // milliseconds. Dropping them here would persist a + // limited key as unlimited. + let totalBudget = keyData["totalBudget"] as? UInt64 + let expiresAt = keyData["expiresAt"] as? UInt64 return IdentityPublicKey( id: UInt32(id), @@ -360,7 +367,9 @@ struct LoadIdentityView: View { keyType: KeyType(rawValue: UInt8(keyType)) ?? .ecdsaSecp256k1, readOnly: readOnly, data: data, - disabledAt: disabledAt + disabledAt: disabledAt, + totalBudget: totalBudget, + expiresAt: expiresAt ) } } else if let publicKeysArray = identityData["publicKeys"] as? [[String: Any]] { @@ -383,6 +392,13 @@ struct LoadIdentityView: View { let readOnly = keyData["readOnly"] as? Bool ?? false let disabledAt = keyData["disabledAt"] as? UInt64 + // Usage limits (protocol version 14). Present only on a + // version 1 key: `totalBudget` is credits for the key's + // whole lifetime, `expiresAt` is block time in + // milliseconds. Dropping them here would persist a + // limited key as unlimited. + let totalBudget = keyData["totalBudget"] as? UInt64 + let expiresAt = keyData["expiresAt"] as? UInt64 return IdentityPublicKey( id: UInt32(id), @@ -392,7 +408,9 @@ struct LoadIdentityView: View { keyType: KeyType(rawValue: UInt8(keyType)) ?? .ecdsaSecp256k1, readOnly: readOnly, data: data, - disabledAt: disabledAt + disabledAt: disabledAt, + totalBudget: totalBudget, + expiresAt: expiresAt ) } } else { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 7daaabbe52d..46e83b7f3fc 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -15,7 +15,8 @@ import XCTest /// the persistence sources as of commit 5f58417079 — the last state before /// V4, the state the frozen copies under `FrozenSchemas/` are generated /// from — through that build's own `DashSchemaV1` / `DashSchemaV2` / -/// `DashSchemaV3`, and `dash-v4` by the build that registered V4, through +/// `DashSchemaV3`, and `dash-v4` / `dash-v5` by the builds that registered +/// V4 and V5, through /// `DashModelContainer.create`. They pin the frozen copies as the pre-V4 /// build defined them, not what the original V1 release wrote (see the /// `DashSchemaV1` doc for why those stores are expected to fail open and @@ -69,6 +70,9 @@ final class DashModelMigrationTests: XCTestCase { Fixture( name: "dash-v4", version: DashSchemaV4.self, hasTrackedMasternode: true, assetLockRecipientIsExternal: true), + Fixture( + name: "dash-v5", version: DashSchemaV5.self, + hasTrackedMasternode: true, assetLockRecipientIsExternal: true), ] /// Every schema version that has ever shipped, oldest first, as @@ -80,7 +84,7 @@ final class DashModelMigrationTests: XCTestCase { /// give it a fixture store in `fixtures`, written by that build with /// `testWriteTheLiveSchemaFixtureStore`. Every entry has a fixture, /// the live one included. - private static let shippedVersions = ["1.0.0", "2.0.0", "3.0.0", "4.0.0"] + private static let shippedVersions = ["1.0.0", "2.0.0", "3.0.0", "4.0.0", "5.0.0"] private static let fixtureWalletId = Data(repeating: 0x31, count: 32) private static let fixtureSpendTxid = Data(repeating: 0x32, count: 32) @@ -576,13 +580,12 @@ final class DashModelMigrationTests: XCTestCase { 1) } - /// The stage this change adds: a V3 store must migrate to V4 and read - /// back with the sweep columns backfilled to their "nothing swept yet" - /// values. V3 registers the frozen graph, so the row goes in as the - /// frozen type and comes out as the live one — which is the whole point - /// of the freeze: the same entity, one property wider. A pending-input - /// row rides along so the tombstone index V4 adds is exercised by the - /// migration too. + /// The V3 -> V4 stage: a V3 store must migrate to V4 and read back with + /// the sweep columns backfilled to their "nothing swept yet" values. + /// Both versions register frozen graphs, so the row goes in as V3's + /// copy and comes out as V4's, which is the whole point of the freeze: + /// the same entity, one property wider. A pending-input row rides along + /// so the tombstone index V4 adds is exercised by the migration too. @MainActor func testV3StoreMigratesToV4AndBackfillsTheSweepColumns() throws { let directory = FileManager.default.temporaryDirectory @@ -645,19 +648,22 @@ final class DashModelMigrationTests: XCTestCase { migrationPlan: DashMigrationPlan.self, configurations: [v4Configuration]) + // V4 registers its own frozen graph, so the read side is those + // types: the live models are schema V5's. let wallets = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(wallets.count, 1, "the V3 row must survive the migration") XCTAssertNil( wallets.first?.lastAppliedChainLockHeight, "a wallet migrated from V3 has no chainlock boundary yet, so no " + "tombstone it later takes can be collected on a fabricated one") let pending = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(pending.count, 1, "the V3 pending row must survive the migration") XCTAssertEqual(pending.first?.isSweptTombstone, false, "backfilled as an ordinary claim") XCTAssertNil(pending.first?.winnerMinedHeight, "and unstamped") - let coins = try migrated.mainContext.fetch(FetchDescriptor()) + let coins = try migrated.mainContext.fetch( + FetchDescriptor()) XCTAssertEqual(coins.count, 1, "the V3 TXO row must survive the migration") XCTAssertEqual(coins.first?.isSpent, true, "its spent flag is carried as stored") XCTAssertNil( @@ -665,16 +671,17 @@ final class DashModelMigrationTests: XCTestCase { "a coin migrated from V3 was never held by a sweep — the stamp backfills to nil, " + "so the release and re-delivery rules see an ordinary spent coin") let transactions = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(transactions.map(\.context), [2], "the V3 transaction row survives unchanged") } /// The whole chain from the oldest registered version, on the models this - /// change actually widens: a V1 store carrying a wallet, a transaction + /// V4 actually widens: a V1 store carrying a wallet, a transaction /// and a coin must arrive at V4 with every row intact and the V4 columns - /// at their backfill values. V1 and V2 register the frozen graph, - /// so the rows go in as frozen types and come out live — the property - /// the freeze exists to guarantee, pinned here where it matters most. + /// at their backfill values. Every version here registers a frozen + /// graph, so the rows go in as V1's copies and come out as V4's: the + /// property the freeze exists to guarantee, pinned here where it + /// matters most. @MainActor func testV1StoreWithWalletTransactionAndCoinMigratesToV4() throws { let directory = FileManager.default.temporaryDirectory @@ -730,15 +737,17 @@ final class DashModelMigrationTests: XCTestCase { migrationPlan: DashMigrationPlan.self, configurations: [v4Configuration]) - let wallets = try migrated.mainContext.fetch(FetchDescriptor()) + let wallets = try migrated.mainContext.fetch( + FetchDescriptor()) XCTAssertEqual(wallets.map(\.walletId), [walletId]) XCTAssertNil(wallets.first?.lastAppliedChainLockHeight) let transactions = try migrated.mainContext.fetch( - FetchDescriptor()) + FetchDescriptor()) XCTAssertEqual(transactions.map(\.txid), [txid]) XCTAssertEqual(transactions.first?.context, 3) XCTAssertEqual(transactions.first?.netAmount, 2_000) - let coins = try migrated.mainContext.fetch(FetchDescriptor()) + let coins = try migrated.mainContext.fetch( + FetchDescriptor()) XCTAssertEqual(coins.count, 1) XCTAssertEqual(coins.first?.vout, 1) XCTAssertEqual(coins.first?.amount, 2_000) @@ -750,6 +759,107 @@ final class DashModelMigrationTests: XCTestCase { "the coin's relationship to its funding transaction survives three stages") } + /// The stage this change adds: a V4 store must migrate to V5 and read + /// back with the key usage-limit columns backfilled to "no limits", + /// which is exactly what a version 0 key is. V4 registers a frozen + /// graph, so the row goes in as V4's copy and comes out as the live + /// one: the same entity, two properties wider. + @MainActor + func testV4StoreMigratesToV5AndBackfillsTheKeyLimitColumns() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("dash.store") + + let identityId = "FixtureIdentityBase58" + + let v4Schema = Schema(versionedSchema: DashSchemaV4.self) + let v4Configuration = ModelConfiguration( + "DashKeyLimitsMigrationTest", + schema: v4Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + var v4Container: ModelContainer? = try ModelContainer( + for: v4Schema, + configurations: [v4Configuration]) + v4Container?.mainContext.insert(DashSchemaV4.PersistentPublicKey( + keyId: 3, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaSecp256k1, + publicKeyData: Data(repeating: 0x02, count: 33), + identityId: identityId)) + try v4Container?.mainContext.save() + v4Container = nil + + let v5Schema = Schema(versionedSchema: DashSchemaV5.self) + let v5Configuration = ModelConfiguration( + "DashKeyLimitsMigrationTest", + schema: v5Schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none) + let migrated = try ModelContainer( + for: v5Schema, + migrationPlan: DashMigrationPlan.self, + configurations: [v5Configuration]) + + let keys = try migrated.mainContext.fetch(FetchDescriptor()) + XCTAssertEqual(keys.count, 1, "the V4 key row must survive the migration") + let key = try XCTUnwrap(keys.first) + XCTAssertEqual(key.keyId, 3) + XCTAssertEqual(key.identityId, identityId) + XCTAssertEqual(key.publicKeyData, Data(repeating: 0x02, count: 33)) + XCTAssertNil(key.totalBudget, "a key migrated from V4 carries no budget") + XCTAssertNil(key.expiresAt, "nor an expiry; together, that is a version 0 key") + XCTAssertFalse(key.hasLimits) + + // And both new columns are writable on the migrated row, through the + // unsigned accessors the rest of the SDK reads them with. + key.totalBudgetCredits = 1_000 + key.expiresAtMillis = 1_800_000_000_000 + try migrated.mainContext.save() + let reread = try XCTUnwrap( + migrated.mainContext.fetch(FetchDescriptor()).first) + XCTAssertEqual(reread.totalBudgetCredits, 1_000) + XCTAssertEqual(reread.expiresAtMillis, 1_800_000_000_000) + XCTAssertTrue(reread.hasLimits) + } + + /// What makes the V4 -> V5 stage lightweight: the two versions name the + /// same entity set, and V5 only widens `PersistentPublicKey`. Also pins + /// that V4's frozen copy does NOT carry the two columns: one that came + /// back would silently change V4's checksum and strand every store the + /// V4 build wrote. + func testV4AndV5NameTheSameEntitySet() throws { + let v4 = Schema(versionedSchema: DashSchemaV4.self) + let v5 = Schema(versionedSchema: DashSchemaV5.self) + XCTAssertEqual( + v4.entities.map(\.name).sorted(), + v5.entities.map(\.name).sorted()) + + let key = try XCTUnwrap(v5.entities.first { $0.name == "PersistentPublicKey" }) + XCTAssertNotNil(key.attributesByName["totalBudget"]) + XCTAssertNotNil(key.attributesByName["expiresAt"]) + + let frozenKey = try XCTUnwrap(v4.entities.first { $0.name == "PersistentPublicKey" }) + XCTAssertNil(frozenKey.attributesByName["totalBudget"]) + XCTAssertNil(frozenKey.attributesByName["expiresAt"]) + + // V5 widens that entity and nothing else. + for name in v5.entities.map(\.name) where name != "PersistentPublicKey" { + let live = try XCTUnwrap(v5.entities.first { $0.name == name }) + let frozen = try XCTUnwrap(v4.entities.first { $0.name == name }) + XCTAssertEqual( + live.attributesByName.keys.sorted(), + frozen.attributesByName.keys.sorted(), + "V5 adds no column to \(name)") + } + } + /// What makes the V3 -> V4 stage lightweight: the two versions name the /// same entity set, and V4 only widens three of them. Also pins that /// `PersistentTransaction` is NOT one of the three — a swept row is @@ -793,7 +903,8 @@ final class DashModelMigrationTests: XCTestCase { Schema(versionedSchema: DashSchemaV1.self), Schema(versionedSchema: DashSchemaV2.self), Schema(versionedSchema: DashSchemaV3.self), - Schema(versionedSchema: DashSchemaV4.self) + Schema(versionedSchema: DashSchemaV4.self), + Schema(versionedSchema: DashSchemaV5.self) ] { let names = schema.entities.map(\.name) XCTAssertTrue( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v5.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v5.store new file mode 100644 index 0000000000000000000000000000000000000000..55040471f23ac31ffa65cb4210716c18b061215f GIT binary patch literal 663552 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 tOD6@LF=~*uhl(7mgP PlatformWalletPersistenceHandler.IdentityKeyEntrySnapshot { + .init( + identityId: identityId, + keyId: keyId, + purpose: KeyPurpose.authentication.rawValue, + securityLevel: SecurityLevel.high.rawValue, + keyType: KeyType.ecdsaSecp256k1.rawValue, + readOnly: false, + disabledAt: nil, + totalBudget: totalBudget, + expiresAt: expiresAt, + publicKeyData: publicKeyData, + publicKeyHash: Data(repeating: 0x03, count: 20), + walletId: nil, + derivationIndices: nil, + contractBounds: nil + ) + } + + private func persist( + _ handler: PlatformWalletPersistenceHandler, + _ entry: PlatformWalletPersistenceHandler.IdentityKeyEntrySnapshot + ) { + handler.beginChangeset(walletId: walletId) + handler.persistIdentityKeys(walletId: walletId, upserts: [entry], removed: []) + _ = handler.endChangeset(walletId: walletId, success: true) + } + + private func storedKey(_ container: ModelContainer) throws -> PersistentPublicKey { + let context = ModelContext(container) + let rows = try context.fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 1, "one key row per (identity, key id)") + return try XCTUnwrap(rows.first) + } + + /// A key registered with both limits persists both, in the units the + /// protocol uses: credits, and block time in milliseconds. + func testPersistWritesBothUsageLimits() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet) + + persist(handler, snapshot(totalBudget: 100_000, expiresAt: 1_800_000_000_000)) + + let row = try storedKey(container) + XCTAssertEqual(row.totalBudgetCredits, 100_000) + XCTAssertEqual(row.expiresAtMillis, 1_800_000_000_000) + XCTAssertTrue(row.hasLimits) + } + + /// A key with neither limit is a version 0 key: both columns stay nil, so + /// nothing downstream mistakes it for a key that may not spend. + func testPersistLeavesAVersionZeroKeyUnlimited() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet) + + persist(handler, snapshot(totalBudget: nil, expiresAt: nil)) + + let row = try storedKey(container) + XCTAssertNil(row.totalBudget) + XCTAssertNil(row.expiresAt) + XCTAssertFalse(row.hasLimits) + } + + /// What a key limits update emits: the same key id upserted again with a + /// raised budget and a later expiry. The row must follow, not keep the + /// values it was first written with: a wallet that showed the old budget + /// after raising it would refuse work Platform now allows. + func testUpsertOfTheSameKeyRaisesTheStoredLimits() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet) + + persist(handler, snapshot(totalBudget: 100_000, expiresAt: 1_800_000_000_000)) + persist(handler, snapshot(totalBudget: 350_000, expiresAt: 1_900_000_000_000)) + + let row = try storedKey(container) + XCTAssertEqual(row.totalBudgetCredits, 350_000) + XCTAssertEqual(row.expiresAtMillis, 1_900_000_000_000) + } + + /// Rust is the source of truth on every round, so a key re-emitted without + /// limits clears the columns rather than leaving stale ones behind. + func testUpsertWithoutLimitsClearsTheStoredOnes() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet) + + persist(handler, snapshot(totalBudget: 100_000, expiresAt: 1_800_000_000_000)) + persist(handler, snapshot(totalBudget: nil, expiresAt: nil)) + + let row = try storedKey(container) + XCTAssertNil(row.totalBudget) + XCTAssertNil(row.expiresAt) + } + + /// The projection the rest of the SDK reads a stored key through, both + /// ways: a limited key must not come back unlimited from either direction. + func testIdentityPublicKeyProjectionRoundTripsTheLimits() throws { + let key = IdentityPublicKey( + id: 3, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaSecp256k1, + readOnly: false, + data: publicKeyData, + totalBudget: 100_000, + expiresAt: 1_800_000_000_000 + ) + XCTAssertTrue(key.hasLimits) + XCTAssertTrue(key.isExpired(at: TimestampMillis(1_800_000_000_000))) + XCTAssertFalse(key.isExpired(at: TimestampMillis(1_799_999_999_999))) + + let row = try XCTUnwrap(PersistentPublicKey.from(key, identityId: "identity")) + XCTAssertEqual(row.totalBudgetCredits, 100_000) + XCTAssertEqual(row.expiresAtMillis, 1_800_000_000_000) + + let projected = try XCTUnwrap(row.toIdentityPublicKey()) + XCTAssertEqual(projected.totalBudget, 100_000) + XCTAssertEqual(projected.expiresAt, 1_800_000_000_000) + + // And a key with neither limit never claims one. + let unlimited = IdentityPublicKey( + id: 4, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaSecp256k1, + readOnly: false, + data: publicKeyData + ) + XCTAssertFalse(unlimited.hasLimits) + XCTAssertFalse(unlimited.isExpired(at: TimestampMillis.max)) + let unlimitedRow = try XCTUnwrap( + PersistentPublicKey.from(unlimited, identityId: "identity")) + XCTAssertNil(unlimitedRow.totalBudget) + XCTAssertNil(unlimitedRow.expiresAt) + XCTAssertNil(try XCTUnwrap(unlimitedRow.toIdentityPublicKey()).totalBudget) + } + + /// `IdentityPublicKey` still round-trips through its synthesized `Codable` + /// with the two limits on it, and a payload carrying the + /// `"$formatVersion"` tag a version 1 key is written with still decodes: + /// the model does not declare that key, and an undeclared key must stay + /// ignored rather than failing the decode. + func testCodableCarriesTheLimitsAndIgnoresTheFormatVersionTag() throws { + let key = IdentityPublicKey( + id: 3, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaSecp256k1, + readOnly: false, + data: publicKeyData, + totalBudget: 100_000, + expiresAt: 1_800_000_000_000 + ) + let encoded = try JSONEncoder().encode(key) + XCTAssertEqual(try JSONDecoder().decode(IdentityPublicKey.self, from: encoded), key) + + var object = try XCTUnwrap( + JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + XCTAssertEqual(object["totalBudget"] as? UInt64, 100_000) + XCTAssertEqual(object["expiresAt"] as? UInt64, 1_800_000_000_000) + object["$formatVersion"] = "1" + let tagged = try JSONSerialization.data(withJSONObject: object) + XCTAssertEqual(try JSONDecoder().decode(IdentityPublicKey.self, from: tagged), key) + + // A version 0 key writes neither field, and decodes to no limits. + let unlimited = IdentityPublicKey( + id: 4, + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaSecp256k1, + readOnly: false, + data: publicKeyData + ) + let unlimitedObject = try XCTUnwrap( + JSONSerialization.jsonObject(with: try JSONEncoder().encode(unlimited)) + as? [String: Any]) + var withTag = unlimitedObject + withTag["$formatVersion"] = "0" + let decoded = try JSONDecoder().decode( + IdentityPublicKey.self, + from: try JSONSerialization.data(withJSONObject: withTag)) + XCTAssertNil(decoded.totalBudget) + XCTAssertNil(decoded.expiresAt) + XCTAssertFalse(decoded.hasLimits) + } +} diff --git a/packages/swift-sdk/scripts/freeze_schema_models.py b/packages/swift-sdk/scripts/freeze_schema_models.py index baf78f0f269..d1b3d18bb35 100755 --- a/packages/swift-sdk/scripts/freeze_schema_models.py +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -147,6 +147,26 @@ "PersistentMasternode", ] +# Every model registered by V4, in the order `DashModelContainer` lists +# them: the shared graph with the asset lock back in its own slot and the +# tracked-masternode registry V2 added appended at the end. +# +# V4 is frozen as a whole graph rather than as a row for the three models it +# widened (`PersistentTxo`, `PersistentPendingInput`, `PersistentWallet`). +# Those three carry relationships, and a frozen model that names a +# relationship target absent from its own schema binds that bare name to the +# live type, which is the partial-freeze failure this file's header warns +# about. The rows above get away with being partial only because the models +# they freeze (`PersistentAssetLock`, `PersistentTrackedMasternode`) are +# relationship-isolated. +_V4_ASSET_LOCK_SLOT = V1_GRAPH_MODELS.index("PersistentInvitation") +V4_GRAPH_MODELS = ( + V1_GRAPH_MODELS[:_V4_ASSET_LOCK_SLOT] + + ["PersistentAssetLock"] + + V1_GRAPH_MODELS[_V4_ASSET_LOCK_SLOT:] + + ["PersistentTrackedMasternode"] +) + @dataclasses.dataclass(frozen=True) class Freeze: @@ -176,6 +196,15 @@ class Freeze: Freeze("DashSchemaV2", "5f58417079", ("PersistentTrackedMasternode",)), # V3 replaces the asset lock with the shape that has `recipientIsExternal`. Freeze("DashSchemaV3", "5f58417079", ("PersistentAssetLock",)), + # V4 as it shipped: the whole graph at the last commit before V5 added + # the key usage-limit columns to `PersistentPublicKey`. + Freeze( + "DashSchemaV4", + "787cac09e7", + tuple(V4_GRAPH_MODELS), + TOKEN_TYPES_FILE, + tuple(TOKEN_VALUE_TYPES), + ), ] HEADER = "import Foundation\nimport SwiftData\n\n" diff --git a/packages/swift-sdk/scripts/test_freeze_schema_models.py b/packages/swift-sdk/scripts/test_freeze_schema_models.py index 260c8670a05..7820bb19e7c 100644 --- a/packages/swift-sdk/scripts/test_freeze_schema_models.py +++ b/packages/swift-sdk/scripts/test_freeze_schema_models.py @@ -41,7 +41,7 @@ def setUp(self): ) def test_should_find_the_committed_files_are_the_generators_output(self): - self.assertEqual(len(self.files), 37) + self.assertEqual(len(self.files), 73) self.assertEqual(gen.check_problems(ROOT, self.files), []) def test_should_report_a_hand_edit_to_a_frozen_file(self): From 52e3fa21a52d962bec96a71d1fe567be54b73e34 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 18 Sep 2026 19:21:07 +0700 Subject: [PATCH 5/7] fix(sdk): review fixes: no invented breadcrumb, bounds-aware key picker, string-encoded limits on iOS Addresses the three review threads on #4811 and the Kotlin unit test that pinned the Room schema at version 11. platform-wallet: - `ManagedIdentity::replace_key` emits the limits upsert without a derivation breadcrumb. The managed identity does not know per key whether it was wallet-derived, so coordinates built from `(wallet_id, identity_index, key_id)` were invented for an external key and would overwrite the linkage the client had persisted. Every persister keeps the breadcrumb it already holds when an upsert arrives without one. Pinned by `replace_key_carries_no_breadcrumb`. - `usable_authentication_key` takes the target contract id and document type and skips a key whose bounds do not cover them: a single-contract bound must name the contract, a document-type bound must match both, and a contract group bound is never chosen offline because membership is state the wallet does not hold. Profile passes DashPay + `profile`, contact info passes DashPay + `contactInfo`. Pinned by the unbound-limited-key-over-bound-unlimited-key test and the accepted/rejected bounds tests. swift-sdk (example app): - `UInt64(jsonValue:)` reads a protocol u64 from a JSON number or the decimal string DPP writes above 2^53 - 1, and refuses booleans, negatives, fractions and out-of-range values. Both parsing branches of `LoadIdentityView` and `IdentityKeyRefresher` use it for `totalBudget`, `expiresAt` and `disabledAt`. Unit tests cover both shapes end to end through `JSONSerialization`. kotlin-sdk: - `DashDatabaseTest` now pins schema version 12 (11 -> 12 added the key limits columns). Co-Authored-By: Claude Fable 5.1 --- .../dashsdk/persistence/DashDatabaseTest.kt | 5 +- .../wallet/identity/network/contact_info.rs | 5 +- .../wallet/identity/network/key_selection.rs | 147 ++++++++++++++++-- .../src/wallet/identity/network/profile.rs | 60 ++++--- .../state/managed_identity/identity_ops.rs | 83 ++++++++-- .../Services/IdentityKeyRefresher.swift | 10 +- .../Utils/JSONSafeInteger.swift | 41 +++++ .../Views/LoadIdentityView.swift | 20 ++- .../JSONSafeIntegerTests.swift | 92 +++++++++++ 9 files changed, 404 insertions(+), 59 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/JSONSafeInteger.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/JSONSafeIntegerTests.swift diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt index 524f6d057e8..379de0ada3d 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt @@ -233,12 +233,13 @@ class DashDatabaseTest { } @Test - fun schemaIsAtVersion11WithTheSweepHoldIndexes() = runTest { + fun schemaIsAtVersion12WithTheSweepHoldIndexes() = runTest { // The sweep-hold columns land in ONE migration (10 → 11), with the // two `pending_inputs` indexes the sweep's claimed-row lookup // (`spendingTxid`) and the end-of-round collector // (`walletId, isSweptTombstone, winnerMinedHeight`) rely on. - assertEquals(11, db.openHelper.readableDatabase.version) + // 11 → 12 adds the identity key usage limits columns on top. + assertEquals(12, db.openHelper.readableDatabase.version) val indexes = mutableSetOf() db.openHelper.readableDatabase.query("PRAGMA index_list('pending_inputs')").use { c -> val nameColumn = c.getColumnIndexOrThrow("name") diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs index 11d8e418145..965742e2c45 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs @@ -538,6 +538,8 @@ impl DashPayView<'_, B> { // here; the encrypt step below reuses these bytes. let plaintext = encode_private_data_bounded(&metadata)?; + let dashpay_contract = super::dashpay_contract()?; + // 1. Local state first — works offline and feeds SwiftData. let (established_count, identity_index, signing_key, root_key_id) = { let mut wm = self.wallet_manager.write().await; @@ -564,6 +566,8 @@ impl DashPayView<'_, B> { let identity_index = managed.identity_index; let signing_key = super::usable_authentication_key( &managed.identity, + dashpay_contract.id(), + "contactInfo", &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], &[KeyType::ECDSA_SECP256K1], ) @@ -741,7 +745,6 @@ impl DashPayView<'_, B> { creator_id: None, }); - let dashpay_contract = super::dashpay_contract()?; let document_type = dashpay_contract .document_type_for_name("contactInfo") .map_err(|e| { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs b/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs index b72ea4833f1..4cec27dc3fa 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs @@ -1,21 +1,28 @@ -//! Choosing an authentication key to sign with, now that a key may carry limits. +//! Choosing an authentication key to sign a document with, now that a key may carry limits +//! or contract bounds. use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; +use dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::prelude::Identifier; use crate::util::now_ms; /// The first AUTHENTICATION key of `identity` at one of `security_levels`, of one of -/// `key_types`, that can sign now. +/// `key_types`, that can sign a `document_type_name` document of `contract_id` now. /// -/// A key without limits is preferred: a key with a budget or an expiry is an application -/// key, taken only when no unlimited key qualifies. A disabled key is skipped, and so is a -/// key whose expiry has passed the wall clock (the block time trails it by seconds at most). -/// What is left of a budget is not known offline; a spent key is refused by Platform. +/// A key bound to another contract, or to another document type of this contract, cannot +/// authorize the write and is skipped. A key without limits is preferred: a key with a budget +/// or an expiry is an application key, taken only when no unlimited key qualifies. A disabled +/// key is skipped, and so is a key whose expiry has passed the wall clock (the block time +/// trails it by seconds at most). What is left of a budget is not known offline; a spent key +/// is refused by Platform. pub(crate) fn usable_authentication_key<'a>( identity: &'a Identity, + contract_id: Identifier, + document_type_name: &str, security_levels: &[SecurityLevel], key_types: &[KeyType], ) -> Option<&'a IdentityPublicKey> { @@ -24,6 +31,7 @@ pub(crate) fn usable_authentication_key<'a>( key.purpose() == Purpose::AUTHENTICATION && security_levels.contains(&key.security_level()) && key_types.contains(&key.key_type()) + && bounds_cover(key.contract_bounds(), contract_id, document_type_name) && !key.is_disabled() && !key.is_expired_at(now) }; @@ -33,21 +41,54 @@ pub(crate) fn usable_authentication_key<'a>( .or_else(|| keys.values().find(|key| qualifies(key))) } +/// Whether a key carrying `bounds` may sign a `document_type_name` document of `contract_id`. +/// +/// A contract group bound is answered by group membership, state the wallet does not hold, +/// so a group-bound key is never chosen here: the DashPay writes this picker serves are +/// signed with an unbound key or one bound to DashPay itself. +fn bounds_cover( + bounds: Option<&ContractBounds>, + contract_id: Identifier, + document_type_name: &str, +) -> bool { + match bounds { + None => true, + Some(ContractBounds::SingleContract { id }) => *id == contract_id, + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name: bound_document_type_name, + }) => *id == contract_id && bound_document_type_name == document_type_name, + Some(ContractBounds::ContractGroup { .. }) => false, + } +} + #[cfg(test)] mod tests { use super::*; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::v0::IdentityV0; use dpp::identity::KeyID; - use dpp::platform_value::{BinaryData, Identifier}; + use dpp::platform_value::BinaryData; use std::collections::BTreeMap; + const CONTRACT: [u8; 32] = [0xDA; 32]; + const OTHER_CONTRACT: [u8; 32] = [0x0E; 32]; + const DOCUMENT_TYPE: &str = "profile"; + fn key(id: KeyID, security_level: SecurityLevel) -> IdentityPublicKey { + bound_key(id, security_level, None) + } + + fn bound_key( + id: KeyID, + security_level: SecurityLevel, + contract_bounds: Option, + ) -> IdentityPublicKey { IdentityPublicKey::V0(IdentityPublicKeyV0 { id, purpose: Purpose::AUTHENTICATION, security_level, - contract_bounds: None, + contract_bounds, key_type: KeyType::ECDSA_SECP256K1, read_only: false, data: BinaryData::new(vec![id as u8; 33]), @@ -68,6 +109,16 @@ mod tests { .into() } + fn pick(identity: &Identity) -> Option<&IdentityPublicKey> { + usable_authentication_key( + identity, + Identifier::from(CONTRACT), + DOCUMENT_TYPE, + &LEVELS, + &TYPES, + ) + } + const LEVELS: [SecurityLevel; 2] = [SecurityLevel::HIGH, SecurityLevel::CRITICAL]; const TYPES: [KeyType; 1] = [KeyType::ECDSA_SECP256K1]; @@ -78,7 +129,7 @@ mod tests { key(1, SecurityLevel::CRITICAL).with_limits(Some(1_000), None), key(2, SecurityLevel::HIGH), ]); - let chosen = usable_authentication_key(&identity, &LEVELS, &TYPES).expect("a key"); + let chosen = pick(&identity).expect("a key"); assert_eq!(chosen.id(), 2); } @@ -90,7 +141,7 @@ mod tests { key(1, SecurityLevel::CRITICAL).with_limits(None, Some(1)), key(2, SecurityLevel::CRITICAL).with_limits(Some(1_000), Some(far_future)), ]); - let chosen = usable_authentication_key(&identity, &LEVELS, &TYPES).expect("a key"); + let chosen = pick(&identity).expect("a key"); assert_eq!(chosen.id(), 2, "the expired key 1 is skipped"); } @@ -106,6 +157,80 @@ mod tests { disabled, key(2, SecurityLevel::CRITICAL).with_limits(None, Some(1)), ]); - assert!(usable_authentication_key(&identity, &LEVELS, &TYPES).is_none()); + assert!(pick(&identity).is_none()); + } + + #[test] + fn prefers_an_unbound_limited_key_over_an_unlimited_key_bound_to_another_contract() { + let identity = identity(vec![ + key(0, SecurityLevel::MASTER), + key(1, SecurityLevel::CRITICAL).with_limits(Some(1_000), None), + bound_key( + 2, + SecurityLevel::HIGH, + Some(ContractBounds::SingleContract { + id: Identifier::from(OTHER_CONTRACT), + }), + ), + ]); + let chosen = pick(&identity).expect("a key"); + assert_eq!( + chosen.id(), + 1, + "the key bound to another contract cannot authorize this write" + ); + } + + #[test] + fn accepts_a_key_bound_to_this_contract_or_this_document_type() { + let whole_contract = bound_key( + 1, + SecurityLevel::HIGH, + Some(ContractBounds::SingleContract { + id: Identifier::from(CONTRACT), + }), + ); + assert_eq!( + pick(&identity(vec![whole_contract])).map(|k| k.id()), + Some(1) + ); + + let this_document_type = bound_key( + 1, + SecurityLevel::HIGH, + Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from(CONTRACT), + document_type_name: DOCUMENT_TYPE.to_string(), + }), + ); + assert_eq!( + pick(&identity(vec![this_document_type])).map(|k| k.id()), + Some(1) + ); + } + + #[test] + fn skips_a_key_bound_to_another_document_type_or_to_a_contract_group() { + let other_document_type = bound_key( + 1, + SecurityLevel::HIGH, + Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from(CONTRACT), + document_type_name: "contactInfo".to_string(), + }), + ); + assert!(pick(&identity(vec![other_document_type])).is_none()); + + let group = bound_key( + 1, + SecurityLevel::HIGH, + Some(ContractBounds::ContractGroup { + id: Identifier::from(CONTRACT), + }), + ); + assert!( + pick(&identity(vec![group])).is_none(), + "group membership is state the wallet does not hold" + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index 78cc63b539a..dbd61fdd628 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -20,10 +20,16 @@ use crate::wallet::identity::{ContactProfileEntry, DashPayProfile}; // Profile documents require HIGH or CRITICAL authentication; MASTER is reserved // for identity operations and cannot authorize an ordinary document write. A key -// without limits is preferred and an expired one is skipped. -fn profile_signing_key(identity: &Identity) -> Option<&IdentityPublicKey> { +// bound to another contract or document type is skipped, a key without limits is +// preferred and an expired one is skipped. +fn profile_signing_key( + identity: &Identity, + dashpay_contract_id: Identifier, +) -> Option<&IdentityPublicKey> { super::usable_authentication_key( identity, + dashpay_contract_id, + "profile", &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], &[KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160], ) @@ -184,7 +190,7 @@ impl DashPayView<'_, B> { .identity_manager .managed_identity(identity_id) .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; - profile_signing_key(&managed.identity) + profile_signing_key(&managed.identity, dashpay_contract.id()) .cloned() .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( @@ -333,7 +339,7 @@ impl DashPayView<'_, B> { .identity_manager .managed_identity(identity_id) .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; - profile_signing_key(&managed.identity) + profile_signing_key(&managed.identity, dashpay_contract.id()) .cloned() .ok_or_else(|| { PlatformWalletError::InvalidIdentityData( @@ -807,15 +813,36 @@ mod tests { identity } + const DASHPAY: [u8; 32] = [0xDA; 32]; + + fn pick(identity: &Identity) -> Option<&IdentityPublicKey> { + profile_signing_key(identity, Identifier::from(DASHPAY)) + } + + #[test] + fn should_reject_a_key_bound_to_another_contract() { + use dpp::identity::identity_public_key::contract_bounds::ContractBounds; + + let mut elsewhere = profile_key(KeyType::ECDSA_SECP256K1, SecurityLevel::HIGH); + elsewhere.contract_bounds = Some(ContractBounds::SingleContract { + id: Identifier::from([0x0E; 32]), + }); + assert!(pick(&identity_with_key(elsewhere)).is_none()); + + let mut dashpay = profile_key(KeyType::ECDSA_SECP256K1, SecurityLevel::HIGH); + dashpay.contract_bounds = Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from(DASHPAY), + document_type_name: "profile".to_string(), + }); + assert!(pick(&identity_with_key(dashpay)).is_some()); + } + #[test] fn should_select_high_and_critical_ecdsa_profile_keys() { for key_type in [KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160] { for level in [SecurityLevel::HIGH, SecurityLevel::CRITICAL] { let identity = identity_with_key(profile_key(key_type, level)); - assert!( - profile_signing_key(&identity).is_some(), - "{key_type:?}/{level:?}" - ); + assert!(pick(&identity).is_some(), "{key_type:?}/{level:?}"); } } } @@ -823,13 +850,13 @@ mod tests { #[test] fn should_reject_ineligible_profile_keys() { let empty = Identity::default_versioned(PlatformVersion::latest()).unwrap(); - assert!(profile_signing_key(&empty).is_none()); + assert!(pick(&empty).is_none()); for key_type in [ KeyType::BLS12_381, KeyType::BIP13_SCRIPT_HASH, KeyType::EDDSA_25519_HASH160, ] { - assert!(profile_signing_key(&identity_with_key(profile_key( + assert!(pick(&identity_with_key(profile_key( key_type, SecurityLevel::HIGH ))) @@ -837,16 +864,14 @@ mod tests { } for key_type in [KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160] { for level in [SecurityLevel::MASTER, SecurityLevel::MEDIUM] { - assert!( - profile_signing_key(&identity_with_key(profile_key(key_type, level))).is_none() - ); + assert!(pick(&identity_with_key(profile_key(key_type, level))).is_none()); } let mut key = profile_key(key_type, SecurityLevel::HIGH); key.disabled_at = Some(1); - assert!(profile_signing_key(&identity_with_key(key)).is_none()); + assert!(pick(&identity_with_key(key)).is_none()); let mut key = profile_key(key_type, SecurityLevel::CRITICAL); key.purpose = Purpose::TRANSFER; - assert!(profile_signing_key(&identity_with_key(key)).is_none()); + assert!(pick(&identity_with_key(key)).is_none()); } } @@ -861,10 +886,7 @@ mod tests { transfer.id = 2; transfer.purpose = Purpose::TRANSFER; identity.add_public_key(transfer.into()); - assert_eq!( - profile_signing_key(&identity), - identity.public_keys().get(&1) - ); + assert_eq!(pick(&identity), identity.public_keys().get(&1)); } fn existing_full() -> BTreeMap { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 2436b75e9f1..1f95e1e1c23 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -403,10 +403,12 @@ impl ManagedIdentity { /// after a key limits update, and emit a single-key [`IdentityKeysChangeSet`] upsert for /// it: the limits-side counterpart to [`Self::add_key`] and [`Self::disable_keys`]. /// - /// The key is layered by id, so a key the identity did not hold is added. The entry reuses - /// the `(wallet_id, identity_index, key_index)` derivation breadcrumb `add_key` carries, so - /// the client keeps the key's private-key linkage across the upsert; an out-of-wallet - /// identity emits a breadcrumb-less entry, matching its watch-only state. + /// The key is layered by id, so a key the identity did not hold is added. The entry carries + /// no derivation breadcrumb: a limits update changes nothing about where the key came from, + /// and the managed identity does not know per key whether it was derived from the wallet + /// seed or supplied from outside. Every persister keeps the breadcrumb it already holds for + /// the key when an upsert arrives without one, so the private-key linkage survives the + /// upsert; inventing coordinates here would overwrite it for an external key. /// /// Does **not** touch the identity revision: a key limits update claims none. pub fn replace_key( @@ -420,17 +422,6 @@ impl ManagedIdentity { let key_id = public_key.id(); let public_key_hash = pubkey_hash_of(&public_key); - let (wallet_id, derivation_indices) = match (self.wallet_id, self.identity_index) { - (Some(wallet_id), Some(identity_index)) => ( - Some(wallet_id), - Some(crate::changeset::IdentityKeyDerivationIndices { - identity_index, - key_index: key_id, - }), - ), - _ => (None, None), - }; - let mut keys = self.identity.public_keys().clone(); keys.insert(key_id, public_key.clone()); self.identity.set_public_keys(keys); @@ -443,8 +434,8 @@ impl ManagedIdentity { key_id, public_key, public_key_hash, - wallet_id, - derivation_indices, + wallet_id: None, + derivation_indices: None, }, ); let cs = crate::changeset::PlatformWalletChangeSet { @@ -693,6 +684,64 @@ mod tests { assert_eq!(upserts[&(id, 1)].wallet_id, None); } + /// `replace_key` emits the updated key without a derivation breadcrumb. The managed + /// identity does not track per key whether it was wallet-derived, so coordinates built + /// from `(wallet_id, identity_index, key_id)` would be invented for an external key and + /// overwrite the linkage the client persisted; every persister keeps its existing + /// breadcrumb when an upsert carries none. + #[test] + fn replace_key_carries_no_breadcrumb() { + use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; + + let identity = Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }); + let mut managed = ManagedIdentity::new(identity, 7); + let wallet_id: WalletId = [0xAB; 32]; + managed.wallet_id = Some(wallet_id); + let persister = std::sync::Arc::new(CapturingPersister::default()); + let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); + + // Key 1 came from outside the wallet: no breadcrumb. + managed + .add_keys( + vec![crate::changeset::KeyWithBreadcrumb { + key: key(1), + breadcrumb: None, + }], + &p, + ) + .expect("add_keys persists in test"); + + managed + .replace_key(key(1).with_limits(Some(1_000), None), &p) + .expect("replace_key persists in test"); + + let stores = persister.stores.lock().unwrap(); + let cs = stores.last().expect("a changeset was stored"); + let id = managed.id(); + let entry = &cs + .identity_keys + .as_ref() + .expect("identity_keys present") + .upserts[&(id, 1)]; + assert_eq!(entry.public_key.total_budget(), Some(1_000)); + assert_eq!( + entry.derivation_indices, None, + "a limits-only upsert must not invent derivation coordinates" + ); + assert_eq!(entry.wallet_id, None); + assert!(cs.identities.is_some(), "the identity snapshot rides along"); + assert_eq!( + managed.identity.public_keys()[&1].total_budget(), + Some(1_000), + "the in-memory key is replaced" + ); + } + /// An empty `add_keys` is a no-op — no changeset stored. #[test] fn add_keys_empty_is_noop() { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityKeyRefresher.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityKeyRefresher.swift index 9c67cfa6fe1..3157a2e20fa 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityKeyRefresher.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityKeyRefresher.swift @@ -72,14 +72,18 @@ enum IdentityKeyRefresher { } let readOnly = keyData["readOnly"] as? Bool ?? false - let disabledAt = keyData["disabledAt"] as? UInt64 + // `disabledAt`, `totalBudget` and `expiresAt` are all protocol + // `u64`s, which DPP writes as a JSON number up to 2^53 - 1 and + // as a decimal string above it. `UInt64(jsonValue:)` reads both + // shapes; a plain `as? UInt64` would drop the large ones. + let disabledAt = UInt64(jsonValue: keyData["disabledAt"]) // Usage limits (protocol version 14). Present only on a // version 1 key: `totalBudget` is credits for the key's // whole lifetime, `expiresAt` is block time in // milliseconds. Dropping them here would persist a // limited key as unlimited. - let totalBudget = keyData["totalBudget"] as? UInt64 - let expiresAt = keyData["expiresAt"] as? UInt64 + let totalBudget = UInt64(jsonValue: keyData["totalBudget"]) + let expiresAt = UInt64(jsonValue: keyData["expiresAt"]) return IdentityPublicKey( id: UInt32(id), diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/JSONSafeInteger.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/JSONSafeInteger.swift new file mode 100644 index 00000000000..73bd3fadac7 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/JSONSafeInteger.swift @@ -0,0 +1,41 @@ +import Foundation + +extension UInt64 { + /// Read a protocol `u64` out of a JSON value produced by DPP. + /// + /// DPP marks its `u64` fields with `#[json_safe_fields]`, which writes a + /// value as a JSON NUMBER while it fits in a JavaScript safe integer + /// (`2^53 - 1`, 9007199254740991) and as a DECIMAL STRING above that, so + /// that a JavaScript client cannot silently round it. A cast that accepts + /// only numbers therefore drops exactly the large values: a key whose + /// `totalBudget` arrives as `"9007199254740992"` would be reconstructed + /// locally as a key with no budget at all. + /// + /// Accepts a JSON number that is non-negative and integral, or a decimal + /// string. Returns `nil` for `nil`, `NSNull`, a negative or fractional + /// number, a value outside the `UInt64` range, a boolean, and anything + /// that is not a number or a string. + init?(jsonValue: Any?) { + switch jsonValue { + case let text as String: + // The shape a value above the safe-integer ceiling arrives in. + // `UInt64(_:)` already rejects a sign, padding, separators and + // anything else that is not plain decimal digits. + guard let parsed = UInt64(text) else { return nil } + self = parsed + case let number as NSNumber: + // A JSON boolean parses into an `NSNumber` too, and that one DOES + // answer an integer cast, with 1 or 0. Reject it by its CoreFoundation + // type: reading `true` as a budget of one credit would be worse + // than reading it as absent. + guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } + // Otherwise the bridge's conditional cast is exact (SE-0170): a + // negative, fractional or out-of-range value fails rather than + // being rounded or wrapped. + guard let parsed = number as? UInt64 else { return nil } + self = parsed + default: + return nil + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift index ea3fdffa2e7..3ff2d52834d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift @@ -350,14 +350,18 @@ struct LoadIdentityView: View { } let readOnly = keyData["readOnly"] as? Bool ?? false - let disabledAt = keyData["disabledAt"] as? UInt64 + // `disabledAt`, `totalBudget` and `expiresAt` are all protocol + // `u64`s, which DPP writes as a JSON number up to 2^53 - 1 and + // as a decimal string above it. `UInt64(jsonValue:)` reads both + // shapes; a plain `as? UInt64` would drop the large ones. + let disabledAt = UInt64(jsonValue: keyData["disabledAt"]) // Usage limits (protocol version 14). Present only on a // version 1 key: `totalBudget` is credits for the key's // whole lifetime, `expiresAt` is block time in // milliseconds. Dropping them here would persist a // limited key as unlimited. - let totalBudget = keyData["totalBudget"] as? UInt64 - let expiresAt = keyData["expiresAt"] as? UInt64 + let totalBudget = UInt64(jsonValue: keyData["totalBudget"]) + let expiresAt = UInt64(jsonValue: keyData["expiresAt"]) return IdentityPublicKey( id: UInt32(id), @@ -391,14 +395,18 @@ struct LoadIdentityView: View { } let readOnly = keyData["readOnly"] as? Bool ?? false - let disabledAt = keyData["disabledAt"] as? UInt64 + // `disabledAt`, `totalBudget` and `expiresAt` are all protocol + // `u64`s, which DPP writes as a JSON number up to 2^53 - 1 and + // as a decimal string above it. `UInt64(jsonValue:)` reads both + // shapes; a plain `as? UInt64` would drop the large ones. + let disabledAt = UInt64(jsonValue: keyData["disabledAt"]) // Usage limits (protocol version 14). Present only on a // version 1 key: `totalBudget` is credits for the key's // whole lifetime, `expiresAt` is block time in // milliseconds. Dropping them here would persist a // limited key as unlimited. - let totalBudget = keyData["totalBudget"] as? UInt64 - let expiresAt = keyData["expiresAt"] as? UInt64 + let totalBudget = UInt64(jsonValue: keyData["totalBudget"]) + let expiresAt = UInt64(jsonValue: keyData["expiresAt"]) return IdentityPublicKey( id: UInt32(id), diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/JSONSafeIntegerTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/JSONSafeIntegerTests.swift new file mode 100644 index 00000000000..51e5b2a4492 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/JSONSafeIntegerTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import SwiftExampleApp + +/// Tests for `UInt64(jsonValue:)`, the reader the hand-rolled key builders +/// use for every protocol `u64` they pull out of DPP's JSON. +/// +/// DPP's `#[json_safe_fields]` writes a `u64` as a JSON number while it fits +/// in a JavaScript safe integer (2^53 - 1 = 9007199254740991) and as a +/// decimal string above that. The two shapes are the whole reason this +/// helper exists: a cast that took only numbers turned a key with +/// `"totalBudget": "9007199254740992"` into a key with no budget, so the +/// wallet would offer it for work Platform meters. +final class JSONSafeIntegerTests: XCTestCase { + + /// The common shape: a value under the ceiling arrives as a JSON number. + func testReadsAPlainNumber() { + XCTAssertEqual(UInt64(jsonValue: 0), 0) + XCTAssertEqual(UInt64(jsonValue: 100_000), 100_000) + XCTAssertEqual(UInt64(jsonValue: 1_800_000_000_000), 1_800_000_000_000) + XCTAssertEqual(UInt64(jsonValue: 9_007_199_254_740_991), 9_007_199_254_740_991) + } + + /// The shape this helper was added for: one past the safe-integer + /// ceiling, which DPP writes as a string. This is the exact value that + /// used to read back as nil. + func testReadsAValueAboveTheSafeIntegerCeilingFromAString() { + XCTAssertEqual(UInt64(jsonValue: "9007199254740992"), 9_007_199_254_740_992) + XCTAssertEqual(UInt64(jsonValue: "18446744073709551615"), UInt64.max) + } + + /// A small value that still arrived as a string reads the same way, so + /// the helper does not depend on which side of the ceiling a value fell. + func testReadsASmallNumericString() { + XCTAssertEqual(UInt64(jsonValue: "0"), 0) + XCTAssertEqual(UInt64(jsonValue: "42"), 42) + } + + /// A negative or fractional number is not a `u64`, and must not be + /// rounded or wrapped into one. + func testRejectsNegativeAndNonIntegralNumbers() { + XCTAssertNil(UInt64(jsonValue: -1)) + XCTAssertNil(UInt64(jsonValue: -9_007_199_254_740_992)) + XCTAssertNil(UInt64(jsonValue: 1.5)) + XCTAssertNil(UInt64(jsonValue: -0.5)) + XCTAssertNil(UInt64(jsonValue: "-1")) + XCTAssertNil(UInt64(jsonValue: "1.5")) + } + + /// Everything else reads as absent rather than as a guess. A JSON boolean + /// is the trap here: it parses into an `NSNumber` that answers an integer + /// cast with 1, so without an explicit guard `true` would read as a + /// budget of one credit. + func testRejectsAbsentAndUnrelatedValues() { + XCTAssertNil(UInt64(jsonValue: nil)) + XCTAssertNil(UInt64(jsonValue: NSNull())) + XCTAssertNil(UInt64(jsonValue: true)) + XCTAssertNil(UInt64(jsonValue: false)) + XCTAssertNil(UInt64(jsonValue: NSNumber(value: true))) + XCTAssertNil(UInt64(jsonValue: "")) + XCTAssertNil(UInt64(jsonValue: " 42")) + XCTAssertNil(UInt64(jsonValue: "0x2a")) + XCTAssertNil(UInt64(jsonValue: "not a number")) + // One past `UInt64.max`, as a string: out of range, not truncated. + XCTAssertNil(UInt64(jsonValue: "18446744073709551616")) + XCTAssertNil(UInt64(jsonValue: ["9007199254740992"])) + XCTAssertNil(UInt64(jsonValue: ["totalBudget": 1])) + } + + /// End to end through `JSONSerialization`, which is how the key builders + /// actually receive these values: both shapes come out of the same + /// payload, and the big one survives. + func testReadsBothShapesOutOfParsedJSON() throws { + let payload = """ + { + "$formatVersion": "1", + "readOnly": false, + "disabledAt": 1700000000000, + "totalBudget": "9007199254740992", + "expiresAt": 1800000000000 + } + """ + let data = try XCTUnwrap(payload.data(using: .utf8)) + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertEqual(UInt64(jsonValue: object["totalBudget"]), 9_007_199_254_740_992) + XCTAssertEqual(UInt64(jsonValue: object["expiresAt"]), 1_800_000_000_000) + XCTAssertEqual(UInt64(jsonValue: object["disabledAt"]), 1_700_000_000_000) + XCTAssertNil(UInt64(jsonValue: object["missing"])) + XCTAssertNil(UInt64(jsonValue: object["readOnly"])) + } +} From 8bc47218eab32a53d13fc85297824614d9845fc0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 18 Sep 2026 19:29:26 +0700 Subject: [PATCH 6/7] fix(kotlin-sdk): verify the key upsert descriptor the trampoline actually uses The instrumented smoke test `persistenceBridgeDescriptorsAllResolve` resolves `BRIDGE_METHOD_TABLE` up front, and that table still listed the pre-limits `onPersistIdentityKeyUpsert` descriptor while the trampoline called the `...ZJZJ)I` one. Bind the descriptor to one constant used at both sites so they cannot drift again. Co-Authored-By: Claude Fable 5.1 --- packages/rs-unified-sdk-jni/src/persistence.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index a87afbe5f4c..fea73af90dc 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -1302,6 +1302,14 @@ unsafe extern "C" fn tramp_persist_identity_keys( }) } +/// `onPersistIdentityKeyUpsert`'s JNI descriptor. The trailing `ZJZJ` is the +/// key usage limits pair (`hasTotalBudget`, `totalBudget`, `hasExpiresAt`, +/// `expiresAt`). Bound at the `call_method` site and in +/// [`BRIDGE_METHOD_TABLE`] so the two cannot drift: the smoke test resolves +/// the table up front, while the call site only resolves when a key is +/// first persisted. +const IDENTITY_KEY_UPSERT_DESCRIPTOR: &str = "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ)I"; + unsafe fn persist_identity_key_upsert( env: &mut JNIEnv, bridge: &JObject, @@ -1317,7 +1325,7 @@ unsafe fn persist_identity_key_upsert( env.call_method( bridge, "onPersistIdentityKeyUpsert", - "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ)I", + IDENTITY_KEY_UPSERT_DESCRIPTOR, &[ wid.into(), (&identity_id).into(), @@ -4551,10 +4559,7 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ "([B[B[BZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;\ [BZ[BZLjava/lang/String;J)I", ), - ( - "onPersistIdentityKeyUpsert", - "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;)I", - ), + ("onPersistIdentityKeyUpsert", IDENTITY_KEY_UPSERT_DESCRIPTOR), ("onPersistIdentityKeyRemoval", "([B[BI)I"), ("onPersistTokenBalanceUpsert", "([B[B[BJ)I"), ("onPersistTokenBalanceRemoval", "([B[B[B)I"), From 60f38bd3e00cc4235bdc662a75381881ea5a6da9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 18 Sep 2026 19:33:48 +0700 Subject: [PATCH 7/7] fix(swift-sdk): read every protocol u64 in the example app as number or decimal string DPP writes a u64 above 2^53 - 1 as a decimal string, so every bare numeric cast in the example app dropped exactly the large values. Route the remaining readers through `UInt64(jsonValue:)`: - AppState query self-test summary: `balance` and a bare integer result. - DiagnosticsView `formatTestResult`: the integer branch, same order. - DocumentWithPriceView: the four-way NSNumber / String / Int / UInt64 chain for `$price` collapsed to one call, which also refuses a negative or fractional price instead of coercing it. - TokenDirectPurchasePricing: `single_price` and every tier's `amount` and `price` had no string fallback, so an expensive token was reported as not for direct sale. Tests cover string-encoded single prices, a tier schedule where each half crosses the ceiling, and unparseable strings. - TokenDetailsView: `FixedAmount.amount` is a `TokenAmount` on a `json_safe_fields` type; `interval` is not and stays as is. Co-Authored-By: Claude Fable 5.1 --- .../SwiftExampleApp/AppState.swift | 7 +++- .../Utils/TokenDirectPurchasePricing.swift | 12 +++--- .../Views/DiagnosticsView.swift | 5 ++- .../Views/DocumentWithPriceView.swift | 16 ++------ .../Views/TokenDetailsView.swift | 5 ++- .../TokenDirectPurchasePricingTests.swift | 38 +++++++++++++++++++ 6 files changed, 62 insertions(+), 21 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift index ceb592db09e..3c404eff0d2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/AppState.swift @@ -317,12 +317,15 @@ class AppState: ObservableObject { NSLog(" Platform version: \(version)") } else if let id = dict["id"] as? String { NSLog(" ID: \(id)") - } else if let balance = dict["balance"] as? UInt64 { + } else if let balance = UInt64(jsonValue: dict["balance"]) { + // Credits are a protocol `u64`: DPP writes one above + // 2^53 - 1 as a decimal string, which a bare cast + // dropped to the generic "Result:" line below. NSLog(" Balance: \(balance)") } else { NSLog(" Result: \(dict.keys.prefix(3).joined(separator: ", "))...") } - } else if let uint = result as? UInt64 { + } else if let uint = UInt64(jsonValue: result) { NSLog(" Value: \(uint)") } else if let bool = result as? Bool { NSLog(" Available: \(bool)") diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenDirectPurchasePricing.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenDirectPurchasePricing.swift index 1c4ed872d2e..3b7bf477b6a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenDirectPurchasePricing.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Utils/TokenDirectPurchasePricing.swift @@ -102,8 +102,10 @@ enum TokenDirectPurchasePricing: Equatable { /// `JSONSerialization`), or `nil` when the token has no usable price (a /// `null`/missing entry, an empty tier list) or the entry can't be read. /// - /// `price`/`amount` values are `u64` and can exceed `Int64.max`, so they - /// are read through `NSNumber.uint64Value` rather than as `Int`. + /// `price`/`amount` values are `u64`: they can exceed `Int64.max`, and DPP + /// writes one above 2^53 - 1 as a decimal string rather than a number, so + /// they are read through `UInt64(jsonValue:)`, which takes both shapes. + /// A bare numeric cast reported such a token as not for direct sale. static func parse( _ response: [String: Any], canonicalTokenId: String @@ -116,7 +118,7 @@ enum TokenDirectPurchasePricing: Equatable { switch entry["type"] as? String { case "single_price": - guard let price = (entry["price"] as? NSNumber)?.uint64Value else { + guard let price = UInt64(jsonValue: entry["price"]) else { return nil } return .singlePrice(price) @@ -126,8 +128,8 @@ enum TokenDirectPurchasePricing: Equatable { return nil } let tiers: [Tier] = rawTiers.compactMap { tier in - guard let amount = (tier["amount"] as? NSNumber)?.uint64Value, - let price = (tier["price"] as? NSNumber)?.uint64Value + guard let amount = UInt64(jsonValue: tier["amount"]), + let price = UInt64(jsonValue: tier["price"]) else { return nil } return Tier(amount: amount, price: price) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift index 6af62548e34..316c76b62be 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DiagnosticsView.swift @@ -633,7 +633,10 @@ struct DiagnosticsView: View { return formatDictionary(dict) } else if let array = result as? [[String: Any]] { return "[\(array.count) items]" - } else if let uint = result as? UInt64 { + } else if let uint = UInt64(jsonValue: result) { + // A protocol `u64` arrives as a number below 2^53 and as a decimal + // string above it. Ordered before the `Bool` branch, which still + // fires because the reader refuses booleans. return String(uint) } else if let bool = result as? Bool { return bool ? "true" : "false" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentWithPriceView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentWithPriceView.swift index 3c71427663a..3a6ec637017 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentWithPriceView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentWithPriceView.swift @@ -257,19 +257,11 @@ struct DocumentWithPriceView: View { if let priceValue = priceValue { print("DEBUG: Found price value: \(priceValue) (type: \(type(of: priceValue)))") - if let priceNum = priceValue as? NSNumber { - documentPrice = priceNum.uint64Value - print("DEBUG: Price as NSNumber: \(documentPrice!)") - } else if let priceString = priceValue as? String, - let price = UInt64(priceString) { + // A price is a protocol `u64`: a number up to 2^53 - 1 and a + // decimal string above that, which is what the reader handles. + if let price = UInt64(jsonValue: priceValue) { documentPrice = price - print("DEBUG: Price as String: \(documentPrice!)") - } else if let priceInt = priceValue as? Int { - documentPrice = UInt64(priceInt) - print("DEBUG: Price as Int: \(documentPrice!)") - } else if let priceUInt = priceValue as? UInt64 { - documentPrice = priceUInt - print("DEBUG: Price as UInt64: \(documentPrice!)") + print("DEBUG: Parsed price: \(price)") } else { print("DEBUG: Could not convert price value to UInt64") } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift index 0d7ab2362a3..1c49ad5dd2e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TokenDetailsView.swift @@ -275,7 +275,10 @@ struct TokenDetailsView: View { if let function = timeBased["function"] as? [String: Any], let fixedAmount = function["FixedAmount"] as? [String: Any], - let amount = fixedAmount["amount"] as? Int { + let amount = UInt64(jsonValue: fixedAmount["amount"]) { + // A token amount is a protocol `u64` on a + // `#[json_safe_fields]` type, so one above + // 2^53 - 1 arrives as a decimal string. InfoRow(label: "Amount per interval:", value: "\(amount)") } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenDirectPurchasePricingTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenDirectPurchasePricingTests.swift index 4fd776346e8..dcd74f766c2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenDirectPurchasePricingTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/TokenDirectPurchasePricingTests.swift @@ -74,6 +74,44 @@ final class TokenDirectPurchasePricingTests: XCTestCase { XCTAssertEqual(parse(single("\(big)")), .singlePrice(big)) } + /// DPP writes a `u64` above the JavaScript safe-integer ceiling + /// (2^53 - 1) as a decimal STRING, not a number. Reading only numbers + /// returned nil here, which the form reports as "not for direct sale": + /// an expensive token looked unpurchasable. + func test_singlePriceEncodedAsString_parses() { + let big: UInt64 = 9_007_199_254_740_992 // 2^53, one past the ceiling + XCTAssertEqual(parse(single("\"\(big)\"")), .singlePrice(big)) + XCTAssertEqual(parse(single("\"100\"")), .singlePrice(100)) + XCTAssertEqual(parse(single("\"\(UInt64.max)\"")), .singlePrice(UInt64.max)) + } + + /// The same for a tier schedule, where either half of a tier can cross + /// the ceiling on its own. + func test_setPricesTiersEncodedAsStrings_parse() { + let bigAmount: UInt64 = 9_007_199_254_740_992 + let bigPrice: UInt64 = 18_014_398_509_481_984 // 2^54 + let json = #"{"\#(tokenId)":{"type":"set_prices","prices":"# + + #"[{"amount":1,"price":"\#(bigPrice)"},"# + + #"{"amount":"\#(bigAmount)","price":50}]}}"# + XCTAssertEqual( + parse(json), + .setPrices([ + .init(amount: 1, price: bigPrice), + .init(amount: bigAmount, price: 50), + ]) + ) + } + + /// A string that is not a plain decimal is still no price, rather than + /// being coerced into one. + func test_unparseableStringPrice_meansNoPrice() { + XCTAssertNil(parse(single("\"\""))) + XCTAssertNil(parse(single("\"-1\""))) + XCTAssertNil(parse(single("\"1.5\""))) + XCTAssertNil(parse(single("\"free\""))) + XCTAssertNil(parse(single("true"))) + } + // MARK: - Cost resolution func test_singlePrice_costIsPriceTimesAmount() {