Skip to content

Commit dd60ef8

Browse files
authored
feat: implement quest completion certificates (SBTs) (lernza#287)
1 parent d33f322 commit dd60ef8

44 files changed

Lines changed: 21816 additions & 15450 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 78 additions & 163 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ members = [
55
]
66

77
[workspace.dependencies]
8-
soroban-sdk = "25"
8+
soroban-sdk = "22"
9+
stellar-tokens = "0.4.1"
10+
stellar-access = "0.4.1"
11+
stellar-macros = "0.4.1"
912

1013
[profile.release]
1114
opt-level = "z"

contracts/certificate/Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "certificate"
3+
version = "0.0.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[lib]
8+
crate-type = ["lib", "cdylib"]
9+
10+
[dependencies]
11+
soroban-sdk = { workspace = true }
12+
stellar-tokens = { workspace = true }
13+
stellar-access = { workspace = true }
14+
stellar-macros = { workspace = true }
15+
16+
[dev-dependencies]
17+
soroban-sdk = { workspace = true, features = ["testutils"] }

contracts/certificate/src/lib.rs

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
#![no_std]
2+
#![allow(deprecated)]
3+
4+
use soroban_sdk::{
5+
contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, String, Vec,
6+
};
7+
use stellar_access::ownable::{self as ownable, Ownable};
8+
use stellar_macros::{default_impl, only_owner};
9+
use stellar_tokens::non_fungible::{burnable::NonFungibleBurnable, Base, NonFungibleToken};
10+
11+
#[cfg(test)]
12+
mod test;
13+
14+
#[contracttype]
15+
#[derive(Clone, Debug, PartialEq)]
16+
pub struct CertificateMetadata {
17+
pub quest_id: u32,
18+
pub quest_name: String,
19+
pub quest_category: String,
20+
pub completion_date: u64,
21+
pub issuer: Address,
22+
pub recipient: Address,
23+
}
24+
25+
#[contracttype]
26+
#[derive(Clone)]
27+
pub enum DataKey {
28+
CertificateMetadata(u32), // token_id -> metadata
29+
QuestCertificate(u32, Address), // quest_id -> recipient -> token_id
30+
UserCertificates(Address), // user -> Vec<token_id>
31+
NextCertificateId,
32+
}
33+
34+
#[contracterror]
35+
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
36+
#[repr(u32)]
37+
pub enum Error {
38+
NotOwner = 1,
39+
NotAuthorized = 2,
40+
AlreadyIssued = 3,
41+
NotFound = 4,
42+
InvalidQuest = 5,
43+
}
44+
45+
const BUMP: u32 = 518_400;
46+
const THRESHOLD: u32 = 120_960;
47+
48+
#[contract]
49+
pub struct CertificateContract;
50+
51+
#[contractimpl]
52+
impl CertificateContract {
53+
/// Initialize the certificate contract
54+
pub fn __constructor(env: Env, owner: Address) {
55+
// Set token metadata
56+
Base::set_metadata(
57+
&env,
58+
String::from_str(&env, "https://lernza.io/certificates"),
59+
String::from_str(&env, "Lernza Quest Completion Certificates"),
60+
String::from_str(&env, "LERNZA_CERT"),
61+
);
62+
63+
// Set the contract owner
64+
ownable::set_owner(&env, &owner);
65+
66+
// Initialize next certificate ID
67+
env.storage()
68+
.instance()
69+
.set(&DataKey::NextCertificateId, &1u32);
70+
env.storage().instance().extend_ttl(THRESHOLD, BUMP);
71+
}
72+
73+
/// Mint a certificate for quest completion
74+
/// Only authorized addresses can mint certificates
75+
#[only_owner]
76+
pub fn mint_certificate(
77+
env: Env,
78+
quest_id: u32,
79+
quest_name: String,
80+
quest_category: String,
81+
recipient: Address,
82+
issuer: Address,
83+
) -> Result<u32, Error> {
84+
// Check if certificate already exists for this quest and recipient
85+
let cert_key = DataKey::QuestCertificate(quest_id, recipient.clone());
86+
if env.storage().persistent().has(&cert_key) {
87+
return Err(Error::AlreadyIssued);
88+
}
89+
90+
// Get next certificate ID
91+
let next_id: u32 = env
92+
.storage()
93+
.instance()
94+
.get(&DataKey::NextCertificateId)
95+
.unwrap_or(1);
96+
97+
// Create metadata
98+
let metadata = CertificateMetadata {
99+
quest_id,
100+
quest_name: quest_name.clone(),
101+
quest_category,
102+
completion_date: env.ledger().timestamp(),
103+
issuer: issuer.clone(),
104+
recipient: recipient.clone(),
105+
};
106+
107+
// Store metadata
108+
let metadata_key = DataKey::CertificateMetadata(next_id);
109+
env.storage().persistent().set(&metadata_key, &metadata);
110+
env.storage()
111+
.persistent()
112+
.extend_ttl(&metadata_key, THRESHOLD, BUMP);
113+
114+
// Store quest -> recipient -> token_id mapping
115+
env.storage().persistent().set(&cert_key, &next_id);
116+
env.storage()
117+
.persistent()
118+
.extend_ttl(&cert_key, THRESHOLD, BUMP);
119+
120+
// Update user certificates list
121+
let user_key = DataKey::UserCertificates(recipient.clone());
122+
let mut certificates: Vec<u32> = env
123+
.storage()
124+
.persistent()
125+
.get(&user_key)
126+
.unwrap_or(Vec::new(&env));
127+
certificates.push_back(next_id);
128+
env.storage().persistent().set(&user_key, &certificates);
129+
env.storage()
130+
.persistent()
131+
.extend_ttl(&user_key, THRESHOLD, BUMP);
132+
133+
// Update next certificate ID
134+
env.storage()
135+
.instance()
136+
.set(&DataKey::NextCertificateId, &(next_id + 1));
137+
env.storage().instance().extend_ttl(THRESHOLD, BUMP);
138+
139+
// Mint the NFT to the recipient
140+
let token_id = Base::sequential_mint(&env, &recipient);
141+
142+
// Emit certificate minted event
143+
// Event topics: (cert, minted)
144+
// Event data: (token_id, quest_id, recipient, quest_name)
145+
env.events().publish(
146+
(symbol_short!("cert"), symbol_short!("minted")),
147+
(token_id, quest_id, recipient, quest_name),
148+
);
149+
150+
Ok(token_id)
151+
}
152+
153+
/// Get certificate metadata
154+
pub fn get_certificate_metadata(env: Env, token_id: u32) -> Result<CertificateMetadata, Error> {
155+
let key = DataKey::CertificateMetadata(token_id);
156+
env.storage().persistent().get(&key).ok_or(Error::NotFound)
157+
}
158+
159+
/// Get certificate ID for a quest and recipient
160+
pub fn get_quest_certificate(
161+
env: Env,
162+
quest_id: u32,
163+
recipient: Address,
164+
) -> Result<u32, Error> {
165+
let key = DataKey::QuestCertificate(quest_id, recipient);
166+
env.storage().persistent().get(&key).ok_or(Error::NotFound)
167+
}
168+
169+
/// Get all certificates for a user
170+
pub fn get_user_certificates(env: Env, user: Address) -> Vec<u32> {
171+
let key = DataKey::UserCertificates(user);
172+
env.storage()
173+
.persistent()
174+
.get(&key)
175+
.unwrap_or(Vec::new(&env))
176+
}
177+
178+
/// Check if a user has completed a specific quest
179+
pub fn has_quest_certificate(env: Env, quest_id: u32, recipient: Address) -> bool {
180+
let key = DataKey::QuestCertificate(quest_id, recipient);
181+
env.storage().persistent().has(&key)
182+
}
183+
184+
/// Mint a certificate for quest completion (internal function called by milestone contract)
185+
pub fn mint_quest_certificate(
186+
env: Env,
187+
quest_id: u32,
188+
quest_name: String,
189+
quest_category: String,
190+
recipient: Address,
191+
) -> Result<u32, Error> {
192+
// Get contract owner (will be the milestone contract)
193+
let owner = ownable::get_owner(&env).ok_or(Error::NotOwner)?;
194+
195+
// Call the owner-only mint function
196+
Self::mint_certificate(env, quest_id, quest_name, quest_category, recipient, owner)
197+
}
198+
199+
/// Get certificate details including metadata and NFT info
200+
pub fn get_certificate_details(
201+
env: Env,
202+
token_id: u32,
203+
) -> Result<(CertificateMetadata, Address), Error> {
204+
let metadata = Self::get_certificate_metadata(env.clone(), token_id)?;
205+
let owner = Base::owner_of(&env, token_id);
206+
Ok((metadata, owner))
207+
}
208+
209+
/// Get all certificate details for a user
210+
pub fn get_user_certificate_details(
211+
env: Env,
212+
user: Address,
213+
) -> Vec<(u32, CertificateMetadata)> {
214+
let certificate_ids = Self::get_user_certificates(env.clone(), user.clone());
215+
let mut details = Vec::new(&env);
216+
217+
for i in 0..certificate_ids.len() {
218+
if let Some(token_id) = certificate_ids.get(i) {
219+
if let Ok(metadata) = Self::get_certificate_metadata(env.clone(), token_id) {
220+
details.push_back((token_id, metadata));
221+
}
222+
}
223+
}
224+
225+
details
226+
}
227+
228+
/// Revoke a certificate (owner only, for exceptional cases)
229+
#[only_owner]
230+
pub fn revoke_certificate(env: Env, token_id: u32) -> Result<(), Error> {
231+
let metadata = Self::get_certificate_metadata(env.clone(), token_id)?;
232+
233+
// Remove from user's certificate list
234+
let user_key = DataKey::UserCertificates(metadata.recipient.clone());
235+
let certificates: Vec<u32> = env
236+
.storage()
237+
.persistent()
238+
.get(&user_key)
239+
.unwrap_or(Vec::new(&env));
240+
241+
// Remove the certificate ID from the list
242+
let mut new_certificates = Vec::new(&env);
243+
for i in 0..certificates.len() {
244+
if let Some(cert_id) = certificates.get(i) {
245+
if cert_id != token_id {
246+
new_certificates.push_back(cert_id);
247+
}
248+
}
249+
}
250+
251+
env.storage().persistent().set(&user_key, &new_certificates);
252+
env.storage()
253+
.persistent()
254+
.extend_ttl(&user_key, THRESHOLD, BUMP);
255+
256+
// Remove quest mapping
257+
let quest_key = DataKey::QuestCertificate(metadata.quest_id, metadata.recipient.clone());
258+
env.storage().persistent().remove(&quest_key);
259+
260+
// Remove metadata
261+
let metadata_key = DataKey::CertificateMetadata(token_id);
262+
env.storage().persistent().remove(&metadata_key);
263+
264+
// Burn the NFT
265+
Base::burn(&env, &metadata.recipient, token_id);
266+
267+
// Emit revocation event
268+
env.events().publish(
269+
(symbol_short!("cert"), symbol_short!("revoked")),
270+
(token_id, metadata.quest_id, metadata.recipient),
271+
);
272+
273+
Ok(())
274+
}
275+
}
276+
277+
#[default_impl]
278+
#[contractimpl]
279+
impl NonFungibleToken for CertificateContract {
280+
type ContractType = Base;
281+
}
282+
283+
#[default_impl]
284+
#[contractimpl]
285+
impl NonFungibleBurnable for CertificateContract {}
286+
287+
#[default_impl]
288+
#[contractimpl]
289+
impl Ownable for CertificateContract {}

0 commit comments

Comments
 (0)