diff --git a/contracts/inheritance-contract/src/lib.rs b/contracts/inheritance-contract/src/lib.rs
index 6f9bbf9a9..22f238804 100644
--- a/contracts/inheritance-contract/src/lib.rs
+++ b/contracts/inheritance-contract/src/lib.rs
@@ -2702,6 +2702,332 @@ impl InheritanceContract {
Ok(())
}
+ /// Batch claim inheritance for multiple beneficiaries in a single transaction.
+ ///
+ /// Atomically validates and processes claims for all specified beneficiaries.
+ /// If any individual claim fails, the entire batch is rolled back (Soroban's
+ /// transaction atomicity guarantees this automatically on `Err` return).
+ ///
+ /// # Arguments
+ /// * `env` - The Soroban environment.
+ /// * `plan_id` - The ID of the inheritance plan to claim from.
+ /// * `claimers` - Ordered list of claimer `Address` values, one per beneficiary.
+ /// * `emails` - Ordered list of plain-text emails matching each claimer.
+ /// * `claim_codes` - Ordered list of claim codes matching each claimer.
+ ///
+ /// All three vectors must be the same length. Each position `i` in the vectors
+ /// represents a single beneficiary claim `(claimers[i], emails[i], claim_codes[i])`.
+ ///
+ /// # Errors
+ /// Returns the first `InheritanceError` encountered; the entire batch is aborted
+ /// on any individual failure (atomic rollback via transaction semantics).
+ pub fn batch_claim_inheritance_plan(
+ env: Env,
+ plan_id: u64,
+ claimers: Vec
,
+ emails: Vec,
+ claim_codes: Vec,
+ ) -> Result<(), InheritanceError> {
+ // ------------------------------------------------------------------ //
+ // 1. Validate input lengths //
+ // ------------------------------------------------------------------ //
+ let batch_len = claimers.len();
+ if batch_len == 0
+ || emails.len() != batch_len
+ || claim_codes.len() != batch_len
+ {
+ return Err(InheritanceError::InvalidBeneficiaryData);
+ }
+
+ // Hard-cap: no batch larger than the maximum beneficiaries per plan.
+ if batch_len > MAX_BENEFICIARIES {
+ return Err(InheritanceError::TooManyBeneficiaries);
+ }
+
+ // ------------------------------------------------------------------ //
+ // 2. Shared pre-flight checks (run once for the entire batch) //
+ // ------------------------------------------------------------------ //
+ Self::check_not_paused(&env);
+ Self::enter_guard(&env);
+
+ // Fetch the plan once; all individual claims operate on the same snapshot
+ // and mutations are accumulated then written back at the end.
+ let plan = Self::get_plan(&env, plan_id).ok_or(InheritanceError::PlanNotFound)?;
+
+ if !plan.is_active {
+ Self::exit_guard(&env);
+ return Err(InheritanceError::PlanNotActive);
+ }
+
+ // Freeze / legal-hold check
+ if env
+ .storage()
+ .persistent()
+ .has(&DataKey::FreezePlan(plan_id))
+ {
+ Self::exit_guard(&env);
+ return Err(InheritanceError::PlanNotActive);
+ }
+ if env.storage().persistent().has(&DataKey::LegalHold(plan_id)) {
+ Self::exit_guard(&env);
+ return Err(InheritanceError::PlanNotActive);
+ }
+
+ // Bring trigger state up to date once for the whole batch.
+ let _ = Self::auto_trigger_check(env.clone(), plan_id);
+ let triggered = Self::get_trigger_info(&env, plan_id).is_some();
+ if !triggered && !Self::is_claim_time_valid(&env, &plan) {
+ Self::exit_guard(&env);
+ return Err(InheritanceError::ClaimNotAllowedYet);
+ }
+
+ // ------------------------------------------------------------------ //
+ // 3. Per-claimer KYC + rate-limit checks (must all pass) //
+ // ------------------------------------------------------------------ //
+ for i in 0..batch_len {
+ let claimer = claimers.get(i).unwrap();
+ claimer.require_auth();
+ Self::check_kyc_approved(&env, &claimer)?;
+ Self::check_and_record_claim_attempt(&env, plan_id, &claimer)?;
+ }
+
+ // Emergency limit: compute once if active.
+ let emergency_limit: Option = if Self::is_emergency_active(&env, plan_id) {
+ let limit = (plan.total_amount as u128)
+ .checked_mul(EMERGENCY_TRANSFER_LIMIT_BP as u128)
+ .and_then(|v| v.checked_div(10000))
+ .unwrap_or(0) as u64;
+ Some(limit)
+ } else {
+ None
+ };
+
+ // ------------------------------------------------------------------ //
+ // 4. Validate every individual claim and collect payouts //
+ // (all validation before any state mutation — atomic semantics). //
+ // ------------------------------------------------------------------ //
+
+ // We work on a mutable copy of the plan. On any error we discard it
+ // and return Err, which leaves on-chain state unchanged.
+ let mut updated_plan = plan.clone();
+ let count = plan.beneficiaries.len().min(MAX_BENEFICIARIES);
+
+ // Track claim keys so we can write them all after the validation pass.
+ let mut pending_claims: Vec<(DataKey, ClaimRecord, u32, u64, u64)> =
+ Vec::new(&env); // (claim_key, record, index, payout, exit_settlement)
+
+ for i in 0..batch_len {
+ let email = emails.get(i).unwrap();
+ let claim_code = claim_codes.get(i).unwrap();
+ let claimer = claimers.get(i).unwrap();
+
+ // Hash the email to match against stored hashes.
+ let hashed_email = Self::hash_string(&env, email.clone());
+
+ // Build the composite claim key (plan_id || hashed_email).
+ let claim_key = {
+ let mut data = Bytes::new(&env);
+ data.extend_from_slice(&plan_id.to_be_bytes());
+ data.extend_from_slice(&hashed_email.to_array());
+ DataKey::Claim(env.crypto().sha256(&data).into())
+ };
+
+ // Reject if already claimed.
+ if env.storage().persistent().has(&claim_key) {
+ Self::exit_guard(&env);
+ return Err(InheritanceError::AlreadyClaimed);
+ }
+
+ // Locate the beneficiary by email and validate the claim code.
+ let mut beneficiary_index: Option = None;
+ for j in 0..count {
+ let b = updated_plan.beneficiaries.get(j).unwrap();
+ if b.hashed_email != hashed_email {
+ continue;
+ }
+ let salt: BytesN<32> = env
+ .storage()
+ .persistent()
+ .get(&DataKey::ClaimSalt(plan_id, j))
+ .unwrap_or(BytesN::<32>::from_array(&env, &[0u8; 32]));
+ let hashed_claim_code =
+ Self::hash_claim_code_with_salt(&env, claim_code, &salt)?;
+ if b.hashed_claim_code == hashed_claim_code {
+ beneficiary_index = Some(j);
+ break;
+ }
+ }
+
+ let index = beneficiary_index.ok_or_else(|| {
+ Self::exit_guard(&env);
+ InheritanceError::BeneficiaryNotFound
+ })?;
+
+ // Frozen beneficiary check.
+ if env
+ .storage()
+ .persistent()
+ .get::(&DataKey::FrozenBeneficiary(plan_id, index))
+ .unwrap_or(false)
+ {
+ Self::exit_guard(&env);
+ return Err(InheritanceError::Unauthorized);
+ }
+
+ // Active vesting schedule check.
+ if Self::has_active_vesting_schedule(&env, plan_id, index) {
+ Self::exit_guard(&env);
+ return Err(InheritanceError::VestingScheduleActive);
+ }
+
+ // Waterfall ordering: all higher-priority beneficiaries must have
+ // already claimed (or be claiming earlier in this very batch).
+ if updated_plan.waterfall_enabled {
+ let this_b = updated_plan.beneficiaries.get(index).unwrap();
+ for j in 0..count {
+ let b = updated_plan.beneficiaries.get(j).unwrap();
+ if b.priority != 0 && b.priority < this_b.priority && !b.is_claimed {
+ Self::exit_guard(&env);
+ return Err(InheritanceError::ClaimNotAllowedYet);
+ }
+ }
+ }
+
+ // Calculate payout using the (possibly mutated) updated_plan so that
+ // earlier claims in the batch correctly reduce total_amount.
+ let mut payout = Self::calculate_waterfall_payout(&env, &updated_plan, index);
+
+ let exit_settlement =
+ Self::get_vesting_exit_settlement(&env, plan_id, index);
+ if exit_settlement > 0 {
+ payout = payout.min(exit_settlement);
+ }
+
+ // Emergency cap.
+ if let Some(limit) = emergency_limit {
+ if payout > limit {
+ Self::exit_guard(&env);
+ return Err(InheritanceError::EmergencyCooldownActive);
+ }
+ }
+
+ // Liquidity check (bypass when triggered).
+ let available = updated_plan
+ .total_amount
+ .saturating_sub(updated_plan.total_loaned);
+ if !triggered && payout > available {
+ Self::exit_guard(&env);
+ return Err(InheritanceError::InsufficientLiquidity);
+ }
+
+ if payout == 0 {
+ Self::exit_guard(&env);
+ return Err(InheritanceError::NothingToClaim);
+ }
+
+ // ---- Accumulate state mutations on the in-memory plan snapshot ----
+
+ let exit_remaining = exit_settlement.saturating_sub(payout);
+ let exit_finalized = exit_settlement == 0 || exit_remaining == 0;
+
+ let mut b = updated_plan.beneficiaries.get(index).unwrap();
+ if exit_finalized {
+ b.is_claimed = true;
+ }
+ updated_plan.beneficiaries.set(index, b);
+ updated_plan.total_amount = updated_plan.total_amount.saturating_sub(payout);
+
+ // Stash everything we need for the write pass.
+ pending_claims.push_back((
+ claim_key,
+ ClaimRecord {
+ plan_id,
+ beneficiary_index: index,
+ claimed_at: env.ledger().timestamp(),
+ },
+ index,
+ payout,
+ exit_settlement,
+ ));
+
+ // Emit per-beneficiary claim event immediately (events are not rolled
+ // back on Err in Soroban, but we only reach this point inside the
+ // validation loop — actual state writes happen in pass 5 below).
+ // We defer event emission to pass 5 to keep the semantics clean.
+ let _ = claimer; // used above for auth; suppress unused warning
+ }
+
+ // ------------------------------------------------------------------ //
+ // 5. Write all accumulated state mutations (all validations passed) //
+ // ------------------------------------------------------------------ //
+
+ // Persist the mutated plan.
+ Self::store_plan(&env, plan_id, &updated_plan);
+
+ for i in 0..pending_claims.len() {
+ let (claim_key, claim_record, index, payout, exit_settlement) =
+ pending_claims.get(i).unwrap();
+
+ // Handle vesting exit settlement bookkeeping.
+ if exit_settlement > 0 {
+ let exit_remaining = exit_settlement.saturating_sub(payout);
+ let settle_key = DataKey::VestingExitSettlement(plan_id, index);
+ if exit_remaining == 0 {
+ env.storage().persistent().remove(&settle_key);
+ } else {
+ env.storage()
+ .persistent()
+ .set(&settle_key, &exit_remaining);
+ }
+ }
+
+ // Only write claim record (and emit events) when the beneficiary is
+ // fully finalized (exit_settlement == 0 or fully consumed).
+ let exit_finalized = exit_settlement == 0
+ || exit_settlement.saturating_sub(payout) == 0;
+
+ if exit_finalized {
+ env.storage()
+ .persistent()
+ .set(&claim_key, &claim_record);
+ Self::add_plan_to_claimed(
+ &env,
+ plan.owner.clone(),
+ plan_id,
+ );
+ }
+
+ // Grant on-chain Beneficiary role to each successful claimer.
+ let claimer = claimers.get(i).unwrap();
+ access_control::assign_role(&env, &claimer, Role::Beneficiary);
+
+ // Emit per-beneficiary CLAIM SUCCESS event.
+ let b = updated_plan.beneficiaries.get(index).unwrap();
+ env.events().publish(
+ (symbol_short!("CLAIM"), symbol_short!("SUCCESS")),
+ (plan_id, b.hashed_email, payout),
+ );
+
+ // Emit fiat payout request if bank account is set.
+ if !b.bank_account.is_empty() {
+ env.events().publish(
+ (symbol_short!("F_PAYOUT"),),
+ (plan_id, index, payout, symbol_short!("BANK")),
+ );
+ }
+ }
+
+ log!(
+ &env,
+ "Batch inheritance claim for plan {} processed {} claims",
+ plan_id,
+ batch_len
+ );
+
+ Self::exit_guard(&env);
+ Ok(())
+ }
+
/// Record KYC submission on-chain (called after off-chain submission).
pub fn submit_kyc(env: Env, user: Address) -> Result<(), InheritanceError> {
user.require_auth();