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#
| Version | Kind | Use it for | Reusable? |
|---|---|---|---|
0x01 | One-shot | A single expected payment. Derive a fresh one per payment received. | No. A debit burns it permanently. |
0x02 | Persistent | Merchants, 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.
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
0x01address per payment you expect. Never publish one twice. Do not sweep or retire an address you disclosed within the lastttl_maxblocks 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.
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:
| Mitigation | What it catches |
|---|---|
--devnet / --testnet / --params | The 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 NODE | Names 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 confirmation | Before 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
0x01moves the whole balance, counting the deposit reservation as part of it — including on programs with no moves at all. RETIRErefuses 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.
RefundTois 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. Seqincrements 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.