Protocol Reference
This page specifies protocol 2 at the level an implementer needs to write an independent client in any language: primitives, serialization, the ECIES envelope, the message, reply signing, limits and test vectors. The Neurai node is the reference implementation.
Primitives
| Primitive | Definition |
|---|---|
| Curve | secp256k1. Public keys are compressed (33 bytes, 02 or 03 prefix) |
SHA256(x) | FIPS 180-4 SHA-256 |
SHA256d(x) | SHA256(SHA256(x)) |
hash160(x) | RIPEMD160(SHA256(x)). The P2PKH payload of an address is hash160(pubkey) |
ECDH(d, Q) | libsecp256k1 secp256k1_ecdh with the default hash: SHA256(prefix ‖ x) where (x, y) = d·Q and prefix is 0x02 for even y, 0x03 for odd. 32 bytes |
KDF(secret, n) | Counter-mode SHA-256: T_i = SHA256(secret ‖ BE32(i)), output the first n bytes of T_1 ‖ T_2 ‖ …. For n = 32 this is SHA256(secret ‖ 00000001) |
| AES-256-GCM | 32-byte key, 12-byte nonce, 16-byte tag, no additional data |
| Base64 | RFC 4648 with padding, for compact signatures |
| Hex | Lowercase, for everything else |
Serialization
All binary structures use Bitcoin's serialization:
- Integers are little-endian (
int64is 8 bytes,uint8is 1 byte). CompactSize(n): 1 byte below 253;0xFD+ LE16 up to0xFFFF;0xFE+ LE32 up to0xFFFFFFFF; else0xFF+ LE64.stringandvector<uint8>:CompactSize(len) ‖ bytes.CPubKey:CompactSize(len) ‖ bytes, so0x21+ 33 bytes.uint160: 20 raw bytes, the same bytes as the address payload.map<K, V>:CompactSize(count)followed byK ‖ Vpairs in ascending byte order ofK.
Hash display order
A uint256 from SHA256d is displayed with its bytes reversed, as Bitcoin does for txids. hash fields and after_hash arguments are hex(reverse(digest)). Signatures are computed over the unreversed digest.
Message signing (recoverable)
Used for challenge requests, challenge responses and poolsig:
msghash(text) = SHA256d( ser_string("Neurai Signed Message:\n") ‖ ser_string(text) )
The signature is a 65-byte compact recoverable ECDSA signature header ‖ r ‖ s with header = 27 + recid + 4, base64-encoded. Verification recovers the public key and checks hash160(recovered) against the address. Signing is deterministic (RFC 6979). Any wallet's signmessage and verifymessage are interoperable.
Message signatures (DER)
Used for the message's signature field only: a DER-encoded ECDSA signature over the 32-byte message digest, verified with the sender's revealed public key. Producers should emit low-s values. Typically 70 to 72 bytes.
ECIES envelope
One construction, CECIESEncryptedMessage, serves three purposes: the message content, the submission envelope for the pool key, and every reply bound to an address. It encrypts a plaintext once for N recipients identified by address.
CECIESEncryptedMessage :=
ephemeralPubKey CPubKey 0x21 ‖ 33 bytes
encryptedPayload vector<uint8> nonce(12) ‖ ciphertext ‖ tag(16)
recipientKeys map<uint160, vector<uint8>> hash160 -> nonce(12) ‖ wrappedKey(32) ‖ tag(16)
Each recipient entry is exactly 60 bytes. encryptedPayload is 28 + len(plaintext) bytes.
Encryption
e <- random 32-byte scalar (ephemeral private key), E = e·G
K = KDF(e, 32) # content key, derived from the ephemeral scalar
n <- random 12 bytes
(C, t) = AES-256-GCM-Encrypt(key=K, nonce=n, plaintext)
encryptedPayload = n ‖ C ‖ t
for each recipient public key P:
S = ECDH(e, P)
W = KDF(S, 32)
n_r <- random 12 bytes
(c, τ) = AES-256-GCM-Encrypt(key=W, nonce=n_r, plaintext=K)
recipientKeys[hash160(P)] = n_r ‖ c ‖ τ
The content key is derived from the ephemeral private scalar. Anyone holding e can decrypt, so e must be discarded after encryption. Plaintext must be non-empty.
Decryption
With private key d:
entry = recipientKeys[hash160(d·G)] # absent => not encrypted for this recipient
S = ECDH(d, ephemeralPubKey)
W = KDF(S, 32)
K = AES-256-GCM-Decrypt(key=W, nonce=entry[0:12], ct=entry[12:44], tag=entry[44:60])
plain = AES-256-GCM-Decrypt(key=K, nonce=payload[0:12], ct=payload[12:-16], tag=payload[-16:])
Both GCM tags must verify. A failure is an integrity error.
The message
CDepinMessage :=
token string e.g. "&NEWS/GENERAL"
senderAddress string base58 P2PKH address
timestamp int64 Unix seconds, sender's clock
messageType uint8 0x01 private, 0x02 group
encryptedPayload vector<uint8> serialized CECIESEncryptedMessage
signature vector<uint8> DER ECDSA signature
The plaintext inside encryptedPayload is the content as UTF-8 bytes with no framing. Applications that need structure define it inside the content. messageType must be 0x01 or 0x02; the node treats both identically and 0x02 is the conventional value.
Identifier and signature
digest = SHA256d( ser_string(token) ‖ ser_string(senderAddress) ‖ LE64(timestamp)
‖ uint8(messageType) ‖ ser_vector(encryptedPayload) )
hash = hex(reverse(digest)) # the "hash" field and paging cursor
signature = DER-ECDSA(senderKey, digest) # over the unreversed digest
The signed bytes are exactly the wire serialization of the first five fields. The pool is keyed by hash; a message with a known hash is rejected as a duplicate.
Acceptance rules
A message is stored only if all of the following hold, in this order:
tokenis the pool root or inside its subtree.messageTypeis0x01or0x02.timestampis at most 60 seconds in the future.- The message is not expired (default 168 hours).
encryptedPayloadis non-empty and at mostmaxmessagesize × maxrecipientsbytes (defaults 1024 × 20).- If the payload parses as an ECIES envelope, it has at most
maxrecipientsentries (default 20, hard cap 50). - The pool size limit is not exceeded.
signatureverifies against the sender's revealed public key.senderAddresshas inherited access totoken.hashis not already in the pool.
The node does not check that the recipient list matches the holder set. The sender chooses its readers. Receivers see a message when they are the sender or appear in recipientKeys; decryption is the final boundary.
Expiry
A message is expired when now − timestamp exceeds the pool's expiry. Expired messages are removed periodically (default every 300 seconds) and never returned. Clients must not assume a message is retrievable later.
Reply authentication
Every reply is a plain body or a bound (encrypted) body, and both carry poolsig:
preimage = "DEPIN-RESP|" method "|" token "|" address "|" challenge "|" sha256hex(bodystr)
poolsig = signmessage(poolKey, preimage)
methodis the RPC name.token,addressandchallengeare the request's values, each empty when the method has no such argument.- Plain replies that take no token (
depingetmsginfo,depinpoolstats,depinmcpstatus,depinlistsectionswithout arguments) bind the pool root astoken.depingetancestorrecipientsbinds the token it was asked about. bodystris the ASCII hex string ofbodyorencryptedexactly as received.sha256hexis the lowercase hex of a single SHA-256 of it.- Verification: the key recovered from
poolsigovermsghash(preimage)must equal the pinneddepinpoolpkey. Equivalently,verifymessage depinpoolkeyaddress poolsig preimage.
Binding the request's token, address and challenge into the preimage makes a reply unusable for any other request.
The pool key is derived deterministically from the service wallet, so a correctly operated service keeps its key across restarts.
Authentication preimages
| Purpose | Preimage |
|---|---|
| Request a challenge | DEPIN-REQ|<type>|<token>|<address>|<milliseconds> with type = receive or admin |
| Use a receive challenge | DEPIN-GET|<token>|<address>|<challenge> |
| Use an admin challenge | DEPIN-CLEAR|<token>|<address>|<challenge> |
| Reply signature | DEPIN-RESP|<method>|<token>|<address>|<challenge>|<sha256hex(body)> |
A challenge request is accepted only when the timestamp is within ±60 seconds of the node's clock, the signature verifies, the address is P2PKH with a revealed key, the address has access, the signature has never been presented before (remembered for 120 seconds) and the address is under its issuance quota. The node keeps at most 4 live challenges per address and 10,000 in total.
Constants and limits
| Constant | Value |
|---|---|
protocol | 2 |
| Message magic | "Neurai Signed Message:\n" |
| Request window | ±60 s |
| Challenge lifetime | 30 s issued, 300 s chained |
| Live challenges | 4 per address, 10,000 total |
| Issuance and submission quota | 20 per address and minute (node default, -depinratelimit) |
| Challenge nonce | 32 random bytes, 64 hex characters |
| Recipients | Default 20, hard maximum 50 |
| Content size | Default 1024 bytes (-depinmsgsize, max 10,240). Payload cap is size × recipients |
| Expiry | Default 168 h (-depinmsgexpire, max 720 h) |
| Timestamp skew accepted on submit | +60 s |
| Page size | limit at most 1000 |
Abuse control
| Layer | Rule | Effect |
|---|---|---|
| Node, per address | Challenges issued plus messages accepted at most depinratelimit per minute, counting only the address's own authenticated requests | JSON-RPC error -1 |
| Node, per address | At most 4 live challenges. Issuance requests are single-use within ±60 s | Oldest evicted, or -32600 |
| Node, global | At most 10,000 live challenges and 10,000 remembered requests | -1 |
| Proxy, per IP | depin* calls limited per minute (default 60). Excess blocks the IP temporarily (default 60 minutes) | HTTP 429 with Retry-After |
| Proxy | Method not whitelisted | HTTP 404 |
Security notes for implementers
- Verify before you trust anything.
poolsigagainst the pinned key, then the message signature against the sender's revealed key, then decrypt. Never display content that failed any step. - The proxy is an adversary in the model. Everything it can do is denial of service once the pool key is pinned. Do not fetch
depinpoolpkeyon every run and trust it. - Keys never leave the client. No RPC in protocol 2 takes a private key.
- Discard the ephemeral scalar after encrypting. It decrypts the message.
- The recipient set is a snapshot. Holders issued later do not gain access to older ciphertexts.
- Metadata is public. Holdings, sections and revealed keys are on chain. The node sees sender, token, timestamp and recipient hashes.
- Clocks matter. A device with a badly skewed clock cannot authenticate or publish.
- Never reuse a signed request or a GCM nonce.
Test vectors
The node repository ships deterministic vectors produced by a regtest node, so a library can check its implementation without running a node:
contrib/depin/vectors.txtholds keys, preimages, signatures, a plain reply with itspoolsig, a bound reply, a full message and eight negative cases that must be rejected.contrib/depin/verify_vectors.pychecks all of them with pure Python plus thecryptographypackage.contrib/depin/regtest_walkthrough.shruns the whole protocol end to end against a regtest node.
python3 contrib/depin/verify_vectors.py contrib/depin/vectors.txt
The regtest keys in the vectors must never be funded or reused.
The full normative specification lives in the node repository as doc/depin-messaging-protocol.md, with the RPC-by-RPC guide in doc/depinreceivemsg.md.