Skip to content

Commit b215860

Browse files
committed
fix: prevent duplicate loaded contracts
1 parent 971129c commit b215860

16 files changed

Lines changed: 1580 additions & 151 deletions

src/backend_task/contract.rs

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,37 @@ use dash_sdk::dpp::group::group_action::GroupAction;
2020
use dash_sdk::dpp::group::group_action_status::GroupActionStatus;
2121
use dash_sdk::dpp::identity::accessors::IdentityGettersV0;
2222
use dash_sdk::dpp::platform_value::Value;
23+
use dash_sdk::dpp::platform_value::string_encoding::Encoding;
2324
use dash_sdk::drive::query::{WhereClause, WhereOperator};
2425
use dash_sdk::platform::group_actions::GroupActionsQuery;
2526
use dash_sdk::platform::{
2627
DataContract, Document, DocumentQuery, Fetch, FetchMany, Identifier, IdentityPublicKey,
2728
};
2829
use dash_sdk::query_types::IndexMap;
2930

31+
/// Returns the first identifier in `identifiers` that appears earlier in the
32+
/// same slice, or `None` if every entry is unique.
33+
///
34+
/// Used as a task-boundary duplicate guard for [`ContractTask::FetchContracts`].
35+
fn first_duplicate_id(identifiers: &[Identifier]) -> Option<Identifier> {
36+
identifiers
37+
.iter()
38+
.enumerate()
39+
.find_map(|(index, id)| identifiers[..index].contains(id).then_some(*id))
40+
}
41+
42+
/// Returns the first identifier in `requested` that is also present in
43+
/// `existing`, or `None` when none of the requested IDs are already loaded.
44+
fn first_already_loaded_id(
45+
requested: &[Identifier],
46+
existing: &[Identifier],
47+
) -> Option<Identifier> {
48+
requested
49+
.iter()
50+
.find(|identifier| existing.contains(identifier))
51+
.copied()
52+
}
53+
3054
#[derive(Debug, Clone, PartialEq)]
3155
pub enum ContractTask {
3256
FetchContracts(Vec<Identifier>),
@@ -47,6 +71,28 @@ impl AppContext {
4771
) -> Result<BackendTaskSuccessResult, crate::backend_task::error::TaskError> {
4872
match task {
4973
ContractTask::FetchContracts(identifiers) => {
74+
// Task-boundary duplicate / already-loaded enforcement.
75+
//
76+
// The `AddContractsScreen` UI also performs these checks, but
77+
// duplicating them here protects non-UI callers (e.g. MCP tools)
78+
// and closes the TOCTOU window between the screen check and
79+
// the network fetch / persistence below.
80+
if let Some(duplicate) = first_duplicate_id(&identifiers) {
81+
return Err(
82+
crate::backend_task::error::TaskError::DuplicateContractInRequest {
83+
contract_id: duplicate.to_string(Encoding::Base58),
84+
},
85+
);
86+
}
87+
let existing_ids = self.loaded_contract_ids()?;
88+
if let Some(already_loaded) = first_already_loaded_id(&identifiers, &existing_ids) {
89+
return Err(
90+
crate::backend_task::error::TaskError::ContractAlreadyLoaded {
91+
contract_id: already_loaded.to_string(Encoding::Base58),
92+
},
93+
);
94+
}
95+
5096
match DataContract::fetch_many(sdk, identifiers).await {
5197
Ok(data_contracts) => {
5298
let mut results = vec![];
@@ -208,9 +254,30 @@ impl AppContext {
208254
}
209255
ContractTask::RemoveContract(identifier) => self
210256
.remove_contract(&identifier)
211-
.map(|_| BackendTaskSuccessResult::RemovedContract)
212-
.map_err(crate::backend_task::error::TaskError::from),
257+
.map(|_| BackendTaskSuccessResult::RemovedContract),
213258
ContractTask::SaveDataContract(data_contract, alias, insert_tokens_too) => {
259+
// Task-boundary enforcement: the local DB layer silently
260+
// skips system contract IDs and `INSERT OR IGNORE` makes a
261+
// duplicate user-contract insert a no-op, so without this
262+
// check non-UI callers could appear to "save" a contract
263+
// when the database state did not actually change.
264+
let contract_id = data_contract.id();
265+
if self.is_system_contract_id(&contract_id) {
266+
return Err(
267+
crate::backend_task::error::TaskError::SystemContractImmutable {
268+
contract_id: contract_id.to_string(Encoding::Base58),
269+
},
270+
);
271+
}
272+
let existing_ids = self.loaded_contract_ids()?;
273+
if existing_ids.contains(&contract_id) {
274+
return Err(
275+
crate::backend_task::error::TaskError::ContractAlreadyLoaded {
276+
contract_id: contract_id.to_string(Encoding::Base58),
277+
},
278+
);
279+
}
280+
214281
self.db.insert_contract_if_not_exists(
215282
&data_contract,
216283
alias.as_deref(),
@@ -222,3 +289,37 @@ impl AppContext {
222289
}
223290
}
224291
}
292+
293+
#[cfg(test)]
294+
mod tests {
295+
use super::*;
296+
297+
fn id(byte: u8) -> Identifier {
298+
Identifier::from_bytes(&[byte; 32]).expect("32 bytes is a valid identifier")
299+
}
300+
301+
#[test]
302+
fn first_duplicate_id_returns_none_for_unique_inputs() {
303+
assert!(first_duplicate_id(&[id(1), id(2), id(3)]).is_none());
304+
}
305+
306+
#[test]
307+
fn first_duplicate_id_returns_first_repeat() {
308+
assert_eq!(
309+
first_duplicate_id(&[id(1), id(2), id(1), id(3)]),
310+
Some(id(1))
311+
);
312+
}
313+
314+
#[test]
315+
fn first_already_loaded_id_returns_first_match_in_request_order() {
316+
let requested = [id(7), id(2), id(9)];
317+
let existing = [id(9), id(2)];
318+
assert_eq!(first_already_loaded_id(&requested, &existing), Some(id(2)));
319+
}
320+
321+
#[test]
322+
fn first_already_loaded_id_returns_none_when_no_overlap() {
323+
assert!(first_already_loaded_id(&[id(1), id(2)], &[id(3), id(4)]).is_none());
324+
}
325+
}

src/backend_task/error.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,39 @@ pub enum TaskError {
458458
)]
459459
DataContractNotFound,
460460

461+
/// A user-driven mutation targeted a built-in system contract. System
462+
/// contracts (DPNS, DashPay, withdrawals, token history, keyword search)
463+
/// are managed by the application and cannot be modified or removed.
464+
#[error(
465+
"Contract {contract_id} is a built-in system contract and cannot be modified or removed. \
466+
Use a different contract."
467+
)]
468+
SystemContractImmutable {
469+
/// Base58-encoded ID of the system contract whose mutation was rejected.
470+
contract_id: String,
471+
},
472+
473+
/// The same contract identifier appeared more than once in a single
474+
/// add-contracts request.
475+
#[error(
476+
"Contract {contract_id} was entered more than once. Remove the duplicate entry before adding contracts."
477+
)]
478+
DuplicateContractInRequest {
479+
/// Base58-encoded ID of the duplicated contract.
480+
contract_id: String,
481+
},
482+
483+
/// An add-contracts request referenced a contract that is already loaded
484+
/// (either persisted in the local database or one of the built-in system
485+
/// contracts).
486+
#[error(
487+
"Contract {contract_id} is already loaded. Select it from the existing contracts list or enter a different contract ID."
488+
)]
489+
ContractAlreadyLoaded {
490+
/// Base58-encoded ID of the already-loaded contract.
491+
contract_id: String,
492+
},
493+
461494
// ──────────────────────────────────────────────────────────────────────────
462495
// Serialization errors
463496
// ──────────────────────────────────────────────────────────────────────────

src/backend_task/update_data_contract.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,7 @@ impl AppContext {
108108

109109
match state_transition.broadcast_and_wait(sdk, None).await {
110110
Ok(returned_contract) => {
111-
self.db
112-
.replace_contract(data_contract.id(), &returned_contract, self)?;
111+
self.replace_contract(data_contract.id(), &returned_contract)?;
113112
let fee_result = FeeResult::new(estimated_fee, estimated_fee);
114113
Ok(BackendTaskSuccessResult::UpdatedContract(fee_result))
115114
}
@@ -148,9 +147,7 @@ impl AppContext {
148147
_ => sleep(Duration::from_secs(10)).await,
149148
}
150149
if let Ok(Some(contract)) = DataContract::fetch(sdk, id).await {
151-
self.db
152-
.replace_contract(contract.id(), &contract, self)
153-
.ok();
150+
self.replace_contract(contract.id(), &contract).ok();
154151

155152
return Ok(BackendTaskSuccessResult::ContractSavedAfterProofError);
156153
}

0 commit comments

Comments
 (0)