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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import org.dashfoundation.dashsdk.ffi.TrackedAssetLocksNativeResult
* Rust-authoritative tracked asset-lock snapshot eligible for generic
* identity recovery. Invitation (3), address/shielded (4/5), consumed (4),
* and malformed rows are deliberately absent.
*
* [Status.RECOVERED_FROM_CHAIN] rows ARE eligible: they are asset locks
* the restore scan rebuilt from chain-locked history (Core finality
* proven, Platform-side consumption unknown), and the registration /
* top-up recovery screens are exactly the user-driven surface allowed
* to try consuming one — Platform rejects an already-spent outpoint
* with a typed error. Do NOT feed this status into any automatic
* stuck-lock retry sweep; blind retries of historical locks are the
* failure mode the dedicated status exists to prevent.
*/
data class TrackedAssetLock(
val outpointTxid: ByteArray,
Expand All @@ -28,6 +37,13 @@ data class TrackedAssetLock(
BROADCAST(1),
INSTANT_SEND_LOCKED(2),
CHAIN_LOCKED(3),
// 4 (CONSUMED) stays deliberately unmapped: consumed rows are
// terminal tombstones and must never surface as recoverable.

/** Rebuilt by the restore scan from a chain-locked record —
* finality proven (chain proof attached Rust-side), consumption
* unknown. Selectable for user-driven recovery only. */
RECOVERED_FROM_CHAIN(5),
}

init {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,20 @@ class TrackedAssetLockTest {
row(0, 0), row(1, 1), row(2, 3),
row(3, 2), // invitation: never generic
row(4, 2), // address top-up
row(0, 4), // consumed
row(0, 4), // consumed: terminal tombstone, never recoverable
row(1, 5), // recovered from chain: user-driven recovery allowed
row(0, 2, txidSize = 31),
),
)

val eligible = TrackedAssetLock.eligibleFromNative(native)

assertEquals(listOf(0, 1, 2), eligible.map { it.fundingType.raw })
assertEquals(listOf(0, 1, 3), eligible.map { it.status.raw })
assertEquals(listOf(0, 1, 2, 1), eligible.map { it.fundingType.raw })
assertEquals(listOf(0, 1, 3, 5), eligible.map { it.status.raw })
assertEquals(
TrackedAssetLock.Status.RECOVERED_FROM_CHAIN,
eligible.last().status,
)
assertTrue(eligible.all { it.outpointTxid.size == 32 })
// Mapper owns its Kotlin copy; callers can't mutate the JNI row.
native.entries[0].outpointTxid[0] = 99
Expand Down
5 changes: 4 additions & 1 deletion packages/rs-platform-wallet-ffi/src/asset_lock/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ pub struct TrackedAssetLockFFI {
pub identity_index: u32,
/// Amount in duffs.
pub amount: u64,
/// Status (0=Built, 1=Broadcast, 2=InstantSendLocked, 3=ChainLocked).
/// Status (0=Built, 1=Broadcast, 2=InstantSendLocked, 3=ChainLocked,
/// 4=Consumed, 5=RecoveredFromChain — finality proven by the restore
/// scan, Platform-side consumption unknown).
pub status: u32,
/// Whether a proof is attached.
pub has_proof: bool,
Expand Down Expand Up @@ -76,6 +78,7 @@ pub unsafe extern "C" fn asset_lock_manager_list_tracked_locks(
AssetLockStatus::InstantSendLocked => 2,
AssetLockStatus::ChainLocked => 3,
AssetLockStatus::Consumed => 4,
AssetLockStatus::RecoveredFromChain => 5,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
has_proof: lock.proof.is_some(),
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,5 +167,6 @@ fn status_to_u8(status: &AssetLockStatus) -> u8 {
AssetLockStatus::InstantSendLocked => 2,
AssetLockStatus::ChainLocked => 3,
AssetLockStatus::Consumed => 4,
AssetLockStatus::RecoveredFromChain => 5,
}
}
8 changes: 8 additions & 0 deletions packages/rs-platform-wallet-ffi/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2031,6 +2031,8 @@ impl PlatformWalletPersistence for FFIPersister {
block_height: e.block_height.unwrap_or(0),
has_block_height: u8::from(e.block_height.is_some()),
created_at_ms: e.created_at_ms,
min_note_position: e.min_note_position.unwrap_or(0),
has_min_note_position: u8::from(e.min_note_position.is_some()),
identity_id,
has_identity_id,
counterparty_ptr,
Expand Down Expand Up @@ -2586,6 +2588,11 @@ impl PlatformWalletPersistence for FFIPersister {
},
status,
created_at_ms: ffi.created_at_ms,
min_note_position: if ffi.has_min_note_position != 0 {
Some(ffi.min_note_position)
} else {
None
},
note_cmxs,
spent_nullifiers,
});
Expand Down Expand Up @@ -4828,6 +4835,7 @@ fn status_from_u8(b: u8) -> Result<platform_wallet::AssetLockStatus, Persistence
2 => AssetLockStatus::InstantSendLocked,
3 => AssetLockStatus::ChainLocked,
4 => AssetLockStatus::Consumed,
5 => AssetLockStatus::RecoveredFromChain,
other => {
return Err(PersistenceError::backend(format!(
"tracked asset lock: unknown status discriminant {}",
Expand Down
14 changes: 13 additions & 1 deletion packages/rs-platform-wallet-ffi/src/shielded_persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,18 @@ pub struct ShieldedActivityFFI {
/// `1` if `block_height` is meaningful (confirmed), `0` while pending.
pub has_block_height: u8,
/// Created-at time in ms since the Unix epoch (display-only;
/// `block_height` is the canonical sort key).
/// `block_height` is the canonical sort key). `0` = unknown —
/// scan-derived (restored) entries carry no wall-clock provenance.
pub created_at_ms: u64,
/// Chain-order key when `has_min_note_position == 1`: the smallest
/// commitment-tree position among the entry's own received notes.
/// Tree positions are exact append-only chain order — hosts use
/// this to order otherwise-undatable restored entries. Set by the
/// scan deriver; live entries (which carry a real `created_at_ms`)
/// and outgoing-only clusters report `0`/`0`.
pub min_note_position: u64,
/// `1` if `min_note_position` is meaningful.
pub has_min_note_position: u8,
/// Created identity id (only meaningful when `kind_tag == 6` /
/// IdentityCreate); all-zero and ignored otherwise.
pub identity_id: [u8; 32],
Expand Down Expand Up @@ -268,6 +278,8 @@ pub struct ShieldedActivityRestoreFFI {
pub block_height: u64,
pub has_block_height: u8,
pub created_at_ms: u64,
pub min_note_position: u64,
pub has_min_note_position: u8,
pub identity_id: [u8; 32],
pub has_identity_id: u8,
pub counterparty_ptr: *const u8,
Expand Down
18 changes: 16 additions & 2 deletions packages/rs-platform-wallet-storage/migrations/V001__initial.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,22 @@ pub fn migration() -> String {
let account_type_check =
build_check_in(crate::sqlite::schema::accounts::ACCOUNT_TYPE_LABELS);
let pool_type_check = build_check_in(crate::sqlite::schema::accounts::POOL_TYPE_LABELS);
let asset_lock_status_check =
build_check_in(crate::sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS);
// FROZEN as of V004: the asset-lock status domain must no longer be
// interpolated from the live `ASSET_LOCK_STATUS_LABELS` const — a
// later variant addition would silently rewrite this migration's
// generated SQL and break its Refinery checksum on every database
// that already applied it (`abort_divergent` default). New status
// labels are introduced by APPENDING a migration that rebuilds the
// table with the widened CHECK (see
// `V004__asset_lock_recovered_status.rs`); this list stays
// byte-identical to what V001 shipped with.
let asset_lock_status_check = build_check_in(&[
"built",
"broadcast",
"is_locked",
"chain_locked",
"consumed",
]);
let contact_state_check =
build_check_in(crate::sqlite::schema::contacts::CONTACT_STATE_LABELS);
let pending_contact_crypto_kind_check =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//! Widen the `asset_locks.status` CHECK domain with
//! `recovered_from_chain` — the status the restore-scan reconstruction
//! assigns to asset locks rebuilt from chain-locked on-chain records
//! (Core finality proven, Platform-side consumption unknown).
//!
//! SQLite cannot alter a CHECK constraint in place, so this rebuilds
//! the table: create the widened twin, copy every row, drop the old
//! table, rename. `asset_locks` is a leaf table (it references
//! `wallet_metadata`; nothing references it), so the drop/rename is
//! safe under `PRAGMA foreign_keys = ON`, and the copied rows satisfy
//! the re-declared FK because they came from a table with the same
//! constraint.
//!
//! The status list below is FROZEN — like V001's, it must never track
//! the live `ASSET_LOCK_STATUS_LABELS` const, or a future variant
//! addition would rewrite this migration's generated SQL and break its
//! Refinery checksum on databases that already applied it. The
//! `asset_lock_status_labels_frozen_in_latest_migration` unit test in
//! `sqlite::schema::asset_locks` pins the live const to this list so a
//! new variant fails compilation of intent loudly: append V005+ with
//! another rebuild, never edit this file.

pub fn migration() -> String {
"\
CREATE TABLE asset_locks_v4 (
wallet_id BLOB NOT NULL,
outpoint BLOB NOT NULL,
status TEXT NOT NULL CHECK (status IN ('built', 'broadcast', 'is_locked', 'chain_locked', 'consumed', 'recovered_from_chain')),
account_index INTEGER NOT NULL,
identity_index INTEGER NOT NULL,
amount_duffs INTEGER NOT NULL,
lifecycle_blob BLOB NOT NULL,
PRIMARY KEY (wallet_id, outpoint),
FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE
);

-- Orphan policy: a row whose wallet was deleted while FK enforcement
-- happened to be off is unreachable garbage (every read path keys
-- through wallet_metadata), but copying it into the FK-declared twin
-- under PRAGMA foreign_keys = ON would abort this whole migration with
-- 'FOREIGN KEY constraint failed'. Drop such rows explicitly — the
-- same outcome the declared ON DELETE CASCADE would have produced had
-- enforcement been on when the wallet was deleted.
DELETE FROM asset_locks
WHERE wallet_id NOT IN (SELECT wallet_id FROM wallet_metadata);

INSERT INTO asset_locks_v4 (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob)
SELECT wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob FROM asset_locks;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

DROP TABLE asset_locks;

ALTER TABLE asset_locks_v4 RENAME TO asset_locks;"
.to_string()
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,25 +66,38 @@ pub fn apply(
Ok(())
}

/// Single source of truth for the `asset_locks.status` TEXT-column
/// domain.
/// Test-only drift guard for the `asset_locks.status` TEXT-column
/// domain **as the writer sees it** (production code never reads this
/// — the writer maps through [`status_str`] and the on-disk CHECK
/// lives frozen inside the migrations).
///
/// Mirrors every variant of
/// [`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`]
/// (writer side: [`status_str`]). The migration in
/// `migrations/V001__initial.rs` interpolates this array into the
/// `CHECK (status IN (...))` clause so an unknown label is rejected at
/// insert time rather than landing as silent garbage. The
/// `asset_lock_status_labels_match_enum` unit test below enforces
/// set-equality between this array and the writer's output — drift (a
/// renamed/added variant) becomes a failing test, not a runtime
/// divergence between Rust and SQLite.
/// (writer side: [`status_str`]). The on-disk `CHECK (status IN (...))`
/// clause rejects an unknown label at insert time rather than letting
/// it land as silent garbage — but the migrations do NOT interpolate
/// this const: each migration freezes its own copy of the domain,
/// because a generated-SQL change breaks that migration's Refinery
/// checksum on every database that already applied it
/// (`abort_divergent` default). `V001__initial.rs` carries the original
/// five labels; `V004__asset_lock_recovered_status.rs` rebuilt the
/// table with the current six.
///
/// Two unit tests below keep the three copies honest:
/// - `asset_lock_status_labels_match_enum` — this array ⇔ the writer's
/// codomain ([`status_str`]);
/// - `asset_lock_status_labels_frozen_in_latest_migration` — this array
/// ⇔ the latest migration's frozen list, so ADDING a variant fails
/// with instructions to append a new table-rebuild migration (V005+)
/// instead of editing a shipped one.
#[cfg(test)]
pub(crate) const ASSET_LOCK_STATUS_LABELS: &[&str] = &[
"built",
"broadcast",
"is_locked",
"chain_locked",
"consumed",
"recovered_from_chain",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines 97 to +100

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Append a migration instead of changing V001's generated CHECK constraint

V001__initial.rs builds its asset_locks.status CHECK clause from ASSET_LOCK_STATUS_LABELS, so adding recovered_from_chain here changes the generated V001 SQL and its Refinery checksum. The storage runner uses Refinery's default abort_divergent = true; any database that already applied the previous V001 therefore fails in SqlitePersister::open with a divergent-migration error before it can be used. Disabling that check would not solve the schema mismatch because the old CHECK constraint would still reject reconstructed rows. Keep V001 byte-identical to the prior release and append a migration that rebuilds asset_locks with the expanded domain, plus an upgrade test beginning from the prior V003 schema.

source: ['codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a6cfb13: V001 is frozen back to its original five-label CHECK (byte-identical generated SQL, so existing databases' Refinery checksums verify), and the domain widens via an appended V004__asset_lock_recovered_status.rs table rebuild that preserves rows and the wallet_metadata FK. tc045_v004_widens_asset_lock_status_on_existing_db drives the exact upgrade an existing install experiences (V003 schema + data → latest), asserting the old CHECK rejected the new label, the pre-upgrade row survives the rebuild, the widened domain admits recovered_from_chain, garbage stays rejected, and ON DELETE CASCADE survives. A new pin test (asset_lock_status_labels_frozen_in_latest_migration) fails with append-a-migration instructions if the live const ever drifts from V004's frozen list.

];

fn status_str(s: &AssetLockStatus) -> &'static str {
Expand All @@ -94,6 +107,7 @@ fn status_str(s: &AssetLockStatus) -> &'static str {
AssetLockStatus::InstantSendLocked => "is_locked",
AssetLockStatus::ChainLocked => "chain_locked",
AssetLockStatus::Consumed => "consumed",
AssetLockStatus::RecoveredFromChain => "recovered_from_chain",
}
}

Expand Down Expand Up @@ -198,14 +212,16 @@ mod tests {
AssetLockStatus::InstantSendLocked,
AssetLockStatus::ChainLocked,
AssetLockStatus::Consumed,
AssetLockStatus::RecoveredFromChain,
];
for v in &variants {
match v {
AssetLockStatus::Built
| AssetLockStatus::Broadcast
| AssetLockStatus::InstantSendLocked
| AssetLockStatus::ChainLocked
| AssetLockStatus::Consumed => {}
| AssetLockStatus::Consumed
| AssetLockStatus::RecoveredFromChain => {}
}
}
variants
Expand All @@ -224,4 +240,28 @@ mod tests {
from_const, from_writer
);
}

/// Pins the live label set to the domain frozen in the LATEST
/// asset-lock migration (`V004__asset_lock_recovered_status.rs`).
/// Shipped migrations interpolate nothing — their generated SQL is
/// checksummed by Refinery, so widening the domain means APPENDING
/// a new table-rebuild migration (V005+) with the new frozen list
/// and updating this pin, never editing V001/V004 in place.
///
/// IF THIS FAILS: do NOT edit a shipped migration (its Refinery
/// checksum would diverge on already-migrated databases). Append a
/// new migration that rebuilds `asset_locks` with the widened
/// CHECK, then update this pin to the new migration's list.
#[test]
fn asset_lock_status_labels_frozen_in_latest_migration() {
let frozen_in_v004 = [
"built",
"broadcast",
"is_locked",
"chain_locked",
"consumed",
"recovered_from_chain",
];
assert_eq!(ASSET_LOCK_STATUS_LABELS, &frozen_in_v004);
}
}
Loading
Loading