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
186 changes: 177 additions & 9 deletions stellar-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ pub enum GroomingKey {
use soroban_sdk::xdr::{FromXdr, ToXdr};
use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Bytes, BytesN,
Env, Map, String, Symbol, Vec,
Env, IntoVal, Map, String, Symbol, Val, Vec,
};

// Bounded-module split (Issue #1146, phase 1): storage keys and value
Expand Down Expand Up @@ -203,7 +203,13 @@ mod test_breeding_genetics;
#[cfg(test)]
mod test_pet_birthday_validation;
#[cfg(test)]
mod test_microchip_normalization;
mod test_persistent_ttl_policy;
#[cfg(test)]
mod test_access_grant_index_invariants;
#[cfg(test)]
mod test_medical_record_hashing;
#[cfg(test)]
mod test_medical_event_timestamps;
#[cfg(test)]
mod test_verify_claim_document;
#[cfg(test)]
Expand Down Expand Up @@ -370,6 +376,40 @@ const MAX_LAB_RESULTS_LEN: u32 = 1_000;
/// Maximum byte length of a `LabResult::reference_ranges`.
const MAX_LAB_REF_RANGES_LEN: u32 = 500;

/// TTL-extension policy for persistent storage entries (Issue #1154).
///
/// Persistent entries (audit/access logs, breeding records, ...) are billed
/// separately from instance storage and, unlike instance storage, are not
/// automatically kept alive by every contract invocation: each entry's TTL
/// must be extended explicitly or it can be archived/expire out from under
/// the contract. `PERSISTENT_TTL_THRESHOLD` is the minimum remaining TTL (in
/// ledgers) below which we proactively bump it back up to
/// `PERSISTENT_TTL_EXTEND_TO` on every write (and on reads of
/// long-lived/critical records) so records that are written once and read
/// rarely still survive.
///
/// At Stellar's ~5s ledger close time, `PERSISTENT_TTL_EXTEND_TO` of
/// ~1,036,800 ledgers is roughly 60 days; `PERSISTENT_TTL_THRESHOLD` bumps
/// as soon as the entry has less than ~30 days of life left, well within the
/// network's max TTL extension window.
const PERSISTENT_TTL_THRESHOLD: u32 = 518_400; // ~30 days
const PERSISTENT_TTL_EXTEND_TO: u32 = 1_036_800; // ~60 days

/// Maximum allowed clock skew (seconds) for a medical-event timestamp that is
/// reported as having already occurred (e.g. `administered_at`), measured
/// relative to the current ledger time. This is deliberately generous (on
/// the order of decades) so it only rejects clearly nonsensical/corrupt
/// future dates (e.g. a caller passing a millisecond timestamp, or a typo
/// adding extra digits) without constraining legitimate historical or
/// synthetic test timestamps, which need not track real-world wall-clock
/// time. (Issue #1174)
const MAX_EVENT_FUTURE_SKEW: u64 = 100 * 365 * 24 * 60 * 60; // ~100 years

/// Furthest a vaccination's `next_due_date` / `expires_at` may be scheduled
/// past `administered_at`, to catch fat-fingered far-future dates while
/// still allowing multi-year vaccination schedules. (Issue #1174)
const MAX_EVENT_HORIZON: u64 = 50 * 365 * 24 * 60 * 60; // ~50 years

/// Maximum byte length of a `Dispute::reason`.
const MAX_DISPUTE_REASON_LEN: u32 = 500;

Expand Down Expand Up @@ -523,6 +563,11 @@ pub enum ContractError {
NotDisputeStakeholder = 166,
NotInEvidencePhase = 167,
NotDisputeParty = 168,

/// A medical-event timestamp fell outside the allowed domain relative to
/// ledger time (too far in the past, too far in the future, or with a
/// due/expiry date before the event it describes). (Issue #1174)
InvalidTimestamp = 169,
}

// --- MULTI-LANGUAGE ERROR REGISTRY (Issue #684) ---
Expand Down Expand Up @@ -3811,6 +3856,21 @@ impl PetChainContract {
result
}

/// Extend the TTL of a persistent-storage entry per the archival policy
/// defined by `PERSISTENT_TTL_THRESHOLD` / `PERSISTENT_TTL_EXTEND_TO`.
/// (Issue #1154). Call this after every `persistent().set(...)` (and on
/// reads of records that must remain reachable even when written once
/// and read rarely) so critical persistent records are not silently
/// archived/expired by the ledger.
fn bump_persistent_ttl<K>(env: &Env, key: &K)
where
K: IntoVal<Env, Val>,
{
env.storage()
.persistent()
.extend_ttl(key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
}

fn log_access(env: &Env, pet_id: u64, user: Address, action: AccessAction, details: String) {
let key = (Symbol::new(env, "access_logs"), pet_id);
let mut logs: Vec<AccessLog> = env
Expand Down Expand Up @@ -3839,6 +3899,7 @@ impl PetChainContract {

logs.push_back(log);
env.storage().persistent().set(&key, &logs);
Self::bump_persistent_ttl(env, &key);
}

/// Read access log entries for a pet. Visible to the pet owner or any admin.
Expand All @@ -3859,10 +3920,13 @@ impl PetChainContract {
}

let key = (Symbol::new(&env, "access_logs"), pet_id);
env.storage()
let logs = env
.storage()
.persistent()
.get(&key)
.unwrap_or(Vec::new(&env))
.unwrap_or(Vec::new(&env));
Self::bump_persistent_ttl(&env, &key);
logs
}

fn require_admin(env: &Env) {
Expand Down Expand Up @@ -7153,6 +7217,25 @@ impl PetChainContract {
.get(&DataKey::Pet(pet_id))
.unwrap_or_else(|| env.panic_with_error(ContractError::PetNotFound));

let now = env.ledger().timestamp();

// Validate medical-event timestamps against ledger time (Issue #1174).
// `administered_at` must not be further in the future than the
// allowed clock-skew tolerance relative to the current ledger time.
if administered_at > now.saturating_add(MAX_EVENT_FUTURE_SKEW) {
panic_with_error!(&env, ContractError::InvalidTimestamp);
}
// `next_due_date` and `expires_at` (when set) describe follow-up
// dates and must not precede the event they follow, nor sit
// absurdly far beyond it.
let max_future = administered_at.saturating_add(MAX_EVENT_HORIZON);
if next_due_date != 0 && (next_due_date < administered_at || next_due_date > max_future) {
panic_with_error!(&env, ContractError::InvalidTimestamp);
}
if expires_at != 0 && (expires_at < administered_at || expires_at > max_future) {
panic_with_error!(&env, ContractError::InvalidTimestamp);
}

// Check storage quota (Issue #676)
Self::increment_pet_storage(&env, pet_id);

Expand All @@ -7164,7 +7247,6 @@ impl PetChainContract {
let vaccine_id = vaccine_count
.checked_add(1)
.unwrap_or_else(|| panic_with_error!(&env, ContractError::CounterOverflow));
let now = env.ledger().timestamp();
let key = PetChainContract::get_encryption_key(&env);

let vname_bytes = vaccine_name.to_xdr(&env);
Expand Down Expand Up @@ -10266,6 +10348,7 @@ impl PetChainContract {
}
logs.push_back(log);
env.storage().persistent().set(&log_key, &logs);
Self::bump_persistent_ttl(&env, &log_key);

Self::write_emergency_audit(&env, pet_id, caller, reason_code);

Expand Down Expand Up @@ -10298,6 +10381,7 @@ impl PetChainContract {
pet_id,
});
env.storage().persistent().set(&audit_key, &entries);
Self::bump_persistent_ttl(env, &audit_key);
}

fn is_admin_address(env: &Env, caller: &Address) -> bool {
Expand Down Expand Up @@ -12148,6 +12232,7 @@ impl PetChainContract {
env.storage()
.persistent()
.set(&ActivityKey::PetActivityStreak(pet_id), &streak);
Self::bump_persistent_ttl(&env, &ActivityKey::PetActivityStreak(pet_id));

activity_id
}
Expand Down Expand Up @@ -12245,9 +12330,11 @@ impl PetChainContract {
env.storage()
.persistent()
.set(&BreedingKey::BreedingRecord(id), &record);
Self::bump_persistent_ttl(&env, &BreedingKey::BreedingRecord(id));
env.storage()
.persistent()
.set(&BreedingKey::BreedingRecordCount, &id);
Self::bump_persistent_ttl(&env, &BreedingKey::BreedingRecordCount);

Self::inc_pet_breeding_count(&env, sire_id);
Self::inc_pet_breeding_count(&env, dam_id);
Expand All @@ -12264,6 +12351,7 @@ impl PetChainContract {
env.storage()
.persistent()
.set(&BreedingKey::PetBreedingCount(pet_id), &safe_increment(count));
Self::bump_persistent_ttl(env, &BreedingKey::PetBreedingCount(pet_id));
}

pub fn add_offspring(env: Env, record_id: u64, offspring_id: u64) -> bool {
Expand All @@ -12288,15 +12376,17 @@ impl PetChainContract {
}

// Store parent pair for pedigree queries (COI, lineage)
env.storage().persistent().set(
&BreedingKey::ParentPair(offspring_id),
&(record.sire_id, record.dam_id),
);
let parent_pair_key = BreedingKey::ParentPair(offspring_id);
env.storage()
.persistent()
.set(&parent_pair_key, &(record.sire_id, record.dam_id));
Self::bump_persistent_ttl(&env, &parent_pair_key);

record.offspring_count = record.offspring_count.saturating_add(1);
env.storage()
.persistent()
.set(&BreedingKey::BreedingRecord(record_id), &record);
Self::bump_persistent_ttl(&env, &BreedingKey::BreedingRecord(record_id));

let count = env
.storage()
Expand All @@ -12306,6 +12396,7 @@ impl PetChainContract {
env.storage()
.persistent()
.set(&BreedingKey::PetOffspringCount(offspring_id), &(count + 1));
Self::bump_persistent_ttl(&env, &BreedingKey::PetOffspringCount(offspring_id));

true
}
Expand Down Expand Up @@ -12831,6 +12922,83 @@ impl PetChainContract {
res.deleted.len()
}

/// Build the canonical, versioned preimage bytes for a [`MedicalRecord`]
/// (Issue #1169).
///
/// Off-chain clients (in any language with a Stellar/Soroban XDR codec)
/// need to be able to reproduce the exact same commitment a contract
/// computes for a medical record, independent of storage/audit
/// metadata that can change without the clinical facts changing. The
/// canonical encoding is:
///
/// ```text
/// sha256(
/// b"petchain:medical-record:v1" (26-byte literal domain tag)
/// || pet_id as 8-byte big-endian u64
/// || vet_address as its XDR-encoded `ScAddress`
/// || diagnosis as its XDR-encoded `ScString`
/// || treatment as its XDR-encoded `ScString`
/// || medications as its XDR-encoded `ScVec` (fixed struct field order)
/// || notes as its XDR-encoded `ScString`
/// || date as 8-byte big-endian u64 (clinical event time)
/// )
/// ```
///
/// Fields are concatenated in this fixed order with no separators
/// (XDR-encoded values are already self-delimiting/length-prefixed, and
/// the two `u64` fields have a fixed 8-byte width, so the encoding is
/// unambiguous). `id`, `updated_at`, `attachment_hashes`, and
/// `deleted_at` are intentionally excluded: they are ledger
/// bookkeeping/audit metadata, not clinical content, so the commitment
/// stays stable across non-clinical housekeeping mutations (e.g. an
/// attachment being added, or a soft-delete).
///
/// The `v1` domain tag is part of the preimage precisely so that any
/// future change to the field set, order, or encoding can ship as a
/// `v2` tag without silently colliding with existing `v1` commitments
/// clients may have already anchored off-chain.
fn canonical_medical_record_preimage(env: &Env, record: &MedicalRecord) -> Bytes {
let mut preimage = Bytes::new(env);
for byte in b"petchain:medical-record:v1" {
preimage.push_back(*byte);
}
for byte in record.pet_id.to_be_bytes() {
preimage.push_back(byte);
}
for byte in record.vet_address.to_xdr(env).iter() {
preimage.push_back(byte);
}
for byte in record.diagnosis.to_xdr(env).iter() {
preimage.push_back(byte);
}
for byte in record.treatment.to_xdr(env).iter() {
preimage.push_back(byte);
}
for byte in record.medications.to_xdr(env).iter() {
preimage.push_back(byte);
}
for byte in record.notes.to_xdr(env).iter() {
preimage.push_back(byte);
}
for byte in record.date.to_be_bytes() {
preimage.push_back(byte);
}
preimage
}

/// Compute the canonical hash commitment for a stored medical record.
/// See [`Self::canonical_medical_record_preimage`] for the exact
/// versioned encoding. (Issue #1169)
pub fn get_medical_record_hash(env: Env, record_id: u64) -> BytesN<32> {
let record: MedicalRecord = env
.storage()
.instance()
.get(&MedicalKey::MedicalRecord(record_id))
.unwrap_or_else(|| panic_with_error!(&env, ContractError::RecordNotFound));
let preimage = Self::canonical_medical_record_preimage(&env, &record);
env.crypto().sha256(&preimage).into()
}

pub fn add_medical_record(
env: Env,
pet_id: u64,
Expand Down
Loading