Exchange integration
What an exchange, custodian or payment processor needs to list ZCD: running a node, deposit addresses, crediting deposits, sending withdrawals, fees and confirmations. Every route named here is described in the RPC API reference.
At a glance#
| Mainnet | |
|---|---|
| Ticker | ZCD |
| Decimals | 8 (1 ZCD = 100,000,000 drops) |
| Consensus today | Proof of work, RandomX |
| Target block time | 30 seconds |
| Recommended confirmations | 20 blocks, about 10 minutes |
| Block reward maturity | 240 blocks, about 2 hours |
| Deepest possible reorganization | 1024 blocks (undo_depth) |
| Address | 32 bytes, written as 0x + 64 hex characters |
| Transaction id | Certificate id, 32 bytes, known before signing |
| Node API | HTTP and JSON on 127.0.0.1:9420, no authentication |
| Chain id | 1 (testnet: 2) |
| Explorer | explorer.zycord.com (testnet: testnet.zycord.com) |
Integrate against the public testnet first. It runs the same release under its own chain id, and its coins are free from the faucet.
What is different from Bitcoin#
Most integrations are written against Bitcoin Core's RPC. Four differences change the design of a Zycord integration:
- The node has no wallet. It holds no key, so there is no
getnewaddressorsendtoaddress. Keys and signing live inzcd, the command-line wallet, which builds a certificate and submits it to the node. - The node keeps no history by address. There is no
listtransactions. You read balances, or you walk blocks, and keep your own ledger. - Addresses have no checksum. A mistyped address is still a well-formed address, which nobody owns.
- Some addresses are single-use. A one-shot address can be spent from exactly once; after that, a payment sent to it is skipped and the sender pays the skip fee.
Run a node#
Install the release (Installation,
Verifying a download), which ships zycordd, the node, and
zcd, the wallet. Then:
zycordd --dir /var/lib/zycord # mainnet
zycordd --testnet --dir /var/lib/zycord-testnet --rpc 127.0.0.1:19420
A build from source names them zycordd-randomx and zcd-randomx.
Before trusting any balance, check that /status answers "syncing": false
and that its height matches the explorer. The RPC binds loopback; reach it from your other services
over an SSH tunnel or an authenticating proxy (Access), and give
those services the trust token so they are not
rate-limited.
Run a second node on different infrastructure. zcd cross-checks
balances and spent flags against it with --confirm-rpc before a spend, so one node's
word is never the only thing sizing a payment that cannot be undone.
Addresses#
An address is 32 bytes: a version byte, then the first 31 bytes of
blake3("zcd/addr/v1" ‖ version ‖ Ed25519 public key).
| First byte | Kind | A user may withdraw to it |
|---|---|---|
0x01 | One-shot user address | Yes, if /balance answers spent: false and a balance of "0" |
0x02 | Persistent user address | Yes |
0x05 | Contract | Only if you support contract deposits |
0x00, 0x03 | Protocol cells, issued assets | No |
Validating a withdrawal address#
# 0x, then 64 hex characters, starting 01 or 02
^0x0[12][0-9a-fA-F]{62}$
Then ask the node: /balance?addr=… must answer spent: false, and for
a one-shot (0x01) also a balance of "0". A one-shot that is already spent
cannot receive, and paying it costs you the skip fee; zcd refuses to pay a one-shot
that is spent or already holds a balance.
The format catches a wrong length and a wrong character, not a wrong character in the right place. Have users paste or scan addresses rather than type them, and show the address back to them before confirming a withdrawal.
One-shot and persistent#
Wallets give out a fresh one-shot address for every payment by default. The first certificate that spends from a one-shot burns its signing authority, and the reference wallet refuses to pay a one-shot that is spent or that already holds a balance. A one-shot is therefore an address for one deposit.
A persistent address can receive and spend any number of times. It is the right kind for a deposit address a customer keeps; anything paid to it is linked together on chain, which for a deposit address is the point.
Deposits#
Giving each customer an address#
With today's tools, the address a customer can keep is the persistent address of a key file of their own:
zcd wallet new --out customers/1001.key # prints the first persistent address
Record the address wallet new prints, and give the customer that one.
zcd wallet address shows the next unused persistent address, which changes once
the first has been paid, so it is not a way to look the deposit address up again.
Each key file is encrypted with its own passphrase and has its own recovery phrase; back both up as you would any hot-wallet key.
For a one-off deposit instead, hand out a one-shot from a single key file:
zcd wallet address --key deposits.key --new
The wallet hands out at most 20 one-shot addresses ahead of the last one that received a
payment, so that restoring the key from its recovery phrase still finds every deposit. It learns of
payments only when it scans (balance, send, sweep), not on
address --new, and every send or sweep from that key file reserves four more one-shot
addresses from the same window. A key file with many unpaid addresses outstanding cannot send. That
suits a payment page, not an exchange with many customers waiting at once.
Detecting a deposit#
The simplest reliable method is the one the reference wallet uses: read the balance of each deposit address, once a block.
curl -s "http://127.0.0.1:9420/balance?addr=0x02…"
{"balance": "250000000", "spent": false, "tip": {"id": "0x…", "height": 12345}}
Store (address, balance, tip.height, tip.id) each time. An increase is a deposit
seen at that height. Read balances with a big-integer type: amounts are decimal strings.
The alternative is to walk the chain: for every new height, fetch
/block?height=N&format=ssz, decode its certificates
(wire protocol), and take the moves into your addresses from the
certificates whose outcome in /block?height=N is APPLIED. This tells you
which certificate paid you, but it does not account for every way a balance changes (a matured
block reward, for one, credits an address without a move), so reconcile against
/balance either way.
Crediting a deposit#
Credit an increase once it has been on the chain for 20 blocks: the tip height is at least 20 past the height you first saw it, and the balance still holds it.
The node answers from its tip only, so "balance at 20 confirmations" is something you keep, not
something you ask for. Keep the block id beside each observation: if
/block?id=<that id> later answers orphaned: true, a reorganization
replaced that block, and the deposits you saw at it must be checked again before they are credited.
A deposit already credited and later reversed is the case the 20-block wait exists to make
unlikely.
Collecting deposits#
Move a customer's balance to your hot wallet with a sweep:
zcd wallet sweep --key customers/1001.key --to 0x02<hot wallet> \
--confirm-rpc http://127.0.0.1:9520 --yes
A sweep sends everything except the fee reservation. The unused part of the reservation is
refunded in the same block to a new one-shot address of the same key file, and goes out with the
next sweep. Spending that one-shot burns it, which asks for a confirmation; --yes
answers it, so unattended sweeps do not stop there.
A balance below the skip fee, 0.01 ZCD, cannot pay for its own sweep. Set a minimum deposit above it, or let small deposits wait until later ones join them.
Withdrawals#
zcd wallet send --key hot.key \
--to 0x02<customer>:150000000 \
--confirm-rpc http://127.0.0.1:9520 --yes
The amount is in drops. One certificate carries up to 28 --to recipients, fewer
when it draws on many of the wallet's addresses, so withdrawals can be batched. Without a terminal,
the passphrase is read from the first line of standard input, and --yes skips the
interactive confirmation (it skips no check). --dry-run builds and checks the certificate, prints its id and the fee,
and submits nothing.
The wallet submits through POST /submit and then follows the certificate for eight
blocks, printing what became of it. With --no-wait it returns as soon as the node has
taken it, and you follow it yourself:
curl -s "http://127.0.0.1:9420/cert?id=0x…"
| Answer | What to do |
|---|---|
pending | Wait |
included, outcome: APPLIED | Paid. Record the height and treat it as final after 20 blocks |
included, any other outcome | Not paid. A skipped certificate (SKIPPED_STALE, SKIPPED_OVERFLOW) was charged the skip fee. Build a new withdrawal once the skip is 20 blocks deep or the TTL has passed: a reorganization could still carry the same certificate into a block where it applies |
dropped | This node removed it from its pool; reason says why. Other nodes may still hold it and include it until its TTL. Rebuild only once the tip is past the TTL and neither of your nodes reports it included |
unknown | This node has no record. Ask your second node, and check the blocks since you submitted |
Each certificate carries a TTL, 30 blocks by default: a certificate not included by then can never be included, so a withdrawal that missed its window is safe to rebuild. Never rebuild one that may still land; that is how a customer gets paid twice.
Fees#
A certificate's fee is gas times price in two markets, sequential and parallel, each with a base
fee the protocol moves by at most an eighth a block, or by one drop per gas when that is more. /fees serves both. The wallet bids
a maximum of eight times the current base fee plus a priority tip (100 drops per gas sequential, 5
parallel), reserves that from the balance, and is refunded what was not used in the same block.
The base fee is burned and the tip goes to the block's producer.
--dry-run prints the current fee for the certificate it would send, which is the
number to show a customer.
A certificate that is included but cannot apply, for example because a one-shot it pays is
already spent, is skipped and pays skip_fee, 0.01 ZCD.
Confirmations and reorganizations#
A block is final by depth. The recommended wait is 20 blocks, about 10 minutes. No node reorganizes deeper than 1024 blocks, and checkpoints built into each release pin the chain behind them.
The network moves to proof of stake in era 2, with a block every 5 seconds, and every duration counted in blocks is multiplied by six. The recommended wait stays 10 minutes, which is then 120 blocks, and reward maturity stays about 2 hours. Configure every block-counted wait in time, or update the counts when that era opens.
Block rewards#
A miner's share of a block reward is credited 240 blocks after the block, and
/maturing lists the rewards still waiting. Until then
it is not in /balance, and a deposit address is never paid by a block reward unless it
was used as a payout address.
What the tools do not do yet#
Stated plainly, so you can plan around them:
- No offline signing.
zcdsigns and submits from one machine; it does not export an unsigned certificate for a cold signer. - One persistent address per key file from the command line.
- No address history in the node: deposits come from balances or from walking blocks.
- No address checksum.
An integration that needs more can build and sign certificates itself. The formats are public
and fixed: an address is derived as above, and a signature is Ed25519 over
blake3("zcd/sig/v1" ‖ chain_id ‖ consensus_root ‖ cert_body_root), where
chain_id is 8 bytes little-endian, consensus_root comes from
/params, and
cert_body_root = blake3("zcd/certbody/v1" ‖ ssz(certificate without signatures)), a
different tag from the certificate id's. The
encoding is in the wire protocol and
docs/PROTOCOL.md,
with golden vectors to test against in
spec/.
Questions from an integrating team go to the forum thread.