Skip to content

Data validation & attestation

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.

  • 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.
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]

secp256k1 recovery comes from the k256 crate compiled into the module; Keccak-256 comes from the host.

attestation/Cargo.toml (dependencies)
[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/src/lib.rs
//! 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).

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):

make_request.py
import json
from eth_account import Account
from eth_account.messages import encode_defunct
key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318" # public test key
issuer = 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" + signature
print(json.dumps({"record": record, "issuer": issuer.address.lower(), "signature": signature,
"rules": {"min_age": 18, "as_of_year": 2026, "allowed_countries": ["GH", "KE", "NG"]}}))
Terminal window
python make_request.py > req.json
ndsr run dist/attestation.hbc attest --input-file req.json --gas 50000000

Recorded: 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.

  1. Register a project with task_source: {"kind": "api"}, functions: ["attest"] and a max_gas_limit of at least 15,000,000 (one recovery costs about 9.6 M gas). Register
  2. Your backend submits POST /v1/projects/{id}/tasks?wait=60 with the request and reads output.
  3. Store or forward only eligible, reasons and record_hash. The record_hash lets you prove later which record was checked without revealing it.
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).