@@ -20,13 +20,37 @@ use dash_sdk::dpp::group::group_action::GroupAction;
2020use dash_sdk:: dpp:: group:: group_action_status:: GroupActionStatus ;
2121use dash_sdk:: dpp:: identity:: accessors:: IdentityGettersV0 ;
2222use dash_sdk:: dpp:: platform_value:: Value ;
23+ use dash_sdk:: dpp:: platform_value:: string_encoding:: Encoding ;
2324use dash_sdk:: drive:: query:: { WhereClause , WhereOperator } ;
2425use dash_sdk:: platform:: group_actions:: GroupActionsQuery ;
2526use dash_sdk:: platform:: {
2627 DataContract , Document , DocumentQuery , Fetch , FetchMany , Identifier , IdentityPublicKey ,
2728} ;
2829use 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 ) ]
3155pub 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+ }
0 commit comments