On-chain audit trail for critical operations: every important state change is logged with actor, timestamp, and payload. Entries are keyed by invoice, global sequence, and indexes for efficient querying. All entries may be validated for integrity to ensure completeness and authenticity.
The audit trail system provides:
- Append-only audit logs for all critical operations (invoice, bid, escrow, settlement, payment)
- Efficient querying by invoice, actor, operation type, or time range with bounded result sets
- Integrity validation to verify audit log completeness and detect missing or corrupted entries
- Audit statistics for comprehensive analysis of contract activities
Critical for post-incident forensics: An audit log that overwrites or reorders entries is worse than useless—it provides false confidence in a compromised record. This implementation makes that impossible:
- No Overwrites: Once stored under its
audit_id, an entry is never modified or replaced. Only new entries are added. - No Reordering: Invoice audit trails grow monotonically. Entries maintain chronological order by insertion time.
- One Entry Per Operation: Every state-changing contract call produces exactly one immutable audit entry.
- Query Safety: All query functions are read-only; they never delete, modify, or reorder entries.
- Storage structure: Each audit entry is keyed by a unique, monotonically-increasing
audit_id. The key for entry N cannot be overwritten; it can only be created once or read. - Index-only growth: Per-invoice, per-operation, and per-actor indices are append-only vectors. The
push_back()operation adds to the end without removing or reordering earlier items. - No internal delete: The codebase has no
remove(),pop(), orupdate()call on audit data structures—onlypush_back()and reads. - Monotonic timestamps: Within each invoice trail, timestamps are guaranteed non-decreasing (ledger timestamps only move forward).
Comprehensive tests (src/test_audit.rs) verify:
- No-overwrite: Creating, verifying, and bidding on an invoice appends entries without modifying earlier ones.
- Monotonic ordering: Each entry's timestamp ≥ the previous entry's timestamp.
- One entry per call:
store_invoiceproduces exactly 1 entry,verify_invoiceproduces exactly 1,place_bidproduces exactly 1, etc. - Stats reconciliation:
AUDIT_STATS.total_entriesmatches the count from a full audit query (respecting the 100-entry limit). - High-volume durability: 50+ rapid invoices all produce exactly 50 entries; no losses under stress.
- Persistence under load: Original entries remain unchanged after many subsequent operations.
See "Testing Coverage" section below for the full test suite.
| Entrypoint | Visibility | Description |
|---|---|---|
log_operation |
Internal | Append a single audit entry (actor, timestamp, operation, payload). Used by invoice, bid, escrow, and settlement flows. |
get_invoice_audit_trail |
Public | Return audit entry IDs for an invoice (chronological by append order). |
query_audit_logs |
Public | Query entries with filters (invoice, actor, operation type, time range) and a bounded limit (max 100). |
validate_invoice_audit_integrity |
Public | Verify that all entries for an invoice are present and pass integrity checks (timestamp, block height, operation-specific data). |
get_audit_entry |
Public | Fetch a single entry by ID. |
get_audit_stats |
Public | Return aggregate stats (total entries, unique actors, date range). |
get_audit_entries_by_operation |
Public | Return entry IDs for a given operation type. |
get_audit_entries_by_actor |
Public | Return entry IDs for a given actor. |
- Invoice:
InvoiceCreated,InvoiceUploaded,InvoiceVerified,InvoiceFunded,InvoicePaid,InvoiceDefaulted,InvoiceStatusChanged,InvoiceRated - Bid:
BidPlaced,BidAccepted,BidWithdrawn - Escrow:
EscrowCreated,EscrowReleased,EscrowRefunded - Payment:
PaymentProcessed,SettlementCompleted
- Per-entry: Stored by
audit_id(unique per append). - Per-invoice: List of
audit_ids keyed by(inv_aud, invoice_id). - Per-operation: List of
audit_ids keyed by(op_aud, operation). - Per-actor: List of
audit_ids keyed by(act_aud, actor). - Time: Entries grouped by day for time-range queries.
- Global: Single list of all
audit_ids for full scan when no filter narrows the set.
Appends are gas-efficient (one entry + index updates). Query results are bounded by the limit parameter and hard-capped to 100 entries (min(limit, 100)) to avoid unbounded reads.
validate_invoice_audit_integrity checks for each entry on the invoice trail:
- Timestamp not in the future.
- Block height not beyond current ledger sequence.
- For amount-bearing operations, amount present and positive.
- For status-change operations, old/new value present.
If any check fails or an entry is missing, the function returns false.
- Append-only guarantee: once an entry is stored under its
audit_id, the storage key is never overwritten. Index lists (per-invoice, per-operation, per-actor, global) only grow viapush_back; no removal path exists. - Mutation guard:
store_audit_entrywrites each entry exactly once. Subsequent operations append new entries with new IDs; they never update existing ones. - No public write endpoint:
log_operationand alllog_*helpers are internal; external callers cannot inject arbitrary audit records. - Bounded queries: all query results are hard-capped at
MAX_QUERY_LIMIT(100) to prevent gas exhaustion. - Query and integrity functions are read-only and do not alter state.
Query audit logs using AuditQueryFilter with any combination of:
Filter entries for a specific invoice:
AuditQueryFilter {
invoice_id: Some(invoice_id),
operation: AuditOperationFilter::Any,
actor: None,
start_timestamp: None,
end_timestamp: None,
}Filter entries for a specific operation:
AuditQueryFilter {
invoice_id: None,
operation: AuditOperationFilter::Specific(AuditOperation::BidPlaced),
actor: None,
start_timestamp: None,
end_timestamp: None,
}Filter entries by who performed the action:
AuditQueryFilter {
invoice_id: None,
operation: AuditOperationFilter::Any,
actor: Some(investor_address),
start_timestamp: None,
end_timestamp: None,
}Filter entries within a time window (timestamps in seconds):
AuditQueryFilter {
invoice_id: None,
operation: AuditOperationFilter::Any,
actor: None,
start_timestamp: Some(start_ts),
end_timestamp: Some(end_ts),
}Combine multiple filters for precise queries:
AuditQueryFilter {
invoice_id: Some(invoice_id),
operation: AuditOperationFilter::Specific(AuditOperation::PaymentProcessed),
actor: Some(admin),
start_timestamp: Some(start_ts),
end_timestamp: Some(end_ts),
}Note: Query results are capped at 100 entries maximum to prevent unbounded reads and gas exhaustion.
validate_invoice_audit_integrity(env, invoice_id) performs comprehensive validation:
Per-Entry Checks:
- Timestamp Validity: Ensures timestamp is not in the future compared to current ledger timestamp
- Block Height Validity: Ensures block height does not exceed current ledger sequence
- Operation-Specific Data:
- For
InvoiceFundedandPaymentProcessed: Amount must be present and > 0 - For
InvoiceStatusChanged: Old and new values must both be present
- For
Trail Completeness:
- Verifies all audit IDs in the invoice trail can be retrieved
- Returns
falseif any audit entry is missing from storage - Returns
falseif any validation check fails - Returns
trueonly if all entries are present and pass all checks
Use Case: Verify audit completeness before settlement or dispute resolution to ensure no operations were lost or corrupted.
get_audit_stats() provides aggregate information:
- total_entries: Total number of audit log entries in the contract
- unique_actors: Count of distinct addresses that performed operations
- date_range: Tuple of (min_timestamp, max_timestamp) for all entries
- min_timestamp = u64::MAX if no entries exist
- max_timestamp = 0 if no entries exist
Use Cases:
- Auditing contract activity levels
- Understanding participation scope
- Determining audit log time windows
Audit IDs are deterministically generated using:
- Audit prefix bytes (0xAD, 0x1F)
- Current ledger timestamp (8 bytes)
- Current ledger sequence (4 bytes)
- Global counter (8 bytes)
- Hash-based pattern fill (8 bytes)
This ensures unique, non-colliding IDs while preserving chronological information.
- Index-based filtering: Per-invoice, per-operation, and per-actor indexes enable efficient filtered queries
- Timestamp grouping: Daily grouping reduces index size for time-range queries
- Gas efficiency: Appending a single entry requires minimal storage operations
- Query limit: Hard-capped at 100 results to prevent gas exhaustion on large result sets
If no audit entries exist for an invoice ID:
get_invoice_audit_trail()returns empty vectorvalidate_invoice_audit_integrity()returnstrue(empty trail is valid)- This allows querying non-existent invoices safely without errors
The audit trail implementation includes 30+ comprehensive tests covering:
- Basic operations: Creating, storing, and retrieving audit entries
- Query filters: Single and combined filter scenarios
- Time-range queries: Past, present, and future timestamp ranges
- Integrity validation: Valid and invalid entry detection
- Edge cases: Empty trails, missing entries, future timestamps, invalid amounts
- Batch operations: Multiple invoices, actors, and operations
- Statistics: Entry counts, actor uniqueness, date ranges
- Query limits: Enforcement of 100-entry maximum
Target coverage: ≥95% of audit module code paths
- Always validate integrity before using audit data for critical decisions
- Use combined filters when possible to reduce query result sizes
- Check audit stats periodically to detect anomalies
- Monitor unique actors to identify unexpected participants
- Archive old entries in external systems if on-chain storage becomes a concern
- Verify timestamps when audit logs span multiple blocks or transactions