Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
3cf342f
feat(platform-wallet): update an identity at a revision the caller names
jeanpierreroma Oct 18, 2026
fde97b1
fix(platform-wallet): bump the cached revision to the one the update …
jeanpierreroma Oct 18, 2026
7ecb917
test(platform-wallet): cover the revision refusal and the landed-upda…
jeanpierreroma Oct 19, 2026
8150967
Merge remote-tracking branch 'origin/v5.1-dev' into feat/platform-wal…
jeanpierreroma Oct 19, 2026
d020575
fix(platform-wallet): keep a newer cached identity from older updates…
jeanpierreroma Oct 19, 2026
7331388
feat(platform-wallet-ffi): report a revision mismatch with its own re…
jeanpierreroma Oct 19, 2026
d58a809
fix(platform-wallet): store a newer cache's keys when an older update…
jeanpierreroma Oct 19, 2026
aa2b6a7
fix(platform-wallet): keep stored breadcrumbs and cover the third fet…
jeanpierreroma Oct 19, 2026
8182dfc
fix(platform-wallet): give stored disabled keys the wallet slot's bre…
jeanpierreroma Oct 19, 2026
bb4380b
fix(platform-wallet): keep keys an older answer has and the cache lacks
jeanpierreroma Oct 19, 2026
f8d3f77
fix(platform-wallet): return the keys a dropped load took into the cache
jeanpierreroma Oct 19, 2026
24c62da
style(platform-wallet): import at the top in the new code and fixtures
jeanpierreroma Oct 19, 2026
b0d364f
fix(platform-wallet): fold older fetches per field and store the land…
jeanpierreroma Oct 19, 2026
dab42a4
fix(platform-wallet): store a landed update in one changeset and keep…
jeanpierreroma Oct 19, 2026
bea3aa2
fix(platform-wallet): store the snapshot when a landed update meets a…
jeanpierreroma Oct 19, 2026
e5cbeba
fix(platform-wallet): prefer Platform's disable time and match the pa…
jeanpierreroma Oct 19, 2026
37c3326
refactor(platform-wallet): one disable rule for both landed paths and…
jeanpierreroma Oct 19, 2026
3508dce
fix(platform-wallet): an older fetch no longer brings its balance or …
jeanpierreroma Oct 19, 2026
840209c
fix(platform-wallet): fold discovered identities, order fetched balan…
jeanpierreroma Oct 19, 2026
88510b8
fix(platform-wallet): keep the higher key limit, retry a fetched disa…
jeanpierreroma Oct 19, 2026
e0b1d7c
fix(platform-wallet): queue refused key rows for the next store, take…
jeanpierreroma Oct 20, 2026
144958e
fix(platform-wallet): refresh stores every key it changes, SQLite kee…
jeanpierreroma Oct 20, 2026
4d1f50a
feat(platform-wallet)!: let an identity update declare the keys it ad…
jeanpierreroma Oct 20, 2026
2c31b5f
fix(platform-wallet): disables carry no breadcrumb, a buffered merge …
jeanpierreroma Oct 20, 2026
8b82cfb
fix(platform-wallet): only a path that knows a key's origin creates i…
jeanpierreroma Oct 20, 2026
f589e11
fix(platform-wallet): a key a refresh adds stays rowless until its or…
jeanpierreroma Oct 20, 2026
bbf2673
fix(platform-wallet): a breadcrumb-less key row means unknown origin,…
jeanpierreroma Oct 20, 2026
ec3e225
test(platform-wallet): a load needs one write to store a new identity
jeanpierreroma Oct 20, 2026
2f3561d
fix(platform-wallet): add_keys and disable_keys queue refused rows, c…
jeanpierreroma Oct 20, 2026
d6d43e3
fix(platform-wallet)!: tell an unknown key origin from a rejected one…
jeanpierreroma Oct 20, 2026
9677750
fix(platform-wallet): an unknown-origin key row keeps a rejection it …
jeanpierreroma Oct 20, 2026
b016e9e
fix(platform-wallet): a rejection refutes only the coordinates it che…
jeanpierreroma Oct 20, 2026
bb184f5
fix(platform-wallet): a rejection drops only its slot's pending repai…
jeanpierreroma Oct 21, 2026
67fbf95
fix(platform-wallet): a rejection clears only the persisting wallet's…
jeanpierreroma Oct 21, 2026
18bc03f
fix(platform-wallet)!: combine a key's rejections as a set, scope Kot…
jeanpierreroma Oct 21, 2026
a683ade
fix(platform-wallet): rebuild a pending Kotlin repair under its bread…
jeanpierreroma Oct 21, 2026
67ad5ba
fix(platform-wallet): leave legacy breadcrumb owners unknown, keep a …
jeanpierreroma Oct 21, 2026
073b8cd
fix(platform-wallet): trace a constraint failure to the row it refuses
jeanpierreroma Oct 21, 2026
1f52ec1
fix(swift-sdk): keep the signer alive through the identity update FFI…
jeanpierreroma Oct 21, 2026
512bbcb
fix(platform-wallet): trace a constraint failure to the snapshot befo…
jeanpierreroma Oct 21, 2026
fcf69a6
fix(platform-wallet-ffi): refuse a null key array with a non-zero count
jeanpierreroma Oct 21, 2026
fd85f8d
test(platform-wallet-storage): pin each check on a stored identity ke…
jeanpierreroma Oct 21, 2026
6131d4e
fix(swift-sdk): leave a breadcrumb with no recorded owner to its unkn…
jeanpierreroma Oct 21, 2026
f52440d
fix(kotlin-sdk): cancel a key repair only on its own owner's rejectio…
jeanpierreroma Oct 21, 2026
03331ba
refactor(kotlin-sdk): apply Rust's key-origin decisions mechanically …
jeanpierreroma Oct 22, 2026
fdf8bf5
refactor(platform-wallet): fold fetched identities without the key-ro…
jeanpierreroma Oct 22, 2026
ad09c6a
refactor(platform-wallet): return the identity FFI to base apart from…
jeanpierreroma Oct 22, 2026
5b3840f
refactor(platform-wallet-storage): keep a stored breadcrumb on the sa…
jeanpierreroma Oct 22, 2026
60b0dfa
refactor(kotlin-sdk): drop the key-origin repair and Room schema 16, …
jeanpierreroma Oct 22, 2026
54135e6
refactor(swift-sdk): drop externalKeyIds and the persister's key-orig…
jeanpierreroma Oct 22, 2026
d49fa67
test(platform-wallet): pin that a load by index stores a new identity…
jeanpierreroma Oct 22, 2026
d62a921
docs(platform-wallet): state that a key entry without coordinates kee…
jeanpierreroma Oct 22, 2026
650fd45
fix(platform-wallet): store a landed update's disables when the cache…
jeanpierreroma Oct 22, 2026
8e5af24
fix(platform-wallet): take back a revision a base build put past the …
jeanpierreroma Oct 22, 2026
2028be0
fix(platform-wallet): lend a buffered breadcrumb only to the same key…
jeanpierreroma Oct 22, 2026
0198486
docs(platform-wallet): mention the equal-keys exception in the update…
jeanpierreroma Oct 22, 2026
0fbfba3
revert(platform-wallet): drop the equal-keys revision recovery
jeanpierreroma Oct 23, 2026
6a9f909
fix(platform-wallet): take a fetch's key states at its revision and k…
jeanpierreroma Oct 23, 2026
693fd17
fix(platform-wallet-storage): lend a held breadcrumb only from a vali…
jeanpierreroma Oct 23, 2026
2b8ba90
fix(platform-wallet): take Drive's revision when it refuses an update…
jeanpierreroma Oct 23, 2026
b891221
Merge remote-tracking branch 'origin/v5.1-dev' into feat/platform-wal…
jeanpierreroma Oct 23, 2026
2be1862
fix(platform-wallet): take a refused update's revision only from a pr…
jeanpierreroma Oct 23, 2026
f8b1bb3
fix(platform-wallet): take a refused update's revision only from a pr…
jeanpierreroma Oct 23, 2026
2aa05cd
fix(platform-wallet): re-send an update Drive refused below the cache…
jeanpierreroma Oct 23, 2026
d67e5a5
fix(platform-wallet): re-send a refused update only once proofs show …
jeanpierreroma Oct 23, 2026
a6e23a6
fix(platform-wallet): fail a refusal proved at u64::MAX instead of ov…
jeanpierreroma Oct 24, 2026
d415e70
fix(kotlin-sdk): pass the update revision through addKey and disableKeys
jeanpierreroma Oct 24, 2026
36c02c8
fix(platform-wallet): order key states after a restore by the restore…
jeanpierreroma Oct 24, 2026
f4d318f
fix(platform-wallet): keep the key watermark monotonic
jeanpierreroma Oct 24, 2026
58d0fc9
docs(kotlin-sdk): note that Kotlin has no next-revision reader for an…
jeanpierreroma Oct 25, 2026
cd00c9a
Merge remote-tracking branch 'origin/v5.1-dev' into feat/platform-wal…
jeanpierreroma Oct 26, 2026
9080abf
fix(platform-wallet)!: go back to lowering the cache on Drive's revis…
jeanpierreroma Oct 26, 2026
610aa57
fix(platform-wallet): store a landed revision with its keys as one write
jeanpierreroma Oct 26, 2026
bf9b415
Merge remote-tracking branch 'origin/v5.1-dev' into feat/platform-wal…
jeanpierreroma Oct 26, 2026
51d7be8
refactor(platform-wallet): drop the revision pin from the identity up…
jeanpierreroma Oct 28, 2026
8df7515
fix(platform-wallet): tell the caller not to resubmit a landed update…
jeanpierreroma Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion packages/rs-platform-wallet-ffi/src/identity_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,58 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer(
disable_public_key_ids: *const u32,
disable_public_key_ids_count: usize,
signer_handle: *mut SignerHandle,
) -> PlatformWalletFFIResult {
update_identity_with_signer(
wallet_handle,
identity_id,
add_public_keys,
add_public_keys_count,
disable_public_key_ids,
disable_public_key_ids_count,
None,
signer_handle,
)
}

/// [`platform_wallet_update_identity_with_signer`], with the revision the
/// update must carry. When the cached identity would give the transition
/// another revision (it moved since the caller read it), the call fails
/// before anything is signed or broadcast, so a caller that recorded
/// `revision` knows the only revision this update can land at.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision(
Comment thread
romchornyi marked this conversation as resolved.
Resolved
wallet_handle: Handle,
identity_id: *const u8,
add_public_keys: *const IdentityPubkeyFFI,
add_public_keys_count: usize,
disable_public_key_ids: *const u32,
disable_public_key_ids_count: usize,
revision: u64,
signer_handle: *mut SignerHandle,
) -> PlatformWalletFFIResult {
update_identity_with_signer(
wallet_handle,
identity_id,
add_public_keys,
add_public_keys_count,
disable_public_key_ids,
disable_public_key_ids_count,
Some(revision),
signer_handle,
)
}
Comment thread
romchornyi marked this conversation as resolved.
Resolved

#[allow(clippy::too_many_arguments)]
unsafe fn update_identity_with_signer(
wallet_handle: Handle,
identity_id: *const u8,
add_public_keys: *const IdentityPubkeyFFI,
add_public_keys_count: usize,
disable_public_key_ids: *const u32,
disable_public_key_ids_count: usize,
revision: Option<u64>,
signer_handle: *mut SignerHandle,
) -> PlatformWalletFFIResult {
check_ptr!(signer_handle);

Expand Down Expand Up @@ -375,7 +427,14 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer(
block_on_worker(async move {
let signer: &VTableSigner = &*(signer_addr as *const VTableSigner);
identity_wallet
.update_identity_with_external_signer(&id, add_keys, disable_ids, signer, None)
.update_identity_with_external_signer_at_revision(
&id,
add_keys,
disable_ids,
revision,
signer,
None,
)
.await
})
});
Expand Down
14 changes: 14 additions & 0 deletions packages/rs-platform-wallet/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ pub enum PlatformWalletError {
#[error("Invalid identity data: {0}")]
InvalidIdentityData(String),

/// An identity update was asked to carry a revision the cached identity
/// would not give it: the cache moved since the caller read the
/// revision, or the caller read it elsewhere. Raised before the
/// transition is signed, so nothing was broadcast.
#[error(
"identity {identity_id}: the update was asked to carry revision {expected}, \
but the cached identity would give it {actual}; nothing was broadcast"
)]
IdentityRevisionMismatch {
identity_id: Identifier,
expected: u64,
actual: u64,
},
Comment thread
romchornyi marked this conversation as resolved.
Resolved

/// A DashPay invitation link's voucher key is for the other network (a
/// testnet link opened in a mainnet wallet, or the reverse). Definitive:
/// the claim refuses the link before any network work.
Expand Down
110 changes: 105 additions & 5 deletions packages/rs-platform-wallet/src/wallet/identity/network/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,42 @@ impl IdentityWallet {
signer: &S,
settings: Option<PutSettings>,
) -> Result<(), PlatformWalletError>
where
S: Signer<IdentityPublicKey> + Send + Sync,
{
self.update_identity_with_external_signer_at_revision(
identity_id,
add_public_keys,
disable_public_keys,
None,
signer,
settings,
)
.await
}

/// [`Self::update_identity_with_external_signer`], with the revision
/// the transition must carry.
///
/// The transition takes the cached identity's revision plus one, read
/// when the update starts. A caller that records the revision before
/// submitting (to tell later whether the update can still land: Drive
/// takes an identity update only at one above the identity's revision)
/// cannot see that read, and a refresh in between moves it. With
/// `revision` set, an update that would carry another one fails with
/// [`PlatformWalletError::IdentityRevisionMismatch`] before anything is
/// signed or broadcast, so the revision the caller recorded is the only
/// one this update can land at. `None` takes whatever the cache gives,
/// as [`Self::update_identity_with_external_signer`] does.
pub async fn update_identity_with_external_signer_at_revision<S>(
&self,
identity_id: &Identifier,
add_public_keys: Vec<IdentityPublicKey>,
disable_public_keys: Vec<u32>,
revision: Option<u64>,
signer: &S,
settings: Option<PutSettings>,
) -> Result<(), PlatformWalletError>
where
S: Signer<IdentityPublicKey> + Send + Sync,
{
Expand Down Expand Up @@ -131,8 +167,8 @@ impl IdentityWallet {
};

// Increment revision for the update transition.
let original_revision = identity.revision();
identity.set_revision(original_revision + 1);
let transition_revision = update_revision(identity_id, identity.revision(), revision)?;
identity.set_revision(transition_revision);

// Pick the MASTER signing key — DPP requires identity update
// transitions to be authorized by MASTER specifically.
Expand Down Expand Up @@ -223,10 +259,15 @@ impl IdentityWallet {
)
})?;
if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) {
// Bump the cached revision so a subsequent update
// doesn't reuse the pre-broadcast value.
// The identity is at the transition's revision now, so a
// subsequent update must not reuse the pre-broadcast value.
// The cache may have been refreshed since the snapshot (to
// this revision or past it), so it takes the higher of the
// two instead of adding one to whatever it holds.
let cached_revision = managed.identity.revision();
managed.identity.set_revision(cached_revision + 1);
managed
.identity
.set_revision(cached_revision.max(transition_revision));
Comment thread
romchornyi marked this conversation as resolved.
Resolved

for key in added_keys_for_local_apply {
let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id()));
Expand Down Expand Up @@ -332,3 +373,62 @@ impl IdentityWallet {
Ok(result)
}
}

/// The revision an update of an identity cached at `cached` carries: one
/// above it. With `expected` set, any other value is refused.
fn update_revision(
identity_id: &Identifier,
cached: u64,
expected: Option<u64>,
) -> Result<u64, PlatformWalletError> {
let next = cached.checked_add(1).ok_or_else(|| {
PlatformWalletError::InvalidIdentityData(format!(
"identity {identity_id} is at the last revision"
))
})?;
match expected {
Some(expected) if expected != next => Err(PlatformWalletError::IdentityRevisionMismatch {
identity_id: *identity_id,
expected,
actual: next,
}),
_ => Ok(next),
}
}

#[cfg(test)]
mod update_revision_tests {
use super::*;

#[test]
fn should_carry_one_above_the_cached_revision() {
let id = Identifier::from([7u8; 32]);
assert_eq!(update_revision(&id, 4, None).unwrap(), 5);
assert_eq!(update_revision(&id, 4, Some(5)).unwrap(), 5);
}

Comment thread
romchornyi marked this conversation as resolved.
Resolved
#[test]
fn should_refuse_an_update_asked_for_another_revision() {
let id = Identifier::from([7u8; 32]);
// The cache moved past the revision the caller read.
match update_revision(&id, 5, Some(5)) {
Err(PlatformWalletError::IdentityRevisionMismatch {
expected, actual, ..
}) => assert_eq!((expected, actual), (5, 6)),
other => panic!("unexpected: {other:?}"),
}
assert!(matches!(
update_revision(&id, 3, Some(5)),
Err(PlatformWalletError::IdentityRevisionMismatch { .. })
));
}

#[test]
fn should_refuse_an_identity_at_the_last_revision() {
let id = Identifier::from([7u8; 32]);
assert!(matches!(
update_revision(&id, u64::MAX, None),
Err(PlatformWalletError::InvalidIdentityData(_))
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3729,10 +3729,21 @@ extension ManagedPlatformWallet {
/// otherwise subsequent operations that try to sign with the
/// newly-added keys will fail. The signer here only signs the
/// update transition itself with an existing MASTER key.
///
/// The transition carries the cached identity's revision plus one,
/// read when the Rust side starts the update. With `revision` set, an
/// update that would carry another one throws before anything is
/// signed or broadcast (as `.unknown`, with a message that says the
/// update was asked for another revision and nothing was broadcast),
/// so a caller that recorded `revision` beforehand knows the only
/// revision this update can land at: Drive takes an identity update
/// only at one above the identity's revision. `nil` takes whatever the
/// cache gives.
public func updateIdentity(
identityId: Identifier,
addPublicKeys: [ManagedPlatformWallet.IdentityPubkey] = [],
disablePublicKeyIds: [UInt32] = [],
revision: UInt64? = nil,
signer: KeychainSigner
) async throws {
let handle = self.handle
Expand All @@ -3751,30 +3762,25 @@ extension ManagedPlatformWallet {
let result = idBytes.withUnsafeBufferPointer {
idBp -> PlatformWalletFFIResult in
disableIds.withUnsafeBufferPointer { disableBp in
let update = { (rows: UnsafePointer<IdentityPubkeyFFI>?, count: Int) -> PlatformWalletFFIResult in
let disable = disableIds.isEmpty ? nil : disableBp.baseAddress
guard let revision else {
return platform_wallet_update_identity_with_signer(
handle, idBp.baseAddress!, rows, UInt(count),
disable, UInt(disableIds.count), signerHandle)
}
return platform_wallet_update_identity_with_signer_at_revision(
handle, idBp.baseAddress!, rows, UInt(count),
disable, UInt(disableIds.count), revision, signerHandle)
}
if addPubkeys.isEmpty {
return platform_wallet_update_identity_with_signer(
handle,
idBp.baseAddress!,
nil,
0,
disableIds.isEmpty ? nil : disableBp.baseAddress,
UInt(disableIds.count),
signerHandle
)
return update(nil, 0)
}
return ManagedPlatformWallet.withPubkeyFFIArray(
addPubkeys,
buffers: pubkeyBuffers
) { ffiRowsPtr, ffiRowsCount in
platform_wallet_update_identity_with_signer(
handle,
idBp.baseAddress!,
ffiRowsPtr,
UInt(ffiRowsCount),
disableIds.isEmpty ? nil : disableBp.baseAddress,
UInt(disableIds.count),
signerHandle
)
update(ffiRowsPtr, ffiRowsCount)
}
}
}
Expand Down
Loading