# ZooBC Developer Guide: Blockchain API, Gateways and Tools

> Everything a program needs to read or write the ZooBC chain: gateways, the chain API, transaction submission, hardware-wallet signing, and operator notes.

- Canonical page: https://zoobc.com/developer/
- Publisher: ZooBC, https://zoobc.com/
- Language: English. Translations: [中文（简体）](https://zoobc.com/zh/developer/), [Русский](https://zoobc.com/ru/developer/), [Italiano](https://zoobc.com/it/developer/), [Español](https://zoobc.com/es/developer/), [Français](https://zoobc.com/fr/developer/), [日本語](https://zoobc.com/ja/developer/), [한국어](https://zoobc.com/ko/developer/)
- Last updated: 2026-10-08
- Topics: API, Blockchain, Digital signature, Software documentation
- Keywords: ZooBC developer guide, blockchain API, blockchain developer documentation, transaction signing, hardware wallet signing, gateway API, ZooBC CLI, Web3 development, ZooBC
- Site index for language models: https://zoobc.com/llms.txt

---

ZOOBC / DEVELOPERS

## Developers

Everything a program needs to read or write this chain. A gateway is an HTTPS front door: it terminates TLS, rate-limits, and proxies the chain API to the nodes, so a browser can talk to ZooBC without running one.

### Which chain are you building against?

The examples below run against the public TestNet gateway. Addresses, balances and transaction hashes do not carry across networks, so confirm which one you are on before acting.

TestNet zoobc.network

[Chain status](https://zoobc.network/api/v1/blockchain/status) [API reference](https://zoobc.network/api-docs)

MainNet zoobc.net

Chain status API reference

MainNet opens 14 February 2027

**Check our GitHub.** The tools around the node are open source under the MIT licence at [github.com/zoobc/tools ↗](https://github.com/zoobc/tools). Today that is `zbc-cli` and the one-program-per-transaction tools the examples below use, in several languages kept in step by one shared specification and one set of test vectors. It will grow as we go ahead.

### One base URL

Everything about the chain lives under `/api/v1/` on this gateway, same origin as this page, no CORS to negotiate, no key to obtain, no rate plan.

**zoobc.network is the public ZooBC TestNet gateway, not MainNet.** The examples below run against the test network.

```
curl https://this-gateway/api/v1/blockchain/status
```

Reads and writes go to different places, which matters more than it sounds:

| GET | proxied to an **archival node**, which holds the deep history, blocks, transactions, accounts as of any height. |
| --- | --- |
| POST | proxied straight to a **full node**, never to archival. Submitting a transaction needs a mempool, and an archival node is a read-only history service, it used to answer POST with 404, which is why broadcasting through a gateway once did not work. |

### The routes you will reach for first

|  |  |
| --- | --- |
| /api/v1/blockchain/status | height, confirmed height, latest block |
| /api/v1/blocks/latest | the newest block in full |
| /api/v1/accounts/<address> | balance and spendable balance. Takes a `ZBC_…` address or the raw 36-byte hex |
| /api/v1/accounts/<address>/tokens | every token that account holds |
| /api/v1/transactions?limit=25 | recent signed transactions |
| /api/v1/movements/latest | balance changes the chain makes itself, rewards, vesting, refunds |
| /api/v1/exchange/markets | open markets; `…/orderbook?market=<id>` for depth, `…/offers` for swap packages |
| /api/v1/tokens | every token, with supply and decimals |
| /api/v1/release/list | published releases and their hashes, what [/verify](https://zoobc.com/verify) checks against |
| /api/v1/registry/nodes | the node registry; also `/gateways`, `/relays`, `/archivals` |

Some routes exist only on an archival node and some only on a full node. This gateway tries archival first and retries the node API on an empty-bodied 404, so you rarely have to care which is which, but a 404 *with* a body is a real answer, not a wrong turn.

### Submitting a transaction

Build and sign it with `zbc-cli`, which takes its parameters as JSON on stdin so a private key never lands in `ps` or your shell history:

```
echo '{"sender_privkey":"…","recipient":"ZBC_…","amount":100000000}' \
  | zbc-cli send --json-input --api https://this-gateway
```

1 ZBC is 10^8 atomic units. Every amount in the API is atomic, there are no decimals anywhere in the wire format.

### What is being built on these rails

The same transaction types you call here are the foundation for the work in progress:

| [AI agents](https://zoobc.com/ai-agents) | scheduled transfers, escrow and multisig used as a spending ceiling an autonomous agent cannot raise on its own. Working today, with a dependency-free Python reference against the public testnet. |
| --- | --- |
| [Decentralized AI](https://zoobc.com/decentralized-ai) | independently operated model specialists, assembled on the user's side and settled openly, so no single company sits between a person and the intelligence they use. Post-mainnet direction. |
| [ProxCell](https://zoobc.com/proxcell) | local optimistic confirmation for in-person payments, with geographic settlement layers above it. Working paper. |

### Full reference

Every route, its parameters and its response shape:

[Open the API reference →](https://zoobc.com/swagger) · [alternate view](https://zoobc.com/api-docs)

Both addresses serve the same document.

### Downloads

Files an operator has published on this gateway are served at `/dl/<name>`. Names are a single segment of `[A-Za-z0-9._-]`, and everything is sent as an attachment and never rendered, this origin also serves the wallet, and a page rendered here would inherit the wallet's origin.

Release bundles live under `/releases/<version>/<arch>/`, with `/releases/latest` naming the current one. The node installer is at `/install`.

### Is this gateway healthy? checking…

`/gateway/status` reports what this gateway knows about itself and the nodes it can reach, mode, domain, how many nodes are on its allowlist, which are answering.

`/gateway/system-stats` reports the machine: CPU, memory, disk, uptime. It is what the figures on [the gateway page](https://zoobc.com/gateway) are drawn from.

```
curl https://this-gateway/gateway/status
curl https://this-gateway/gateway/system-stats
```

Neither needs a key. Neither exposes anything about the chain, for that use `/api/v1/…` above.

### Reaching one specific node

The chain routes answer from whichever archival node this gateway picks. To query a **named** node, put its IP in the hostname, dots become dashes, and an optional `-p<port>` chooses the API port:

```
curl https://ip-192-168-1-100.this-gateway/api/v1/node/info
curl https://ip-192-168-1-100-p8080.this-gateway/api/v1/node/info
```

Only nodes on this gateway's allowlist answer. Anything else is refused rather than proxied, so the hostname cannot be used to reach arbitrary hosts through the gateway.

### Relay

Voice, video and screen-share go peer-to-peer; when a firewall blocks a direct link a relay carries the traffic for credit. `/relay/info` reports its address, its rate and the genesis hash of the chain it settles on, a wallet compares that against its own node before paying, because a relay on another chain cannot credit what it is paid.

```
curl https://this-gateway/relay/info
```

### Admin panel

The gateway's own controls live at [zoobc.network/admin](https://zoobc.network/admin). It is protected by the `admin_api_key` from `/etc/zoobc/gateway.json`, which the installer generates per box, there is no default key.

### The allowlist

A gateway only proxies to nodes it has been told about. That list is what stops the `ip-…` hostname form from turning the gateway into an open proxy.

|  |  |
| --- | --- |
| POST /gateway/allowlist/add | admit a node, `{"ip":"…","port":8080}` |
| POST /gateway/allowlist/remove | drop one |
| POST /gateway/allowlist/refresh | re-read the registry and re-check health |
| GET /gateway/allowlist | what is currently admitted |

All three writes need the admin key. In public mode the gateway also discovers nodes from the chain's own registry; in private mode it uses only `static_nodes`.

### Release tooling

[The file hasher](https://zoobc.com/hash) computes a SHA-256 in the browser for anything you want to check by hand, and [/verify](https://zoobc.com/verify) checks a downloaded bundle against the hashes the chain has published.

### The two documents

Everything a hardware-wallet team needs is specified here, and in full in the two PDFs below. Both were verified against ZooBC node **v0.4.0** (`zoobc-main` at `7ad161a4`) and against transaction vectors a live v0.4.0 node accepted.

[Specification, PDF Hardware Wallet Signing Specification v2.1 28 pages, chain-bound signing, seven chain-validated test vectors](https://zoobc.com/docs/zoobc-hw-wallet-signing-spec-v2.1.pdf) [Annex, PDF Answers to the Integration Questions, annex 1.1 16 pages, the nine vendor questions answered one by one](https://zoobc.com/docs/zoobc-integration-questions-answered-v1.1.pdf)

Version 2.1 supersedes draft 1 in one substantive respect: transaction signatures are now **bound to the chain**. Two changes are breaking for anyone who built against draft 1, the signing digest and the ApprovalEscrow body. Nothing implements the old v0.3.2 construction, so build against the current document only.

### One-page summary

| Signature scheme | **Ed25519** (RFC 8032, pure, no prehash variant, no context string). 64-byte signature, 32-byte public key. |
| --- | --- |
| What is signed | `Ed25519.sign(sk, SHA3-256("ZBC-TX" &Vert; genesis_hash(32) &Vert; unsigned_bytes))`. The 32-byte digest **is** the Ed25519 message. |
| Chain binding | The digest covers the chain's **genesis block hash**, so a signature made for one chain cannot be replayed on another. The wire format is unchanged; the genesis hash is not a transaction field. |
| Signing tag | `"ZBC-TX"`, six ASCII bytes `5a 42 43 2d 54 58`. No terminator, no length prefix. |
| Hash function | **SHA3-256 (FIPS 202)**. Not Keccak-256, not SHA-256. |
| Key derivation | BIP-39 mnemonic, then seed (PBKDF2-HMAC-SHA512, 2048 rounds), then **SLIP-0010 Ed25519**, path `m/44'/883'/account'`. Three hardened levels, no change or index levels. |
| SLIP-44 coin type | **883** (registered: `883 \| ZBC \| ZooBC`). |
| Account (on-wire) | 36 bytes: `u32le(0)` &Vert; `pubkey(32)`. Hex form is what the JSON API takes. |
| Address (display) | `ZBC_` plus 56 base32 chars in 7 groups of 8. 3-byte checksum = `SHA3-256(pubkey &Vert; "ZBC")[0..3]`. |
| Version byte | **`0x01`, unchanged.** It was not bumped for chain binding and is not a version discriminator. |
| Native unit | 1 ZBC = 100 000 000 atomic (8 decimals). Amounts and fees are `u64` little-endian atomic units. |
| Byte order | Everything on the wire is **little-endian**, except SLIP-0010 child indexes (big-endian, per the standard). |
| Transaction hash | `tx_hash = SHA3-256(unsigned_bytes &Vert; signature)`; `tx_id = int64le(tx_hash[0..8])`. The genesis hash enters only the signing digest. |
| Replay protection | Chain binding, plus hash uniqueness, plus a **3600 s** inclusion window. There is no nonce. |
| Minimum fee | 0.025 ZBC base (2 500 000 atomic) times the network fee scale, plus size rent. Ask the node: `GET /api/v1/blockchain/estimate-fee`. |
| Tier 1 types | **1 SendZBC**, **11 TransferToken**, **4 ApprovalEscrow**. |
| Chain discovery | `GET /api/v1/node/info` returns `genesis_hash`, `signing_version: 2` and `signing_tag: "ZBC-TX"`. Read it from the endpoint you will broadcast to. |

### Read these four first

Four points that shape an implementation more than the rest.

| 1 | **There is no nonce, and there is no unsigned-transaction endpoint.** ZooBC is account-based, but account-based does not imply either one here. The host serialises the transaction itself; the layout below is complete and vector-pinned. |
| --- | --- |
| 2 | **The signature covers a tagged prefix**, `"ZBC-TX"` plus the chain's genesis hash, not the transaction bytes alone. Get this wrong and every signature fails with a generic error. |
| 3 | **The broadcast endpoint does not take the signed transaction blob.** It takes the transaction *body* bytes plus the envelope as named JSON keys, and rebuilds the envelope itself. |
| 4 | **History is returned newest-first, bounded by `limit`.** Two endpoints together give a complete picture, and the amount is type-dependent rather than an envelope field. |

### Key derivation and addresses

Reference: `zoobc-signer/extension/lib/zoobc-crypto.js`, byte-identical to the reference wallet; node-side equivalents in `src/crypto/slip10.cpp`.

```
seed = PBKDF2-HMAC-SHA512(NFKD(mnemonic), "mnemonic" + passphrase, 2048, 64)
I    = HMAC-SHA512("ed25519 seed", seed);  k = I[0..32], c = I[32..64]

for index in [44, 883, account]:                       # all hardened
    data = 0x00 || k || u32be(index + 0x80000000)
    I    = HMAC-SHA512(c, data);  k = I[0..32], c = I[32..64]

pubkey = Ed25519.publicKeyFromSeed(k)                  # k is the 32-byte seed
```

Account 0 is `m/44'/883'/0'`, account 1 is `m/44'/883'/1'`, and so on. Key derivation is **not** affected by chain binding: one key set serves every ZooBC chain, only the signature differs.

The display address is built from the public key, not from the 36-byte account:

```
buf[35] = pubkey(32) || "ZBC"
h       = SHA3-256(buf);  buf[32..35] = h[0..3]        # 3-byte checksum
s       = base32(buf)                                   # RFC 4648 A-Z2-7, no padding, 56 chars
address = "ZBC_" + s[0..8] + "_" + s[8..16] + ...       # 7 groups, canonical length 66
```

Decoding accepts underscores, dashes, spaces or no separators, in any case, and the checksum must be recomputed and compared. The 3-byte SHA3 checksum above is the definition the code and the chain use, so implement from it, and from the reference implementation in the specification, which reproduces the worked addresses.

### Unsigned transaction wire format

Source of truth: `src/util/transaction_util.cpp:144-270`. **The wire format did not change in v0.4.0.** No length or offset below is affected by chain binding.

| # | size | field | notes |
| --- | --- | --- | --- |
| 1 | 4 | transaction\_type | u32le, see the type catalog below |
| 2 | 1 | version | always `0x01`; the node rejects anything else |
| 3 | 8 | timestamp | u64le, Unix seconds, greater than 0 |
| 4 | 36 | sender | `00000000` &Vert; pubkey(32) for a ZBC signer |
| 5 | 4 or 4+n | recipient | 36 bytes for ZBC; `u32le(type)` &Vert; payload for others; **`02 00 00 00` alone** when there is no recipient |
| 6 | 8 | fee | u64le, atomic units |
| 7 | 4 | body\_length | u32le |
| 8 | n | body | per-type layout |
| 9 | 4 or var | escrow | **no escrow:** the 4 bytes `02 00 00 00`. **With escrow:** approver(36) &Vert; commission u64le &Vert; timeout u64le &Vert; instr\_len u32le &Vert; instruction &Vert; **multi\_party(1)** |
| 10 | 4 | message\_length | u32le |
| 11 | m | message | plain text, 256 bytes or fewer for ordinary transactions |

The escrow block's trailing `multi_party` byte is mandatory. Omitting it, or writing the no-escrow marker with a different value, produces only "Invalid transaction signature" on the node, with no further diagnostic.

### Worked example, chain-validated

SendZBC of 1 ZBC to self, fee 0.05 ZBC, timestamp 1700000000, no escrow, no message. This is vector 0 of the seven in the specification.

```
01000000                 type = 1 (SendZBC)
01                       version
00f1536500000000         timestamp 1700000000
00000000 5e8eb28d...2152 sender    (type 0 + pubkey)
00000000 5e8eb28d...2152 recipient (type 0 + pubkey)
404b4c0000000000         fee 5 000 000
08000000                 body length 8
00e1f50500000000         body: amount 100 000 000
02000000                 no-escrow marker
00000000                 message length 0
```

That is 113 bytes. The signing preimage prepends 38 more:

```
5a42432d5458              tag "ZBC-TX"          6 bytes
090ab3c7...a61878         genesis hash         32 bytes
0100000001...00000000     the 113 bytes above 113 bytes
                                              ---------
                                              151 bytes
```

`digest = SHA3-256(preimage) = f37e5340...4644fc`, signature `9a3b044d...03bb07`, `tx_hash = f00a6692...ce78b0`. A live v0.4.0 node returned HTTP 202 and that exact hash.

### Signing procedure, device side

```
input : account_index, genesis_hash(32), unsigned_bytes

1. k      = SLIP10(m/44'/883'/account_index')
2. pub    = Ed25519.pub(k);  self = 00000000 || pub
3. parse unsigned_bytes; every length must land exactly at the end
4. require version == 1 and sender == self
5. decode body per type; render the approval screen; wait for the user
6. digest = SHA3-256("ZBC-TX" || genesis_hash || unsigned_bytes)
7. sig    = Ed25519.sign(k, digest)          # the digest IS the message
8. return sig(64), and tx_hash = SHA3-256(unsigned_bytes || sig) for the host to cross-check
```

Use streaming SHA3 so step 6 does not need the whole transaction in RAM. Feed the 6 tag bytes, then the 32 genesis bytes, then the transaction.

**The tag is the single most likely implementation error in the whole specification.** `"ZBC-TX"` is six ASCII bytes, `5a 42 43 2d 54 58`, with no NUL terminator and no length prefix. A firmware that terminates the string, or writes seven bytes, produces a valid-looking signature that fails on chain with a generic error and no diagnostic. Test it against vector 0 before anything else.

### What chain binding does and does not do

The host supplies the 32 genesis bytes; the device does not look them up. Signing is therefore chain-agnostic, and one code path serves testnet, devnet, mainnet and any private or parallel ZooBC chain.

| Does | Stops **replay across chains**. A signature made for chain A will not verify on chain B. That is what it was built for and it does that completely. |
| --- | --- |
| Does not | Stop a compromised host from naming the wrong chain. The device signs for whatever hash it is handed. |

So a firmware table of known genesis hashes is still wanted, but for one purpose only: putting a trustworthy network *name* on the screen, falling back to "UNKNOWN CHAIN" plus the first 8 hex when the hash is not in the table.

**Do not refuse unrecognised chains by default.** Devnet's hash changes at every relaunch, and private or parallel chains are normal on ZooBC, so a hard refusal makes the device useless for integration and for legitimate deployments. If a vendor wants that lock, make it an explicit user setting, off by default.

### Transaction types: what a standard firmware should carry

Type id = `group + 256 × subtype`. Fifty-six types exist. These three cover pay ZBC, pay any token, and complete an escrow, and their bodies are small and fixed.

| id | name | body | recipient |
| --- | --- | --- | --- |
| 1 | **SendZBC** | amount u64le (8) | required, any type |
| 11 | **TransferToken** | token\_id i64le (8) &Vert; amount i64le (8) &Vert; optional fee\_in\_token u8 | required |
| 4 | **ApprovalEscrow** | approval u32le &Vert; escrowed\_transaction\_hash (32) = 36 bytes | empty marker; sender is the approver |

`token_id` is a signed 64-bit value derived from a hash, so it can be negative. Genesis coins are ids 1 to 14; ZBC itself is id 0 and never appears in TransferToken. Each token has its own `decimals` (0 to 8) that the device cannot verify.

**ApprovalEscrow changed in v0.4.0.** The body was `approval` plus an 8-byte id (12 bytes); it is now `approval` plus the escrowed transaction's 32-byte hash (36 bytes), and the parser refuses any other length. The reason is directly a hardware-wallet one: a signer that saw only an 8-byte id could not check what it was releasing, and an 8-byte id is findable by a 2^64 search against a fake escrow shown to the device. With the full hash the device can verify what it approves.

### What the device verifies

| Structure | Parse the 11 fields. Each length prefix must be consistent and the buffer must end exactly after the message. Anything else: refuse. |
| --- | --- |
| Version | Must be 1. |
| Sender | Must equal the device's own derived account for the requested path. The device never trusts the sender bytes it was handed; it re-derives and compares all 36 bytes. This is the whole "where is it from" guarantee. |
| Type | Must be in the supported set, or expert mode is on. |
| Body length | **8** for SendZBC, **16 or 17** for TransferToken, **36** for ApprovalEscrow. Extra bytes: refuse. The node would accept a longer SendZBC body and ignore the tail, which is exactly how a hidden payload would ride along. |
| Escrow timeout | Must be greater than the transaction timestamp. |
| Genesis hash | Must be exactly 32 bytes. The device does not validate it against a list, but a wrong length is a malformed request. |
| Never | Accept a pre-computed digest to sign. The device hashes the bytes itself, tag and genesis included. |
| Size caps | Recommended: unsigned transaction 2 KB or less, message 256 B, escrow instruction 512 B, refusing anything larger with `TOO_LARGE`. Tier 1 transactions are under 300 bytes, but an ApprovalEscrow arrives with a second transaction attached for verification, so the cap must cover both buffers. Larger payloads belong in software wallets. |

### What the user approves on screen

Per screen, in this order, adapted from the chain-validated reference signer.

| Network | Name from the firmware's table for the supplied genesis hash; **"UNKNOWN CHAIN" plus the first 8 hex** when it is not in the table. |
| --- | --- |
| Type | "Send ZBC", "Send token", "Approve escrow". |
| Amount | `amount / 1e8` with the ZBC symbol. |
| Recipient | **Full** address, never elided. A middle-elided address is what a vanity-generated look-alike key defeats. |
| Fee | atomic / 1e8 ZBC. |
| Total leaving | amount plus fee as one number, for ZBC amounts only. A token amount and a ZBC fee are different units and must not be summed. |
| Message | Shown if printable, otherwise "N bytes (binary)" plus the first 8 hex of its SHA3-256. |
| Escrow | Approver in full, commission, timeout as a date and time, instruction text. |
| Token (type 11) | Token id (signed decimal) and the **atomic** amount. If the host supplies `decimals` or `symbol` as display hints, show the scaled amount labelled "as reported by the app", because the device cannot verify them. |
| Escrow approval | "APPROVE" or "REJECT" in large type, then the verified escrow detail, then the fee. |

Timestamps need not be shown. They are not user-meaningful and the node enforces the window.

### Host and device protocol

The transport is the vendor's own USB/HID layer and its existing chunking. The content is what matters.

| command | request | response |
| --- | --- | --- |
| GET\_APP\_VERSION | none | firmware version, supported type set, max tx size |
| GET\_PUBLIC\_KEY | account\_index, confirm\_on\_device | `pubkey(32)`, `address` (66-char string). When confirming, the device shows the full address for the user to compare with the website. |
| SIGN\_TX | account\_index, genesis\_hash(32), unsigned\_tx\_bytes | `signature(64)`, `tx_hash(32)`, or a typed refusal: `SENDER_MISMATCH`, `UNSUPPORTED_TYPE`, `MALFORMED`, `USER_REJECTED`, `TOO_LARGE`, `ESCROW_HASH_MISMATCH` |
| SIGN\_MESSAGE | account\_index, message\_bytes | `signature(64)`, raw Ed25519, see the rule below |

**`genesis_hash` is load-bearing, not a display hint.** In draft 1 the network parameter could be an enum, because it only chose a label. It is now inside the digest, so it must be the actual 32 bytes, and a wrong value produces a signature that no chain will accept.

**The raw-sign rule.** A raw-sign command must never sign an input of exactly 32 bytes, and should only sign inputs it can display as text. A 32-byte raw input could be the SHA3 digest of a transaction the user never saw, which turns the message command into blind transaction signing. Refusing length 32 closes that hole with no chain change.

### The full sequence

```
Website (page)         Companion / host           device                    Gateway / node
   |-- connect ---------->|                            |                          |
   |                      |-- GET_PUBLIC_KEY(0) ----->|  (shows ZBC_... address) |
   |<-- address ----------|<-- pubkey, address -------|                          |
   |-- GET /api/v1/node/info  (genesis_hash, signing_version, height) ---------->|
   |-- GET /api/v1/accounts/<addr>  (balances) --------------------------------->|
   |-- GET /api/v1/accounts/<addr>/tokens, /api/v1/tokens/<id> (decimals) ------>|
   |   user fills the form                             |                          |
   |-- GET /api/v1/blockchain/estimate-fee?type=1&body_length=8 ---------------->|
   |<-- {minimum_fee, timestamp} ------------------------------------------------|
   |-- fields {type, recipient, body, fee, message} -->|                          |
   |                      |  serialize the 11 fields   |                          |
   |                      |-- SIGN_TX(0, genesis, -->|  parse, verify, display,  |
   |                      |            bytes)         |  user approves            |
   |                      |<-- signature, tx_hash ---|                          |
   |<-- signature --------|                            |                          |
   |-- POST /api/v1/transactions {JSON} ---------------------------------------->|
   |<-- 202 {status:"success", transaction_hash} --------------------------------|
   |-- GET /api/v1/transactions/<hash>/status  (staging, mempool, confirmed) --->|
```

The host compares the node's `transaction_hash` with the device's `tx_hash`. They must be identical, otherwise the host altered the bytes after signing.

**Read `genesis_hash` and `signing_version` from the endpoint you will broadcast to**, since that is the chain that judges the signature.

### Chain identity

The only network identifier ZooBC has is the **genesis block hash**. Nodes compare it before peering; there is no network-id byte. `GET /api/v1/node/info` returns it alongside two fields new in v0.4.0:

```
"genesis_hash": "090ab3c7...", "signing_version": 2, "signing_tag": "ZBC-TX"
```

`signing_version` absent means the node predates v0.4.0 and enforces the legacy unbound digest. A host that supports only current chains can treat its absence as "do not sign here". A node prints lower-case hex and the archival API upper-case, so decode case-insensitively and strip a leading `0x` if present. The bytes are the same either way.

**Where to read it from.** A gateway answers `/api/v1/node/info` from the archival service rather than from a node. Archival passes the two signing fields through from the node it mirrors, emitting them exactly when the node reports them, from build `7ad161a4` onward. Read `signing_version` from a node endpoint, or from a gateway on that build or later, and treat its absence as "ask a node" rather than as an answer.

### Broadcasting

`POST /api/v1/transactions`. **Unchanged in v0.4.0, no new field.** `transaction_body_bytes` is the **body only**: the node rebuilds the envelope from the named fields, prepends its **own** genesis hash and the tag, and recomputes the digest, so every field must equal what was signed.

```
{
  "version": 1,
  "timestamp": 1700000000,
  "transaction_type": 1,
  "fee": 5000000,
  "sender_account_address":    "00000000 5e8eb28d ... 2152",
  "recipient_account_address": "00000000 5e8eb28d ... 2152",
  "transaction_body_bytes": "00e1f50500000000",
  "signature": "9a3b044d ... 03bb07",
  "message_hex": "...optional...",
  "escrow": { "approver_address": "...", "commission": 0, "timeout": 1700003600 }
}
```

Success is HTTP **202** with `{"status":"success","duplicate":false,"transaction_hash":"..."}`. `duplicate:true` means the node already holds it, so do not resend. Treat 200 as success too. Failure is 400 with an error body, or 503 under load.

**A useful integration test:** the staging pool checks the signature first, so a transaction that reaches a later error such as "Sender account does not exist" has already passed signature verification. You can validate your signing path on an unfunded key.

### When a signature is rejected

A chain mismatch is **not** distinguishable from any other signature failure today. A legacy digest and a wrong-chain digest both return the identical body:

```
{"success":false,"error":"Transaction validation failed: ValidationError: Invalid transaction signature","code":400}
```

So work through the causes in this order, most likely first:

| 1 | The tag omitted, or NUL-terminated, or written as seven bytes. |
| --- | --- |
| 2 | Keccak-256 used instead of SHA3-256. |
| 3 | A genesis hash that does not match the chain being broadcast to. |
| 4 | The escrow's trailing `multi_party` byte omitted. |
| 5 | An ApprovalEscrow body still 12 bytes rather than 36. |

### Endpoints to build against

| chain | gateway (HTTPS) | signing | note |
| --- | --- | --- | --- |
| testnet | https://this-gateway | moving to v0.4.0 | the integration target once it flips |
| devnet | https://socialconnect.network | v0.4.0 | 11 nodes, faucet at `/faucet`; genesis hash changes at every relaunch |
| mainnet | not yet launched | v0.4.0 or later | read its genesis hash from the chain when it opens |

Route reads archival, then gateway, then node. Broadcast to a gateway or a node, **never to an archival endpoint**.

**Read the genesis hash at runtime; do not hard-code it.** Devnet's changes at every relaunch. A firmware table of hashes exists only to put a name on the screen, so a stale entry degrades to "UNKNOWN CHAIN" rather than breaking signing. When devnet's hash does change, every signature made before it is void, which is the feature working.
