Data validation & attestation
The problem
Section titled “The problem”Apps often need to answer a yes/no question about a person or a record (“is this account over 18 and in an allowed country?”, “was this invoice issued by our supplier?”) based on data a third party vouches for. The check has to be done the same way every time, by rules everyone can read, and the result should not leak the underlying data more than necessary.
Why Necter fits
Section titled “Why Necter fits”- The rules are a deterministic module with a public address: anyone can read the code and re-run a check.
- A committee of miners verifies the issuer’s signature and applies the rules; validators audit. The answer is a signed, agreed receipt instead of one server’s opinion.
- The module returns a verdict and a record hash, never the record’s fields.
Architecture
Section titled “Architecture”flowchart LR
I[Issuer, e.g. a KYC provider] -- "signs canonical record (EIP-191)" --> U[User or app]
U -- record + signature --> B[Your backend]
B -- "task: attest(record, issuer, signature, rules)" --> C[Committee of miners]
C -- "{eligible, reasons, record_hash}" --> B
B -- verdict only --> A[Your app / contract]
The module (Rust)
Section titled “The module (Rust)”secp256k1 recovery comes from the k256 crate compiled into the module; Keccak-256 comes from the host.
[dependencies]hivekit = { path = "../necter-sdk/hivekit-rs" }serde = { version = "1", features = ["derive"] }k256 = { version = "0.13", default-features = false, features = ["ecdsa"] }hex = "0.4"serde_json = "1"//! Attestation checker: verify that a trusted issuer signed a record (EIP-191,//! the `personal_sign` every Ethereum wallet supports), then apply eligibility//! rules to it. The output carries a verdict and a hash of the record, never the//! record's fields.//!//! Stateless: secp256k1 recovery is compiled into the module (k256 crate), the//! only host function used for crypto is keccak256 (`crypto.hash`).use hivekit::prelude::*;use k256::ecdsa::{RecoveryId, Signature, VerifyingKey};use serde::Deserialize;
#[derive(Deserialize)]struct Rules { min_age: u32, as_of_year: u32, allowed_countries: Vec<String>,}
#[derive(Deserialize)]struct Request { /// The signed record (any JSON object; integers only). Signed as canonical JSON. record: Value, /// Address of the issuer you trust, lowercase 0x-hex. issuer: String, /// EIP-191 signature, 0x + 130 hex (r ‖ s ‖ v). signature: String, rules: Rules,}
fn keccak(data: &[u8]) -> [u8; 32] { let h = hash(data); // "0x" + 64 hex, computed by the host let mut out = [0u8; 32]; hex::decode_to_slice(&h[2..], &mut out).expect("host returns 32-byte hex"); out}
/// Recover the address that produced an EIP-191 signature over `msg`.fn recover_eip191(msg: &[u8], sig_hex: &str) -> Result<String, String> { let raw = hex::decode(sig_hex.trim_start_matches("0x")).map_err(|_| "signature is not hex")?; if raw.len() != 65 { return Err("signature must be 65 bytes".into()); } let v = match raw[64] { 27 | 28 => raw[64] - 27, 0 | 1 => raw[64], _ => return Err("bad recovery byte".into()), }; let sig = Signature::from_slice(&raw[..64]).map_err(|_| "malformed signature")?; if sig.normalize_s().is_some() { return Err("high-s signature".into()); // EIP-2 } let mut prefixed = format!("\x19Ethereum Signed Message:\n{}", msg.len()).into_bytes(); prefixed.extend_from_slice(msg); let digest = keccak(&prefixed); let rid = RecoveryId::from_byte(v).ok_or("bad recovery id")?; let key = VerifyingKey::recover_from_prehash(&digest, &sig, rid).map_err(|_| "recovery failed")?; let point = key.to_encoded_point(false); let addr = keccak(&point.as_bytes()[1..]); Ok(format!("0x{}", hex::encode(&addr[12..])))}
/// Verify the issuer's signature on the record and evaluate the rules.#[hive_export]fn attest(req: Request) -> Result<Value, String> { if !req.record.is_object() { return Err("record must be a JSON object".into()); } // serde_json's default map is sorted, so this is canonical JSON (sorted keys, no spaces). let canonical = serde_json::to_string(&req.record).map_err(|e| e.to_string())?; let signer = recover_eip191(canonical.as_bytes(), &req.signature)?; let issuer = req.issuer.to_ascii_lowercase(); if signer != issuer { return Err(format!("record is not signed by the issuer (signer {signer})")); }
let mut reasons: Vec<&str> = Vec::new(); match req.record["birth_year"].as_u64() { Some(y) if (req.rules.as_of_year as u64).saturating_sub(y) >= req.rules.min_age as u64 => {} Some(_) => reasons.push("under_min_age"), None => reasons.push("missing_birth_year"), } match req.record["country"].as_str() { Some(c) if req.rules.allowed_countries.iter().any(|a| a == c) => {} Some(_) => reasons.push("country_not_allowed"), None => reasons.push("missing_country"), } let eligible = reasons.is_empty(); let record_hash = hash(canonical.as_bytes()); emit("attestation", &json!({ "record_hash": record_hash, "eligible": eligible })); Ok(json!({ "eligible": eligible, "reasons": reasons, "issuer": issuer, "record_hash": record_hash, }))}
hive_module!(attest);The built module imports only crypto.hash, hive.abort and hive.emit (stateless, committee-ready).
Create a test record and run it
Section titled “Create a test record and run it”The issuer signs the record’s canonical JSON with personal_sign. With Python and eth-account, using the
well-known public test key from the web3 documentation (never use it for anything real):
import jsonfrom eth_account import Accountfrom eth_account.messages import encode_defunct
key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318" # public test keyissuer = Account.from_key(key)record = {"birth_year": 1994, "country": "NG", "doc_hash": "0x" + "ab" * 32, "subject": "0x" + "11" * 20}canonical = json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False)signature = Account.sign_message(encode_defunct(text=canonical), key).signature.hex()if not signature.startswith("0x"): signature = "0x" + signatureprint(json.dumps({"record": record, "issuer": issuer.address.lower(), "signature": signature, "rules": {"min_age": 18, "as_of_year": 2026, "allowed_countries": ["GH", "KE", "NG"]}}))python make_request.py > req.jsonndsr run dist/attestation.hbc attest --input-file req.json --gas 50000000Recorded: success: true, gas_used: 9630772, output
{"eligible":true,"issuer":"0x2c7536e3605d9c16a7a3d7b1898e529396a65c23","reasons":[],"record_hash":"0x1ab814b07113b7cedb64d8704ea137a186e7ac825bfe6fb012dd6616db0701b4"}Change country to "US" after signing and the call fails, because the signature no longer matches:
guest abort: record is not signed by the issuer (signer 0xf1426acd13eec82848f655825bce74802bc7a027).
A correctly signed record that breaks a rule returns "eligible": false with reasons such as
under_min_age or country_not_allowed.
Publish and consume
Section titled “Publish and consume”- Register a project with
task_source: {"kind": "api"},functions: ["attest"]and amax_gas_limitof at least 15,000,000 (one recovery costs about 9.6 M gas). Register - Your backend submits
POST /v1/projects/{id}/tasks?wait=60with the request and readsoutput. - Store or forward only
eligible,reasonsandrecord_hash. Therecord_hashlets you prove later which record was checked without revealing it.
Costs and variations
Section titled “Costs and variations”| Gas | ~9.6 M per signature check → 10 compute units per agreeing member |
| Committee of 5, 1 NECTA per unit | 50 NECTA gross per check (test tokens) |
Variations: verify ed25519 instead (cheaper: ~2.5 M gas with ed25519-dalek, see
telemetry); check Merkle membership of a record in a published list; run
the same rules on validators with /v1/execute for interactive use (capped at 50 M gas per call).