Skip to content

Commit 5defee2

Browse files
committed
feat(reputation): add transfer blockers, profile existence checks, delete with rebate, and bulk lookups
Implements four reputation contract enhancements: - Transfer blockers (#408): reputation is non-transferable by default; admin can toggle per-profile - Profile existence checkpoint (#411): `profile_exists` function for job registry integration - Storage rent rebate on delete (#412): `delete_profile` removes storage and frees rent - Gas-efficient bulk lookups (#413): `get_scores_bulk` and `query_reputations_bulk` for frontend All 22 tests pass including 10 new tests covering the added functionality. Fixes #408 Fixes #411 Fixes #412 Fixes #413
1 parent d114b5c commit 5defee2

23 files changed

Lines changed: 17387 additions & 85 deletions

contracts/reputation/src/lib.rs

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ pub enum ReputationError {
7979
AlreadyReviewed = 6,
8080
ContractStateError = 7,
8181
Blacklisted = 8,
82+
ProfileNotFound = 9,
83+
TransferBlocked = 10,
8284
}
8385

8486
#[contracttype]
@@ -137,6 +139,21 @@ pub struct BlacklistUpdatedEvent {
137139
pub updated_at: u64,
138140
}
139141

142+
#[contracttype]
143+
#[derive(Clone)]
144+
pub struct TransferBlockedEvent {
145+
pub address: Address,
146+
pub blocked: bool,
147+
pub updated_at: u64,
148+
}
149+
150+
#[contracttype]
151+
#[derive(Clone)]
152+
pub struct ProfileDeletedEvent {
153+
pub address: Address,
154+
pub deleted_at: u64,
155+
}
156+
140157
#[contract]
141158
pub struct ReputationContract;
142159

@@ -619,6 +636,89 @@ impl ReputationContract {
619636
is_blacklisted: profile.is_blacklisted,
620637
}
621638
}
639+
640+
// ── Issue #408: Transfer Blockers ──────────────────────────────
641+
642+
pub fn set_transfer_blocked(env: Env, admin: Address, address: Address, blocked: bool) {
643+
Self::require_admin(&env, &admin);
644+
let mut profile = storage::read_profile_or_default(&env, &address);
645+
profile.transfer_blocked = blocked;
646+
storage::write_profile(&env, &address, &profile);
647+
env.events().publish(
648+
("reputation", "TransferBlocked"),
649+
TransferBlockedEvent {
650+
address,
651+
blocked,
652+
updated_at: env.ledger().timestamp(),
653+
},
654+
);
655+
Self::bump_instance_ttl(&env);
656+
}
657+
658+
pub fn is_transfer_blocked(env: Env, address: Address) -> bool {
659+
Self::bump_instance_ttl(&env);
660+
let profile = storage::read_profile_or_default(&env, &address);
661+
profile.transfer_blocked
662+
}
663+
664+
// ── Issue #411: Profile Existence Checkpoint ───────────────────
665+
666+
pub fn profile_exists(env: Env, address: Address) -> bool {
667+
Self::bump_instance_ttl(&env);
668+
storage::profile_exists(&env, &address)
669+
}
670+
671+
// ── Issue #412: Storage Rent Rebate on Delete ──────────────────
672+
673+
pub fn delete_profile(env: Env, admin: Address, address: Address) -> bool {
674+
Self::require_admin(&env, &admin);
675+
let deleted = storage::delete_profile(&env, &address);
676+
if deleted {
677+
env.events().publish(
678+
("reputation", "ProfileDeleted"),
679+
ProfileDeletedEvent {
680+
address,
681+
deleted_at: env.ledger().timestamp(),
682+
},
683+
);
684+
}
685+
Self::bump_instance_ttl(&env);
686+
deleted
687+
}
688+
689+
// ── Issue #413: Bulk Reputation Lookups ────────────────────────
690+
691+
pub fn get_scores_bulk(
692+
env: Env,
693+
addresses: Vec<Address>,
694+
role: Role,
695+
) -> Vec<ReputationScore> {
696+
Self::bump_instance_ttl(&env);
697+
let mut results = Vec::new(&env);
698+
for addr in addresses.iter() {
699+
let profile = storage::read_profile_or_default(&env, &addr);
700+
results.push_back(Self::score_from_profile(&addr, role.clone(), &profile));
701+
}
702+
results
703+
}
704+
705+
pub fn query_reputations_bulk(
706+
env: Env,
707+
addresses: Vec<Address>,
708+
) -> Vec<ReputationView> {
709+
Self::bump_instance_ttl(&env);
710+
let mut results = Vec::new(&env);
711+
for addr in addresses.iter() {
712+
let profile = storage::read_profile_or_default(&env, &addr);
713+
results.push_back(ReputationView {
714+
address: addr.clone(),
715+
client: Self::score_from_profile(&addr, Role::Client, &profile),
716+
freelancer: Self::score_from_profile(&addr, Role::Freelancer, &profile),
717+
is_blacklisted: profile.is_blacklisted,
718+
});
719+
}
720+
results
721+
}
622722
}
623723

624724
#[cfg(test)]
@@ -970,4 +1070,206 @@ mod test {
9701070
let wasm_hash = BytesN::from_array(&env, &[0; 32]);
9711071
client.upgrade(&attacker, &wasm_hash);
9721072
}
1073+
1074+
// ── Issue #408: Transfer Blockers ──────────────────────────────
1075+
1076+
#[test]
1077+
fn test_transfer_blocked_by_default() {
1078+
let env = Env::default();
1079+
let address = Address::generate(&env);
1080+
let contract_id = env.register_contract(None, ReputationContract);
1081+
let client = ReputationContractClient::new(&env, &contract_id);
1082+
1083+
assert!(client.is_transfer_blocked(&address));
1084+
}
1085+
1086+
#[test]
1087+
fn test_admin_can_toggle_transfer_block() {
1088+
let env = Env::default();
1089+
env.mock_all_auths();
1090+
1091+
let admin = Address::generate(&env);
1092+
let address = Address::generate(&env);
1093+
let contract_id = env.register_contract(None, ReputationContract);
1094+
let client = ReputationContractClient::new(&env, &contract_id);
1095+
1096+
client.initialize(&admin);
1097+
assert!(client.is_transfer_blocked(&address));
1098+
1099+
client.set_transfer_blocked(&admin, &address, &false);
1100+
assert!(!client.is_transfer_blocked(&address));
1101+
1102+
client.set_transfer_blocked(&admin, &address, &true);
1103+
assert!(client.is_transfer_blocked(&address));
1104+
}
1105+
1106+
#[test]
1107+
#[should_panic(expected = "Error(Contract, #2)")]
1108+
fn test_non_admin_cannot_toggle_transfer_block() {
1109+
let env = Env::default();
1110+
env.mock_all_auths();
1111+
1112+
let admin = Address::generate(&env);
1113+
let attacker = Address::generate(&env);
1114+
let address = Address::generate(&env);
1115+
let contract_id = env.register_contract(None, ReputationContract);
1116+
let client = ReputationContractClient::new(&env, &contract_id);
1117+
1118+
client.initialize(&admin);
1119+
client.set_transfer_blocked(&attacker, &address, &false);
1120+
}
1121+
1122+
// ── Issue #411: Profile Existence Checkpoint ───────────────────
1123+
1124+
#[test]
1125+
fn test_profile_exists_returns_false_for_unknown() {
1126+
let env = Env::default();
1127+
let address = Address::generate(&env);
1128+
let contract_id = env.register_contract(None, ReputationContract);
1129+
let client = ReputationContractClient::new(&env, &contract_id);
1130+
1131+
assert!(!client.profile_exists(&address));
1132+
}
1133+
1134+
#[test]
1135+
fn test_profile_exists_returns_true_after_rating() {
1136+
let env = Env::default();
1137+
env.mock_all_auths();
1138+
1139+
let admin = Address::generate(&env);
1140+
let job_client = Address::generate(&env);
1141+
let freelancer = Address::generate(&env);
1142+
let contract_id = env.register_contract(None, ReputationContract);
1143+
let registry_id = env.register_contract(None, MockJobRegistry);
1144+
let client = ReputationContractClient::new(&env, &contract_id);
1145+
1146+
client.initialize(&admin);
1147+
client.set_job_registry(&admin, &registry_id);
1148+
setup_job(&env, &registry_id, 50, &job_client, &freelancer);
1149+
1150+
assert!(!client.profile_exists(&freelancer));
1151+
client.submit_rating(&job_client, &50, &freelancer, &5);
1152+
assert!(client.profile_exists(&freelancer));
1153+
}
1154+
1155+
// ── Issue #412: Storage Rent Rebate on Delete ──────────────────
1156+
1157+
#[test]
1158+
fn test_delete_profile_removes_storage() {
1159+
let env = Env::default();
1160+
env.mock_all_auths();
1161+
1162+
let admin = Address::generate(&env);
1163+
let job_client = Address::generate(&env);
1164+
let freelancer = Address::generate(&env);
1165+
let contract_id = env.register_contract(None, ReputationContract);
1166+
let registry_id = env.register_contract(None, MockJobRegistry);
1167+
let client = ReputationContractClient::new(&env, &contract_id);
1168+
1169+
client.initialize(&admin);
1170+
client.set_job_registry(&admin, &registry_id);
1171+
setup_job(&env, &registry_id, 60, &job_client, &freelancer);
1172+
1173+
client.submit_rating(&job_client, &60, &freelancer, &5);
1174+
assert!(client.profile_exists(&freelancer));
1175+
1176+
let deleted = client.delete_profile(&admin, &freelancer);
1177+
assert!(deleted);
1178+
assert!(!client.profile_exists(&freelancer));
1179+
}
1180+
1181+
#[test]
1182+
fn test_delete_nonexistent_profile_returns_false() {
1183+
let env = Env::default();
1184+
env.mock_all_auths();
1185+
1186+
let admin = Address::generate(&env);
1187+
let address = Address::generate(&env);
1188+
let contract_id = env.register_contract(None, ReputationContract);
1189+
let client = ReputationContractClient::new(&env, &contract_id);
1190+
1191+
client.initialize(&admin);
1192+
let deleted = client.delete_profile(&admin, &address);
1193+
assert!(!deleted);
1194+
}
1195+
1196+
#[test]
1197+
#[should_panic(expected = "Error(Contract, #2)")]
1198+
fn test_delete_profile_requires_admin() {
1199+
let env = Env::default();
1200+
env.mock_all_auths();
1201+
1202+
let admin = Address::generate(&env);
1203+
let attacker = Address::generate(&env);
1204+
let address = Address::generate(&env);
1205+
let contract_id = env.register_contract(None, ReputationContract);
1206+
let client = ReputationContractClient::new(&env, &contract_id);
1207+
1208+
client.initialize(&admin);
1209+
client.delete_profile(&attacker, &address);
1210+
}
1211+
1212+
// ── Issue #413: Bulk Reputation Lookups ────────────────────────
1213+
1214+
#[test]
1215+
fn test_get_scores_bulk_empty() {
1216+
let env = Env::default();
1217+
let contract_id = env.register_contract(None, ReputationContract);
1218+
let client = ReputationContractClient::new(&env, &contract_id);
1219+
1220+
let addresses = Vec::new(&env);
1221+
let results = client.get_scores_bulk(&addresses, &Role::Freelancer);
1222+
assert_eq!(results.len(), 0);
1223+
}
1224+
1225+
#[test]
1226+
fn test_get_scores_bulk_returns_defaults_for_unknown() {
1227+
let env = Env::default();
1228+
let a = Address::generate(&env);
1229+
let b = Address::generate(&env);
1230+
let contract_id = env.register_contract(None, ReputationContract);
1231+
let client = ReputationContractClient::new(&env, &contract_id);
1232+
1233+
let mut addresses = Vec::new(&env);
1234+
addresses.push_back(a.clone());
1235+
addresses.push_back(b.clone());
1236+
1237+
let results = client.get_scores_bulk(&addresses, &Role::Freelancer);
1238+
assert_eq!(results.len(), 2);
1239+
assert_eq!(results.get_unchecked(0).score, 5_000);
1240+
assert_eq!(results.get_unchecked(1).score, 5_000);
1241+
}
1242+
1243+
#[test]
1244+
fn test_query_reputations_bulk() {
1245+
let env = Env::default();
1246+
env.mock_all_auths();
1247+
1248+
let admin = Address::generate(&env);
1249+
let job_client = Address::generate(&env);
1250+
let freelancer = Address::generate(&env);
1251+
let contract_id = env.register_contract(None, ReputationContract);
1252+
let registry_id = env.register_contract(None, MockJobRegistry);
1253+
let client = ReputationContractClient::new(&env, &contract_id);
1254+
1255+
client.initialize(&admin);
1256+
client.set_job_registry(&admin, &registry_id);
1257+
setup_job(&env, &registry_id, 70, &job_client, &freelancer);
1258+
client.submit_rating(&job_client, &70, &freelancer, &4);
1259+
1260+
let mut addresses = Vec::new(&env);
1261+
addresses.push_back(freelancer.clone());
1262+
addresses.push_back(job_client.clone());
1263+
1264+
let results = client.query_reputations_bulk(&addresses);
1265+
assert_eq!(results.len(), 2);
1266+
1267+
let freelancer_view = results.get_unchecked(0);
1268+
assert_eq!(freelancer_view.freelancer.score, 8_000);
1269+
assert_eq!(freelancer_view.freelancer.total_jobs, 1);
1270+
1271+
let client_view = results.get_unchecked(1);
1272+
assert_eq!(client_view.client.score, 5_000);
1273+
assert_eq!(client_view.client.total_jobs, 0);
1274+
}
9731275
}

contracts/reputation/src/profile.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ pub struct Profile {
4646
pub freelancer: RoleMetrics,
4747
pub is_blacklisted: bool,
4848
pub metadata_hash: Option<Bytes>,
49+
pub transfer_blocked: bool,
4950
}
5051

5152
impl Profile {
@@ -56,6 +57,7 @@ impl Profile {
5657
freelancer: RoleMetrics::new(),
5758
is_blacklisted: false,
5859
metadata_hash: None,
60+
transfer_blocked: true,
5961
}
6062
}
6163
}

contracts/reputation/src/storage.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,18 @@ pub fn write_profile(env: &Env, address: &Address, profile: &Profile) {
3333
.persistent()
3434
.extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
3535
}
36+
37+
pub fn profile_exists(env: &Env, address: &Address) -> bool {
38+
let key = StorageKey::Profile(address.clone());
39+
env.storage().persistent().has(&key)
40+
}
41+
42+
pub fn delete_profile(env: &Env, address: &Address) -> bool {
43+
let key = StorageKey::Profile(address.clone());
44+
if env.storage().persistent().has(&key) {
45+
env.storage().persistent().remove(&key);
46+
true
47+
} else {
48+
false
49+
}
50+
}

0 commit comments

Comments
 (0)