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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/tests-rs-workspace.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ jobs:
|| github.event.pull_request.head.repo.full_name == github.repository
|| github.event.pull_request.head.repo.owner.login == 'thepastaclaw'
timeout-minutes: 90
defaults:
run:
# Self-hosted macOS runners may keep their work directory on a volume
# with spaces in its name. Quote the generated script path explicitly.
shell: bash --noprofile --norc -eo pipefail "{0}"
steps:
- name: Check out repo
uses: actions/checkout@v4
Expand Down
6 changes: 4 additions & 2 deletions packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,13 @@ These are shipped ABI. Do not renumber.
| 98 | `NotFound` | Sentinel — `Option` returned as an error |
| 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors |

**Next allocatable integer: 58** — 27–57 are all claimed (27, 29, 31, 34–42
**Next allocatable integer: 59** — 27–58 are all claimed (27, 29, 31, 34–42
and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and
48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their
rows below); 49–54 proposed by active #4586 (the persister
operation × kind block); 55–57 proposed by #4715 for pending identity-funded
shield debits and durable recovery errors; 28, 30,
shield debits and durable recovery errors; 58 proposed by #4799 for an unavailable
identity balance response; 28, 30,
32 and 33 reserved). **28, 30,
32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the
reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners
Expand Down Expand Up @@ -170,6 +171,7 @@ Fork-era numbers remain in the collision history, which is immutable record.
| 55 | `ErrorShieldedIdentityDebitPending` | #4715 | Proposed — an earlier identity-funded shield is unresolved. This request was not built or broadcast; wait for shielded sync to reconcile the original debit. Rust's blanket and shielded-operation mappers preserve the code and message; Swift and Kotlin expose matching typed errors |
| 56 | `ErrorShieldedRecoveryCorrupted` | #4715 | Proposed — durable shielded recovery data is malformed or invalid; preserved for diagnosis. Rust, Swift and Kotlin preserve this typed error |
| 57 | `ErrorShieldedRecoveryKeysRequired` | #4715 | Proposed — recovery needs the account and compatible keys; ciphertext damage can produce the same symptom. Rust, Swift and Kotlin preserve this typed error |
| 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding |

**Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here
as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged
Expand Down
24 changes: 24 additions & 0 deletions packages/rs-platform-wallet-ffi/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,10 @@ pub enum PlatformWalletFFIResultCode {
/// Incompatible keys and damaged ciphertext can produce the same symptom.
ErrorShieldedRecoveryKeysRequired = 57,

/// Platform returned no balance for a managed identity. Retrying this read
/// is safe; this does not imply missing ownership or require registration.
ErrorIdentityBalanceUnavailable = 58,

/// The named thing does not exist.
///
/// Originally (and still mostly) the code for every `Option` returned as an
Expand Down Expand Up @@ -860,6 +864,9 @@ impl From<PlatformWalletError> for PlatformWalletFFIResult {
// assigned a dedicated code yet — those still carry the
// typed Display rendering as the message.
let code = match &error {
PlatformWalletError::IdentityBalanceUnavailable(_) => {
PlatformWalletFFIResultCode::ErrorIdentityBalanceUnavailable
}
PlatformWalletError::NoSpendableInputs { .. }
| PlatformWalletError::OnlyOutputAddressesFunded { .. }
| PlatformWalletError::OnlyDustInputs { .. } => {
Expand Down Expand Up @@ -2570,3 +2577,20 @@ mod tests {
.into_owned()
}
}

#[cfg(test)]
mod identity_balance_error_tests {
use super::*;
#[test]
fn should_distinguish_unavailable_network_balance_with_a_retryable_read_code() {
let result = PlatformWalletFFIResult::from(
PlatformWalletError::IdentityBalanceUnavailable([7; 32].into()),
);
assert_eq!(
result.code,
PlatformWalletFFIResultCode::ErrorIdentityBalanceUnavailable
);
assert_eq!(result.code as u32, 58);
assert!(!result.message.is_null());
}
}
80 changes: 79 additions & 1 deletion packages/rs-platform-wallet-ffi/src/managed_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,12 @@ pub unsafe extern "C" fn managed_identity_get_last_updated_balance_block_time(
PlatformWalletFFIResult::ok()
}

/// Set last updated balance block time.
/// Set the block time on this detached managed-identity handle only.
///
/// Wallet lookups return snapshot clones. This setter neither updates the live
/// wallet's verified balance watermark nor persists metadata. Obtain a fresh
/// snapshot after `platform_wallet_refresh_identity_balance` to observe the
/// authoritative wallet state; this function is not a live-watermark reset API.
#[no_mangle]
pub unsafe extern "C" fn managed_identity_set_last_updated_balance_block_time(
identity_handle: Handle,
Expand Down Expand Up @@ -379,6 +384,79 @@ mod tests {
}
}

#[test]
fn should_keep_manual_balance_block_time_changes_on_detached_handles() {
use crate::identity_manager::{
identity_manager_add_identity, identity_manager_create, identity_manager_destroy,
identity_manager_get_identity,
};

unsafe {
let original =
MANAGED_IDENTITY_STORAGE.insert(ManagedIdentity::new(create_test_identity(), 0));
let mut manager = NULL_HANDLE;
assert_eq!(
identity_manager_create(&mut manager).code,
PlatformWalletFFIResultCode::Success
);
assert_eq!(
identity_manager_add_identity(manager, original).code,
PlatformWalletFFIResultCode::Success
);
let mut snapshot = NULL_HANDLE;
assert_eq!(
identity_manager_get_identity(manager, [1u8; 32].as_ptr(), &mut snapshot).code,
PlatformWalletFFIResultCode::Success
);
let stamp = BlockTime {
height: u64::MAX,
core_height: u32::MAX,
timestamp: u64::MAX,
};
assert_eq!(
managed_identity_set_last_updated_balance_block_time(snapshot, &stamp).code,
PlatformWalletFFIResultCode::Success
);
assert_eq!(
MANAGED_IDENTITY_STORAGE.with_item(snapshot, |identity| identity
.last_updated_balance_block_time
.unwrap()
.height),
Some(u64::MAX)
);
assert_eq!(
IDENTITY_MANAGER_STORAGE.with_item(manager, |manager| manager
.managed_identity(&Identifier::from([1; 32]))
.unwrap()
.last_updated_balance_block_time),
Some(None)
);

// Even adding the edited snapshot to another standalone manager
// imports only its DPP identity, not its manually assigned metadata.
let mut other_manager = NULL_HANDLE;
assert_eq!(
identity_manager_create(&mut other_manager).code,
PlatformWalletFFIResultCode::Success
);
assert_eq!(
identity_manager_add_identity(other_manager, snapshot).code,
PlatformWalletFFIResultCode::Success
);
assert_eq!(
IDENTITY_MANAGER_STORAGE.with_item(other_manager, |manager| manager
.managed_identity(&Identifier::from([1; 32]))
.unwrap()
.last_updated_balance_block_time),
Some(None)
);
managed_identity_destroy(snapshot);
managed_identity_destroy(original);
identity_manager_destroy(manager);
identity_manager_destroy(other_manager);
}
}

#[test]
fn test_block_time_operations() {
unsafe {
Expand Down
54 changes: 53 additions & 1 deletion packages/rs-platform-wallet-ffi/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use crate::event_handler::{
};
use crate::handle::*;
use crate::persistence::{
FFIPersister, FreeTrackedMasternodesFn, LoadTrackedMasternodesFn, PersistDpnsNameStatesFn,
FFIPersister, FreeTrackedMasternodesFn, LoadIdentityBalanceBlockTimeFn,
LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn,
PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn,
PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks,
PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks,
Expand Down Expand Up @@ -268,6 +269,14 @@ unsafe fn persistence_extension_callbacks(
on_persist_wallet_changeset_utxo_verdicts_fn,
PersistWalletChangesetUtxoVerdictsFn
),
persist_identity_balance_block_time: slot!(
on_persist_identity_balance_block_time_fn,
PersistIdentityBalanceBlockTimeFn
),
load_identity_balance_block_time: slot!(
on_load_identity_balance_block_time_fn,
LoadIdentityBalanceBlockTimeFn
),
}
}

Expand Down Expand Up @@ -1254,6 +1263,49 @@ mod tests {
assert!(read_unknown.wallet_changeset_utxo_verdicts.is_none());
}

#[test]
fn should_size_gate_identity_balance_watermark_slots_independently() {
unsafe extern "C" fn persist(
_: *mut c_void,
_: *const u8,
_: *const u8,
_: *const crate::types::BlockTime,
) -> i32 {
0
}
unsafe extern "C" fn load(
_: *mut c_void,
_: *const u8,
_: *const u8,
_: *mut bool,
_: *mut crate::types::BlockTime,
) -> i32 {
0
}
let mut ext = PersistenceCallbacksExtension {
on_persist_identity_balance_block_time_fn: Some(persist),
on_load_identity_balance_block_time_fn: Some(load),
..Default::default()
};
let parsed = unsafe { persistence_extension_callbacks(&ext) };
assert!(parsed.persist_identity_balance_block_time.is_some());
assert!(parsed.load_identity_balance_block_time.is_some());
ext.struct_size = std::mem::offset_of!(
PersistenceCallbacksExtension,
on_load_identity_balance_block_time_fn
);
let parsed = unsafe { persistence_extension_callbacks(&ext) };
assert!(parsed.persist_identity_balance_block_time.is_some());
assert!(parsed.load_identity_balance_block_time.is_none());
ext.struct_size = std::mem::offset_of!(
PersistenceCallbacksExtension,
on_persist_identity_balance_block_time_fn
);
let parsed = unsafe { persistence_extension_callbacks(&ext) };
assert!(parsed.persist_identity_balance_block_time.is_none());
assert!(parsed.load_identity_balance_block_time.is_none());
}

/// A host whose `struct_size` stops right after the chainlock-height
/// slot (built before the credit-verdict slot existed) keeps every
/// earlier slot and simply never has the verdict slot read; a host
Expand Down
Loading
Loading