Skip to content
Open
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
35 changes: 35 additions & 0 deletions stellar-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub use admin_multisig::*;
mod admin_multisig_test;
#[cfg(test)]
mod multisig_test;
#[cfg(test)]
mod update_metadata_uri_test;

#[contract]
pub struct CertificateContract;
Expand Down Expand Up @@ -212,6 +214,39 @@ impl CertificateContract {
.set(&DataKey::Certificate(id.clone()), &cert);
}

/// Update the metadata URI of an existing certificate.
/// Only the original issuer of the certificate may call this function.
pub fn update_metadata_uri(env: Env, id: String, new_uri: String) {
let mut cert: Certificate = env
.storage()
.instance()
.get(&DataKey::Certificate(id.clone()))
.expect("Certificate not found");

// Only the original issuer is authorised to update the metadata URI
cert.issuer.require_auth();

if new_uri.len() == 0 {
panic!("metadata_uri cannot be empty");
}

let old_uri = cert.metadata_uri.clone();
cert.metadata_uri = new_uri.clone();
env.storage()
.instance()
.set(&DataKey::Certificate(id.clone()), &cert);

env.events().publish(
(symbol_short!("meta_upd"), id.clone()),
MetadataUriUpdatedEvent {
id,
issuer: cert.issuer,
old_uri,
new_uri,
},
);
}

/// Verify if a certificate is valid (active and not expired)
pub fn is_valid(env: Env, id: String) -> bool {
if let Some(cert) = env
Expand Down
112 changes: 112 additions & 0 deletions stellar-contracts/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,124 @@ fn test_issue_and_revoke() {
let revoked = client.is_revoked(&id);
assert!(revoked);

let metadata_uri = String::from_str(&env, "ipfs://Qm...");

env.mock_all_auths();
client.issue_certificate(&id, &issuer, &owner, &metadata_uri);

let cert = client.get_certificate(&id);
assert_eq!(cert.id, id);
assert_eq!(cert.status, CertificateStatus::Active);
assert_eq!(cert.revoked, false);

let reason = String::from_str(&env, "Violation of terms");
client.revoke_certificate(&id, &reason);

let revoked = client.is_revoked(&id);
assert!(revoked);

let cert_revoked = client.get_certificate(&id);
assert_eq!(cert_revoked.status, CertificateStatus::Revoked);
assert_eq!(cert_revoked.revoked, true);
assert_eq!(cert_revoked.revocation_reason, Some(reason));
}

#[test]
fn test_update_metadata_uri_by_original_issuer() {
let env = Env::default();
let contract_id = env.register_contract(None, CertificateContract);
let client = CertificateContractClient::new(&env, &contract_id);

let issuer = Address::generate(&env);
let owner = Address::generate(&env);
let id = String::from_str(&env, "cert-meta-1");
let original_uri = String::from_str(&env, "ipfs://QmOriginal");
let new_uri = String::from_str(&env, "ipfs://QmMigrated");

env.mock_all_auths();
client.initialize(&issuer);
client.add_issuer(&issuer);
client.issue_certificate(&id, &issuer, &owner, &original_uri, &None);

client.update_metadata_uri(&id, &new_uri);

let cert = client.get_certificate(&id).expect("Certificate not found");
assert_eq!(cert.metadata_uri, new_uri);
}

#[test]
fn test_update_metadata_uri_rejected_for_non_issuer() {
let env = Env::default();
let contract_id = env.register_contract(None, CertificateContract);
let client = CertificateContractClient::new(&env, &contract_id);

let issuer = Address::generate(&env);
let other = Address::generate(&env);
let owner = Address::generate(&env);
let id = String::from_str(&env, "cert-meta-2");
let original_uri = String::from_str(&env, "ipfs://QmOriginal");
let new_uri = String::from_str(&env, "ipfs://QmAttacker");

env.mock_all_auths();
client.initialize(&issuer);
client.add_issuer(&issuer);
client.issue_certificate(&id, &issuer, &owner, &original_uri, &None);

// Simulate auth as `other` only — require_auth on issuer will fail
env.set_auths(&[(
other.clone(),
soroban_sdk::testutils::AuthorizedInvocation {
function: soroban_sdk::testutils::AuthorizedFunction::Contract((
contract_id.clone(),
soroban_sdk::symbol_short!("upd_meta"),
soroban_sdk::vec![&env],
)),
sub_invocations: soroban_sdk::vec![&env],
},
)]);

let result = client.try_update_metadata_uri(&id, &new_uri);
assert!(result.is_err(), "Non-issuer should not be able to update metadata_uri");
}

#[test]
fn test_update_metadata_uri_certificate_not_found() {
let env = Env::default();
let contract_id = env.register_contract(None, CertificateContract);
let client = CertificateContractClient::new(&env, &contract_id);

let issuer = Address::generate(&env);
let missing_id = String::from_str(&env, "cert-does-not-exist");
let new_uri = String::from_str(&env, "ipfs://QmAnything");

env.mock_all_auths();
client.initialize(&issuer);

let result = client.try_update_metadata_uri(&missing_id, &new_uri);
assert!(result.is_err(), "Should panic when certificate is not found");
}

#[test]
fn test_update_metadata_uri_rejects_empty_uri() {
let env = Env::default();
let contract_id = env.register_contract(None, CertificateContract);
let client = CertificateContractClient::new(&env, &contract_id);

let issuer = Address::generate(&env);
let owner = Address::generate(&env);
let id = String::from_str(&env, "cert-meta-empty");
let original_uri = String::from_str(&env, "ipfs://QmOriginal");
let empty_uri = String::from_str(&env, "");

env.mock_all_auths();
client.initialize(&issuer);
client.add_issuer(&issuer);
client.issue_certificate(&id, &issuer, &owner, &original_uri, &None);

let result = client.try_update_metadata_uri(&id, &empty_uri);
assert!(result.is_err(), "Empty metadata_uri should be rejected");
}

#[test]
fn test_batch_verify_certificates_partial_failure_and_cost() {
fn test_certificate_transfer_flow() {
Expand Down
9 changes: 9 additions & 0 deletions stellar-contracts/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ pub struct CertificateRevokedEvent {
pub reason: String,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetadataUriUpdatedEvent {
pub id: String,
pub issuer: Address,
pub old_uri: String,
pub new_uri: String,
}

// Multisig Types
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down
128 changes: 128 additions & 0 deletions stellar-contracts/src/update_metadata_uri_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#![cfg(test)]

use super::*;
use soroban_sdk::{testutils::Address as _, Address, Env, String};

fn setup(env: &Env) -> (CertificateContractClient, Address) {
let contract_id = env.register_contract(None, CertificateContract);
let client = CertificateContractClient::new(env, &contract_id);
let admin = Address::generate(env);
env.mock_all_auths();
client.initialize(&admin);
(client, admin)
}

fn issue(
env: &Env,
client: &CertificateContractClient,
id: &str,
issuer: &Address,
owner: &Address,
uri: &str,
) {
client.add_issuer(issuer);
client.issue_certificate(
&String::from_str(env, id),
issuer,
owner,
&String::from_str(env, uri),
&None,
);
}

// ---------------------------------------------------------------------------
// Happy path: original issuer updates the URI
// ---------------------------------------------------------------------------
#[test]
fn test_update_metadata_uri_by_original_issuer() {
let env = Env::default();
let (client, _admin) = setup(&env);

let issuer = Address::generate(&env);
let owner = Address::generate(&env);
let id = String::from_str(&env, "cert-meta-1");
let new_uri = String::from_str(&env, "ipfs://QmMigrated");

issue(&env, &client, "cert-meta-1", &issuer, &owner, "ipfs://QmOriginal");

client.update_metadata_uri(&id, &new_uri);

let cert = client.get_certificate(&id).expect("Certificate not found");
assert_eq!(cert.metadata_uri, new_uri);
}

// ---------------------------------------------------------------------------
// Auth failure: caller is NOT the original issuer
// ---------------------------------------------------------------------------
#[test]
fn test_update_metadata_uri_rejected_for_non_issuer() {
let env = Env::default();
let contract_id = env.register_contract(None, CertificateContract);
let client = CertificateContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
let issuer = Address::generate(&env);
let owner = Address::generate(&env);
let other = Address::generate(&env);
let id = String::from_str(&env, "cert-meta-2");
let new_uri = String::from_str(&env, "ipfs://QmAttacker");

env.mock_all_auths();
client.initialize(&admin);
issue(&env, &client, "cert-meta-2", &issuer, &owner, "ipfs://QmOriginal");

// Allow only `other` to authorise — issuer.require_auth() must fail
env.mock_auths(&[soroban_sdk::testutils::MockAuth {
address: &other,
invoke: &soroban_sdk::testutils::MockAuthInvoke {
contract: &contract_id,
fn_name: "update_metadata_uri",
args: soroban_sdk::vec![&env],
sub_invokes: &[],
},
}]);

let result = client.try_update_metadata_uri(&id, &new_uri);
assert!(
result.is_err(),
"Non-issuer should not be able to update metadata_uri"
);
}

// ---------------------------------------------------------------------------
// Certificate does not exist
// ---------------------------------------------------------------------------
#[test]
fn test_update_metadata_uri_certificate_not_found() {
let env = Env::default();
let (client, _admin) = setup(&env);

let missing_id = String::from_str(&env, "cert-does-not-exist");
let new_uri = String::from_str(&env, "ipfs://QmAnything");

let result = client.try_update_metadata_uri(&missing_id, &new_uri);
assert!(
result.is_err(),
"Should return an error when certificate is not found"
);
}

// ---------------------------------------------------------------------------
// Empty URI must be rejected
// ---------------------------------------------------------------------------
#[test]
fn test_update_metadata_uri_rejects_empty_uri() {
let env = Env::default();
let (client, _admin) = setup(&env);

let issuer = Address::generate(&env);
let owner = Address::generate(&env);
let id = String::from_str(&env, "cert-meta-empty");

issue(&env, &client, "cert-meta-empty", &issuer, &owner, "ipfs://QmOriginal");

let result = client.try_update_metadata_uri(&id, &String::from_str(&env, ""));
assert!(
result.is_err(),
"Empty metadata_uri should be rejected"
);
}