Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
6f322d1
feat: add signed meter metadata payloads (#547)
jessicanath Jun 27, 2026
e0b4cae
docs: add product persona docs for auditors, cooperatives, and meter …
jessicanath Jun 27, 2026
847dfc5
feat: add multilingual fallback and locale detection (#535)
jessicanath Jun 27, 2026
75238eb
research: I-REC / Energy Web interoperability requirements (#605)
jessicanath Jun 27, 2026
18ba6ec
docs: add hardware meter provisioning UX backlog item
Vera3289 Jun 27, 2026
c08df1c
feat: normalize API error messaging across pages (#469)
jaydenkalu Jun 29, 2026
3dfe2c4
test(energy-token): add SEP-41 metadata and trustline edge-case tests…
katecatherina Jun 29, 2026
558b4ce
feat: centralize Supabase client initialization (#544)
FaithOnuh Jun 30, 2026
abbe01d
chore: harden contract initialization guards (#554) (#736)
Ginaonuh Jul 1, 2026
45d027c
Add consistent skeleton loaders to data pages. (#735)
mexes20 Jul 1, 2026
6f002c8
Add accessible labels to charts and graph visualizations (#734)
mexes20 Jul 1, 2026
5d81fc4
Feat/anchor status (#733)
mexes20 Jul 1, 2026
bee465e
feat: note to backlog for real-time certificate alerts (#732)
mexes20 Jul 1, 2026
fbcc634
feat: browser back navigation support for dialog state (#478) (#731)
Agnes432 Jul 1, 2026
83dfced
feat: add loading state for governance proposal operations #484 (#730)
Eucharia67 Jul 1, 2026
81f8d35
feat: improve admin suspension action confirmation (#485) (#729)
precious522 Jul 1, 2026
bc4f503
chore: scaffold unsupported browser landing page #481 (#728)
Claudian2122 Jul 1, 2026
b894a2e
feat(#475): accessible inline validation on all forms (#726)
sophiawilliamz Jul 1, 2026
8ec133c
fix(#470): clear stale wallet state on session expiry (#725)
sophiawilliamz Jul 1, 2026
a39465e
feat(#468): add disconnected wallet fallback UI (#724)
sophiawilliamz Jul 1, 2026
9fa2e81
docs,test(contracts): add storage cost estimation for contract method…
katecatherina Jul 1, 2026
144aea1
test(energy-token): add explicit access control tests for set_minter …
katecatherina Jul 1, 2026
68e8dc0
feat(db): add migration registry and drift detection (#548) (#720)
katecatherina Jul 1, 2026
a97311c
chore(deps): bump the minor-updates group with 4 updates (#719)
dependabot[bot] Jul 1, 2026
1cbcf79
feat: copy feedback and mobile clipboard support on verify page (#476…
jaydenkalu Jul 1, 2026
e4a225c
feat: add retry/backoff for transient Stellar RPC errors (#496) (#715)
jaydenkalu Jul 1, 2026
834e4fb
feat: add structured error categories for external failures (#493) (#…
jaydenkalu Jul 1, 2026
b3639f7
fix(#477): accessible theme toggle with aria-pressed, aria-label, and…
Hellenjoseph Jul 1, 2026
b2fd1d7
fix(#474): optimize dashboard performance and reduce re-renders (#712)
Hellenjoseph Jul 1, 2026
dbb348e
fix(#490): harden /api/readings schema validation (#710)
Hellenjoseph Jul 1, 2026
10bde52
chore(deps): bump the patch-updates group across 1 directory with 7 u…
dependabot[bot] Jul 1, 2026
a4eaeb0
Merge branch 'main' into feat/centralize-supabase-544
AnnabelJoe Jul 1, 2026
a956d93
Merge pull request #737 from FaithOnuh/feat/centralize-supabase-544
AnnabelJoe Jul 1, 2026
00bfefb
Merge branch 'main' into feat/613-hardware-meter-provisioning-backlog
AnnabelJoe Jul 1, 2026
df3c509
Merge pull request #705 from Vera3289/feat/613-hardware-meter-provisi…
AnnabelJoe Jul 1, 2026
4622eb7
Merge pull request #702 from jessicanath/docs/531-product-personas
AnnabelJoe Jul 3, 2026
33eaf56
Merge branch 'main' into feat/547-signed-meter-metadata
AnnabelJoe Jul 3, 2026
6289698
Merge pull request #701 from jessicanath/feat/547-signed-meter-metadata
AnnabelJoe Jul 3, 2026
bb5e085
Merge branch 'main' into feat/560-sep41-edge-cases
AnnabelJoe Jul 3, 2026
0a158b2
Merge pull request #721 from katecatherina/feat/560-sep41-edge-cases
AnnabelJoe Jul 3, 2026
b344cc9
Merge pull request #704 from jessicanath/research/605-irec-energyweb-…
AnnabelJoe Jul 3, 2026
ef4d620
Merge branch 'main' into feat/535-i18n-locale-detection
AnnabelJoe Jul 3, 2026
16c5362
Merge pull request #703 from jessicanath/feat/535-i18n-locale-detection
AnnabelJoe Jul 3, 2026
19b40dd
Merge branch 'main' into issues/469-normalize-api-error-messaging
AnnabelJoe Jul 3, 2026
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
198 changes: 122 additions & 76 deletions apps/contracts/audit_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,17 @@ pub struct AuditAnchor {
pub anchored_at_ledger: u32,
}

/// Enumeration of all storage keys used by this contract.
/// Consolidated read-only view of anchor status for a reading hash.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AnchorStatus {
/// Whether `reading_hash` has been anchored.
pub is_anchored: bool,
/// Ledger sequence at anchor time; `0` when `is_anchored` is `false`.
pub anchored_at_ledger: u32,
}

/// Enumeration of all storage keys used by this contract.
#[contracttype]
pub enum DataKey {
/// `Address` — the contract administrator.
Expand Down Expand Up @@ -175,6 +185,15 @@ impl AuditRegistry {
((b0 << 8) | b1) % 1024
}

fn lookup_ledger(env: Env, reading_hash: BytesN<32>) -> Option<u32> {
let bucket_id = Self::get_bucket_id(&reading_hash);
let bucket: Map<BytesN<32>, u32> = env
.storage()
.persistent()
.get(&DataKey::Bucket(bucket_id))?;
bucket.get(reading_hash)
}

/// Anchor a reading hash on-chain. Only the registered `api_signer` may call this.
///
/// # Arguments
Expand Down Expand Up @@ -266,9 +285,7 @@ impl AuditRegistry {
/// }
/// ```
pub fn verify(env: Env, reading_hash: BytesN<32>) -> Option<AuditAnchor> {
let bucket_id = Self::get_bucket_id(&reading_hash);
let bucket: Map<BytesN<32>, u32> = env.storage().persistent().get(&DataKey::Bucket(bucket_id))?;
let anchored_at_ledger = bucket.get(reading_hash.clone())?;
let anchored_at_ledger = Self::lookup_ledger(env.clone(), reading_hash.clone())?;
Some(AuditAnchor {
reading_hash,
anchored_at_ledger,
Expand All @@ -277,14 +294,38 @@ impl AuditRegistry {

/// Returns `true` if `reading_hash` has been anchored, `false` otherwise.
pub fn is_anchored(env: Env, reading_hash: BytesN<32>) -> bool {
let bucket_id = Self::get_bucket_id(&reading_hash);
let bucket: Option<Map<BytesN<32>, u32>> = env.storage().persistent().get(&DataKey::Bucket(bucket_id));
match bucket {
Some(b) => b.contains_key(reading_hash),
None => false,
Self::lookup_ledger(env, reading_hash).is_some()
}

/// Returns consolidated anchor status for `reading_hash` in a single call.
///
/// Prefer this over separate `is_anchored` + `verify` calls when you need
/// both the presence flag and the anchor ledger sequence.
pub fn anchor_status(env: Env, reading_hash: BytesN<32>) -> AnchorStatus {
match Self::lookup_ledger(env, reading_hash) {
Some(anchored_at_ledger) => AnchorStatus {
is_anchored: true,
anchored_at_ledger,
},
None => AnchorStatus {
is_anchored: false,
anchored_at_ledger: 0,
},
}
}

/// Returns the ledger sequence at which `reading_hash` was anchored.
///
/// Returns `None` when the hash has not been anchored.
pub fn anchored_at_ledger(env: Env, reading_hash: BytesN<32>) -> Option<u32> {
Self::lookup_ledger(env, reading_hash)
}

/// Returns the persistent storage bucket index (0–1023) for `reading_hash`.
pub fn bucket_for_hash(_env: Env, reading_hash: BytesN<32>) -> u32 {
Self::get_bucket_id(&reading_hash)
}

/// Returns the total number of reading hashes anchored so far.
pub fn total_anchors(env: Env) -> u32 {
env.storage()
Expand Down Expand Up @@ -336,11 +377,13 @@ impl AuditRegistry {
max_extension: u32,
) {
Self::require_admin(&env);
env.storage().persistent().extend_ttl_with_limits(
if extend_to < min_extension || extend_to > max_extension {
return;
}
env.storage().persistent().extend_ttl(
&DataKey::Bucket(bucket_id),
extend_to,
min_extension,
max_extension,
extend_to,
);
}

Expand All @@ -364,9 +407,12 @@ impl AuditRegistry {
max_extension: u32,
) {
Self::require_admin(&env);
if extend_to < min_extension || extend_to > max_extension {
return;
}
env.storage()
.instance()
.extend_ttl_with_limits(extend_to, min_extension, max_extension);
.extend_ttl(min_extension, extend_to);
}
}

Expand All @@ -377,7 +423,12 @@ impl AuditRegistry {
#[cfg(test)]
mod tests {
use super::*;
use soroban_sdk::{testutils::Address as _, Env};
use soroban_sdk::{
testutils::storage::{Instance, Persistent},
testutils::Address as _,
testutils::Ledger,
Env,
};

fn setup() -> (Env, soroban_sdk::Address, AuditRegistryClient<'static>) {
let env = Env::default();
Expand Down Expand Up @@ -510,8 +561,11 @@ mod tests {
fn test_total_anchors_increments() {
let (env, api_signer, client) = setup();
for i in 0u8..5 {
client
.anchor(&api_signer, &BytesN::from_array(&env, &[i; 32]), &make_nonce(&env, i));
client.anchor(
&api_signer,
&BytesN::from_array(&env, &[i; 32]),
&make_nonce(&env, i),
);
}
assert_eq!(client.total_anchors(), 5);
}
Expand Down Expand Up @@ -593,31 +647,66 @@ mod tests {
#[test]
fn test_extend_bucket_ttl() {
let (env, api_signer, client) = setup();
let contract_id = client.address.clone();
let h = hash(&env);
let n = make_nonce(&env, 1);
client.anchor(&api_signer, &h, &n).unwrap();
client.anchor(&api_signer, &h, &n);
let bucket_id = AuditRegistry::get_bucket_id(&h);
let before = env
.storage()
.persistent()
.get_ttl(&DataKey::Bucket(bucket_id));
client.extend_bucket_ttl(bucket_id, u32::MAX, before + 50);
let after = env
.storage()
.persistent()
.get_ttl(&DataKey::Bucket(bucket_id));
let before = env.as_contract(&contract_id, || {
env.storage()
.persistent()
.get_ttl(&DataKey::Bucket(bucket_id))
});
client.extend_bucket_ttl(&bucket_id, &before, &(before + 50));
let after = env.as_contract(&contract_id, || {
env.storage()
.persistent()
.get_ttl(&DataKey::Bucket(bucket_id))
});
assert!(after >= before);
}

#[test]
fn test_extend_contract_ttl() {
let (env, _api_signer, client) = setup();
let before = env.storage().instance().get_ttl();
client.extend_contract_ttl(u32::MAX, before + 50);
let after = env.storage().instance().get_ttl();
let contract_id = client.address.clone();
let before = env.as_contract(&contract_id, || env.storage().instance().get_ttl());
client.extend_contract_ttl(&before, &(before + 50));
let after = env.as_contract(&contract_id, || env.storage().instance().get_ttl());
assert!(after >= before);
}

#[test]
fn test_anchor_status_not_anchored() {
let (env, _api_signer, client) = setup();
let h = BytesN::from_array(&env, &[9u8; 32]);
assert_eq!(
client.anchor_status(&h),
AnchorStatus {
is_anchored: false,
anchored_at_ledger: 0,
}
);
assert!(client.anchored_at_ledger(&h).is_none());
}

#[test]
fn test_anchor_status_after_anchor() {
let (env, api_signer, client) = setup();
env.ledger().set_sequence_number(42);
let h = hash(&env);
client.anchor(&api_signer, &h, &make_nonce(&env, 1));
assert_eq!(
client.anchor_status(&h),
AnchorStatus {
is_anchored: true,
anchored_at_ledger: 42,
}
);
assert_eq!(client.anchored_at_ledger(&h), Some(42));
assert_eq!(client.bucket_for_hash(&h), AuditRegistry::get_bucket_id(&h));
}

#[test]
fn test_issue_281_bucket_collision() {
let (env, api_signer, client) = setup();
Expand All @@ -627,62 +716,19 @@ mod tests {
let mut h1_arr = [0u8; 32];
h1_arr[2] = 1;
let h1 = BytesN::from_array(&env, &h1_arr);

let mut h2_arr = [0u8; 32];
h2_arr[2] = 2;
let h2 = BytesN::from_array(&env, &h2_arr);

client.anchor(&api_signer, &h1, &make_nonce(&env, 1));
client.anchor(&api_signer, &h2, &make_nonce(&env, 2));

assert!(client.is_anchored(&h1));
assert!(client.is_anchored(&h2));
assert_eq!(client.total_anchors(), 2);
}

#[test]
fn test_admin_lookup() {
let (env, client) = setup();
let admin = client.admin();
assert_eq!(admin, Address::generate(&env));
}

// --- Invariant: zero and negative kwh_stroops must be rejected ---

#[test]
#[should_panic(expected = "kwh must be positive")]
fn test_zero_kwh_rejected() {
let (env, client) = setup();
let signer = soroban_sdk::testutils::ed25519::Signer::generate(&env);
let reading_hash = BytesN::from_array(&env, &[2u8; 32]);
let sig = signer.sign(&env, &Bytes::from_slice(&env, reading_hash.to_array().as_ref()));
client.anchor(
&reading_hash,
&signer.public_key(&env),
&sig,
&0_i128,
&soroban_sdk::String::from_str(&env, "METER-001"),
&1_700_000_000_u64,
);
}

#[test]
#[should_panic(expected = "kwh must be positive")]
fn test_negative_kwh_rejected() {
let (env, client) = setup();
let signer = soroban_sdk::testutils::ed25519::Signer::generate(&env);
let reading_hash = BytesN::from_array(&env, &[3u8; 32]);
let sig = signer.sign(&env, &Bytes::from_slice(&env, reading_hash.to_array().as_ref()));
client.anchor(
&reading_hash,
&signer.public_key(&env),
&sig,
&-1_i128,
&soroban_sdk::String::from_str(&env, "METER-001"),
&1_700_000_000_u64,
);
}
}

#[cfg(test)]
mod verify_integration_test;
mod verify_integration_test;
Loading