Skip to content

Commit 76d90b2

Browse files
authored
Merge pull request #63 from Promise278/wallet-score-delegation
Closes #55: Implement Wallet Score Delegation: Sub-Wallets Inherit Risk Posture from a Registered Custodian
2 parents a760b93 + 5263b9c commit 76d90b2

8 files changed

Lines changed: 7618 additions & 2 deletions

contracts/ledgerlens-score/src/lib.rs

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,16 @@ impl LedgerLensScoreContract {
395395
/// assert_eq!(score.score, 10);
396396
/// ```
397397
pub fn get_score(env: Env, wallet: Address, asset_pair: Symbol) -> Result<RiskScore, Error> {
398-
storage::get_score(&env, &wallet, &asset_pair).ok_or(Error::ScoreNotFound)
398+
match storage::get_score(&env, &wallet, &asset_pair) {
399+
Some(score) => Ok(score),
400+
None => {
401+
if let Some(custodian) = storage::get_score_delegate(&env, &wallet) {
402+
storage::get_score(&env, &custodian, &asset_pair).ok_or(Error::ScoreNotFound)
403+
} else {
404+
Err(Error::ScoreNotFound)
405+
}
406+
}
407+
}
399408
}
400409

401410
/// Returns the ordered history of the last `HISTORY_MAX_DEPTH` risk scores
@@ -550,6 +559,57 @@ impl LedgerLensScoreContract {
550559
storage::get_history_max_depth(&env)
551560
}
552561

562+
// ── Wallet Score Delegation ───────────────────────────────────────────────
563+
564+
/// Registers a custodian wallet as the fallback score source for `sub_wallet`.
565+
/// Admin only. Rejects cyclic delegation where a wallet delegates to itself,
566+
/// or a custodian delegates back to one of its sub-wallets.
567+
pub fn set_score_delegate(
568+
env: Env,
569+
sub_wallet: Address,
570+
custodian: Address,
571+
) -> Result<(), Error> {
572+
if !storage::has_admin(&env) {
573+
return Err(Error::NotInitialized);
574+
}
575+
storage::get_admin(&env).require_auth();
576+
577+
if sub_wallet == custodian {
578+
return Err(Error::CyclicDelegation);
579+
}
580+
if let Some(custodian_delegate) = storage::get_score_delegate(&env, &custodian) {
581+
if custodian_delegate == sub_wallet {
582+
return Err(Error::CyclicDelegation);
583+
}
584+
}
585+
586+
storage::set_score_delegate(&env, &sub_wallet, &custodian);
587+
events::delegate_set(&env, &sub_wallet, &custodian);
588+
Ok(())
589+
}
590+
591+
/// Removes a registered score delegation for `sub_wallet`. Admin only.
592+
pub fn remove_score_delegate(env: Env, sub_wallet: Address) -> Result<(), Error> {
593+
if !storage::has_admin(&env) {
594+
return Err(Error::NotInitialized);
595+
}
596+
storage::get_admin(&env).require_auth();
597+
598+
if storage::get_score_delegate(&env, &sub_wallet).is_none() {
599+
return Err(Error::DelegateNotFound);
600+
}
601+
602+
storage::remove_score_delegate(&env, &sub_wallet);
603+
events::delegate_removed(&env, &sub_wallet);
604+
Ok(())
605+
}
606+
607+
/// Returns the currently registered score delegate (custodian) for `sub_wallet`,
608+
/// or `None` if no delegation exists.
609+
pub fn get_score_delegate(env: Env, sub_wallet: Address) -> Option<Address> {
610+
storage::get_score_delegate(&env, &sub_wallet)
611+
}
612+
553613
// ── Cross-asset aggregate risk ───────────────────────────────────────────
554614

555615
/// Computes `wallet`'s cross-asset aggregate risk score: a weighted
@@ -571,6 +631,9 @@ impl LedgerLensScoreContract {
571631
/// `submit_scores_batch` refresh as a side effect, so the result is
572632
/// always consistent with the latest submissions.
573633
///
634+
/// If `wallet` has no direct scores, it falls back to computing the
635+
/// aggregate score of its delegated custodian, if one exists.
636+
///
574637
/// Complexity is O(N) in the number of distinct pairs the wallet has
575638
/// a score for. The contract does not enforce a hard cap on N, but the
576639
/// aggregate engine is designed around [`constants::MAX_WALLET_PAIRS`]
@@ -582,6 +645,12 @@ impl LedgerLensScoreContract {
582645
/// would overflow — this can only happen with extreme admin-configured
583646
/// weights, since per-pair scores are bounded to 0-100.
584647
pub fn get_aggregate_score(env: Env, wallet: Address) -> Result<AggregateRiskScore, Error> {
648+
let pairs = storage::get_wallet_pairs(&env, &wallet);
649+
if pairs.is_empty() {
650+
if let Some(custodian) = storage::get_score_delegate(&env, &wallet) {
651+
return Self::compute_aggregate_score(&env, &custodian);
652+
}
653+
}
585654
Self::compute_aggregate_score(&env, &wallet)
586655
}
587656

@@ -674,7 +743,14 @@ impl LedgerLensScoreContract {
674743
) -> bool {
675744
match storage::peek_score(&env, &wallet, &asset_pair) {
676745
Some(risk) => risk.score < gate_threshold,
677-
None => false,
746+
None => {
747+
if let Some(custodian) = storage::peek_score_delegate(&env, &wallet) {
748+
if let Some(risk) = storage::peek_score(&env, &custodian, &asset_pair) {
749+
return risk.score < gate_threshold;
750+
}
751+
}
752+
false
753+
}
678754
}
679755
}
680756

contracts/ledgerlens-score/src/test.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1943,3 +1943,116 @@ fn test_clear_score_does_not_affect_history() {
19431943
assert_eq!(client.get_score_history(&wallet, &pair).len(), 1);
19441944
assert_eq!(client.get_score_history(&wallet, &pair).get(0).unwrap().score, 33);
19451945
}
1946+
1947+
// ── Wallet Score Delegation ───────────────────────────────────────────────────
1948+
1949+
#[test]
1950+
fn test_delegate_inherits_custodian_score() {
1951+
let (env, client, _admin, _service) = initialized();
1952+
let custodian = Address::generate(&env);
1953+
let sub_wallet = Address::generate(&env);
1954+
let pair = symbol_short!("XLM_USDC");
1955+
1956+
// 1. Set delegate
1957+
client.set_score_delegate(&sub_wallet, &custodian);
1958+
1959+
// 2. Submit score to custodian
1960+
client.submit_score(&Vec::new(&env), &custodian, &pair, &40, &false, &false, &1, &80, &1, &None);
1961+
1962+
// 3. Sub-wallet inherits score
1963+
let score = client.get_score(&sub_wallet, &pair);
1964+
assert_eq!(score.score, 40);
1965+
1966+
// 4. Sub-wallet inherits gate check
1967+
let is_safe = client.query_risk_gate(&sub_wallet, &pair, &50);
1968+
assert!(is_safe);
1969+
1970+
// 5. Sub-wallet inherits aggregate score
1971+
let aggregate = client.get_aggregate_score(&sub_wallet);
1972+
assert_eq!(aggregate.aggregate_score, 40);
1973+
}
1974+
1975+
#[test]
1976+
fn test_delegate_direct_score_overrides_delegation() {
1977+
let (env, client, _admin, _service) = initialized();
1978+
let custodian = Address::generate(&env);
1979+
let sub_wallet = Address::generate(&env);
1980+
let pair = symbol_short!("XLM_USDC");
1981+
1982+
client.set_score_delegate(&sub_wallet, &custodian);
1983+
client.submit_score(&Vec::new(&env), &custodian, &pair, &80, &false, &false, &1, &80, &1, &None);
1984+
1985+
// Sub-wallet overrides with its own score
1986+
client.submit_score(&Vec::new(&env), &sub_wallet, &pair, &10, &false, &false, &1, &80, &1, &None);
1987+
1988+
let score = client.get_score(&sub_wallet, &pair);
1989+
assert_eq!(score.score, 10); // Not 80
1990+
}
1991+
1992+
#[test]
1993+
fn test_cyclic_delegation_rejected() {
1994+
let (env, client, _admin, _service) = initialized();
1995+
let wallet_a = Address::generate(&env);
1996+
let wallet_b = Address::generate(&env);
1997+
1998+
// A -> A is rejected
1999+
assert_eq!(client.try_set_score_delegate(&wallet_a, &wallet_a), Err(Ok(Error::CyclicDelegation)));
2000+
2001+
// A -> B -> A is rejected
2002+
client.set_score_delegate(&wallet_a, &wallet_b);
2003+
assert_eq!(client.try_set_score_delegate(&wallet_b, &wallet_a), Err(Ok(Error::CyclicDelegation)));
2004+
}
2005+
2006+
#[test]
2007+
fn test_remove_delegate_clears_fallback() {
2008+
let (env, client, _admin, _service) = initialized();
2009+
let custodian = Address::generate(&env);
2010+
let sub_wallet = Address::generate(&env);
2011+
let pair = symbol_short!("XLM_USDC");
2012+
2013+
client.set_score_delegate(&sub_wallet, &custodian);
2014+
client.submit_score(&Vec::new(&env), &custodian, &pair, &40, &false, &false, &1, &80, &1, &None);
2015+
2016+
// Works with delegate
2017+
assert_eq!(client.get_score(&sub_wallet, &pair).score, 40);
2018+
2019+
client.remove_score_delegate(&sub_wallet);
2020+
2021+
// Fallback is gone
2022+
assert_eq!(client.try_get_score(&sub_wallet, &pair), Err(Ok(Error::ScoreNotFound)));
2023+
}
2024+
2025+
#[test]
2026+
fn test_delegate_propagates_embargo() {
2027+
let (env, client, _admin, _service) = initialized();
2028+
let custodian = Address::generate(&env);
2029+
let sub_wallet = Address::generate(&env);
2030+
let pair = symbol_short!("XLM_USDC");
2031+
2032+
client.set_score_delegate(&sub_wallet, &custodian);
2033+
// Submit embargo score (e.g. 90) which is >= gate_threshold of 75
2034+
client.submit_score(&Vec::new(&env), &custodian, &pair, &90, &false, &false, &1, &80, &1, &None);
2035+
2036+
let is_safe = client.query_risk_gate(&sub_wallet, &pair, &75);
2037+
assert!(!is_safe); // Embargo propagates
2038+
}
2039+
2040+
#[test]
2041+
fn test_delegate_snapshot() {
2042+
let (env, client, _admin, _service) = initialized();
2043+
let custodian = Address::generate(&env);
2044+
let sub_wallet = Address::generate(&env);
2045+
let pair = symbol_short!("XLM_USDC");
2046+
2047+
client.set_score_delegate(&sub_wallet, &custodian);
2048+
client.submit_score(&Vec::new(&env), &custodian, &pair, &20, &false, &false, &1, &80, &1, &None);
2049+
2050+
assert_eq!(client.get_score(&sub_wallet, &pair).score, 20);
2051+
2052+
// Update custodian's score
2053+
env.ledger().with_mut(|l| l.timestamp += 3_601);
2054+
client.submit_score(&Vec::new(&env), &custodian, &pair, &50, &false, &false, &2, &80, &1, &None);
2055+
2056+
// Sub-wallet immediately sees the new score without any update to itself
2057+
assert_eq!(client.get_score(&sub_wallet, &pair).score, 50);
2058+
}

0 commit comments

Comments
 (0)