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
50 changes: 50 additions & 0 deletions AUDIT_TRAIL_RUNBOOK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Audit Trail Hash Chain Runbook

## Purpose

The utility contract maintains a compact, tamper-evident audit trail for critical service operations. Each audit entry commits to the previous entry hash, operation metadata, and a privacy-preserving payload digest so off-chain monitors can detect deletion, reordering, or mutation of historical records.

## Covered operations

The initial system-wide trail appends records for the critical billing and lifecycle paths:

- `register_meter`: meter registration and service provisioning.
- `top_up`: prepaid/postpaid funding events.
- `deduct_units`: signed IoT usage deductions.

Additional services should call the same internal append helper when adding state-changing critical paths.

## Verification workflow

1. Poll `get_audit_head()` and record the returned `sequence` and `record_hash` in monitoring storage.
2. Backfill missing records with `get_audit_record(sequence)`.
3. Run `verify_audit_chain(start_sequence, end_sequence)` for every contiguous range received from the contract.
4. Alert immediately if verification returns `false`, a sequence is missing, or the observed head hash regresses.

## Monitoring and alerting

Recommended production alerts:

- **AuditChainVerificationFailed**: `verify_audit_chain` returns `false` for any range.
- **AuditSequenceGap**: monitor observes a missing sequence between the previous head and current head.
- **AuditHeadRegression**: current head sequence is less than the last finalized monitor checkpoint.
- **AuditIngestionLag**: latest indexed sequence lags on-chain head for more than five minutes.

## Deployment guidance

Deploy the audit trail with the standard blue-green contract upgrade process:

1. Deploy the upgraded WASM to the green environment.
2. Replay a canary workload that registers a meter, funds it, and posts a signed usage report.
3. Verify the green audit chain from sequence `1` through the canary head.
4. Compare P99 latency against the `<100ms` critical-path target.
5. Promote green only after chain verification and latency checks pass.

## Incident response

If a monitor detects tampering:

1. Freeze downstream settlement automation that depends on the affected sequence range.
2. Capture `get_audit_head()` and all records around the failed range.
3. Re-run verification from the last known-good sequence.
4. Escalate to security review with the failed sequence, expected previous hash, observed previous hash, and record hash.
94 changes: 94 additions & 0 deletions contracts/utility_contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,28 @@ pub struct ReadingRejected {
pub timestamp: u64,
}

/// Tamper-evident audit trail entry chained to the previous entry hash.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuditRecord {
/// Monotonic sequence number for the contract-wide audit stream.
pub sequence: u64,
/// Previous record hash, or all zeroes for the genesis record.
pub previous_hash: BytesN<32>,
/// Hash of this record's canonical payload.
pub record_hash: BytesN<32>,
/// Service operation being audited, e.g. register/topup/deduct/claim.
pub action: Symbol,
/// Primary subject identifier for the event, usually a meter or stream id.
pub subject_id: u64,
/// Account that authorized or initiated the audited operation.
pub actor: Address,
/// Ledger timestamp at the point the audit record was appended.
pub timestamp: u64,
/// Domain-specific payload digest for compact, privacy-preserving verification.
pub payload_hash: BytesN<32>,
}

/// Emitted when the deposit is slashed at 100 % utilisation.
#[contracttype]
#[derive(Clone)]
Expand Down Expand Up @@ -1123,6 +1145,9 @@ pub enum DataKey {
PendingSettlement(Address, BytesN<32>),
// Batch finalization idempotency guard
BatchFinalized(u64),
// Tamper-evident audit trail
AuditHead,
AuditRecord(u64),
}

// ============================================================================
Expand Down Expand Up @@ -5079,6 +5104,8 @@ impl UtilityContract {

env.storage().instance().set(&DataKey::Meter(count), &meter);
env.storage().instance().set(&DataKey::Count, &count);
let payload_hash = audit_payload_hash(&env, &(count, off_peak_rate, priority_index, resource_type));
append_audit_record(&env, Symbol::new(&env, "register_meter"), count, meter.user.clone(), payload_hash);
count
}

Expand Down Expand Up @@ -5173,6 +5200,9 @@ impl UtilityContract {
.instance()
.set(&DataKey::Meter(meter_id), &meter);

let payload_hash = audit_payload_hash(&env, &(amount, converted_amount, meter.balance, meter.debt));
append_audit_record(&env, Symbol::new(&env, "top_up"), meter_id, contributor, payload_hash);

// Emit conversion event
env.events().publish(
(symbol_short!("TokUp"), meter_id),
Expand Down Expand Up @@ -5514,13 +5544,77 @@ impl UtilityContract {
.instance()
.set(&DataKey::Meter(signed_data.meter_id), &meter);

let payload_hash = audit_payload_hash(&env, &(signed_data.timestamp, signed_data.units_consumed, cost));
append_audit_record(&env, Symbol::new(&env, "deduct_units"), signed_data.meter_id, meter.provider.clone(), payload_hash);

// Emit UsageReported event
env.events().publish(
(Symbol::new(&env, "UsageReported"), signed_data.meter_id),
(signed_data.units_consumed, cost),
);
}

/// Return the current audit-chain head for off-chain monitors.
pub fn get_audit_head(env: Env) -> Option<AuditRecord> {
env.storage().instance().get(&DataKey::AuditHead)
}

/// Return an audit record by sequence number for verification or dashboard indexing.
pub fn get_audit_record(env: Env, sequence: u64) -> Option<AuditRecord> {
env.storage().instance().get(&DataKey::AuditRecord(sequence))
}

/// Verify the audit hash chain between two inclusive sequence numbers.
pub fn verify_audit_chain(env: Env, start_sequence: u64, end_sequence: u64) -> bool {
if start_sequence == 0 || end_sequence < start_sequence {
return false;
}

let mut expected_previous = if start_sequence == 1 {
zero_hash(&env)
} else if let Some(previous) = env
.storage()
.instance()
.get::<DataKey, AuditRecord>(&DataKey::AuditRecord(start_sequence - 1))
{
previous.record_hash
} else {
return false;
};

let mut sequence = start_sequence;
while sequence <= end_sequence {
let Some(record) = env
.storage()
.instance()
.get::<DataKey, AuditRecord>(&DataKey::AuditRecord(sequence))
else {
return false;
};

if record.sequence != sequence || record.previous_hash != expected_previous {
return false;
}

let expected_hash = calculate_audit_record_hash(
&env,
record.sequence,
&record.previous_hash,
&record.action,
record.subject_id,
&record.actor,
record.timestamp,
&record.payload_hash,
);
if record.record_hash != expected_hash {
return false;
}
expected_previous = record.record_hash;
sequence = sequence.saturating_add(1);
}
true
}

pub fn claim(env: Env, meter_id: u64) {
let mut meter = get_meter_or_panic(&env, meter_id);
meter.provider.require_auth();
Expand Down