Skip to content
Merged
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
2 changes: 1 addition & 1 deletion contracts/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["utility_contracts", "price_oracle", "resource-token", "common", "settlement"]
members = ["utility_contracts", "price_oracle", "resource-token", "common", "settlement", "meter-aggregator"]

[workspace.dependencies]
soroban-sdk = "23.2.4"
15 changes: 15 additions & 0 deletions contracts/meter-aggregator/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "meter-aggregator"
version = "0.0.0"
edition = "2021"
publish = false

[lib]
crate-type = ["lib", "cdylib"]
doctest = false

[dependencies]
soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
68 changes: 68 additions & 0 deletions contracts/meter-aggregator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# meter-aggregator

Per-device meter reading aggregation with **bounded storage**.

## Why

Appending every raw meter reading to an unbounded per-device vector exhausts
Soroban contract storage. At one reading every ~5 seconds (~17,280/day) a naive
design overruns the contract storage budget within hours, after which all further
readings *and* settlements for that device fail — a cheap denial of service.

This contract keeps live storage bounded regardless of device lifetime or
submission frequency.

## How

- **Raw readings** are stored under individual keys `RawReading(device, seq)`
with a monotonically increasing sequence number (O(1) append; seq order == time
order).
- On every submission the value is folded into the matching **hourly** and
**daily** rollup buckets using overflow-checked `i128` addition.
- Raw readings older than `MAX_RAW_RETENTION_SECS` (7 days) are pruned **inline**,
amortized to O(1) per submission via a watermark cursor `PruneCursor(device)`,
deleting at most `PRUNE_BATCH_SIZE` (10) entries per call so a backlog drains
over several submissions instead of blowing one call's instruction budget.
- Long-term volume lives in compact rollup buckets, read by
`get_aggregated_volume` which prefers **daily → hourly → raw** in that order.
- `rollup_day` consolidates a completed day by reclaiming its now-redundant
hourly buckets (the daily total is maintained incrementally), keeping
hourly-bucket growth bounded too.

## Public API

| fn | description |
|----|-------------|
| `initialize(admin)` | one-time admin setup |
| `submit_reading(device, source, value) -> seq` | store + rollup + inline prune |
| `prune(device) -> u32` | manual batch prune (callable by anyone) |
| `rollup_day(device, day_epoch) -> i128` | admin; reclaim a day's hourly buckets |
| `get_aggregated_volume(device, from_ts, to_ts) -> i128` | tiered windowed total |
| `get_hourly_bucket` / `get_daily_bucket` / `get_raw_reading` | views |
| `get_prune_cursor` / `get_reading_count` / `get_live_reading_count` | views |

## Constants

| name | value | meaning |
|------|-------|---------|
| `MAX_RAW_RETENTION_SECS` | `604_800` | 7-day raw retention window |
| `PRUNE_BATCH_SIZE` | `10` | max deletions per call |
| `ROLLUP_INTERVAL_SECS` | `3_600` | hourly bucket width |
| `SECONDS_PER_DAY` | `86_400` | daily bucket width |
| `FIXED_POINT_SCALE` | `10_000_000` | 7-decimal fixed point |

## Limitation

Sub-day query resolution is retained only while hourly buckets exist. After a day
is consolidated via `rollup_day` (and its raw readings pruned), that day is
queryable at day granularity only. Full-day and multi-day windows remain exact.

## Test

```sh
cargo test --package meter-aggregator
```

Covers: hourly/daily rollup correctness, daily fast-path reads, `rollup_day`
reclamation, overflow rejection, negative-value rejection, the pruning retention
boundary, batch-size limiting, and end-to-end storage-exhaustion prevention.
38 changes: 38 additions & 0 deletions contracts/meter-aggregator/src/constants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! Tunable bounds for raw-reading retention, pruning, and time-windowed rollups.
//!
//! These constants encode the invariants from the storage-exhaustion mitigation:
//! raw readings are short-lived audit records that get rolled up into compact
//! hourly/daily buckets and then pruned, keeping per-device storage bounded
//! regardless of how long a device runs or how fast it submits.

/// How long raw readings are retained before they become eligible for pruning.
/// 7 days = `7 * 86_400` seconds. After this window the data lives only in the
/// rolled-up hourly/daily buckets.
pub const MAX_RAW_RETENTION_SECS: u64 = 604_800;

/// Maximum number of stale raw readings deleted during a single submission.
///
/// Pruning is amortized across submissions: each `submit_reading` call deletes
/// at most this many entries, so a backlog of stale readings is drained over
/// several calls instead of blowing the instruction budget of one call. Each
/// deletion costs on the order of a few thousand instructions, so a small batch
/// keeps the per-call cost predictable.
pub const PRUNE_BATCH_SIZE: u32 = 10;

/// Number of seconds covered by one hourly rollup bucket (1 hour).
pub const ROLLUP_INTERVAL_SECS: u64 = 3_600;

/// Number of seconds in one day, used to derive daily bucket epochs.
pub const SECONDS_PER_DAY: u64 = 86_400;

/// Number of hourly buckets that make up one day.
pub const HOURS_PER_DAY: u64 = SECONDS_PER_DAY / ROLLUP_INTERVAL_SECS;

/// Fixed-point scale for reported volumes: 7 decimal places (1.0 == 10_000_000).
/// Values are summed as raw scaled `i128` integers; this constant documents the
/// interpretation and is exposed for clients that need to format volumes.
pub const FIXED_POINT_SCALE: i128 = 10_000_000;

/// TTL bump (in ledgers) applied to long-lived rollup/bookkeeping entries so the
/// aggregated history is not archived out from under an active device.
pub const BUCKET_TTL_LEDGERS: u32 = 30 * 17_280; // ~30 days at ~5s ledgers
Loading
Loading