Skip to content
Closed
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,142 changes: 2,142 additions & 0 deletions contracts/Cargo.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
[workspace]
resolver = "2"
members = ["stellar_insights"]
members = ["stellar_insights", "analytics", "multisig", "token_swap"]

[workspace.dependencies]
soroban-sdk = "26.0.1"
soroban-sdk = "27.0.6"

[profile.release]
opt-level = "z"
Expand Down
18 changes: 18 additions & 0 deletions contracts/analytics/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "analytics"
version = "0.1.0"
edition = "2021"
publish = false

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

[features]
testutils = ["soroban-sdk/testutils"]

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

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
44 changes: 44 additions & 0 deletions contracts/analytics/STORAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Analytics contract: storage lifecycle

This is the durable design record for issue #339 (bounded on-chain storage).

## What must be readable on-chain at diff time

`diff.rs` compares the incoming snapshot to **the immediately previous snapshot
only**. It never walks older epochs. Therefore the live working set is:

1. `PreviousSnapshot` (persistent) — metrics map + hashes for epoch *N−1*
2. Control-plane instance data — admin, pause flag, latest availability proof

Full historical snapshots are **not** required to ingest epoch *N*.

## TTL / bump policy

| State | Kind | Bump | If we did not bump |
|---|---|---|---|
| Admin / paused | instance | Every ingest and pause/unpause (`HOT_TTL_EXTEND_TO` ≈ 31d) | Contract becomes unusable until instance is restored. Tiny, so we keep it hot. |
| Latest availability proof | instance | Overwritten + bumped every ingest | Proof of the last committed epoch; one slot, not a log. |
| Previous snapshot | persistent | Overwritten + bumped every ingest | Diff cannot run; next ingest would look like genesis unless the operator restores from the off-chain store and resubmits. |
| Epoch *N−2* and older | not stored | — | Would grow keys and rent linearly. Allowed to “archive” by never existing. |

Constants live in `src/storage.rs` (`HOT_TTL_THRESHOLD`, `HOT_TTL_EXTEND_TO`).

We do **not** aggressively bump “everything ever written”: there is no historical
key set. Rent liability is O(1) keys, not O(ingests).

## On-chain vs off-chain

| Role | Where |
|---|---|
| Live working set + availability proof | This contract |
| Full snapshot history, query, analytics API | Backend off-chain store in this repo |
| Ingest receipt (diff counts) | Return value of `submit_snapshot`; backend persists it |

On-chain storage is a **minimal live working set plus an availability proof**,
not a duplicate of the backend ledger.

## Growth invariant

After the first successful ingest, `persistent_entry_count() == 1` for any
number of subsequent submissions. `storage_growth_test.rs` submits a long
sequence and asserts that count does not grow with *N*.
63 changes: 63 additions & 0 deletions contracts/analytics/src/diff.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//! Diff incoming snapshot metrics against the retained previous snapshot.
//!
//! Required on-chain read: **only** `storage::PreviousSnapshot` (or none on
//! the first ingest). Historical epochs are never consulted.

use soroban_sdk::{Env, Map, Symbol};

use crate::storage::Snapshot;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SnapshotDiff {
pub from_epoch: u64,
pub to_epoch: u64,
pub added: Map<Symbol, i128>,
pub removed: Map<Symbol, i128>,
/// Values are (old, new).
pub changed: Map<Symbol, (i128, i128)>,
}

pub fn diff_against_previous(
env: &Env,
previous: Option<&Snapshot>,
next: &Snapshot,
) -> SnapshotDiff {
let mut added = Map::new(env);
let mut removed = Map::new(env);
let mut changed = Map::new(env);

let Some(prev) = previous else {
for key in next.metrics.keys().iter() {
added.set(key.clone(), next.metrics.get(key).unwrap());
}
return SnapshotDiff {
from_epoch: 0,
to_epoch: next.epoch,
added,
removed,
changed,
};
};

for key in next.metrics.keys().iter() {
let new_val = next.metrics.get(key.clone()).unwrap();
match prev.metrics.get(key.clone()) {
None => added.set(key, new_val),
Some(old_val) if old_val != new_val => changed.set(key, (old_val, new_val)),
Some(_) => {}
}
}
for key in prev.metrics.keys().iter() {
if next.metrics.get(key.clone()).is_none() {
removed.set(key.clone(), prev.metrics.get(key).unwrap());
}
}

SnapshotDiff {
from_epoch: prev.epoch,
to_epoch: next.epoch,
added,
removed,
changed,
}
}
137 changes: 137 additions & 0 deletions contracts/analytics/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#![no_std]

mod diff;
mod pause;
mod storage;

use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, Map, Symbol,
};

pub use storage::{AvailabilityProof, Snapshot, PERSISTENT_LIVE_KEYS};

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[repr(u32)]
pub enum Error {
AlreadyInitialized = 1,
NotInitialized = 2,
Unauthorized = 3,
InvalidEpoch = 4,
EpochMonotonicityViolated = 5,
ContractPaused = 6,
EmptyMetrics = 7,
}

/// Return value of a successful ingest. Diff is **not** persisted (that would
/// grow storage); the backend records it off-chain from this receipt.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IngestReceipt {
pub epoch: u64,
pub from_epoch: u64,
pub added_count: u32,
pub removed_count: u32,
pub changed_count: u32,
/// Persistent keys after this ingest. Always 1 once a snapshot exists.
pub persistent_entries: u32,
}

#[contract]
pub struct AnalyticsContract;

#[contractimpl]
impl AnalyticsContract {
pub fn initialize(env: Env, admin: Address) -> Result<(), Error> {
if env.storage().instance().has(&storage::DataKey::Admin) {
return Err(Error::AlreadyInitialized);
}
env.storage()
.instance()
.set(&storage::DataKey::Admin, &admin);
pause::set_paused(&env, false);
Ok(())
}

pub fn pause(env: Env, caller: Address) -> Result<(), Error> {
caller.require_auth();
storage::require_admin(&env, &caller)?;
pause::set_paused(&env, true);
Ok(())
}

pub fn unpause(env: Env, caller: Address) -> Result<(), Error> {
caller.require_auth();
storage::require_admin(&env, &caller)?;
pause::set_paused(&env, false);
Ok(())
}

pub fn is_paused(env: Env) -> bool {
pause::is_paused(&env)
}

/// Ingest a batched snapshot. Diffs against the retained previous snapshot,
/// then **overwrites** that same persistent key. History is not kept on-chain.
pub fn submit_snapshot(
env: Env,
caller: Address,
epoch: u64,
metrics: Map<Symbol, i128>,
snapshot_hash: BytesN<32>,
source_data_hash: BytesN<32>,
) -> Result<IngestReceipt, Error> {
caller.require_auth();
storage::require_admin(&env, &caller)?;
if pause::is_paused(&env) {
return Err(Error::ContractPaused);
}
if epoch == 0 {
return Err(Error::InvalidEpoch);
}
if metrics.is_empty() {
return Err(Error::EmptyMetrics);
}

let previous = storage::load_previous(&env);
if let Some(ref prev) = previous {
if epoch <= prev.epoch {
return Err(Error::EpochMonotonicityViolated);
}
}

let next = Snapshot {
epoch,
metrics,
snapshot_hash,
source_data_hash,
submitted_at: env.ledger().timestamp(),
};
let d = diff::diff_against_previous(&env, previous.as_ref(), &next);
storage::retain_as_previous(&env, &next);

Ok(IngestReceipt {
epoch,
from_epoch: d.from_epoch,
added_count: d.added.len(),
removed_count: d.removed.len(),
changed_count: d.changed.len(),
persistent_entries: storage::persistent_entry_count(&env),
})
}

pub fn previous_snapshot(env: Env) -> Option<Snapshot> {
storage::load_previous(&env)
}

pub fn latest_proof(env: Env) -> Option<AvailabilityProof> {
env.storage()
.instance()
.get(&storage::DataKey::LatestProof)
}

/// Live persistent working-set size. Bounded at 1; independent of ingest count.
pub fn persistent_entry_count(env: Env) -> u32 {
storage::persistent_entry_count(&env)
}
}
15 changes: 15 additions & 0 deletions contracts/analytics/src/pause.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
use soroban_sdk::Env;

use crate::storage::DataKey;

pub fn is_paused(env: &Env) -> bool {
env.storage()
.instance()
.get(&DataKey::Paused)
.unwrap_or(false)
}

pub fn set_paused(env: &Env, paused: bool) {
env.storage().instance().set(&DataKey::Paused, &paused);
crate::storage::bump_hot_state(env);
}
Loading
Loading