From 5a93f06ae01cae4985b1a42b82935d60741a8ffe Mon Sep 17 00:00:00 2001 From: Anioke Sebastian Date: Wed, 27 May 2026 15:39:47 +0100 Subject: [PATCH] feat(reputation): add badge metadata mapping to decentralised storage Adds BadgeTier enum and BadgeMetadataEntry struct to Profile for per-tier IPFS/URI storage. set_badge_metadata (admin-only) stores a URI for a given tier and address; get_badge_metadata retrieves it. Updating an existing tier overwrites the entry in-place. Four tests cover: set/get round-trip, absent tier returns None, overwrite semantics, and multiple tiers stored independently. Closes #406 Co-Authored-By: Claude Sonnet 4.6 --- contracts/reputation/src/lib.rs | 125 ++++++++++++++++++++++++++++ contracts/reputation/src/profile.rs | 21 +++++ 2 files changed, 146 insertions(+) diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index 6ec8fefe..eea00c8b 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -7,6 +7,7 @@ use soroban_sdk::{ mod profile; mod storage; +pub use profile::{BadgeMetadataEntry, BadgeTier}; // Types matching Job Registry contract's public types for cross-contract decoding #[contracttype] @@ -389,6 +390,61 @@ impl ReputationContract { Self::bump_instance_ttl(&env); } + /// Admin-only: set the decentralised-storage URI for a badge tier. + /// `uri` is typically an IPFS CID pointing to the badge image/JSON. + pub fn set_badge_metadata( + env: Env, + admin: Address, + address: Address, + tier: BadgeTier, + uri: Bytes, + ) { + let configured_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); + admin.require_auth(); + assert!(admin == configured_admin, "unauthorized"); + + let mut profile = storage::read_profile_or_default(&env, &address); + + // Replace existing entry for this tier or push a new one. + let mut found = false; + let len = profile.badge_metadata.len(); + for i in 0..len { + let entry = profile.badge_metadata.get(i).unwrap(); + if entry.tier == tier { + profile.badge_metadata.set(i, BadgeMetadataEntry { tier: tier.clone(), uri: uri.clone() }); + found = true; + break; + } + } + if !found { + profile.badge_metadata.push_back(BadgeMetadataEntry { tier, uri }); + } + + storage::write_profile(&env, &address, &profile); + Self::bump_instance_ttl(&env); + } + + /// Return the metadata URI for a given badge tier, or `None` if not set. + pub fn get_badge_metadata( + env: Env, + address: Address, + tier: BadgeTier, + ) -> Option { + Self::bump_instance_ttl(&env); + let profile = storage::read_profile_or_default(&env, &address); + for i in 0..profile.badge_metadata.len() { + let entry = profile.badge_metadata.get(i).unwrap(); + if entry.tier == tier { + return Some(entry.uri); + } + } + None + } + pub fn get_score(env: Env, address: Address, role: Role) -> ReputationScore { Self::bump_instance_ttl(&env); let profile = storage::read_profile_or_default(&env, &address); @@ -654,6 +710,75 @@ mod test { assert_eq!(freelancer_score.reviews, 1); } + // ── Issue #406: badge metadata mapping ── + + #[test] + fn test_set_and_get_badge_metadata() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + let uri = Bytes::from_slice(&env, b"ipfs://QmBronzeBadge"); + client.set_badge_metadata(&admin, &addr, &BadgeTier::Bronze, &uri); + + let result = client.get_badge_metadata(&addr, &BadgeTier::Bronze); + assert_eq!(result, Some(uri)); + } + + #[test] + fn test_badge_metadata_returns_none_when_unset() { + let env = Env::default(); + env.mock_all_auths(); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + + let result = client.get_badge_metadata(&addr, &BadgeTier::Gold); + assert_eq!(result, None); + } + + #[test] + fn test_badge_metadata_update_overwrites_existing() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + let uri_v1 = Bytes::from_slice(&env, b"ipfs://QmSilverV1"); + let uri_v2 = Bytes::from_slice(&env, b"ipfs://QmSilverV2"); + client.set_badge_metadata(&admin, &addr, &BadgeTier::Silver, &uri_v1); + client.set_badge_metadata(&admin, &addr, &BadgeTier::Silver, &uri_v2); + + assert_eq!(client.get_badge_metadata(&addr, &BadgeTier::Silver), Some(uri_v2)); + } + + #[test] + fn test_multiple_tiers_stored_independently() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + let bronze_uri = Bytes::from_slice(&env, b"ipfs://Bronze"); + let gold_uri = Bytes::from_slice(&env, b"ipfs://Gold"); + client.set_badge_metadata(&admin, &addr, &BadgeTier::Bronze, &bronze_uri); + client.set_badge_metadata(&admin, &addr, &BadgeTier::Gold, &gold_uri); + + assert_eq!(client.get_badge_metadata(&addr, &BadgeTier::Bronze), Some(bronze_uri)); + assert_eq!(client.get_badge_metadata(&addr, &BadgeTier::Gold), Some(gold_uri)); + assert_eq!(client.get_badge_metadata(&addr, &BadgeTier::Silver), None); + } + #[test] #[should_panic(expected = "Error(Contract, #2)")] fn test_upgrade_requires_admin() { diff --git a/contracts/reputation/src/profile.rs b/contracts/reputation/src/profile.rs index 5a47aa10..8372e94e 100644 --- a/contracts/reputation/src/profile.rs +++ b/contracts/reputation/src/profile.rs @@ -1,5 +1,23 @@ use soroban_sdk::{contracttype, Address, Bytes, Env}; +/// Badge tiers keyed in the metadata map. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BadgeTier { + Bronze, + Silver, + Gold, + Platinum, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct BadgeMetadataEntry { + pub tier: BadgeTier, + /// IPFS CID (or any URI) pointing to the badge image / JSON metadata. + pub uri: Bytes, +} + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct Profile { @@ -11,6 +29,8 @@ pub struct Profile { pub freelancer_points: i32, pub freelancer_jobs: u32, pub metadata_hash: Option, + /// Per-tier badge metadata URIs set by the admin. + pub badge_metadata: soroban_sdk::Vec, } impl Profile { @@ -24,6 +44,7 @@ impl Profile { freelancer_points: 0, freelancer_jobs: 0, metadata_hash: None, + badge_metadata: soroban_sdk::Vec::new(_env), } }