diff --git a/contracts/risk_registry/src/lib.rs b/contracts/risk_registry/src/lib.rs index 8cd81a5..28a00c7 100644 --- a/contracts/risk_registry/src/lib.rs +++ b/contracts/risk_registry/src/lib.rs @@ -37,6 +37,8 @@ pub enum RiskRegistryError { ScoreUpdateCooldownNotElapsed = 16, Unauthorized = 17, UpgradeTimelockNotElapsed = 18, + /// Caller is a valid verifier but is not the verifier-of-record for this SME. + NotSmeVerifier = 19, } impl From for RiskRegistryError { @@ -92,7 +94,8 @@ pub enum DataKey { /// Sub-accounts can act on behalf of the primary for all verifier operations. SubAccount(Address), SmeProfile(Address), - DebtorScore(Bytes), // keyed by debtor_hash (SHA-256 of PII) + DebtorScoreAttestation(Bytes, Address), // (debtor_hash, verifier) -> u32 score + DebtorAttestors(Bytes), // debtor_hash -> Vec
of attesting verifiers /// Ledger timestamp of the last set_debtor_score call per (verifier, debtor_hash). DebtorScoreLastUpdate(Address, Bytes), UpgradeProposal, @@ -526,12 +529,14 @@ impl RiskRegistryContract { /// Update SME risk score. Verifier only. /// /// **Parameters:** - /// - `verifier` — A registered verifier address (must sign). + /// - `verifier` — A registered verifier address (must sign). Must be the + /// verifier-of-record for this SME (i.e. the verifier who registered them). /// - `sme` — The SME whose score is being updated. /// - `new_score` — The new risk score (0–100). /// /// **Errors:** /// - `RiskRegistryError::NotVerifier` — Caller is not a registered verifier. + /// - `RiskRegistryError::NotSmeVerifier` — Caller is not the verifier-of-record for this SME. /// - `RiskRegistryError::InvalidRiskScore` — `new_score` > 100. /// - `RiskRegistryError::SMENotRegistered` — SME has not been registered. /// - `RiskRegistryError::Reentrancy` — Reentrancy guard triggered. @@ -544,7 +549,7 @@ impl RiskRegistryContract { new_score: u32, ) -> Result<(), RiskRegistryError> { verifier.require_auth(); - Self::require_verifier(&env, &verifier)?; + let primary = Self::resolve_verifier(&env, &verifier)?; require_valid_risk_score(new_score)?; let _guard = ReentrancyGuard::new(&env)?; @@ -555,12 +560,18 @@ impl RiskRegistryContract { .get(&DataKey::SmeProfile(sme.clone())) .ok_or(RiskRegistryError::SMENotRegistered)?; + // Enforce verifier-of-record: only the verifier who registered this SME + // (or their sub-accounts resolving to that same primary) may update its score. + if profile.verifier != primary { + return Err(RiskRegistryError::NotSmeVerifier); + } + profile.risk_score = new_score; env.storage() .persistent() .set(&DataKey::SmeProfile(sme.clone()), &profile); Self::bump_persistent(&env, &DataKey::SmeProfile(sme.clone())); - events::sme_score_updated(&env, &verifier, &sme, new_score); + events::sme_score_updated(&env, &primary, &sme, new_score); Ok(()) } @@ -570,12 +581,14 @@ impl RiskRegistryContract { /// non-Repaid, non-Defaulted invoices. Set to 0 to remove the limit. /// /// **Parameters:** - /// - `verifier` — A registered verifier address (must sign). + /// - `verifier` — A registered verifier address (must sign). Must be the + /// verifier-of-record for this SME. /// - `sme` — The SME to update. /// - `credit_limit` — The new limit in stroops (≥ 0). 0 means uncapped. /// /// **Errors:** /// - `RiskRegistryError::NotVerifier` — Caller is not a registered verifier. + /// - `RiskRegistryError::NotSmeVerifier` — Caller is not the verifier-of-record for this SME. /// - `RiskRegistryError::InvalidAmount` — `credit_limit` is negative. /// - `RiskRegistryError::SMENotRegistered` — SME has not been registered. /// @@ -587,6 +600,7 @@ impl RiskRegistryContract { credit_limit: i128, ) -> Result<(), RiskRegistryError> { verifier.require_auth(); + let primary = Self::resolve_verifier(&env, &verifier)?; Self::require_verifier(&env, &verifier)?; require_non_negative_amount(credit_limit)?; if credit_limit < 0 { @@ -599,12 +613,70 @@ impl RiskRegistryContract { .get(&DataKey::SmeProfile(sme.clone())) .ok_or(RiskRegistryError::SMENotRegistered)?; + // Enforce verifier-of-record: only the assigned verifier may change the credit limit. + if profile.verifier != primary { + return Err(RiskRegistryError::NotSmeVerifier); + } + profile.credit_limit = credit_limit; env.storage() .persistent() .set(&DataKey::SmeProfile(sme.clone()), &profile); Self::bump_persistent(&env, &DataKey::SmeProfile(sme.clone())); - events::sme_credit_limit_set(&env, &verifier, &sme, credit_limit); + events::sme_credit_limit_set(&env, &primary, &sme, credit_limit); + Ok(()) + } + + /// Reassign an SME to a new verifier-of-record. Admin only. + /// + /// Used when the original verifier has been removed (via `remove_verifier`) or when + /// the admin explicitly transfers custody of an SME to a different verifier. + /// After reassignment the new verifier becomes the sole authority for `update_sme_score` + /// and `set_credit_limit` on that SME. + /// + /// **Parameters:** + /// - `admin` — Must be the current admin address. + /// - `sme` — The SME whose verifier-of-record is being changed. + /// - `new_verifier` — An active registered verifier to assign as the new verifier-of-record. + /// + /// **Errors:** + /// - `RiskRegistryError::NotAdmin` — Caller is not the admin. + /// - `RiskRegistryError::SMENotRegistered` — SME has not been registered. + /// - `RiskRegistryError::NotVerifier` — `new_verifier` is not an active verifier. + /// + /// **Security:** Requires `admin.require_auth()`. Emits `sme_verifier_reassigned` event. + pub fn reassign_sme_verifier( + env: Env, + admin: Address, + sme: Address, + new_verifier: Address, + ) -> Result<(), RiskRegistryError> { + admin.require_auth(); + Self::require_admin(&env, &admin)?; + + // new_verifier must be an active primary verifier + if !env + .storage() + .persistent() + .get::<_, bool>(&DataKey::Verifier(new_verifier.clone())) + .unwrap_or(false) + { + return Err(RiskRegistryError::NotVerifier); + } + + let mut profile: SmeProfile = env + .storage() + .persistent() + .get(&DataKey::SmeProfile(sme.clone())) + .ok_or(RiskRegistryError::SMENotRegistered)?; + + profile.verifier = new_verifier.clone(); + env.storage() + .persistent() + .set(&DataKey::SmeProfile(sme.clone()), &profile); + Self::bump_persistent(&env, &DataKey::SmeProfile(sme.clone())); + events::sme_verifier_reassigned(&env, &admin, &sme, &new_verifier); + Self::append_audit_entry(&env, &admin, AdminActionType::RecordDefault); // reuse closest action type Ok(()) } @@ -758,10 +830,22 @@ impl RiskRegistryContract { } } - env.storage() + let attestation_key = DataKey::DebtorScoreAttestation(debtor_hash.clone(), verifier.clone()); + env.storage().persistent().set(&attestation_key, &score); + Self::bump_persistent(&env, &attestation_key); + + let attestors_key = DataKey::DebtorAttestors(debtor_hash.clone()); + let mut attestors: Vec
= env + .storage() .persistent() - .set(&DataKey::DebtorScore(debtor_hash.clone()), &score); - Self::bump_persistent(&env, &DataKey::DebtorScore(debtor_hash.clone())); + .get(&attestors_key) + .unwrap_or_else(|| Vec::new(&env)); + + if !attestors.contains(&verifier) { + attestors.push_back(verifier.clone()); + env.storage().persistent().set(&attestors_key, &attestors); + } + Self::bump_persistent(&env, &attestors_key); // Record the update timestamp so the next call can check the cooldown. let now = env.ledger().timestamp(); @@ -897,7 +981,46 @@ impl RiskRegistryContract { pub fn get_debtor_score(env: Env, debtor_hash: Bytes) -> Result { /// Returns the debtor score or `RiskRegistryError::DebtorNotRegistered` if not found. pub fn get_debtor_score(env: Env, debtor_hash: Bytes) -> Result { - let key = DataKey::DebtorScore(debtor_hash); + let attestors_key = DataKey::DebtorAttestors(debtor_hash.clone()); + let attestors: Vec
= env + .storage() + .persistent() + .get(&attestors_key) + .ok_or(RiskRegistryError::DebtorNotRegistered)?; + Self::bump_persistent(&env, &attestors_key); + + let mut total_score: u64 = 0; + let mut count: u32 = 0; + + for verifier in attestors.iter() { + if Self::is_verifier(env.clone(), verifier.clone()) { + let key = DataKey::DebtorScoreAttestation(debtor_hash.clone(), verifier); + if let Some(score) = env.storage().persistent().get::<_, u32>(&key) { + total_score = total_score + .checked_add(score as u64) + .ok_or(RiskRegistryError::ArithmeticOverflow)?; + count += 1; + Self::bump_persistent(&env, &key); + } + } + } + + if count == 0 { + return Err(RiskRegistryError::DebtorNotRegistered); + } + + let avg = total_score / (count as u64); + Ok(avg as u32) + } + + /// Returns a specific verifier's score attestation for a debtor, + /// or `RiskRegistryError::DebtorNotRegistered` if not found. + pub fn get_debtor_score_attestation( + env: Env, + verifier: Address, + debtor_hash: Bytes, + ) -> Result { + let key = DataKey::DebtorScoreAttestation(debtor_hash, verifier); let score: u32 = env .storage() .persistent() @@ -2017,6 +2140,132 @@ mod tests { assert!(client .try_set_debtor_score(&verifier_b, &debtor_hash, &55u32) .is_ok()); + + // Both attestations preserved independently + assert_eq!(client.get_debtor_score_attestation(&verifier_a, &debtor_hash), 40u32); + assert_eq!(client.get_debtor_score_attestation(&verifier_b, &debtor_hash), 55u32); + + // Aggregate average: (40 + 55) / 2 = 47 + assert_eq!(client.get_debtor_score(&debtor_hash), 47u32); + } + + #[test] + fn test_debtor_score_aggregation_ignores_removed_verifier() { + let (env, admin, _, staking_token, client) = setup(); + let verifier_a = Address::generate(&env); + let verifier_b = Address::generate(&env); + let debtor_hash = Bytes::from_slice(&env, &[0xFFu8; 32]); + mint_stake(&env, &staking_token, &verifier_a, 1_000_000i128); + client.add_verifier(&admin, &verifier_a, &1_000_000i128); + mint_stake(&env, &staking_token, &verifier_b, 1_000_000i128); + client.add_verifier(&admin, &verifier_b, &1_000_000i128); + + client.set_debtor_score(&verifier_a, &debtor_hash, &20u32); + client.set_debtor_score(&verifier_b, &debtor_hash, &80u32); + assert_eq!(client.get_debtor_score(&debtor_hash), 50u32); + + // Remove verifier_a + client.remove_verifier(&admin, &verifier_a); + + // Aggregate score now reflects active verifier_b only (80) + assert_eq!(client.get_debtor_score(&debtor_hash), 80u32); + // Individual attestation of verifier_a is still independently retrievable + assert_eq!(client.get_debtor_score_attestation(&verifier_a, &debtor_hash), 20u32); + } + + // ── add_verifier re-registration guard ─────────────────────────────────── + + #[test] + fn test_add_verifier_rejects_already_active_verifier() { + // Re-registering an active verifier must fail — previously this silently + // overwrote VerifierStake (stranding funds) and reset VerifierReputation. + let (env, admin, _, staking_token, client) = setup(); + let verifier = Address::generate(&env); + mint_stake(&env, &staking_token, &verifier, 2_000_000i128); + client.add_verifier(&admin, &verifier, &1_000_000i128); + let result = client.try_add_verifier(&admin, &verifier, &500_000i128); + assert!(result.is_err()); + // Stake unchanged at original amount — no funds stranded + assert_eq!(client.get_verifier_stake(&verifier), 1_000_000i128); + assert!(client.is_verifier(&verifier)); + } + + #[test] + fn test_regression_fund_stranding_prevented() { + // Regression: second add_verifier with a lower amount must no longer + // silently overwrite VerifierStake, stranding the difference on-chain. + let (env, admin, _, staking_token, client) = setup(); + let verifier = Address::generate(&env); + mint_stake(&env, &staking_token, &verifier, 2_000_000i128); + client.add_verifier(&admin, &verifier, &1_000_000i128); + assert_eq!(client.get_verifier_stake(&verifier), 1_000_000i128); + // Attempt re-register with a lower stake — must be rejected + assert!(client.try_add_verifier(&admin, &verifier, &500_000i128).is_err()); + // Tracked stake is still 1_000_000, not silently reduced to 500_000 + assert_eq!(client.get_verifier_stake(&verifier), 1_000_000i128); + } + + #[test] + fn test_regression_reputation_reset_prevented() { + // Regression: second add_verifier must not reset VerifierReputation to 100, + // laundering slashing history recorded via record_default. + let (env, admin, _, staking_token, client) = setup(); + let verifier = Address::generate(&env); + let sme = Address::generate(&env); + mint_stake(&env, &staking_token, &verifier, 2_000_000i128); + client.add_verifier(&admin, &verifier, &1_000_000i128); + client.register_sme(&verifier, &sme, &40u32, &true); + client.record_default(&admin, &sme); + let rep_after_slash = client.get_verifier_reputation(&verifier); + assert!(rep_after_slash < 100, "reputation should have been reduced"); + // Attempt re-register — rejected, reputation preserved + let _ = client.try_add_verifier(&admin, &verifier, &1_000_000i128); + assert_eq!(client.get_verifier_reputation(&verifier), rep_after_slash); + } + + #[test] + fn test_top_up_stake_increases_stake_additively() { + // top_up_stake must ADD to existing tracked stake, not overwrite it. + let (env, admin, _, staking_token, client) = setup(); + let verifier = Address::generate(&env); + mint_stake(&env, &staking_token, &verifier, 3_000_000i128); + client.add_verifier(&admin, &verifier, &1_000_000i128); + assert_eq!(client.get_verifier_stake(&verifier), 1_000_000i128); + client.top_up_stake(&admin, &verifier, &500_000i128); + assert_eq!(client.get_verifier_stake(&verifier), 1_500_000i128); + } + + #[test] + fn test_top_up_stake_preserves_reputation() { + // top_up_stake must leave VerifierReputation untouched. + let (env, admin, _, staking_token, client) = setup(); + let verifier = Address::generate(&env); + let sme = Address::generate(&env); + mint_stake(&env, &staking_token, &verifier, 3_000_000i128); + client.add_verifier(&admin, &verifier, &1_000_000i128); + client.register_sme(&verifier, &sme, &40u32, &true); + client.record_default(&admin, &sme); + let rep = client.get_verifier_reputation(&verifier); + assert!(rep < 100); + client.top_up_stake(&admin, &verifier, &500_000i128); + assert_eq!(client.get_verifier_reputation(&verifier), rep); + } + + #[test] + fn test_top_up_stake_requires_active_verifier() { + let (env, admin, _, staking_token, client) = setup(); + let stranger = Address::generate(&env); + mint_stake(&env, &staking_token, &stranger, 1_000_000i128); + assert!(client.try_top_up_stake(&admin, &stranger, &500_000i128).is_err()); + } + + #[test] + fn test_top_up_stake_rejects_zero_amount() { + let (env, admin, _, staking_token, client) = setup(); + let verifier = Address::generate(&env); + mint_stake(&env, &staking_token, &verifier, 1_000_000i128); + client.add_verifier(&admin, &verifier, &1_000_000i128); + assert!(client.try_top_up_stake(&admin, &verifier, &0i128).is_err()); } // ── add_verifier re-registration guard ─────────────────────────────────── diff --git a/contracts/shared/src/events.rs b/contracts/shared/src/events.rs index 11734c0..93149e8 100644 --- a/contracts/shared/src/events.rs +++ b/contracts/shared/src/events.rs @@ -697,12 +697,21 @@ pub fn reserve_disbursed(env: &Env, caller: &Address, token: &Address, recipient pub fn sme_credit_limit_set(env: &Env, verifier: &Address, sme: &Address, credit_limit: i128) { emit( env, - symbol_short!("SME_CLSET"), symbol_short!("SME_CL"), (verifier.clone(), sme.clone(), credit_limit, env.ledger().timestamp()), ); } +/// Schema: (actor=admin, sme, new_verifier, timestamp) +/// Emitted when the admin reassigns an SME to a new verifier-of-record. +pub fn sme_verifier_reassigned(env: &Env, admin: &Address, sme: &Address, new_verifier: &Address) { + emit( + env, + symbol_short!("SME_REAS"), + (admin.clone(), sme.clone(), new_verifier.clone(), env.ledger().timestamp()), + ); +} + // ── Invoice Freeze Events ───────────────────────────────────────────────────── /// Schema: (actor=admin, invoice_id, timestamp) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5a17d5e..841dba8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -162,7 +162,8 @@ Verifier-managed SME and debtor scoring. **Storage:** - `Verifier(Address)` → `bool` (persistent) - `SmeProfile(Address)` → `SmeProfile` (persistent) -- `DebtorScore(Bytes)` → `u32` (persistent, keyed by debtor hash) +- `DebtorScoreAttestation(Bytes, Address)` → `u32` (persistent, per-verifier attestation) +- `DebtorAttestors(Bytes)` → `Vec
` (persistent, list of attesting verifiers per debtor) - `Admin` (instance) Verifiers are trusted off-chain entities (e.g., credit bureaus, KYC providers) who have been whitelisted by the admin. They assign risk scores to SMEs and debtors. The marketplace can optionally gate listings based on minimum risk score. diff --git a/docs/risk-registry.md b/docs/risk-registry.md index c43dc9a..7904295 100644 --- a/docs/risk-registry.md +++ b/docs/risk-registry.md @@ -144,6 +144,8 @@ Debtors are the counterparties who owe payment on the underlying invoices. To pr privacy, debtor PII is never stored on-chain — only a **SHA-256 hash** of the debtor identity is used as the key. +Debtor risk scores are tracked per-verifier as independent attestations rather than a single overwritable global value: + ``` set_debtor_score(verifier, debtor_hash, score) ``` @@ -151,16 +153,19 @@ set_debtor_score(verifier, debtor_hash, score) - `debtor_hash` must be exactly 32 bytes (the raw SHA-256 digest). - `score` is 0–100, following the same `RiskTier` mapping as SME scores. - Only registered verifiers may set debtor scores. +- Updates are saved as independent attestations keyed by `(debtor_hash, verifier)`, with a per-verifier cooldown (`MIN_SCORE_UPDATE_INTERVAL = 3,600s`) enforced per attestation. -Query: +Queries: ``` -get_debtor_score(debtor_hash) → Result +get_debtor_score(debtor_hash) → Result ``` +Computes and returns the aggregated average score across all active verifiers' attestations for `debtor_hash`. Unregistered/removed verifiers are excluded from the aggregate computation. -Debtor scores are currently informational — they are not enforced by any contract -logic in v1 but are available for off-chain risk dashboards and future marketplace -tier-based fee differentiation. +``` +get_debtor_score_attestation(verifier, debtor_hash) → Result +``` +Returns an individual verifier's specific score attestation for `debtor_hash`. --- @@ -185,7 +190,8 @@ activity volume for risk analysis. | `get_verifier_stake(verifier)` | Remaining stake in token units (0 if not registered) | | `get_verifier_reputation(verifier)` | Reputation score 0–100 (0 if not registered) | | `is_verifier(verifier)` | `true` if the address is a registered verifier | -| `get_debtor_score(debtor_hash)` | Debtor score, or `KoraError::DebtorNotRegistered` | +| `get_debtor_score(debtor_hash)` | Aggregated average debtor score across active verifiers, or `RiskRegistryError::DebtorNotRegistered` | +| `get_debtor_score_attestation(verifier, debtor_hash)` | Specific verifier's score attestation, or `RiskRegistryError::DebtorNotRegistered` | | `get_admin()` | Current admin address | All read functions are authorization-free and safe to call from any context. diff --git a/docs/storage-rent-cost-model.md b/docs/storage-rent-cost-model.md index eff3018..5052202 100644 --- a/docs/storage-rent-cost-model.md +++ b/docs/storage-rent-cost-model.md @@ -57,7 +57,7 @@ All constants assume ~5 s per ledger. |----------|--------------|-----------------|-----------|------------| | `treasury` | `PERSISTENT_BUMP_AMOUNT` | 535 680 | 31 days | Admin, FeeBps, Collected, WhitelistedToken | | `treasury` | `PERSISTENT_LIFETIME_THRESHOLD` | 267 840 | 15.5 days | Trigger threshold for bump | -| `risk_registry` | `PERSISTENT_TTL_BUMP` | 518 400 | 30 days | Verifier, VerifierStake, VerifierReputation, SmeProfile, DebtorScore | +| `risk_registry` | `PERSISTENT_TTL_BUMP` | 518 400 | 30 days | Verifier, VerifierStake, VerifierReputation, SmeProfile, DebtorScoreAttestation, DebtorAttestors | | `risk_registry` | `PERSISTENT_TTL_THRESHOLD` | 518 400 | 30 days | Trigger threshold | | `invoice_nft` | (see source) | ≥ 518 400 | ≥ 30 days | Invoice entries | | `marketplace` | (see source) | ≥ 518 400 | ≥ 30 days | Listing entries | @@ -78,7 +78,7 @@ Cost scales with two factors: **entry size** (bytes stored) and **extension freq 2. **`Invoice(u64)`** in `invoice_nft` — one entry per minted invoice, held for the full invoice lifetime (potentially years for long-dated trade finance). -3. **`SmeProfile(Address)`** and **`DebtorScore(Bytes)`** in `risk_registry` — long-lived entries for every registered SME and debtor. +3. **`SmeProfile(Address)`** and **`DebtorScoreAttestation(Bytes, Address)`** in `risk_registry` — long-lived entries for every registered SME and debtor attestation. ### Lower cost diff --git a/scripts/ttl_keeper.sh b/scripts/ttl_keeper.sh index 73f3224..47a0214 100755 --- a/scripts/ttl_keeper.sh +++ b/scripts/ttl_keeper.sh @@ -134,7 +134,7 @@ fi # ── Risk Registry ──────────────────────────────────────────────────────────── # Persistent keys: Admin, InvoiceNft, Verifier(addr), SmeProfile(addr), -# DebtorScore(hash) +# DebtorScoreAttestation(hash, addr), DebtorAttestors(hash) # get_admin and get_sme_profile both call bump_persistent internally. # We touch the admin key; SME profiles are bumped on every read via # get_sme_profile which calls bump_persistent.