DePIN & IoT telemetry
The problem
Section titled “The problem”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.
Why Necter fits
Section titled “Why Necter fits”- 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.
Architecture
Section titled “Architecture”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.
The module
Section titled “The module”[dependencies]hivekit = { path = "../necter-sdk/hivekit-rs" }serde = { version = "1", features = ["derive"] }ed25519-dalek = { version = "2", default-features = false }hex = "0.4"//! 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);// telemetry: verify 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 physical range and do not jump faster than max_step per reading.// It returns accepted/rejected counts and a digest of the accepted readings.// Stateless: suitable for a mining project (committee execution).package main
import ( "crypto/ed25519" "encoding/hex" "errors" "strconv" "strings"
hivekit "github.com/necter-network/hivekit-go")
type reading struct { Seq int64 `json:"seq"` TS int64 `json:"ts"` // unix seconds, from the device Value int64 `json:"value"` // integer units, e.g. deci-degrees C (215 = 21.5 C) Sig string `json:"sig"` // ed25519 signature, hex}
type batch struct { Device string `json:"device"` // ed25519 public key, hex (32 bytes) Min int64 `json:"min"` Max int64 `json:"max"` MaxStep int64 `json:"max_step"` AfterSeq int64 `json:"after_seq"` // last sequence number already accepted upstream Readings []reading `json:"readings"`}
type rejection struct { Seq int64 `json:"seq"` Reason string `json:"reason"`}
type verdict struct { Device string `json:"device"` Accepted int `json:"accepted"` Rejected []rejection `json:"rejected"` LastSeq int64 `json:"last_seq"` Digest string `json:"digest"`}
func abs(x int64) int64 { if x < 0 { return -x } return x}
func init() { hivekit.DefineJSON("verify", func(b batch) (verdict, error) { pub, err := hex.DecodeString(strings.TrimPrefix(b.Device, "0x")) if err != nil || len(pub) != ed25519.PublicKeySize { return verdict{}, errors.New("device must be a 32-byte hex ed25519 public key") } if len(b.Readings) == 0 || len(b.Readings) > 256 { return verdict{}, errors.New("readings must hold 1..256 entries") } if b.Min >= b.Max || b.MaxStep <= 0 { return verdict{}, errors.New("need min < max and max_step > 0") } v := verdict{Device: strings.ToLower(b.Device), Rejected: []rejection{}, LastSeq: b.AfterSeq} var lastTS int64 var lastVal int64 haveLast := false var accepted []string for _, r := range b.Readings { msg := b.Device + "|" + strconv.FormatInt(r.Seq, 10) + "|" + strconv.FormatInt(r.TS, 10) + "|" + strconv.FormatInt(r.Value, 10) sig, err := hex.DecodeString(strings.TrimPrefix(r.Sig, "0x")) reason := "" switch { case err != nil || len(sig) != ed25519.SignatureSize || !ed25519.Verify(pub, []byte(msg), sig): reason = "bad_signature" case r.Seq <= v.LastSeq: reason = "replayed_seq" case haveLast && r.TS <= lastTS: reason = "time_not_increasing" case r.Value < b.Min || r.Value > b.Max: reason = "out_of_range" case haveLast && abs(r.Value-lastVal) > b.MaxStep: reason = "jump_too_large" } if reason != "" { v.Rejected = append(v.Rejected, rejection{Seq: r.Seq, Reason: reason}) continue } v.Accepted++ v.LastSeq, lastTS, lastVal, haveLast = r.Seq, r.TS, r.Value, true accepted = append(accepted, msg) } v.Digest = hivekit.Hash([]byte(strings.Join(accepted, "\n"))) if err := hivekit.Emit("telemetry.verified", map[string]any{"accepted": v.Accepted, "rejected": len(v.Rejected), "last_seq": v.LastSeq}); err != nil { return verdict{}, err } return v, nil })}
func main() {}Both build to stateless modules (imports: crypto.hash, hive.abort, hive.emit).
Try it
Section titled “Try it”Generate a batch from a demo device key (Python, pip install pynacl), with one out-of-range reading and one
reading tampered after signing:
import jsonfrom nacl.signing import SigningKey
sk = SigningKey(bytes(range(32))) # deterministic demo key; a real device keeps its owndev = 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 signingprint(json.dumps({"device": dev, "min": -400, "max": 850, "max_step": 50, "after_seq": 0, "readings": out}))python make_batch.py > batch.jsonndsr run dist/telemetry.hbc verify --input-file batch.json --gas 100000000 # the Go build needs ~74 MRecorded 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 |
Publish and consume
Section titled “Publish and consume”- Register the worker as a project with
task_source: {"kind": "api"},functions: ["verify"], amax_gas_limitsized 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 excludepulley32, the portable engine on 32-bit phones, if batches are large), andcategory: "iot"or"depin". Register - Your gateway submits one task per device batch with
after_seqset to the device’s last accepted sequence number, andidempotency_key= device + first seq. - Record each verified batch’s
last_seqanddigestin 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.