ZYCORD docs
English
ZycordDocsWallet

Wallet

Keys, addresses and sending — plus the behavioural contract every wallet on this protocol has to meet. Each rule describes a way to lose money that the protocol permits, and that a wallet must therefore prevent.

The commands#

zcd wallet new     --out KEYFILE                        # create an encrypted key file
zcd wallet address --key KEYFILE                        # show an address
zcd wallet balance --key KEYFILE                        # ask a node for a balance
zcd wallet send    --key KEYFILE --to ADDR --amount N
zcd wallet sweep   --key KEYFILE --to ADDR --one-shot
zcd wallet retire  --key KEYFILE [--addr ADDR]
zcd ui             --key KEYFILE                        # the same wallet in a browser

The graphical wallet is not a second implementation. zcd wallet, zcd ui and the desktop application are three interfaces over one wallet/session: each builds its certificate there, and the rule checks run inside that call. A graphical wallet is therefore structurally incapable of being more permissive than the CLI — not because whoever wrote it was careful, but because there is no second code path on which it could be.

Two kinds of address#

VersionKindUse it forReusable?
0x01One-shotA single expected payment. Derive a fresh one per payment received.No. A debit burns it permanently.
0x02PersistentMerchants, donation addresses, hot wallets, mining payouts.Yes, forever. Can never enter the spent registry.

One-shot versus persistent is a wallet policy surfaced as an address bit, not two ledgers. Wallets default to 0x01 per payment received, because that is what makes a payment unlinkable to the next; anyone who wants an account uses 0x02.

The eight rules#

Every rule below describes a way to lose money or fees that the protocol permits — deliberately, because the alternative was worse — and that a wallet must therefore prevent. The reference wallet implements them. It does not merely document them.

Rule 1 — sweep whole cells#

When spending from a 0x01 address, move the entire balance — to the payee and to a change address you control. The certificate carries a MARK_SPENT of that address, and after it applies every read and write under the address fails permanently. There is no second transaction.

The chain now softens this: whatever a burned address still holds moves to the certificate's own RefundTo cell at commit — for the address's native balance and for every cell the certificate itself names. So under-sweeping no longer destroys drops; it delivers them to your change address.

The obligation that comes with it

A certificate has one RefundTo and may burn one-shot addresses belonging to several signers, so a residual under your cell can be delivered to theirs. Never co-sign a certificate that burns a one-shot address of yours and refunds to an address you do not control.

What this rule cannot save: a balance in an asset the certificate never mentions. The assets under an address are not derivable from a slot, so reaching an unnamed asset would mean scanning the whole cell table in the one stage that does not parallelise. Name every asset in the certificate that burns the address.

Rule 2 — RefundTo must be an address you can still use#

It must name either a persistent address you control or a fresh one-shot address that this certificate does not burn. Settling into a burned cell strands the remainder: the fold burns it rather than writing it into a cell nobody can read, and reports it as refund_burned rather than refunded, so a wallet reconciling a balance can tell.

Rule 3 — one address, one expected payment#

Two obligations, one on each side:

  • Receiving. Derive a fresh 0x01 address per payment you expect. Never publish one twice. Do not sweep or retire an address you disclosed within the last ttl_max blocks unless you accept that a payment in flight to it will skip and bill its sender.
  • Sending. Refuse to pay a one-shot address you can see has already been credited or spent. If a payee hands you an address a second time, treat it as an error, not a convenience.

For anything paid more than once — a merchant, a donation address, a mining payout — use a persistent (0x02) address. This is the single rule that removes the exposure rather than narrowing it.

Rule 4 — dependent chains: confirm, or accept the risk#

Increment Seq for every certificate that depends on a previous one. Within one block the fold commits a signer's certificates in Seq order, so a dependent chain in the same block is safe. Across blocks it is not: broadcast Seq = n+1 only after Seq = n has confirmed, or accept that the dependent one may commit alone and skip. This is not a protocol defect — the signer accepted staleness risk by signing.

Rule 5 — set the maximum generously and the priority honestly#

Each market takes two prices. The maximum is a solvency bound: once the base fee passes it the certificate is unincludable and must be re-signed. The priority is what a miner is actually paid.

  • Raising the maximum costs nothing in fees — the safety buffer is free.
  • But the deposit reserves gas × max, so the maximum bounds how much balance is locked for one fold step. Size it against the balance actually available.
  • A certificate with a long TTL needs more headroom than one with a short TTL, because the base fee has more blocks in which to move.

The escape hatch for small balances is to shrink the window, not the safety: sign with a short TTL and re-sign on expiry, trading lockup for latency. The wrong response is to keep the long TTL and shrink the maximum, which makes the certificate strandable exactly when the market moves — the case the buffer existed for.

Rule 6 — sort moves canonically#

The protocol imposes no order on a transfer's moves, so without a canonical order a retry of the same logical payment produces a different id and the wallet cannot tell "already sent" from "sent twice". wallet.Transfer sorts by asset, source, destination, then amount, so a retry reproduces the id.

A retry does not have to reproduce the signature: the id's preimage excludes the signature list, so a retry re-signed at a fresh nonce is the same id and the network refuses it as the duplicate it is. Idempotency is a property the protocol provides, for every wallet rather than for the careful ones.

Rule 7 — never hand out a seed#

The seed is the key. Anyone who reads it owns everything both of its addresses hold — the one-shot and the persistent, which are unrelated on chain but derived from the same key.

Key files are always encrypted: Argon2id over the passphrase, AES-256-GCM over the seed, both with their parameters stored in the file so that a user locked out of this binary can recover with any language's standard library. There is no flag to write one unencrypted. Passphrases are read from the terminal without echo, never from a flag — a passphrase on a command line is in the shell history and in the process table.

Writing holds two properties at once, and both are about the same sentence — losing one is losing money:

  • Never silently overwrite. A key file already at the destination is left untouched and the write fails.
  • Never leave a torn one. The seed goes to a temporary file in the same directory, is fsynced there, and only then is published under its final name in a single filesystem operation.

Both hold on every filesystem the CLI was measured against, FAT32 and exFAT included — the formats a cold-storage USB backup is most likely to use.

An encrypted key file on a FAT-formatted stick is protected by its passphrase and by nothing else

FAT32 and exFAT have no Unix permission bits, so the 0600 a key file is created with does not survive on them — the mount options decide. On Linux the default fmask typically leaves it world-readable; macOS mounts such volumes noowners, which reports 0700 and enforces nothing. Choose the passphrase accordingly. They also have no journal, so if a publish is interrupted on FAT and reports failure, check the destination before rerunning.

Rule 8 — refuse what the network will refuse#

A wallet that reports success for something the network can only discard has moved the failure somewhere the user will never look. Two shapes of this, both found in the field:

  • Bytes no peer can decode. The codec is an authority in its own right, with rules the validator does not restate. The builder now asserts the property directly: whatever it emits, the decoder accepts.
  • A transfer the fold can only skip. A transfer above the source's balance breaks no rule, so every node admits it — and nothing refuses a producer that includes it anyway, at which point the fold settles it at skip_fee, burned out of the deposit and paid to nobody. The bad case is not "no effect", it is "the fee was burned and the value never moved".

zcd wallet send --force bypasses that one refusal and nothing else, because a deposit expected to land inside the TTL window makes the same certificate apply and the wallet cannot see the future. It cannot make an invalid certificate valid; it can only submit a valid one that may skip — and a skip is not free.

Trusting a node you do not run#

zcd and the wallet are not full nodes. They believe what a node tells them, and a CLI that talks RPC to a node it does not itself validate has to trust something about that node's answers — that is not a bug to fix, it is what a wallet is. Three mitigations, each for a specific lie:

MitigationWhat it catches
--devnet / --testnet / --paramsThe operator asserts which network they mean to sign for, and every node asked is checked against that assertion. Network identity is never taken from a node alone. This applies to balance too, not only to the commands that sign.
--confirm-rpc NODENames a second, independent node. Every address's balance and spent flag must be reported identically by both before anything proceeds. A node whose chain_id disagrees is refused, and a --confirm-rpc naming the endpoint --rpc already names is refused outright — a node cross-checking itself agrees with itself.
The typed confirmationBefore submitting, zcd wallet sweep prints the exact numbers and requires you to type sweep. --yes skips this prompt only, never the checks above it.

Checklist for a wallet implementation#

If you are writing a wallet against this protocol, this is the contract:

  • Spending from 0x01 moves the whole balance, counting the deposit reservation as part of it — including on programs with no moves at all.
  • RETIRE refuses a target that still holds a balance.
  • The node's report of a balance is not treated as beyond question: network identity asserted by the operator, a second source cross-checkable, and the exact numbers confirmed before an irreversible sweep.
  • RefundTo is validated as persistent-or-fresh before signing.
  • Receiving derives a fresh address per expected payment; sending refuses a one-shot address already credited or spent.
  • Merchant and payout addresses default to 0x02.
  • Seq increments per dependent certificate, and the wallet waits for confirmation before broadcasting the next — or says out loud that it is not.
  • Fee maxima are sized from available balance and TTL, not hardcoded.
  • Moves are sorted canonically, so a retry reproduces the certificate id.
  • Key files are encrypted, written durably with no silent overwrite, and passphrases never reach a flag.
  • Nothing is signed that its own encoding cannot be decoded back from, and nothing is submitted that the source balance cannot cover — with any override named, narrow, and reported in the preview.