Skip to content

Commit 0901600

Browse files
Merge pull request #668 from sochima2/issue-571
Issue 571
2 parents 4e652dc + 8a62bdc commit 0901600

6 files changed

Lines changed: 804 additions & 1922 deletions

File tree

contracts/src/file_notarization.rs

Lines changed: 238 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,121 +1,291 @@
1-
#![no_std]
2-
use crate::timestamping::{get_current_proof, TimestampedProof};
3-
use soroban_sdk::{contracttype, Address, BytesN, Env, String, Symbol, Vec};
1+
//! Educational Soroban file notarization contract.
2+
//!
3+
//! A notary does not store a document on-chain. Instead, users hash a document
4+
//! off-chain (for example with SHA-256) and register only that 32-byte digest.
5+
//! Anyone can later hash the same document and call `verify` to prove the file
6+
//! existed at or before the stored ledger timestamp without revealing contents.
47
5-
/// Immutable record of a notarized file hash.
8+
use soroban_sdk::{
9+
contract, contracterror, contractimpl, contracttype, panic_with_error, Address, BytesN, Env,
10+
String, Symbol, Vec,
11+
};
12+
13+
/// Immutable proof-of-existence data captured when a hash is registered.
614
#[contracttype]
715
#[derive(Clone, Debug, Eq, PartialEq)]
816
pub struct NotarizationRecord {
9-
/// SHA-256 hash of the notarized file.
17+
/// SHA-256 (or equivalent 32-byte) document digest supplied by the user.
1018
pub hash: BytesN<32>,
11-
/// Address that performed the notarization.
19+
/// Account that registered the hash and must authorize registration.
1220
pub owner: Address,
13-
/// On-chain proof including timestamp and ledger sequence.
14-
pub proof: TimestampedProof,
15-
/// Optional metadata or descriptive text for the file.
21+
/// Ledger close timestamp in seconds. This is the educational notarization
22+
/// time anchor used by verifiers.
23+
pub timestamp: u64,
24+
/// Ledger sequence captured with the timestamp for deterministic ordering.
25+
pub ledger_sequence: u32,
26+
/// Short user-facing note such as a filename, version, or classroom label.
27+
/// The real document should stay off-chain to preserve privacy and save gas.
1628
pub metadata: String,
1729
}
1830

19-
/// Storage keys for notarization data.
31+
/// Storage layout for the notarization registry.
2032
#[contracttype]
33+
#[derive(Clone, Debug, Eq, PartialEq)]
2134
pub enum NotarizationKey {
22-
/// Individual record indexed by file hash.
35+
/// Global record lookup by document hash.
2336
Record(BytesN<32>),
24-
/// List of file hashes notarized by a specific address.
25-
OwnerHistory(Address),
37+
/// Per-owner list of hashes to power playground history screens.
38+
OwnerHashes(Address),
2639
}
2740

28-
/// Logic for the File Notarization System.
29-
pub struct NotarizationManager;
41+
/// Revert reasons intentionally use stable numeric discriminants so tests and
42+
/// learners can identify exactly why a transaction failed.
43+
#[contracterror]
44+
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
45+
pub enum NotarizationError {
46+
/// A hash can be notarized only once because the first timestamp is the
47+
/// legally meaningful proof-of-existence anchor in this lab.
48+
HashAlreadyRegistered = 1,
49+
/// Bulk calls must provide one metadata entry per hash to avoid accidental
50+
/// mismatches in classroom exercises.
51+
MetadataLengthMismatch = 2,
52+
/// Empty batches waste ledger resources and usually indicate a UI mistake.
53+
EmptyBatch = 3,
54+
}
3055

31-
impl NotarizationManager {
32-
/// Notarizes a file hash on-chain.
56+
#[contract]
57+
pub struct FileNotarizationContract;
58+
59+
#[contractimpl]
60+
impl FileNotarizationContract {
61+
/// Register a 32-byte file hash with the current ledger timestamp.
3362
///
34-
/// # Arguments
35-
/// * `env` - The Soroban environment.
36-
/// * `owner` - The address notarizing the file (must authorize).
37-
/// * `hash` - The SHA-256 hash of the file.
38-
/// * `metadata` - Optional metadata for the notarization.
39-
pub fn notarize(env: &Env, owner: Address, hash: BytesN<32>, metadata: String) {
63+
/// The caller supplies `owner` explicitly so tests and frontends can teach
64+
/// authorization: `owner.require_auth()` ensures only that address can create
65+
/// records in its own name. If the hash already exists the contract reverts,
66+
/// preserving the original timestamp and owner.
67+
pub fn register_hash(
68+
env: Env,
69+
owner: Address,
70+
hash: BytesN<32>,
71+
metadata: String,
72+
) -> NotarizationRecord {
4073
owner.require_auth();
74+
Self::store_new_record(&env, owner, hash, metadata)
75+
}
4176

42-
// Check if already notarized to maintain immutability and uniqueness
43-
if env
44-
.storage()
45-
.persistent()
46-
.has(&NotarizationKey::Record(hash.clone()))
47-
{
48-
// We return early instead of panicking to save gas if the file is already protected
49-
return;
77+
/// Register several hashes in one transaction.
78+
///
79+
/// This helper mirrors the single-hash path and still emits one event per
80+
/// file. It is useful for showing how notarization can batch classroom
81+
/// submissions while keeping each file independently verifiable.
82+
pub fn register_batch(
83+
env: Env,
84+
owner: Address,
85+
hashes: Vec<BytesN<32>>,
86+
metadata: Vec<String>,
87+
) -> Vec<NotarizationRecord> {
88+
if hashes.is_empty() {
89+
panic_with_error!(&env, NotarizationError::EmptyBatch);
90+
}
91+
if hashes.len() != metadata.len() {
92+
panic_with_error!(&env, NotarizationError::MetadataLengthMismatch);
93+
}
94+
95+
owner.require_auth();
96+
let mut records = Vec::new(&env);
97+
for index in 0..hashes.len() {
98+
let record = Self::store_new_record(
99+
&env,
100+
owner.clone(),
101+
hashes.get(index).unwrap(),
102+
metadata.get(index).unwrap(),
103+
);
104+
records.push_back(record);
105+
}
106+
records
107+
}
108+
109+
fn store_new_record(
110+
env: &Env,
111+
owner: Address,
112+
hash: BytesN<32>,
113+
metadata: String,
114+
) -> NotarizationRecord {
115+
let record_key = NotarizationKey::Record(hash.clone());
116+
if env.storage().persistent().has(&record_key) {
117+
panic_with_error!(env, NotarizationError::HashAlreadyRegistered);
50118
}
51119

52120
let record = NotarizationRecord {
53121
hash: hash.clone(),
54122
owner: owner.clone(),
55-
proof: get_current_proof(env),
123+
timestamp: env.ledger().timestamp(),
124+
ledger_sequence: env.ledger().sequence(),
56125
metadata,
57126
};
58127

59-
// Store the record indexed by hash
60-
env.storage()
61-
.persistent()
62-
.set(&NotarizationKey::Record(hash.clone()), &record);
128+
env.storage().persistent().set(&record_key, &record);
63129

64-
// Update owner's notarization history
65-
let mut history: Vec<BytesN<32>> = env
130+
let history_key = NotarizationKey::OwnerHashes(owner.clone());
131+
let mut hashes: Vec<BytesN<32>> = env
66132
.storage()
67133
.persistent()
68-
.get(&NotarizationKey::OwnerHistory(owner.clone()))
134+
.get(&history_key)
69135
.unwrap_or_else(|| Vec::new(env));
136+
hashes.push_back(hash.clone());
137+
env.storage().persistent().set(&history_key, &hashes);
70138

71-
history.push_back(hash.clone());
72-
env.storage()
73-
.persistent()
74-
.set(&NotarizationKey::OwnerHistory(owner), &history);
75-
76-
// Emit notarization event
77139
env.events().publish(
78-
(Symbol::new(env, "notarize"), Symbol::new(env, "v1")),
79-
(record.hash, record.owner, record.proof.timestamp),
140+
(Symbol::new(env, "file_notarized"), owner.clone()),
141+
(hash, record.timestamp, record.ledger_sequence),
80142
);
143+
144+
record
81145
}
82146

83-
/// Verifies if a file hash has been notarized on-chain.
84-
pub fn verify(env: &Env, hash: BytesN<32>) -> Option<NotarizationRecord> {
147+
/// Return the notarization record for `hash`, if it exists.
148+
pub fn verify(env: Env, hash: BytesN<32>) -> Option<NotarizationRecord> {
85149
env.storage()
86150
.persistent()
87151
.get(&NotarizationKey::Record(hash))
88152
}
89153

90-
/// Retrieves all notarization records for a specific address.
91-
pub fn get_history(env: &Env, owner: Address) -> Vec<NotarizationRecord> {
154+
/// Convenience boolean for playground simulations and beginner exercises.
155+
pub fn is_registered(env: Env, hash: BytesN<32>) -> bool {
156+
env.storage()
157+
.persistent()
158+
.has(&NotarizationKey::Record(hash))
159+
}
160+
161+
/// Return all records created by `owner` in registration order.
162+
pub fn history_for_owner(env: Env, owner: Address) -> Vec<NotarizationRecord> {
92163
let hashes: Vec<BytesN<32>> = env
93164
.storage()
94165
.persistent()
95-
.get(&NotarizationKey::OwnerHistory(owner))
96-
.unwrap_or_else(|| Vec::new(env));
166+
.get(&NotarizationKey::OwnerHashes(owner))
167+
.unwrap_or_else(|| Vec::new(&env));
97168

98-
let mut records = Vec::new(env);
169+
let mut records = Vec::new(&env);
99170
for hash in hashes.iter() {
100-
if let Some(record) = Self::verify(env, hash) {
171+
if let Some(record) = Self::verify(env.clone(), hash) {
101172
records.push_back(record);
102173
}
103174
}
104175
records
105176
}
177+
}
106178

107-
/// Bulk notarization helper.
108-
pub fn bulk_notarize(
109-
env: &Env,
110-
owner: Address,
111-
hashes: Vec<BytesN<32>>,
112-
metadata: Vec<String>,
113-
) {
114-
owner.require_auth();
115-
for i in 0..hashes.len() {
116-
let hash = hashes.get(i).unwrap();
117-
let meta = metadata.get(i).unwrap_or_else(|| String::from_str(env, ""));
118-
Self::notarize(env, owner.clone(), hash, meta);
119-
}
179+
#[cfg(test)]
180+
mod tests {
181+
use super::*;
182+
use soroban_sdk::{
183+
testutils::{Address as _, Ledger},
184+
vec, BytesN, Env, String,
185+
};
186+
187+
fn hash(env: &Env, seed: u8) -> BytesN<32> {
188+
BytesN::from_array(env, &[seed; 32])
189+
}
190+
191+
fn setup() -> (Env, Address, FileNotarizationContractClient<'static>) {
192+
let env = Env::default();
193+
env.mock_all_auths();
194+
env.ledger().with_mut(|ledger| {
195+
ledger.timestamp = 1_772_600_400;
196+
ledger.sequence_number = 42;
197+
});
198+
let owner = Address::generate(&env);
199+
let contract_id = env.register(FileNotarizationContract, ());
200+
let client = FileNotarizationContractClient::new(&env, &contract_id);
201+
(env, owner, client)
202+
}
203+
204+
#[test]
205+
fn registers_hash_with_timestamp_and_metadata() {
206+
let (env, owner, client) = setup();
207+
let digest = hash(&env, 7);
208+
let note = String::from_str(&env, "final-report.pdf");
209+
210+
let record = client.register_hash(&owner, &digest, &note);
211+
212+
assert_eq!(record.hash, digest);
213+
assert_eq!(record.owner, owner);
214+
assert_eq!(record.timestamp, 1_772_600_400);
215+
assert_eq!(record.ledger_sequence, 42);
216+
assert_eq!(record.metadata, note);
217+
assert!(client.is_registered(&digest));
218+
}
219+
220+
#[test]
221+
fn verify_returns_record_and_missing_hash_returns_none() {
222+
let (env, owner, client) = setup();
223+
let digest = hash(&env, 11);
224+
let missing = hash(&env, 12);
225+
226+
let created = client.register_hash(&owner, &digest, &String::from_str(&env, "lab"));
227+
228+
assert_eq!(client.verify(&digest), Some(created));
229+
assert_eq!(client.verify(&missing), None);
230+
}
231+
232+
#[test]
233+
fn owner_history_preserves_registration_order() {
234+
let (env, owner, client) = setup();
235+
let first = hash(&env, 1);
236+
let second = hash(&env, 2);
237+
238+
let first_record = client.register_hash(&owner, &first, &String::from_str(&env, "a"));
239+
let second_record = client.register_hash(&owner, &second, &String::from_str(&env, "b"));
240+
241+
let history = client.history_for_owner(&owner);
242+
assert_eq!(history.len(), 2);
243+
assert_eq!(history.get(0).unwrap(), first_record);
244+
assert_eq!(history.get(1).unwrap(), second_record);
245+
}
246+
247+
#[test]
248+
fn batch_registration_creates_independent_records() {
249+
let (env, owner, client) = setup();
250+
let hashes = vec![&env, hash(&env, 21), hash(&env, 22)];
251+
let metadata = vec![
252+
&env,
253+
String::from_str(&env, "chapter-1"),
254+
String::from_str(&env, "chapter-2"),
255+
];
256+
257+
let records = client.register_batch(&owner, &hashes, &metadata);
258+
259+
assert_eq!(records.len(), 2);
260+
assert!(client.is_registered(&hashes.get(0).unwrap()));
261+
assert!(client.is_registered(&hashes.get(1).unwrap()));
262+
assert_eq!(client.history_for_owner(&owner).len(), 2);
263+
}
264+
265+
#[test]
266+
#[should_panic(expected = "Error(Contract, #1)")]
267+
fn duplicate_hash_reverts_to_keep_first_timestamp_immutable() {
268+
let (env, owner, client) = setup();
269+
let digest = hash(&env, 5);
270+
271+
client.register_hash(&owner, &digest, &String::from_str(&env, "original"));
272+
client.register_hash(&owner, &digest, &String::from_str(&env, "duplicate"));
273+
}
274+
275+
#[test]
276+
#[should_panic(expected = "Error(Contract, #2)")]
277+
fn batch_metadata_length_must_match_hashes() {
278+
let (env, owner, client) = setup();
279+
let hashes = vec![&env, hash(&env, 31), hash(&env, 32)];
280+
let metadata = vec![&env, String::from_str(&env, "only-one")];
281+
282+
client.register_batch(&owner, &hashes, &metadata);
283+
}
284+
285+
#[test]
286+
#[should_panic(expected = "Error(Contract, #3)")]
287+
fn empty_batch_reverts() {
288+
let (env, owner, client) = setup();
289+
client.register_batch(&owner, &Vec::new(&env), &Vec::new(&env));
120290
}
121291
}

0 commit comments

Comments
 (0)