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
41 changes: 38 additions & 3 deletions app/contract/contracts/Folder/src/escrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,11 +352,40 @@ pub fn deposit_partial(
// INV-3: validated, overflow-safe expiry computation
let expires_at = compute_expires_at(env, timeout_secs)?;

let commitment = commitment::create_amount_commitment(env, owner.clone(), amount_due, salt)?;
let now = env.ledger().timestamp();
// Derive a deterministic escrow_id that includes initial_payment so
// identical retries are detected and idempotently return the existing
// commitment without creating a duplicate entry.
let escrow_id = escrow_id::derive_partial_escrow_id(
env,
&token,
amount_due,
initial_payment,
&owner,
&salt,
timeout_secs,
&arbiter,
)?;
if let Some(existing) = get_escrow_id_mapping(env, &escrow_id) {
return Ok(existing);
}

let token_client = token::Client::new(env, &token);
let (commitment, legacy_commitment) =
commitment::amount_commitment_hashes(env, &owner, amount_due, &salt)?;
let commitment_bytes: Bytes = commitment.clone().into();

// Reject duplicate commitment to prevent overwriting an existing escrow.
if has_escrow(env, &commitment_bytes) {
return Err( RustAcademyError::CommitmentAlreadyExists);
}
if legacy_commitment != commitment {
let legacy_bytes: Bytes = legacy_commitment.into();
if has_escrow(env, &legacy_bytes) {
return Err( RustAcademyError::CommitmentAlreadyExists);
}
}

let now = env.ledger().timestamp();
let token_client = token::Client::new(env, &token);
let entry = EscrowEntry {
token, // moved
amount_due,
Expand All @@ -371,6 +400,12 @@ pub fn deposit_partial(
};

put_escrow(env, &commitment_bytes, &entry);
// Store forward (escrow_id → commitment) and reverse (commitment → escrow_id)
// mappings so the backend indexer can correlate partial escrow events with
// their stable IDs, and terminal cleanup can drop the dedup mapping.
put_escrow_id_mapping(env, &escrow_id, &commitment);
put_commitment_escrow_id(env, &commitment_bytes, &escrow_id);

token_client.transfer(&owner, env.current_contract_address(), &initial_payment);

let token_addr = token_client.address.clone();
Expand Down
64 changes: 64 additions & 0 deletions app/contract/contracts/Folder/src/escrow_id.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,67 @@ pub fn derive_escrow_id(

Ok(env.crypto().sha256(&payload).into())
}

/// Domain separation tag for partial-escrow-id derivation.
///
/// Distinct from [`ESCROW_ID_DOMAIN_TAG`] to prevent cross-protocol collisions
/// between full-payment and partial-payment escrow IDs.
pub const PARTIAL_ESCROW_ID_DOMAIN_TAG: &[u8] = b" RustAcademy::PARTIAL_ESCROW_ID::v1";

/// Derive a deterministic 32-byte escrow id for a partial-payment deposit.
///
/// Extends the full-payment derivation by committing to both `amount_due` and
/// `initial_payment`, so two partial escrows with the same `amount_due` but
/// different initial payments are treated as distinct and never alias each other.
///
/// # Errors
///
/// - [` RustAcademyError::InvalidAmount`] if `amount_due < 0` or `initial_payment < 0`.
/// - [` RustAcademyError::InvalidSalt`] if `salt.len() > 1024`.
pub fn derive_partial_escrow_id(
env: &Env,
token: &Address,
amount_due: i128,
initial_payment: i128,
owner: &Address,
salt: &Bytes,
timeout_secs: u64,
arbiter: &Option<Address>,
) -> Result<BytesN<32>, RustAcademyError> {
if amount_due < 0 || initial_payment < 0 {
return Err( RustAcademyError::InvalidAmount);
}
if salt.len() > MAX_SALT_LEN {
return Err( RustAcademyError::InvalidSalt);
}

let mut payload = Bytes::new(env);

payload.append(&Bytes::from_slice(env, PARTIAL_ESCROW_ID_DOMAIN_TAG));

let token_xdr = token.to_xdr(env);
append_len_prefixed(env, &mut payload, &token_xdr);

payload.append(&Bytes::from_array(env, &amount_due.to_be_bytes()));
payload.append(&Bytes::from_array(env, &initial_payment.to_be_bytes()));

let owner_xdr = owner.to_xdr(env);
append_len_prefixed(env, &mut payload, &owner_xdr);

payload.append(&Bytes::from_array(env, &timeout_secs.to_be_bytes()));

match arbiter {
None => {
payload.append(&Bytes::from_array(env, &[ARBITER_TAG_NONE]));
}
Some(arb) => {
payload.append(&Bytes::from_array(env, &[ARBITER_TAG_SOME]));
let arb_xdr = arb.to_xdr(env);
append_len_prefixed(env, &mut payload, &arb_xdr);
}
}

append_len_prefixed(env, &mut payload, salt);

Ok(env.crypto().sha256(&payload).into())
}
75 changes: 75 additions & 0 deletions app/contract/contracts/Folder/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3784,3 +3784,78 @@ fn test_single_arbiter_still_works() {
);
assert_eq!(token_client.balance(&recipient), amount);
}

// ============================================================================
// deposit_partial duplicate detection and escrow ID mapping (Issue #13)
// ============================================================================

#[test]
fn test_deposit_partial_identical_retry_returns_existing_commitment() {
let (env, client) = setup();
let token = create_test_token(&env);
let owner = Address::generate(&env);
let amount_due: i128 = 1000;
let initial: i128 = 300;
let salt = Bytes::from_slice(&env, b"idempotent_salt_partial");

let token_client = token::StellarAssetClient::new(&env, &token);
token_client.mint(&owner, &(initial * 2));

let c1 = client.deposit_partial(&token, &amount_due, &initial, &owner, &salt, &0, &None);
// Identical retry must return the same commitment, not create a duplicate.
let c2 = client.deposit_partial(&token, &amount_due, &initial, &owner, &salt, &0, &None);
assert_eq!(c1, c2);
}

#[test]
fn test_deposit_partial_duplicate_commitment_rejected() {
// Two partial deposits with the same (amount_due, salt) must not overwrite each other.
let (env, client) = setup();
let token = create_test_token(&env);
let owner = Address::generate(&env);
let amount_due: i128 = 1000;
let salt = Bytes::from_slice(&env, b"dup_check_salt");

let token_client = token::StellarAssetClient::new(&env, &token);
token_client.mint(&owner, &2000);

client.deposit_partial(&token, &amount_due, &200, &owner, &salt, &0, &None);
// Changing initial_payment but same (amount_due, salt) key → CommitmentAlreadyExists.
let result = client.try_deposit_partial(&token, &amount_due, &300, &owner, &salt, &0, &None);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().unwrap(),
crate::errors:: RustAcademyError::CommitmentAlreadyExists
);
}

#[test]
fn test_deposit_partial_has_escrow_id_mapping() {
let (env, client) = setup();
let token = create_test_token(&env);
let owner = Address::generate(&env);
let amount_due: i128 = 2000;
let initial: i128 = 500;
let salt = Bytes::from_slice(&env, b"id_map_salt");

let token_client = token::StellarAssetClient::new(&env, &token);
token_client.mint(&owner, &initial);

let commitment = client.deposit_partial(&token, &amount_due, &initial, &owner, &salt, &0, &None);

// Backend / indexer can correlate the commitment to a stable escrow ID.
let escrow_id = crate::escrow_id::derive_partial_escrow_id(
&env,
&token,
amount_due,
initial,
&owner,
&Bytes::from_slice(&env, b"id_map_salt"),
0,
&None,
)
.unwrap();

let mapped = client.get_escrow_id_commitment(&escrow_id);
assert_eq!(mapped, Some(commitment));
}
Loading