SIP (Soulprint Identity Protocol) defines a standard for verifying the human identity behind any AI agent, bot, or automated process. It enables cryptographic proof of human identity without revealing personal data, using Zero-Knowledge Proofs and decentralized validator networks.
As AI agents become autonomous actors on the internet, services need to distinguish legitimate human-backed bots from anonymous or malicious ones. Existing KYC solutions require centralized infrastructure, paid APIs, and store sensitive personal data. SIP provides the same guarantees without any of these requirements.
- Principal: the human who owns and controls a bot/agent
- Agent DID: a Decentralized Identifier for the bot (
did:key:...) - Nullifier: a cryptographic commitment to the principal's identity, unique per person, non-reversible
- SPT: Soulprint Token — a signed credential containing trust score and nullifier, no PII
- Validator: a node in the Soulprint network that verifies ZK proofs
1. VERIFY (local, on principal's device)
a. OCR document → extract cedula_number, birthdate
b. Face match selfie vs document → derive face_key
c. Compute nullifier = Poseidon(cedula, birthdate, face_key)
d. Generate ZK proof: "I know inputs such that Poseidon(inputs) = nullifier"
e. Delete all raw biometric data
2. BROADCAST (P2P network)
a. Principal broadcasts (nullifier, zkp_proof, did) to validator network
b. 5 random validators verify the ZK proof (math only, no biometrics)
c. 3/5 validators sign threshold attestation
d. Attestation stored on IPFS
3. ISSUE (agent DID)
a. Principal signs: "did:key:<agent> acts on behalf of <principal>"
b. SPT issued: JWT with score, level, nullifier, no PII
c. SPT lifetime: 24 hours (renewable automatically)
4. VERIFY (service side, offline)
a. Service receives HTTP header: X-Soulprint: <SPT>
b. Service resolves principal DID, checks threshold signature
c. Service verifies SPT signature locally (<50ms, no internet)
d. Service applies trust threshold: minScore, require levelinterface SoulprintToken {
sip: "1"; // protocol version
did: string; // agent DID (did:key:...)
score: number; // 0-100 trust score
level: TrustLevel; // Unverified | EmailVerified | KYCLite | KYCFull
country?: string; // ISO 3166-1 alpha-2
credentials: CredentialType[]; // what was verified
nullifier: string; // 0x + 64 hex chars, unique per human
issued: number; // Unix timestamp
expires: number; // Unix timestamp
network_sig?: string; // threshold signature from validators
}Request header: X-Soulprint: <base64url(SPT)>{
"capabilities": {
"identity": {
"soulprint": "<base64url(SPT)>"
}
}
}The trust registry is a JSON file maintained by the community:
{
"version": "1",
"issuers": [
{
"id": "soulprint.network",
"type": "ValidatorNetwork",
"minValidators": 3,
"publicKey": "0x..."
}
]
}The reputation layer extends the trust score with a behavioral dimension:
Total Score (0–100) = Identity Score (0–80) + Bot Reputation (0–20)A signed statement from a verified service (score ≥ 60) about a bot's behavior:
{
issuer_did: string; // service DID (must have score >= 60)
target_did: string; // bot DID being rated
value: 1 | -1; // reward (+1) or penalty (-1)
context: string; // e.g. "spam-detected", "normal-usage"
timestamp: number; // unix seconds
sig: string; // Ed25519(payload, issuer_privateKey)
}score = clamp(base + sum(valid_attestations.value), 0, 20)Where valid_attestation means: Ed25519 signature verified AND not a duplicate.
POST /reputation/attest
body: { attestation: BotAttestation, service_spt: string }
Guards:
service_spt.score >= 60
service_spt.did == attestation.issuer_did
verifyAttestation(attestation) === true
attestation age < 3600 seconds
not a duplicate (issuer_did, timestamp, context)
Side effects:
store attestation locally
gossip dual-channel:
1. libp2p GossipSub (soulprint:attestations:v1) — primary
2. HTTP fire-and-forget to HTTP peers — fallback for legacy nodesA low-reputation bot cannot recover by creating a new DID — the new DID starts at 10 (neutral), not at the old score. Services choosing to trust only bots with score ≥ X ensure new/unknown bots are excluded from sensitive operations.
- Privacy: ZK proof reveals nothing about the human's identity
- Sybil resistance: nullifier derived from biometrics prevents multiple registrations per person
- Key loss: KYC allows re-issuance if private key is lost
- Revocation: principal can revoke their agent DID at any time
- Validator compromise: threshold (3/5) prevents single-validator attacks
- Reputation gaming: only services with score ≥ 60 can issue attestations; max score clamped at 20
- Attestation forgery: Ed25519 sig bound to issuer_did; tampered attestations always fail verification
Each validator node now runs a dual-stack: HTTP (port 4888) + libp2p (port 6888).
Gossip channel priority:
1. libp2p GossipSub — primary, internet-wide
2. HTTP fire-and-forget — fallback for legacy nodes| Component | Package | Role |
|---|---|---|
| Transport | @libp2p/tcp |
TCP connections |
| Encryption | @chainsafe/libp2p-noise |
Noise protocol (E2E) |
| Muxing | @chainsafe/libp2p-yamux |
Stream multiplexing |
| DHT | @libp2p/kad-dht |
Kademlia peer routing |
| PubSub | @chainsafe/libp2p-gossipsub |
Attestation broadcast |
| Discovery | @libp2p/mdns |
LAN auto-discovery |
| Bootstrap | @libp2p/bootstrap |
Internet entry points |
soulprint:attestations:v1 — BotAttestation JSON messages
soulprint:nullifiers:v1 — reserved (future anti-Sybil)1. mDNS (zero config) — discovers nodes on same LAN automatically
2. Bootstrap (SOULPRINT_BOOTSTRAP env var) — internet entry points
3. Kademlia DHT — routing table maintained continuouslyEach node generates a persistent Ed25519 keypair at startup:
- Stored at
~/.soulprint/node/node-identity.json - Peer ID derived from public key (multihash format:
12D3KooW...) - Multiaddr:
/ip4/<ip>/tcp/<p2p-port>/p2p/<peer-id>
GossipSub maintains a seen-messages cache per message-id. Unlike the HTTP
gossip which uses X-Gossip: 1 headers, GossipSub never re-forwards a message
it has already seen — this is guaranteed by the protocol.
All validator nodes enforce FARMING_RULES (immutable via Object.freeze()). Farming attempts are penalized (-1), not just rejected.
Key rules: max +1/day, max +2/week, min 30s session, min 4 distinct tools, robotic call patterns (interval stddev/mean < 10%) → penalty.
DIDs under 7 days old are in probation: they cannot earn points until they have 2+ existing attestations.
Validator nodes ship 3 open-source credential verifiers under /credentials/:
| Endpoint | Method | Technology |
|---|---|---|
/credentials/email/start + /verify |
POST | nodemailer + crypto.randomInt |
/credentials/phone/start + /verify |
POST | otpauth RFC 6238 (no SMS) |
/credentials/github/start + /callback |
GET | GitHub OAuth + native fetch |
Each verified credential issues a BotAttestation with context credential:<Type>, signed by the node keypair, gossiped to all peers.
Biometric thresholds are now part of PROTOCOL (Object.freeze()), mandatory for all implementations:
| Constant | Value | Rationale |
|---|---|---|
FACE_SIM_DOC_SELFIE |
0.35 |
Validated: real cédula CO + selfie → 0.365 ✅ |
FACE_SIM_SELFIE_SELFIE |
0.65 |
Stricter: live photos, same quality |
FACE_KEY_DIMS |
32 |
First 32 dims balance uniqueness vs speed |
FACE_KEY_PRECISION |
1 |
1 decimal = 0.1 steps, absorbs ±0.01 InsightFace noise |
Threshold rationale: a completely different person scores < 0.15 with buffalo_sc. A same-person doc-vs-selfie with an older photo scores ~0.35–0.42.
- Reference (TypeScript): https://github.com/manuelariasfz/soulprint
- First verified service: https://github.com/manuelariasfz/mcp-colombia
- Circuit: Groth16 over BN128, Circom 2.1.8
- Hash function: Poseidon (ZK-friendly, 3 inputs)
- Architecture: ARCHITECTURE.md
Decentralized nullifier registration and attestation propagation without a blockchain.
Three-phase protocol over encrypted GossipSub:
Phase 1 — PROPOSE:
{ type, nullifier, did, proofHash, proposerDid, ts, protocolHash, sig }
Phase 2 — VOTE (from each receiving node):
{ type, nullifier, vote: "accept"|"reject", voterDid, ts, protocolHash, sig }
Phase 3 — COMMIT (when N/2+1 accepts received):
{ type, nullifier, did, votes[], commitDid, ts, protocolHash, sig }Quorum: floor(connectedPeers / 2) + 1
Single mode: triggered when connectedPeers === 0 — immediate local commit
Timeout: 10 seconds per round; client retries on timeout
No multi-round needed — Ed25519 signature provides non-repudiation:
ATTEST { type, issuerDid, targetDid, value: +1|-1, context, ts, protocolHash, sig }Anti-farming enforced per-node:
- Cooldown: 24h per
issuerDid:targetDidpair - Cap: ≥7 attestations/week from same issuer → converts +1 to −1
- Anti-replay:
Set<msgHash>— each message applied exactly once
New nodes sync state on startup:
GET /consensus/state-info → { nullifierCount, attestationCount, protocolHash }
GET /consensus/state?page=N&since=TS → { nullifiers[], attestations{}, reps{}, totalPages }
POST /consensus/message → receive PROPOSE | VOTE | COMMIT | ATTEST (encrypted)Protocol hash verified in handshake — incompatible nodes rejected immediately.
| Property | Mechanism |
|---|---|
| Fault tolerance | Tolerates up to N/2 malicious nodes |
| Non-repudiation | Ed25519 signature on every consensus message |
| Network isolation | PROTOCOL_HASH checked on every message |
| Anti-replay | seen: Set<msgHash> — exactly-once semantics |
| ZK grounding | Each voter verifies the ZK proof locally (deterministic) |
Async anchoring of P2P-committed data to EVM blockchain (Base Sepolia/mainnet).
P2P BFT COMMIT (primary, ~2s, $0)
└──▶ BlockchainAnchor.anchorNullifier() [async, non-blocking]
├── retry x3: 0s → 2s → 8s backoff
├── success: tx hash logged
└── 3 fails → blockchain-queue.json (flushed every 60s)| Contract | Address |
|---|---|
| ProtocolConstants | 0x20EEeFe3e59e6c76065A3037375053e7A9c94529 |
| SoulprintRegistry | 0xE6F804c3c90143721A938a20478a779F142254Fd |
| AttestationLedger | 0xD91595bbb8f649e4E3a14cF525cC83D098FEfE57 |
| ValidatorRegistry | 0xE9418dBF769082363e784de006008b1597F5EeE9 |
| Groth16Verifier | 0x21D65c437eC2C024339eA97e7739387Fbe854381 (mock) |
SOULPRINT_RPC_URL=https://sepolia.base.org
SOULPRINT_PRIVATE_KEY=0x<deployer_key>
SOULPRINT_NETWORK=base-sepolia- P2P-only mode if no blockchain config (no downtime)
- Queue persists to disk on failure (blockchain-queue.json)
- Flush retry every 60s on reconnection
NullifierAlreadyUsedon-chain → idempotent (not an error)
SPTs have a 24-hour lifetime. Previously, bots had to re-submit a full ZK proof after expiry. v0.3.6 adds a lightweight renewal path.
| State | Condition | Action |
|---|---|---|
| Pre-emptive | expires - now < TOKEN_RENEW_PREEMPTIVE_SECS (3600) |
Renew |
| Grace | now - expires < TOKEN_RENEW_GRACE_SECS (604800) |
Renew |
| Stale | now - expires >= 604800 |
Full re-verify required |
Request: { "spt": "<current_token>" }
Guards (in order):
- Token decodeable (valid Ed25519 signature)
- Within renewal window (pre-emptive OR grace)
- Anti-spam: 60-second cooldown per DID
- DID registered in node's P2P state
- Current reputation score ≥
VERIFIED_SCORE_FLOOR(52)
Response: { spt, expires_in: 86400, renewed: true, method: "preemptive" | "grace_window" }
TOKEN_LIFETIME_SECONDS = 86400 (24h)
TOKEN_RENEW_PREEMPTIVE_SECS = 3600 (1h before expiry)
TOKEN_RENEW_GRACE_SECS = 604800 (7d post-expiry)
TOKEN_RENEW_COOLDOWN_SECS = 60 (anti-spam)These constants are NOT included in PROTOCOL_HASH (operational, not consensus).
An attacker with server access could modify verifyProof() to bypass ZK validation. The P2P network now actively verifies peer code integrity before accepting connections.
- Challenger generates:
{ nonce, valid_proof: PROTOCOL_CHALLENGE_VECTOR, invalid_proof: mutate(vector, nonce) } - Peer responds:
{ result_valid, result_invalid, signature: Ed25519(results, node_key) } - Challenger validates:
result_valid == true AND result_invalid == false AND sig valid
invalid.pi_a[0] = (valid.pi_a[0] + BigInt("0x" + nonce)) mod field_prime- Any mutation to pi_a makes the Groth16 bilinear pairing equation fail
- Fresh nonce per challenge prevents pre-computed responses
TTL: 30 seconds. Response timeout: 10 seconds.
POST /peers/register runs verifyPeerBehavior() before registering any peer:
- Peer passes challenge → registered
- Peer fails challenge → HTTP 403, peer rejected
verifyProof() was silently broken since v0.1.0 due to snarkjs CJS/ESM interop:
import snarkjs from "snarkjs"→snarkjs_1.default.groth16→ undefined (crash)- Fix:
import * as snarkjs from "snarkjs"→snarkjs.groth16✅
All existing tests used the on-chain Groth16Verifier (Solidity), not snarkjs, so the bug went undetected. Challenge tests explicitly call snarkjs.groth16.verify().
Draft — v0.3.7. Phases 1–5 complete + anti-farming + credential validators + biometric PROTOCOL constants + BFT P2P consensus (sin blockchain).
Phase 6 (multi-country expansion) in progress.
Feedback welcome: open an issue at https://github.com/manuelariasfz/soulprint/issues