Client Guide
This page walks through what a non-custodial client or library does to talk to a DePIN service node: bootstrap and pin the pool key, authenticate, read and decrypt messages, publish, and purge. The client holds the user's private key; the node never sees it.
Every operation is a standard Neurai JSON-RPC call on the node's RPC port, normally reached through an RPC proxy such as neurai-rpc-proxy as POST /rpc. There is no other port, socket or endpoint. The byte-exact formats are in the protocol reference.
Reply shapes
Every DePIN reply is one of two shapes, and both carry poolsig:
{ "body": "<hex of UTF-8 JSON>", "poolsig": "<base64>" }
{ "encrypted": "<hex CECIESEncryptedMessage>", "poolsig": "<base64>" }
Plain replies (depingetmsginfo, depinpoolstats, depinmcpstatus, depinlistsections without arguments) hex-encode the result JSON in body. Bound replies (everything that takes an address) encrypt the result JSON for that address's revealed key.
poolsig is the pool key's signmessage-compatible signature over
DEPIN-RESP|<method>|<token>|<address>|<challenge>|<sha256hex(body)>
where body is the body or encrypted hex string exactly as received. Hash the string you got, never a re-serialization. Verify poolsig before decoding or decrypting anything, and treat a missing or invalid signature as a protocol error.
Step 1: bootstrap and pin the pool key
{ "jsonrpc": "2.0", "id": 1, "method": "depingetmsginfo", "params": [] }
Decoded, body looks like:
{
"enabled": true,
"token": "&NEWS",
"cipher": "AES-256-GCM",
"maxrecipients": 20,
"maxmessagesize": 1024,
"messageexpiryhours": 168,
"maxpoolsizemb": 100,
"messages": 0,
"protocol": 2,
"depinpoolpkey": "03649c...",
"depinpoolkeyaddress": "tDudNS..."
}
A pin is the tuple (service, pool root token, pool public key). The root is part of the pin because plain replies bind it into the poolsig preimage.
| Situation | What to do |
|---|---|
| Full pin available (shipped with the app, published by the project, or recorded earlier) | Build the preimage from the pinned root, verify poolsig against the pinned key, then check that the announced depinpoolpkey and token match. A mismatch is an alert for the user, never something to re-pin over. |
| Key pinned, root unknown | Read body.token alone as untrusted input, build the preimage, verify, then store the token with the key. |
| No pin (trust on first use) | Decode body as untrusted material, recover the signer from poolsig and check that it equals depinpoolpkey. Then record the pin. This proves consistency, not the identity of the service. |
Projects should publish their service's pool key together with its pool root. That removes the only moment the protocol cannot protect: the first contact.
Record maxrecipients and protocol from the verified body. Then call depinlistsections without arguments for the section list.
Step 2: authenticate with a challenge
Reads and purges are authenticated per call with a single-use challenge. There are no sessions.
Request a challenge
The request itself is signed, so nobody can spend a holder's quota by merely naming its address:
t = current Unix time in MILLISECONDS
preimage = "DEPIN-REQ|receive|<token>|<address>|<t>"
sig = signmessage(holderKey, preimage)
{
"jsonrpc": "2.0", "id": 2,
"method": "depinchallenge",
"params": ["&NEWS/GENERAL", "Nholder...", 1730000000000, "<sig>", "receive"]
}
Use admin instead of receive when the challenge is for depinclearmsg. The node accepts the request only if the timestamp is within 60 seconds of its clock, the signature verifies, the address has access and the signature was never presented before. Signatures are deterministic, so never reuse a signed request.
The reply is bound to the address. Decrypted:
{ "challenge": "<64 hex>", "expires_in": 30, "type": "receive" }
A challenge is bound to (token, address, type), expires after 30 seconds and is consumed by its first valid use. A failed call does not consume it.
Use the challenge
receive: preimage = "DEPIN-GET|<token>|<address>|<challenge>"
admin: preimage = "DEPIN-CLEAR|<token>|<address>|<challenge>"
sig = signmessage(holderKey, preimage)
The token must be exactly the one the challenge was issued for.
Chaining
Every authenticated bound reply contains next_challenge and next_expires_in (300 seconds): a fresh challenge for the same token, address and type. Sign it for the next call. A client that keeps reading calls depinchallenge once per conversation.
Step 3: read and decrypt messages
depinreceivemsg "token" "address" "challenge" "signature" ( timestamp "after_hash" limit )
{
"jsonrpc": "2.0", "id": 3,
"method": "depinreceivemsg",
"params": ["&NEWS/GENERAL", "Nholder...", "<challenge>", "<sig>", 0, "", 25]
}
| Parameter | Meaning |
|---|---|
timestamp | Optional. When non-zero, only messages with timestamp at or after timestamp - 1 are returned |
after_hash | Optional exclusive cursor. "" starts at the oldest message. Must be a hash visible to the address |
limit | Optional page size, at most 1000. 0 or omitted returns everything |
The reply is bound to the address and its poolsig preimage includes the challenge. Decrypted:
{
"messages": [
{
"hash": "4e3972...",
"token": "&NEWS/GENERAL",
"sender": "Nsender...",
"timestamp": 1730000000,
"message_type": "group",
"encrypted_payload_hex": "2102...",
"signature_hex": "3044..."
}
],
"has_more": false,
"next_challenge": "<64 hex>",
"next_expires_in": 300
}
Messages are ordered oldest first. Save the last hash and pass it as after_hash on the next page, signing next_challenge for that call.
An address sees a message when it is the sender or has an entry in the message's recipient list. What it can actually read is fixed cryptographically by that list.
Validate before displaying
For every message:
- Recompute the digest and verify
signature_hexwith the sender's revealed key (getpubkey, cacheable). The digest is also thehashfield. - Check that
tokenis inside the requested scope. For a root request any descendant is valid. - Decrypt
encrypted_payload_hexwith the address's private key. Both GCM tags must verify. - Drop anything that fails any step. Never display it.
Step 4: publish
depinsubmitmsg {"sender": "<address>", "encrypted": "<hex>"}
- Read
depingetmsginfofor the pool root,maxrecipients,depinpoolpkeyanddepinpoolkeyaddress. Verifypoolsigand check the pin. - Resolve recipients with
depingetancestorrecipients "<token>" <maxrecipients> "<pool root>". The result is the deduplicated set of active holders with revealed keys of the token and each ancestor up to the pool root. Iftruncatedis true, do not send.skipped_no_pubkeylists holders that cannot be reached. - Encrypt the content once for those public keys. Include yourself if you want to re-read your own messages from another device.
- Fill
token,senderAddress,timestamp,messageType = 0x02and the encrypted payload. Compute the digest, sign it with the sender's key, serialize the message and hex-encode it. - Encrypt that hex string, as ASCII bytes, for the single recipient
depinpoolpkeykeyed bydepinpoolkeyaddress. Hex-encode the envelope. - Call
depinsubmitmsgwithsenderand the envelope hex.
The node opens the envelope, checks that sender equals the signer, verifies the signature against the sender's revealed key, checks the sender's inherited access, applies the per-sender quota and stores the message. The reply is bound to the sender:
{ "result": "success", "hash": "...", "timestamp": 1730000000 }
The node never sees the content. The envelope only hides the message's metadata from the proxy.
Step 5: list sections
depinlistsections names only, plain reply
depinlistsections "address" "scope" "challenge" "signature"
Without arguments the reply lists every section's name, label and depth. With all four arguments the reply is bound to the address, limited to the subtree of scope, and adds access and a messages counter where there is access. The challenge is a receive challenge issued for scope.
Step 6: purge (owners)
depinclearmsg "scope" "address" "challenge" "signature" ( "all" | hours )
Removes the messages of the scope's subtree ("" means the whole pool), either all of them or those older than hours. Requires an admin challenge issued for exactly that scope, to an address with owner access. The reply reports removed and remaining.
Read loop summary
On HTTP 429 from the proxy, wait the Retry-After seconds before retrying.
Wallet-only helpers
depinsignrequest, depinsignchallenge, depindecrypt, depinsendmsg, depingetmsg and depinpoolpkey exist for neurai-cli scripting on a node that holds the keys. They are never whitelisted by a proxy and a client library must not depend on them.
# On a node that holds the holder's key
neurai-cli depinsignrequest "Nholder..." "&NEWS/GENERAL"
neurai-cli depinsignchallenge "Nholder..." "&NEWS/GENERAL" "<challenge>"
neurai-cli depindecrypt "Nholder..." "<encrypted>"
# Send from a node that runs the pool and holds the sender's key
neurai-cli depinsendmsg "&NEWS/GENERAL" "Hello" "Nsender..."
Error codes
| Code | Meaning |
|---|---|
-32600 | Authentication failed: request window, signature, replay, missing or expired challenge, wrong bindings, access lost |
-8 | Malformed parameter: type, hex, arity, unknown after_hash, limit above 1000, bad scope |
-5 | Invalid address, or address without a revealed public key |
-22 | Envelope or message fails to deserialize |
-25 | Envelope cannot be opened with the pool key, or sender, signature or access mismatch on submit |
-1 | Service disabled, pool key not loaded, rate limited, or the pool refused the message |
error.message is human-readable and stable enough to surface. Branch on code and show message.
Libraries
- DePIN-Messaging: JavaScript implementation of the message cryptography, published as
@neuraiproject/neurai-depin-msg, with a web demo. - Test vectors: check an implementation without a node.