Skip to content

Commit f847302

Browse files
stellar-contracts: TTL policy, access-grant index invariants, canonical medical-record hashing, event timestamp validation (#1270)
* Add TTL extension policy for persistent storage Persistent-storage entries (access/emergency/audit logs, activity streaks, breeding records and their indexes) were never having their TTL extended, so they could be archived/expire out from under the contract even while still logically active. Add a bump_persistent_ttl helper backed by PERSISTENT_TTL_THRESHOLD/PERSISTENT_TTL_EXTEND_TO policy constants and call it after every persistent().set() (and on reads of the access-log record) in the affected modules, plus tests asserting a freshly written persistent entry's TTL is actually extended to the policy's target. Refs #1154 * Add invariant tests for access-grant indexes Grant, revoke, and cleanup (compact_storage) maintain three related keys per pet: AccessGrant, AccessGrantCount, and AccessGrantIndex. Add tests proving the index stays unique/reachable and consistent with the count across multiple grants and a re-grant, that a revoked or expired grant can never authorize access via check_access even before compaction runs, and that compact_storage fully removes stale grant records and index slots while leaving the remaining index contiguous and active grantees authorized. Refs #1158 * Define canonical medical-record hashing Add PetChainContract::get_medical_record_hash / the underlying canonical_medical_record_preimage encoder, giving off-chain clients a deterministic, versioned commitment ("petchain:medical-record:v1") over a record's clinical fields (pet_id, vet_address, diagnosis, treatment, medications, notes, date) using the fixed field order and XDR encoding documented on the function. Storage/audit metadata (id, updated_at, attachment_hashes, deleted_at) is intentionally excluded so the commitment is stable across non-clinical mutations like a soft-delete. Tests cover determinism, sensitivity to clinical content changes, and stability across delete_medical_record. Refs #1169 * Validate medical event timestamps against ledger time add_vaccination previously accepted an arbitrary caller-supplied administered_at/next_due_date/expires_at with no validation, letting a nonsensical or maliciously far-future timestamp corrupt reminder scheduling and medical history ordering. Reject administered_at that is further in the future than MAX_EVENT_FUTURE_SKEW relative to ledger time, and reject a next_due_date/expires_at (when set) that precedes administered_at or sits beyond MAX_EVENT_HORIZON past it, via the new InvalidTimestamp error. The skew/horizon windows are deliberately generous (decades) so legitimate historical records and existing synthetic test timestamps are unaffected; only clearly corrupt/out-of-domain values are rejected. Tests cover the accepted boundary, the first rejected value past it, and the ordering checks. Refs #1174 --------- Co-authored-by: llins <llinsomoudu@gmail.com>
1 parent 4adaa05 commit f847302

5 files changed

Lines changed: 793 additions & 9 deletions

File tree

stellar-contracts/src/lib.rs

Lines changed: 177 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ pub enum GroomingKey {
166166
use soroban_sdk::xdr::{FromXdr, ToXdr};
167167
use soroban_sdk::{
168168
contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Bytes, BytesN,
169-
Env, Map, String, Symbol, Vec,
169+
Env, IntoVal, Map, String, Symbol, Val, Vec,
170170
};
171171

172172
// Bounded-module split (Issue #1146, phase 1): storage keys and value
@@ -203,7 +203,13 @@ mod test_breeding_genetics;
203203
#[cfg(test)]
204204
mod test_pet_birthday_validation;
205205
#[cfg(test)]
206-
mod test_microchip_normalization;
206+
mod test_persistent_ttl_policy;
207+
#[cfg(test)]
208+
mod test_access_grant_index_invariants;
209+
#[cfg(test)]
210+
mod test_medical_record_hashing;
211+
#[cfg(test)]
212+
mod test_medical_event_timestamps;
207213
#[cfg(test)]
208214
mod test_verify_claim_document;
209215
#[cfg(test)]
@@ -370,6 +376,40 @@ const MAX_LAB_RESULTS_LEN: u32 = 1_000;
370376
/// Maximum byte length of a `LabResult::reference_ranges`.
371377
const MAX_LAB_REF_RANGES_LEN: u32 = 500;
372378

379+
/// TTL-extension policy for persistent storage entries (Issue #1154).
380+
///
381+
/// Persistent entries (audit/access logs, breeding records, ...) are billed
382+
/// separately from instance storage and, unlike instance storage, are not
383+
/// automatically kept alive by every contract invocation: each entry's TTL
384+
/// must be extended explicitly or it can be archived/expire out from under
385+
/// the contract. `PERSISTENT_TTL_THRESHOLD` is the minimum remaining TTL (in
386+
/// ledgers) below which we proactively bump it back up to
387+
/// `PERSISTENT_TTL_EXTEND_TO` on every write (and on reads of
388+
/// long-lived/critical records) so records that are written once and read
389+
/// rarely still survive.
390+
///
391+
/// At Stellar's ~5s ledger close time, `PERSISTENT_TTL_EXTEND_TO` of
392+
/// ~1,036,800 ledgers is roughly 60 days; `PERSISTENT_TTL_THRESHOLD` bumps
393+
/// as soon as the entry has less than ~30 days of life left, well within the
394+
/// network's max TTL extension window.
395+
const PERSISTENT_TTL_THRESHOLD: u32 = 518_400; // ~30 days
396+
const PERSISTENT_TTL_EXTEND_TO: u32 = 1_036_800; // ~60 days
397+
398+
/// Maximum allowed clock skew (seconds) for a medical-event timestamp that is
399+
/// reported as having already occurred (e.g. `administered_at`), measured
400+
/// relative to the current ledger time. This is deliberately generous (on
401+
/// the order of decades) so it only rejects clearly nonsensical/corrupt
402+
/// future dates (e.g. a caller passing a millisecond timestamp, or a typo
403+
/// adding extra digits) without constraining legitimate historical or
404+
/// synthetic test timestamps, which need not track real-world wall-clock
405+
/// time. (Issue #1174)
406+
const MAX_EVENT_FUTURE_SKEW: u64 = 100 * 365 * 24 * 60 * 60; // ~100 years
407+
408+
/// Furthest a vaccination's `next_due_date` / `expires_at` may be scheduled
409+
/// past `administered_at`, to catch fat-fingered far-future dates while
410+
/// still allowing multi-year vaccination schedules. (Issue #1174)
411+
const MAX_EVENT_HORIZON: u64 = 50 * 365 * 24 * 60 * 60; // ~50 years
412+
373413
/// Maximum byte length of a `Dispute::reason`.
374414
const MAX_DISPUTE_REASON_LEN: u32 = 500;
375415

@@ -523,6 +563,11 @@ pub enum ContractError {
523563
NotDisputeStakeholder = 166,
524564
NotInEvidencePhase = 167,
525565
NotDisputeParty = 168,
566+
567+
/// A medical-event timestamp fell outside the allowed domain relative to
568+
/// ledger time (too far in the past, too far in the future, or with a
569+
/// due/expiry date before the event it describes). (Issue #1174)
570+
InvalidTimestamp = 169,
526571
}
527572

528573
// --- MULTI-LANGUAGE ERROR REGISTRY (Issue #684) ---
@@ -3811,6 +3856,21 @@ impl PetChainContract {
38113856
result
38123857
}
38133858

3859+
/// Extend the TTL of a persistent-storage entry per the archival policy
3860+
/// defined by `PERSISTENT_TTL_THRESHOLD` / `PERSISTENT_TTL_EXTEND_TO`.
3861+
/// (Issue #1154). Call this after every `persistent().set(...)` (and on
3862+
/// reads of records that must remain reachable even when written once
3863+
/// and read rarely) so critical persistent records are not silently
3864+
/// archived/expired by the ledger.
3865+
fn bump_persistent_ttl<K>(env: &Env, key: &K)
3866+
where
3867+
K: IntoVal<Env, Val>,
3868+
{
3869+
env.storage()
3870+
.persistent()
3871+
.extend_ttl(key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
3872+
}
3873+
38143874
fn log_access(env: &Env, pet_id: u64, user: Address, action: AccessAction, details: String) {
38153875
let key = (Symbol::new(env, "access_logs"), pet_id);
38163876
let mut logs: Vec<AccessLog> = env
@@ -3839,6 +3899,7 @@ impl PetChainContract {
38393899

38403900
logs.push_back(log);
38413901
env.storage().persistent().set(&key, &logs);
3902+
Self::bump_persistent_ttl(env, &key);
38423903
}
38433904

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

38613922
let key = (Symbol::new(&env, "access_logs"), pet_id);
3862-
env.storage()
3923+
let logs = env
3924+
.storage()
38633925
.persistent()
38643926
.get(&key)
3865-
.unwrap_or(Vec::new(&env))
3927+
.unwrap_or(Vec::new(&env));
3928+
Self::bump_persistent_ttl(&env, &key);
3929+
logs
38663930
}
38673931

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

7220+
let now = env.ledger().timestamp();
7221+
7222+
// Validate medical-event timestamps against ledger time (Issue #1174).
7223+
// `administered_at` must not be further in the future than the
7224+
// allowed clock-skew tolerance relative to the current ledger time.
7225+
if administered_at > now.saturating_add(MAX_EVENT_FUTURE_SKEW) {
7226+
panic_with_error!(&env, ContractError::InvalidTimestamp);
7227+
}
7228+
// `next_due_date` and `expires_at` (when set) describe follow-up
7229+
// dates and must not precede the event they follow, nor sit
7230+
// absurdly far beyond it.
7231+
let max_future = administered_at.saturating_add(MAX_EVENT_HORIZON);
7232+
if next_due_date != 0 && (next_due_date < administered_at || next_due_date > max_future) {
7233+
panic_with_error!(&env, ContractError::InvalidTimestamp);
7234+
}
7235+
if expires_at != 0 && (expires_at < administered_at || expires_at > max_future) {
7236+
panic_with_error!(&env, ContractError::InvalidTimestamp);
7237+
}
7238+
71567239
// Check storage quota (Issue #676)
71577240
Self::increment_pet_storage(&env, pet_id);
71587241

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

71707252
let vname_bytes = vaccine_name.to_xdr(&env);
@@ -10266,6 +10348,7 @@ impl PetChainContract {
1026610348
}
1026710349
logs.push_back(log);
1026810350
env.storage().persistent().set(&log_key, &logs);
10351+
Self::bump_persistent_ttl(&env, &log_key);
1026910352

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

@@ -10298,6 +10381,7 @@ impl PetChainContract {
1029810381
pet_id,
1029910382
});
1030010383
env.storage().persistent().set(&audit_key, &entries);
10384+
Self::bump_persistent_ttl(env, &audit_key);
1030110385
}
1030210386

1030310387
fn is_admin_address(env: &Env, caller: &Address) -> bool {
@@ -12148,6 +12232,7 @@ impl PetChainContract {
1214812232
env.storage()
1214912233
.persistent()
1215012234
.set(&ActivityKey::PetActivityStreak(pet_id), &streak);
12235+
Self::bump_persistent_ttl(&env, &ActivityKey::PetActivityStreak(pet_id));
1215112236

1215212237
activity_id
1215312238
}
@@ -12245,9 +12330,11 @@ impl PetChainContract {
1224512330
env.storage()
1224612331
.persistent()
1224712332
.set(&BreedingKey::BreedingRecord(id), &record);
12333+
Self::bump_persistent_ttl(&env, &BreedingKey::BreedingRecord(id));
1224812334
env.storage()
1224912335
.persistent()
1225012336
.set(&BreedingKey::BreedingRecordCount, &id);
12337+
Self::bump_persistent_ttl(&env, &BreedingKey::BreedingRecordCount);
1225112338

1225212339
Self::inc_pet_breeding_count(&env, sire_id);
1225312340
Self::inc_pet_breeding_count(&env, dam_id);
@@ -12264,6 +12351,7 @@ impl PetChainContract {
1226412351
env.storage()
1226512352
.persistent()
1226612353
.set(&BreedingKey::PetBreedingCount(pet_id), &safe_increment(count));
12354+
Self::bump_persistent_ttl(env, &BreedingKey::PetBreedingCount(pet_id));
1226712355
}
1226812356

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

1229012378
// Store parent pair for pedigree queries (COI, lineage)
12291-
env.storage().persistent().set(
12292-
&BreedingKey::ParentPair(offspring_id),
12293-
&(record.sire_id, record.dam_id),
12294-
);
12379+
let parent_pair_key = BreedingKey::ParentPair(offspring_id);
12380+
env.storage()
12381+
.persistent()
12382+
.set(&parent_pair_key, &(record.sire_id, record.dam_id));
12383+
Self::bump_persistent_ttl(&env, &parent_pair_key);
1229512384

1229612385
record.offspring_count = record.offspring_count.saturating_add(1);
1229712386
env.storage()
1229812387
.persistent()
1229912388
.set(&BreedingKey::BreedingRecord(record_id), &record);
12389+
Self::bump_persistent_ttl(&env, &BreedingKey::BreedingRecord(record_id));
1230012390

1230112391
let count = env
1230212392
.storage()
@@ -12306,6 +12396,7 @@ impl PetChainContract {
1230612396
env.storage()
1230712397
.persistent()
1230812398
.set(&BreedingKey::PetOffspringCount(offspring_id), &(count + 1));
12399+
Self::bump_persistent_ttl(&env, &BreedingKey::PetOffspringCount(offspring_id));
1230912400

1231012401
true
1231112402
}
@@ -12831,6 +12922,83 @@ impl PetChainContract {
1283112922
res.deleted.len()
1283212923
}
1283312924

12925+
/// Build the canonical, versioned preimage bytes for a [`MedicalRecord`]
12926+
/// (Issue #1169).
12927+
///
12928+
/// Off-chain clients (in any language with a Stellar/Soroban XDR codec)
12929+
/// need to be able to reproduce the exact same commitment a contract
12930+
/// computes for a medical record, independent of storage/audit
12931+
/// metadata that can change without the clinical facts changing. The
12932+
/// canonical encoding is:
12933+
///
12934+
/// ```text
12935+
/// sha256(
12936+
/// b"petchain:medical-record:v1" (26-byte literal domain tag)
12937+
/// || pet_id as 8-byte big-endian u64
12938+
/// || vet_address as its XDR-encoded `ScAddress`
12939+
/// || diagnosis as its XDR-encoded `ScString`
12940+
/// || treatment as its XDR-encoded `ScString`
12941+
/// || medications as its XDR-encoded `ScVec` (fixed struct field order)
12942+
/// || notes as its XDR-encoded `ScString`
12943+
/// || date as 8-byte big-endian u64 (clinical event time)
12944+
/// )
12945+
/// ```
12946+
///
12947+
/// Fields are concatenated in this fixed order with no separators
12948+
/// (XDR-encoded values are already self-delimiting/length-prefixed, and
12949+
/// the two `u64` fields have a fixed 8-byte width, so the encoding is
12950+
/// unambiguous). `id`, `updated_at`, `attachment_hashes`, and
12951+
/// `deleted_at` are intentionally excluded: they are ledger
12952+
/// bookkeeping/audit metadata, not clinical content, so the commitment
12953+
/// stays stable across non-clinical housekeeping mutations (e.g. an
12954+
/// attachment being added, or a soft-delete).
12955+
///
12956+
/// The `v1` domain tag is part of the preimage precisely so that any
12957+
/// future change to the field set, order, or encoding can ship as a
12958+
/// `v2` tag without silently colliding with existing `v1` commitments
12959+
/// clients may have already anchored off-chain.
12960+
fn canonical_medical_record_preimage(env: &Env, record: &MedicalRecord) -> Bytes {
12961+
let mut preimage = Bytes::new(env);
12962+
for byte in b"petchain:medical-record:v1" {
12963+
preimage.push_back(*byte);
12964+
}
12965+
for byte in record.pet_id.to_be_bytes() {
12966+
preimage.push_back(byte);
12967+
}
12968+
for byte in record.vet_address.to_xdr(env).iter() {
12969+
preimage.push_back(byte);
12970+
}
12971+
for byte in record.diagnosis.to_xdr(env).iter() {
12972+
preimage.push_back(byte);
12973+
}
12974+
for byte in record.treatment.to_xdr(env).iter() {
12975+
preimage.push_back(byte);
12976+
}
12977+
for byte in record.medications.to_xdr(env).iter() {
12978+
preimage.push_back(byte);
12979+
}
12980+
for byte in record.notes.to_xdr(env).iter() {
12981+
preimage.push_back(byte);
12982+
}
12983+
for byte in record.date.to_be_bytes() {
12984+
preimage.push_back(byte);
12985+
}
12986+
preimage
12987+
}
12988+
12989+
/// Compute the canonical hash commitment for a stored medical record.
12990+
/// See [`Self::canonical_medical_record_preimage`] for the exact
12991+
/// versioned encoding. (Issue #1169)
12992+
pub fn get_medical_record_hash(env: Env, record_id: u64) -> BytesN<32> {
12993+
let record: MedicalRecord = env
12994+
.storage()
12995+
.instance()
12996+
.get(&MedicalKey::MedicalRecord(record_id))
12997+
.unwrap_or_else(|| panic_with_error!(&env, ContractError::RecordNotFound));
12998+
let preimage = Self::canonical_medical_record_preimage(&env, &record);
12999+
env.crypto().sha256(&preimage).into()
13000+
}
13001+
1283413002
pub fn add_medical_record(
1283513003
env: Env,
1283613004
pet_id: u64,

0 commit comments

Comments
 (0)