Skip to content

DePIN & IoT telemetry

Device networks (sensors, meters, trackers, chargers) pay or make decisions based on readings that arrive from the field. Readings get replayed, edited in transit, or faked by devices that report impossible values. Validation is usually done by one backend that everyone has to trust.

  • Each device signs its readings with its own ed25519 key. A committee of miners checks every signature and the physical rules, and must agree; validators audit. Payers and devices can both verify the outcome.
  • The verdict includes a digest of the accepted readings, so downstream systems can prove which data was accepted without storing it on Necter.
  • Miners earn per verified batch; the project’s vault funds it.

What it does not do: it cannot prove a sensor measured the real world honestly. It proves the readings came from the registered device key, were not altered or replayed, and are physically plausible by your rules. Hardware attestation of devices is coming soon.

flowchart LR
  D1[Device 1<br/>ed25519 key] -- signed readings --> GW[Gateway / backend]
  D2[Device 2] -- signed readings --> GW
  GW -- "task: verify(device, rules, readings)" --> C[Committee of miners]
  C -- "{accepted, rejected, last_seq, digest}" --> GW
  GW -- "/v1/execute record(device, last_seq, digest)" --> L[(Device ledger<br/>on validators)]
  GW -- pay / alert / store --> X[Your systems]

Each reading is signed over "<device>|<seq>|<ts>|<value>". The module rejects a reading for a bad signature, a sequence number at or below the last accepted one (after_seq), a timestamp that does not increase, a value outside [min, max], or a jump larger than max_step from the previous accepted reading.

telemetry/Cargo.toml (dependencies)
[dependencies]
hivekit = { path = "../necter-sdk/hivekit-rs" }
serde = { version = "1", features = ["derive"] }
ed25519-dalek = { version = "2", default-features = false }
hex = "0.4"
telemetry/src/lib.rs
//! Telemetry verifier: check a batch of signed sensor readings from one device.
//!
//! Each reading is signed by the device's ed25519 key over
//! "<device>|<seq>|<ts>|<value>". The module checks every signature, that
//! sequence numbers and timestamps strictly increase, that values stay in the
//! sensor's range and never jump more than `max_step` between accepted readings.
//! It returns accepted/rejected counts and a digest of the accepted readings.
//! Stateless (no storage, no hive.call): suitable for committee execution.
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use hivekit::prelude::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct Reading {
seq: u64,
ts: u64, // unix seconds, from the device
value: i64, // integer units, e.g. deci-degrees C (215 = 21.5 °C)
sig: String, // ed25519 signature, hex
}
#[derive(Deserialize)]
struct Batch {
device: String, // ed25519 public key, hex
min: i64,
max: i64,
max_step: i64,
after_seq: u64, // last sequence number already accepted upstream
readings: Vec<Reading>,
}
fn decode<const N: usize>(h: &str) -> Option<[u8; N]> {
let mut out = [0u8; N];
hex::decode_to_slice(h.trim_start_matches("0x"), &mut out).ok()?;
Some(out)
}
#[hive_export]
fn verify(b: Batch) -> Result<Value, String> {
let device = b.device.to_ascii_lowercase();
let key = decode::<32>(&device)
.and_then(|k| VerifyingKey::from_bytes(&k).ok())
.ok_or("device must be a 32-byte hex ed25519 public key")?;
if b.readings.is_empty() || b.readings.len() > 256 {
return Err("readings must hold 1..=256 entries".into());
}
if b.min >= b.max || b.max_step <= 0 {
return Err("need min < max and max_step > 0".into());
}
let mut last_seq = b.after_seq;
let mut last: Option<(u64, i64)> = None; // (ts, value) of the last accepted reading
let mut accepted: Vec<String> = Vec::new();
let mut rejected: Vec<Value> = Vec::new();
for r in &b.readings {
let msg = format!("{}|{}|{}|{}", b.device, r.seq, r.ts, r.value);
let sig_ok = decode::<64>(&r.sig)
.map(|s| key.verify(msg.as_bytes(), &Signature::from_bytes(&s)).is_ok())
.unwrap_or(false);
let reason = if !sig_ok {
Some("bad_signature")
} else if r.seq <= last_seq {
Some("replayed_seq")
} else if last.is_some_and(|(ts, _)| r.ts <= ts) {
Some("time_not_increasing")
} else if r.value < b.min || r.value > b.max {
Some("out_of_range")
} else if last.is_some_and(|(_, v)| (r.value - v).abs() > b.max_step) {
Some("jump_too_large")
} else {
None
};
match reason {
Some(why) => rejected.push(json!({ "seq": r.seq, "reason": why })),
None => {
last_seq = r.seq;
last = Some((r.ts, r.value));
accepted.push(msg);
}
}
}
let digest = hash(accepted.join("\n").as_bytes());
emit(
"telemetry.verified",
&json!({ "accepted": accepted.len(), "rejected": rejected.len(), "last_seq": last_seq }),
);
Ok(json!({
"device": device,
"accepted": accepted.len(),
"rejected": rejected,
"last_seq": last_seq,
"digest": digest,
}))
}
hive_module!(verify);

Both build to stateless modules (imports: crypto.hash, hive.abort, hive.emit).

Generate a batch from a demo device key (Python, pip install pynacl), with one out-of-range reading and one reading tampered after signing:

make_batch.py
import json
from nacl.signing import SigningKey
sk = SigningKey(bytes(range(32))) # deterministic demo key; a real device keeps its own
dev = sk.verify_key.encode().hex()
rows = [(1, 1791470000, 215), (2, 1791470060, 217), (3, 1791470120, 980), (4, 1791470180, 219), (4, 1791470240, 220)]
out = []
for seq, ts, val in rows:
msg = f"{dev}|{seq}|{ts}|{val}".encode()
out.append({"seq": seq, "ts": ts, "value": val, "sig": sk.sign(msg).signature.hex()})
out[3]["value"] = 230 # tampered after signing
print(json.dumps({"device": dev, "min": -400, "max": 850, "max_step": 50, "after_seq": 0, "readings": out}))
Terminal window
python make_batch.py > batch.json
ndsr run dist/telemetry.hbc verify --input-file batch.json --gas 100000000 # the Go build needs ~74 M

Recorded output (identical from the Rust and the Go module):

{"accepted":3,"device":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","digest":"0xb77c1fc056e5529ab5cac2ef5f598d810d617d993192fe7b41fb91021f8b0cc2","last_seq":4,"rejected":[{"reason":"out_of_range","seq":3},{"reason":"bad_signature","seq":4}]}

Readings 1, 2 and the second reading 4 are accepted; 3 (98.0 °C) is out of range; the tampered 4 fails its signature.

Module Gas for this 5-reading batch
Rust (ed25519-dalek) 12 570 719
Go (TinyGo crypto/ed25519) 73 651 149
  1. Register the worker as a project with task_source: {"kind": "api"}, functions: ["verify"], a max_gas_limit sized for your largest batch (about 2.5 M gas per reading with Rust; 256 readings per batch maximum), device requirements suited to the work (for example exclude pulley32, the portable engine on 32-bit phones, if batches are large), and category: "iot" or "depin". Register
  2. Your gateway submits one task per device batch with after_seq set to the device’s last accepted sequence number, and idempotency_key = device + first seq.
  3. Record each verified batch’s last_seq and digest in a small stateful module on validators (or your own database) and use them for the next batch and for payouts to device owners.

With Rust, about 2.5 M gas per reading: a 100-reading batch is ~250 M gas, i.e. 250 compute units per agreeing committee member. Price reward_per_unit accordingly, or batch fewer readings per task for faster finality.