Bitcoin PIR

Wire format

Every PIR exchange happens over a single WebSocket per server. All three backends — DPF, HarmonyPIR, OnionPIR — share the same outer envelope and codec; they only differ in which variant codes they emit on the inside. This page is the reference for the envelope and the shared variants. Per-backend variants are linked at the bottom.

Frame envelope

Each WebSocket message is one length-prefixed binary frame:

+----------+-------------+--------------------+
| 4B u32   | 1B u8       | payload bytes      |
| total_len| variant_byte| variant-specific   |
+----------+-------------+--------------------+
  • total_len (little-endian) is the length of variant_byte + payload. It does not include the four bytes of the prefix itself.
  • variant_byte identifies the request or response type — see the variant table below.
  • payload shape depends on the variant.

The codec is implemented in web/src/protocol.ts (TS reference) and pir-runtime-core::protocol (Rust authoritative). A wire-level recorder is in the Wire explorer.

Encrypted channel

Once upgradeToSecureChannel has run, the frame above is wrapped in an outer AEAD envelope (ChaCha20-Poly1305). Every byte after the upgrade frame is sealed. The plaintext shape inside the AEAD envelope is unchanged — same codec, same variants.

The AEAD envelope contains:

+--------+---------+----------------+
| 12B    | ciphertext              |
| nonce  | (frame || auth-tag 16B) |
+--------+----------+--------------+

The X25519 static keys are obtained via attest() and verified against the operator pin — see Attestation.

Variant codes

The shared variant codes (relevant to DPF + HarmonyPIR + Merkle):

CodeRequestResponse
0x00REQ_PINGRESP_PONG
0x01REQ_GET_INFORESP_INFO
0x02REQ_GET_DB_CATALOGRESP_DB_CATALOG
0x03REQ_GET_INFO_JSONRESP_INFO_JSON
0x04REQ_RESIDENCYRESP_RESIDENCY
0x08REQ_CREDENTIAL_PRESENT (ARC)RESP_CREDENTIAL_OK
0x09REQ_CASHU_BAT_PRESENTRESP_CASHU_BAT_OK
0x11REQ_INDEX_BATCH (DPF)RESP_INDEX_BATCH
0x21REQ_CHUNK_BATCH (DPF)RESP_CHUNK_BATCH
0x31REQ_MERKLE_SIBLING_BATCH (legacy)RESP_MERKLE_SIBLING_BATCH
0x32REQ_MERKLE_TREE_TOP (legacy)RESP_MERKLE_TREE_TOP
0x33REQ_BUCKET_MERKLE_SIB_BATCHRESP_BUCKET_MERKLE_SIB_BATCH
0x34REQ_BUCKET_MERKLE_TREE_TOPSRESP_BUCKET_MERKLE_TREE_TOPS
0x40REQ_HARMONY_GET_INFORESP_HARMONY_INFO
0x41REQ_HARMONY_HINTSRESP_HARMONY_HINTS
0x42REQ_HARMONY_QUERYRESP_HARMONY_QUERY
0x43REQ_HARMONY_BATCH_QUERYRESP_HARMONY_BATCH_QUERY
0x53REQ_ONIONPIR_MERKLE_INDEX_SIBLINGRESP_ONIONPIR_MERKLE_INDEX_SIBLING
0x54REQ_ONIONPIR_MERKLE_INDEX_TREE_TOPRESP_ONIONPIR_MERKLE_INDEX_TREE_TOP
0x55REQ_ONIONPIR_MERKLE_DATA_SIBLINGRESP_ONIONPIR_MERKLE_DATA_SIBLING
0x56REQ_ONIONPIR_MERKLE_DATA_TREE_TOPRESP_ONIONPIR_MERKLE_DATA_TREE_TOP
0xFF(n/a)RESP_ERROR

OnionPIR uses its own request variants outside this table — see OnionPIR protocol.

Batch query payload

REQ_INDEX_BATCH, REQ_CHUNK_BATCH, and the per-bucket Merkle sibling-batch requests all share a BatchQuery payload:

+------------+--------+-------------+----------+----------+-----+----------+
| 2B u16     | 1B u8  | 1B u8       | per group:                          |
| round_id   | count  | keys_per_grp| 2B u16 len | key_bytes (len bytes)  |
+------------+--------+-------------+----------+----------+-----+----------+
                                    | (repeated count × keys_per_grp times)|
                                    +-------------------------------------+
 
(optional trailing 1B db_id — only appended when non-zero, for backward compat)
  • count is the number of PBC groups in the batch. For INDEX it's always K = 75. For CHUNK it's always K_CHUNK = 80. These are fixed — see the four invariants.
  • keys_per_group is the number of DPF keys per group. Index level is 2, chunk level is 3 (one per cuckoo position within the bin).
  • The per-group keys are concatenated in order.

The db_id byte is omitted when querying the main UTXO database (db_id = 0), kept for backwards compatibility with older servers. For deltas (db_id ≥ 1), it's appended.

Batch result payload

Symmetric to the query — same round_id, same per-group structure, but carrying response shares instead of keys:

+------------+--------+-------------+-------------------------------+
| 2B u16     | 1B u8  | 1B u8       | per group:                    |
| round_id   | count  | per_group   | 2B u16 len | result bytes     |
+------------+--------+-------------+-------------------------------+
                                    | (repeated count × per_group times)|
                                    +-------------------------------+

per_group matches keys_per_group from the request (2 for INDEX, 3 for CHUNK).

Length-prefix encoding example

A Ping request is the shortest valid frame: one byte of payload (the variant), four bytes of length prefix.

01 00 00 00   00
^^^^^^^^^^^   ^^
length=1      REQ_PING

A GetInfo response (RESP_INFO) carries the published catalog parameters:

+----+----+----+----+----+--------+--------+----+----+--------+
| 4B length-prefix    | 1B | 4B    | 4B    | 1B | 1B | 8B    |
| (total below)       | 01 | bins  | bins  | K  | K  | seed  |
+--------------------+----+--------+--------+----+----+--------+
                          INDEX    CHUNK   IDX  CHK  tag
                          bins/    bins/   K    K    seed
                          table    table              (u64 LE)

Error frames

A RESP_ERROR (0xFF) carries a UTF-8 message:

+----+----+--------+-------------------+
| 4B length         | 1B | 4B    | len bytes
+----+----+--------+----+--------+-----+
                    FF   message_len   message

The SDK surfaces this as PirError (Rust) / a rejected promise (WASM).

Padding contract (privacy-critical)

The codec itself is content-agnostic, but the SDK must always emit the padded shapes:

  • INDEX batches: exactly count = K = 75 groups, keys_per_group = 2.
  • CHUNK batches: exactly count = K_CHUNK = 80 groups, keys_per_group = 3.
  • HarmonyPIR per-group requests: exactly T − 1 sorted u32 indices.

A wire observer that watches a request whose count deviates from these constants is looking at a regression in the SDK or a non-SDK client. The reference implementation enforces these counts in pir-sdk-client and pir-sdk-wasm; the Wire explorer asserts them frame-by-frame.

Where to go next