Skip to main content

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.

SituationWhat 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 unknownRead 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.
Publish your pool key

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]
}
ParameterMeaning
timestampOptional. When non-zero, only messages with timestamp at or after timestamp - 1 are returned
after_hashOptional exclusive cursor. "" starts at the oldest message. Must be a hash visible to the address
limitOptional 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:

  1. Recompute the digest and verify signature_hex with the sender's revealed key (getpubkey, cacheable). The digest is also the hash field.
  2. Check that token is inside the requested scope. For a root request any descendant is valid.
  3. Decrypt encrypted_payload_hex with the address's private key. Both GCM tags must verify.
  4. Drop anything that fails any step. Never display it.

Step 4: publish

depinsubmitmsg {"sender": "<address>", "encrypted": "<hex>"}
1getmsginfopool root, maxrecipientsdepinpoolpkeyverify poolsig, check pin2recipientsactive holders with keysup to the pool rootrefuse if truncated3encrypt + signECIES for each recipientdigest of the 5 fieldsDER signature, serialize4envelopeencrypt the hex stringfor depinpoolpkey onlyhides it from the proxy5submitmsgsender + envelope hexnode verifies and storesreply bound to sender
  1. Read depingetmsginfo for the pool root, maxrecipients, depinpoolpkey and depinpoolkeyaddress. Verify poolsig and check the pin.
  2. 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. If truncated is true, do not send. skipped_no_pubkey lists holders that cannot be reached.
  3. Encrypt the content once for those public keys. Include yourself if you want to re-read your own messages from another device.
  4. Fill token, senderAddress, timestamp, messageType = 0x02 and the encrypted payload. Compute the digest, sign it with the sender's key, serialize the message and hex-encode it.
  5. Encrypt that hex string, as ASCII bytes, for the single recipient depinpoolpkey keyed by depinpoolkeyaddress. Hex-encode the envelope.
  6. Call depinsubmitmsg with sender and 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

1depingetmsginfoverify poolsig, check the pinread pool root, limitsand protocol = 22depinchallengesign DEPIN-REQ witha millisecond timestampverify poolsig, decrypt3depinreceivemsgsign DEPIN-GET overthe challengeverify poolsig, decrypt4validatesender signaturetoken inside the scopedecrypt content, else dropnext_challenge · valid 300 s · no new depinchallenge needed

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

CodeMeaning
-32600Authentication failed: request window, signature, replay, missing or expired challenge, wrong bindings, access lost
-8Malformed parameter: type, hex, arity, unknown after_hash, limit above 1000, bad scope
-5Invalid address, or address without a revealed public key
-22Envelope or message fails to deserialize
-25Envelope cannot be opened with the pool key, or sender, signature or access mismatch on submit
-1Service 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.