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
269 changes: 259 additions & 10 deletions contracts/risk_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommonError> for RiskRegistryError {
Expand Down Expand Up @@ -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<Address> of attesting verifiers
/// Ledger timestamp of the last set_debtor_score call per (verifier, debtor_hash).
DebtorScoreLastUpdate(Address, Bytes),
UpgradeProposal,
Expand Down Expand Up @@ -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.
Expand All @@ -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)?;
Expand All @@ -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(())
}

Expand All @@ -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.
///
Expand All @@ -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 {
Expand All @@ -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(())
}

Expand Down Expand Up @@ -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<Address> = 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();
Expand Down Expand Up @@ -897,7 +981,46 @@ impl RiskRegistryContract {
pub fn get_debtor_score(env: Env, debtor_hash: Bytes) -> Result<u32, KoraError> {
/// Returns the debtor score or `RiskRegistryError::DebtorNotRegistered` if not found.
pub fn get_debtor_score(env: Env, debtor_hash: Bytes) -> Result<u32, RiskRegistryError> {
let key = DataKey::DebtorScore(debtor_hash);
let attestors_key = DataKey::DebtorAttestors(debtor_hash.clone());
let attestors: Vec<Address> = 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<u32, RiskRegistryError> {
let key = DataKey::DebtorScoreAttestation(debtor_hash, verifier);
let score: u32 = env
.storage()
.persistent()
Expand Down Expand Up @@ -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 ───────────────────────────────────
Expand Down
11 changes: 10 additions & 1 deletion contracts/shared/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>` (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.
Expand Down
Loading
Loading