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
12 changes: 8 additions & 4 deletions docs/adr/ADR-003-immutable-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ The `revoked` flag is checked by `get_attestation_status`, `has_valid_claim`,
and all related query functions, so revoked attestations are treated as
invalid for all practical purposes while remaining queryable for audit.

Revocation also prunes the attestation ID from the subject and issuer
indexes used for pagination and listing.

Implementation: [`src/lib.rs`](../../src/lib.rs) — `revoke_attestation`,
`revoke_attestations_batch`. [`src/types.rs`](../../src/types.rs) —
`Attestation::get_status`.
Expand All @@ -51,14 +54,15 @@ Implementation: [`src/lib.rs`](../../src/lib.rs) — `revoke_attestation`,
corresponding storage entry.
- Revocation is itself auditable — the `["revoked", issuer]` event records
who revoked and when.
- Off-chain indexers can reconstruct complete issuer history without gaps.
- Off-chain indexers can reconstruct complete issuer history using events /
audit log entries, even though pagination indexes prune revoked IDs.

**Negative**
- Storage is never reclaimed for revoked attestations. High-volume issuers
accumulate entries indefinitely (subject to TTL expiry).
- The `SubjectAttestations` and `IssuerAttestations` index vectors grow
monotonically. Pagination (`get_subject_attestations` with `start`/`limit`)
mitigates this for queries, but the underlying `Vec` still grows.
- The attestation record remains in storage, but revoked attestation IDs are
removed from the subject/issuer index vectors, so pagination counts reflect
only non-revoked IDs.
- There is no on-chain mechanism for a subject to request erasure (e.g. GDPR
right-to-erasure). Any such requirement must be handled off-chain or via a
wrapper contract.
Expand Down
8 changes: 5 additions & 3 deletions docs/storage-layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,9 +431,11 @@ const adminKey = xdr.LedgerKey.contractData(
- **TTL eviction.** A key that is not touched for 30 days will be evicted.
Indexers should snapshot state proactively rather than relying on keys always
being present.
- **Subject and issuer indexes are append-only.** `SubjectAttestations` and
`IssuerAttestations` grow monotonically; they are never pruned even when
attestations are revoked.
- **Subject and issuer indexes are maintained for pagination.** `SubjectAttestations`
and `IssuerAttestations` store ordered attestation IDs used by listing queries.
When an attestation is revoked, its ID is removed from both indexes so
pagination counts shrink; the attestation record itself remains in storage
(with `revoked = true`) until TTL eviction.
- **`ClaimTypeList` is insertion-ordered.** The order reflects the sequence in
which `register_claim_type` was first called for each type.
- **Status is computed, not stored.** `AttestationStatus` (`Valid`, `Expired`,
Expand Down
9 changes: 2 additions & 7 deletions src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,8 @@ impl Events {
.publish((symbol_short!("unpaused"),), (admin.clone(), timestamp));
}

/// Emitted when a subject requests deletion of their attestation.
pub fn deletion_requested(
env: &Env,
subject: &Address,
attestation_id: &String,
timestamp: u64,
) {
/// Emitted when a subject requests 94 of their attestation.
pub fn deletion_requested(env: &Env, subject: &Address, attestation_id: &String, timestamp: u64) {
env.events().publish(
(symbol_short!("del_req"), subject.clone()),
(attestation_id.clone(), timestamp),
Expand Down
15 changes: 13 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,6 @@ impl TrustLinkContract {
) -> Result<(), Error> {
issuer.require_auth();
Validation::require_not_paused(&env)?;
Validation::require_issuer(&env, &issuer)?;
validate_reason(&reason)?;
let mut attestation = Storage::get_attestation(&env, &attestation_id)?;

Expand All @@ -848,6 +847,13 @@ impl TrustLinkContract {
attestation.revoked = true;
attestation.revocation_reason = reason.clone();
Storage::set_attestation(&env, &attestation);

// Prune revoked attestation ID from both indexes so pagination reflects
// only non-revoked entries, while preserving immutable attestation
// history in storage.
Storage::remove_subject_attestation(&env, &attestation.subject, &attestation_id);
Storage::remove_issuer_attestation(&env, &issuer, &attestation_id);

Events::attestation_revoked(&env, &attestation_id, &issuer, &reason);
Storage::append_audit_entry(
&env,
Expand All @@ -870,7 +876,6 @@ impl TrustLinkContract {
reason: Option<String>,
) -> Result<u32, Error> {
issuer.require_auth();
Validation::require_issuer(&env, &issuer)?;
validate_reason(&reason)?;

let mut count = 0;
Expand All @@ -888,6 +893,12 @@ impl TrustLinkContract {
attestation.revoked = true;
attestation.revocation_reason = reason.clone();
Storage::set_attestation(&env, &attestation);

// Prune revoked attestation ID from both indexes so pagination
// counts shrink after revocation.
Storage::remove_subject_attestation(&env, &attestation.subject, &attestation_id);
Storage::remove_issuer_attestation(&env, &issuer, &attestation_id);

Events::attestation_revoked(&env, &attestation_id, &issuer, &reason);
Storage::append_audit_entry(
&env,
Expand Down
18 changes: 18 additions & 0 deletions src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,24 @@ impl Storage {
env.storage().persistent().extend_ttl(&key, ttl, ttl);
}

/// Remove `attestation_id` from `issuer`'s attestation index.
///
/// Note: this does not delete the attestation record; it only removes the ID
/// from the issuer's listing index so pagination results shrink.
pub fn remove_issuer_attestation(env: &Env, issuer: &Address, attestation_id: &String) {
let key = StorageKey::IssuerAttestations(issuer.clone());
let ttl = get_ttl_lifetime(env);
let existing = Self::get_issuer_attestations(env, issuer);
let mut updated = Vec::new(env);
for id in existing.iter() {
if &id != attestation_id {
updated.push_back(id);
}
}
env.storage().persistent().set(&key, &updated);
env.storage().persistent().extend_ttl(&key, ttl, ttl);
}

/// Return the ordered list of attestation IDs created by `issuer`, or an
/// empty [`Vec`] if none exist. TTL is only extended on index modification,
/// not on read, to reduce compute costs for frequent queries.
Expand Down
28 changes: 28 additions & 0 deletions src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,34 @@ fn test_has_valid_claim_and_revocation() {
assert!(client.get_attestation(&id).revoked);
}

#[test]
fn test_revoke_removes_ids_from_subject_and_issuer_indexes() {
let env = Env::default();
env.mock_all_auths();

let (_, issuer, client) = setup(&env);
let subject = Address::generate(&env);
let claim_type = String::from_str(&env, "KYC_PASSED");

let id = client.create_attestation(&issuer, &subject, &claim_type, &None, &None, &None);

// Index pagination counts should reflect the initial state.
assert_eq!(client.get_subject_attestations(&subject, &0, &10).len(), 1);
assert_eq!(client.get_issuer_attestations(&issuer, &0, &10).len(), 1);

client.revoke_attestation(&issuer, &id, &None);

// After revocation, the ID should be removed from both indexes.
assert_eq!(client.get_subject_attestations(&subject, &0, &10).len(), 0);
assert_eq!(client.get_issuer_attestations(&issuer, &0, &10).len(), 0);

// The underlying attestation record must still exist (immutable history),
// but be marked as revoked.
let att = client.get_attestation(&id);
assert!(att.revoked);
assert!(!att.deleted);
}

#[test]
fn test_expired_attestation_status() {
let env = Env::default();
Expand Down
12 changes: 2 additions & 10 deletions test_snapshots/snapshot_after_revocation.1.json
Original file line number Diff line number Diff line change
Expand Up @@ -481,11 +481,7 @@
},
"durability": "persistent",
"val": {
"vec": [
{
"string": "b0f688d8a0428a71dd2a204b0d13fe60be4e2ae394d30ab2f9399bfc046980fb"
}
]
"vec": []
}
}
},
Expand Down Expand Up @@ -584,11 +580,7 @@
},
"durability": "persistent",
"val": {
"vec": [
{
"string": "b0f688d8a0428a71dd2a204b0d13fe60be4e2ae394d30ab2f9399bfc046980fb"
}
]
"vec": []
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@
"string": "KYC_PASSED"
}
},
{
"key": {
"symbol": "deleted"
},
"val": {
"bool": false
}
},
{
"key": {
"symbol": "expiration"
Expand Down Expand Up @@ -1210,6 +1218,14 @@
"string": "KYC_PASSED"
}
},
{
"key": {
"symbol": "deleted"
},
"val": {
"bool": false
}
},
{
"key": {
"symbol": "expiration"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,14 @@
"string": "KYC_PASSED"
}
},
{
"key": {
"symbol": "deleted"
},
"val": {
"bool": false
}
},
{
"key": {
"symbol": "expiration"
Expand Down Expand Up @@ -351,6 +359,14 @@
"string": "KYC_PASSED"
}
},
{
"key": {
"symbol": "deleted"
},
"val": {
"bool": false
}
},
{
"key": {
"symbol": "expiration"
Expand Down Expand Up @@ -775,14 +791,7 @@
},
"durability": "persistent",
"val": {
"vec": [
{
"string": "d589a0d5e1193ce98a615444e6a69be7f602fda644d72f3706408c026b55c875"
},
{
"string": "91fdc54183c9ec8099a218b964a3eb1e06855af23b9eeccd7a7f4de3163a8cec"
}
]
"vec": []
}
}
},
Expand Down Expand Up @@ -881,11 +890,7 @@
},
"durability": "persistent",
"val": {
"vec": [
{
"string": "d589a0d5e1193ce98a615444e6a69be7f602fda644d72f3706408c026b55c875"
}
]
"vec": []
}
}
},
Expand Down Expand Up @@ -930,11 +935,7 @@
},
"durability": "persistent",
"val": {
"vec": [
{
"string": "91fdc54183c9ec8099a218b964a3eb1e06855af23b9eeccd7a7f4de3163a8cec"
}
]
"vec": []
}
}
},
Expand Down
8 changes: 8 additions & 0 deletions test_snapshots/test/test_audit_log_create_attestation.1.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@
"string": "KYC_PASSED"
}
},
{
"key": {
"symbol": "deleted"
},
"val": {
"bool": false
}
},
{
"key": {
"symbol": "expiration"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@
"string": "KYC_PASSED"
}
},
{
"key": {
"symbol": "deleted"
},
"val": {
"bool": false
}
},
{
"key": {
"symbol": "expiration"
Expand Down Expand Up @@ -529,11 +537,7 @@
},
"durability": "persistent",
"val": {
"vec": [
{
"string": "d589a0d5e1193ce98a615444e6a69be7f602fda644d72f3706408c026b55c875"
}
]
"vec": []
}
}
},
Expand Down Expand Up @@ -632,11 +636,7 @@
},
"durability": "persistent",
"val": {
"vec": [
{
"string": "d589a0d5e1193ce98a615444e6a69be7f602fda644d72f3706408c026b55c875"
}
]
"vec": []
}
}
},
Expand Down
8 changes: 8 additions & 0 deletions test_snapshots/test/test_audit_log_renew_appends_entry.1.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,14 @@
"string": "KYC_PASSED"
}
},
{
"key": {
"symbol": "deleted"
},
"val": {
"bool": false
}
},
{
"key": {
"symbol": "expiration"
Expand Down
Loading
Loading