Introduction
Zallet is a full-node Zcash wallet written in Rust. It is being built as a replacement for
the zcashd wallet.
Security Warnings
Zallet is currently under development and has not been fully reviewed.
Current phase: Alpha release
Zallet is currently in alpha. What this means is:
- Breaking changes may occur at any time, requiring you to delete and recreate your Zallet wallet.
- Many JSON-RPC methods that will be ported from
zcashdhave not yet been implemented. - We will be rapidly making changes as we release new alpha versions.
We encourage everyone to test out Zallet during the alpha period and provide feedback,
either by opening issues on GitHub or contacting us in the #wallet-dev channel of the
Zcash R&D Discord.
Future phase: Beta release
After alpha testing will come the beta phase. At this point, all of the JSON-RPC methods that we intend to support will exist. Users will be expected to migrate to the provided JSON-RPC methods; semantic differences will need to be taken into account.
Installation
There are multiple ways to install the zallet binary. The table below has a summary of
the simplest options:
| Environment | CLI command |
|---|---|
| Debian | Debian packages |
| Ubuntu | Debian packages |
Help from new packagers is very welcome. However, please note that Zallet is currently ALPHA software, and is rapidly changing. If you create a Zallet package before the 1.0.0 production release, please ensure you mark it as alpha software and regularly update it.
Pre-compiled binaries
WARNING: This approach does not have automatic updates.
Executable binaries are available for download on the GitHub Releases page.
Build from source using Rust
WARNING: This approach does not have automatic updates.
To build Zallet from source, you will first need to install Rust and Cargo. Follow the instructions on the Rust installation page. Zallet currently requires at least Rust version 1.85.
WARNING: The following does not yet work because Zallet cannot be published to crates.io while it has unpublished dependencies. This will be fixed during the alpha phase. In the meantime, follow the instructions to install the latest development version.
Once you have installed Rust, the following command can be used to build and install Zallet:
cargo install --locked zallet
This will automatically download Zallet from crates.io, build it, and install it in
Cargo’s global binary directory (~/.cargo/bin/ by default).
To update, run cargo install zallet again. It will check if there is a newer version,
and re-install Zallet if a new version is found. You will need to shut down and restart
any running Zallet instances to apply the new version.
To uninstall, run the command cargo uninstall zallet. This will only uninstall the
binary, and will not alter any existing wallet datadir.
Installing the latest development version
If you want to run the latest unpublished changes, then you can instead install Zallet directly from the main branch of its code repository:
cargo install --locked --git https://github.com/zcash/wallet.git
Debian binary packages setup
The Electric Coin Company operates a package repository for 64-bit Debian-based distributions. If you’d like to try out the binary packages, you can set it up on your system and install Zallet from there.
First install the following dependency so you can talk to our repository using HTTPS:
sudo apt-get update && sudo apt-get install apt-transport-https wget gnupg2
Next add the Zcash master signing key to apt’s trusted keyring:
wget -qO - https://apt.z.cash/zcash.asc | gpg --import
gpg --export B1C9095EAA1848DBB54D9DDA1D05FDC66B372CFE | sudo apt-key add -
Key fingerprint = B1C9 095E AA18 48DB B54D 9DDA 1D05 FDC6 6B37 2CFE
Add the repository to your Bullseye sources:
echo "deb [arch=amd64] https://apt.z.cash/ bullseye main" | sudo tee /etc/apt/sources.list.d/zcash.list
Or add the repository to your Bookworm sources:
echo "deb [arch=amd64] https://apt.z.cash/ bookworm main" | sudo tee /etc/apt/sources.list.d/zcash.list
Update the cache of sources and install Zcash:
sudo apt update && sudo apt install zallet
Troubleshooting
Missing Public Key Error
If you see:
The following signatures couldn't be verified because the public key is not available: NO_PUBKEY B1C9095EAA1848DB
Get the new key directly from the z.cash site:
wget -qO - https://apt.z.cash/zcash.asc | gpg --import
gpg --export B1C9095EAA1848DBB54D9DDA1D05FDC66B372CFE | sudo apt-key add -
to retrieve the new key and resolve this error.
Revoked Key error
If you see something similar to:
The following signatures were invalid: REVKEYSIG AEFD26F966E279CD
Remove the key marked as revoked:
sudo apt-key del AEFD26F966E279CD
Then retrieve the updated key:
wget -qO - https://apt.z.cash/zcash.asc | gpg --import
gpg --export B1C9095EAA1848DBB54D9DDA1D05FDC66B372CFE | sudo apt-key add -
Then update the list again:
sudo apt update
Expired Key error
If you see something similar to:
The following signatures were invalid: KEYEXPIRED 1539886450
Remove the old signing key:
sudo apt-key del 1539886450
Remove the list item from local apt:
sudo rm /etc/apt/sources.list.d/zcash.list
Update the repository list:
sudo apt update
Then start again at the beginning of this document.
Setting up a Zallet wallet
WARNING: This process is currently unstable, very manual, and subject to change as we make Zallet easier to use.
Create a config file
Zallet by default uses $HOME/.zallet as its data directory. You can override
this with the -d/--datadir flag.
Once you have picked a datadir for Zallet, create a zallet.toml file in it.
You currently need at least the following:
[builder.limits]
[consensus]
network = "main"
[database]
[external]
[features]
as_of_version = "0.0.0"
[features.deprecated]
[features.experimental]
[indexer]
validator_user = ".."
validator_password = ".."
# Required by the default backend; see "Reading chain state from a local zebrad".
[indexer.read_state_service]
grpc_address = "127.0.0.1:8230"
zebra_state_path = "/path/to/zebrad/state/cache"
[keystore]
[note_management]
[rpc]
bind = ["127.0.0.1:SOMEPORT"]
In particular, you currently need to configure the [indexer] section to point
at your full node’s JSON-RPC endpoint. The relevant config options in that
section are:
validator_address(if not running on localhost at the default port)validator_cookie_auth = trueandvalidator_cookie_path(if using cookie auth)validator_userandvalidator_password(if using basic auth)
Reading chain state from a local zebrad
Zallet can read finalized chain state directly from a co-located zebrad’s state
database (opened read-only), rather than fetching every block over JSON-RPC. This is
enabled by the [indexer.read_state_service] section.
The default zebra-state backend requires this section; without one, zallet start fails with:
the zebra-state backend requires an [indexer.read_state_service] config section
The zaino backend uses the section when it is present, and otherwise fetches all
chain data over JSON-RPC.
This relies on zebrad’s indexer gRPC interface, which is not available
in a default zebrad build. You must compile zebrad with the indexer feature
flag and set an indexer_listen_addr in its [rpc] config section:
# zebrad config (e.g. ~/.config/zebra/zebrad.toml)
[rpc]
# Any free address/port; must match Zallet's grpc_address below.
indexer_listen_addr = '127.0.0.1:8230'
Then configure the matching [indexer.read_state_service] section in Zallet’s
config:
[indexer.read_state_service]
# Must match zebrad's [rpc] indexer_listen_addr.
grpc_address = "127.0.0.1:8230"
# zebrad's existing state cache directory (the directory containing its on-disk
# state database). Relative paths are resolved against Zallet's datadir.
zebra_state_path = "/home/<username>/.cache/zebra"
Notes:
- The JSON-RPC
[indexer]settings above are still required: they are used for the mempool, transaction submission, and non-best-chain block reads. zebradmust be running on the same machine (Zallet reads its state files directly), built with theindexerfeature, and configured with anindexer_listen_addr.- zebrad’s on-disk state format must match Zallet’s
zebra-stateversion; a mismatch fails fast with a “no zebra-state v… database found” error rather than silently creating an empty database. - Reading state this way does not support regtest; use the JSON-RPC
zainobackend (without this section) for regtest.
If you have an existing zcash.conf, you can use it as a starting point:
$ zallet migrate-zcash-conf --datadir /path/to/zcashd/datadir -o /path/to/zallet/datadir/zallet.toml
Initialize the wallet encryption
Zallet uses age encryption to encrypt all key
material internally. Currently you can use two kinds of age identities, which you
can generate with zallet generate-encryption-identity (no external tooling
required):
-
A plain identity file directly on disk:
$ zallet -d /path/to/zallet/datadir generate-encryption-identity Public key: age1... -
A passphrase-encrypted identity file:
$ zallet -d /path/to/zallet/datadir generate-encryption-identity -p Enter passphrase to encrypt the identity: Confirm passphrase: Public key: age1...In non-interactive contexts, the passphrase is read from the
ZALLET_IDENTITY_PASSPHRASEenvironment variable instead of prompting.
(age plugins will also be supported but currently are tricky to set up, and
require the external age or rage CLI to create the identity.)
Once you have created your identity file, initialize your Zallet wallet:
$ zallet -d /path/to/zallet/datadir init-wallet-encryption
Generate a mnemonic phrase
$ zallet -d /path/to/zallet/datadir generate-mnemonic
Each time you run this, a new BIP 39 mnemonic will be added to the wallet. Be careful to only run it multiple times if you want multiple independent roots of spend authority!
Start Zallet
$ zallet -d /path/to/zallet/datadir start
Command-line tool
The zallet command-line tool is used to create and maintain wallet datadirs, as well as
run wallets themselves. After you have installed zallet, you
can run the zallet help command in your terminal to view the available commands.
The following sections provide in-depth information on the different commands available.
zallet startzallet example-configzallet migrate-zcash-confzallet migrate-zcashd-walletzallet init-wallet-encryptionzallet generate-mnemoniczallet import-mnemoniczallet export-mnemoniczallet add-rpc-userzallet rpczallet repairsubcommands
The start command
zallet start starts a Zallet wallet!
The command takes no arguments (beyond the top-level flags on zallet itself). When run,
Zallet will connect to the backing full node (which must be running), start syncing, and
begin listening for JSON-RPC connections.
You can shut down a running Zallet wallet with Ctrl+C if zallet is in the foreground,
or (on Unix systems) by sending it the signal SIGINT or SIGTERM.
Tuning history recovery
When Zallet syncs a wallet for the first time (or recovers history for newly imported
keys), it downloads and trial-decrypts historical blocks in batches. The maximum number
of blocks in a batch is controlled by the recover_batch_size option in the [sync]
section of the configuration file:
[sync]
# Download and scan up to 10,000 blocks per batch.
recover_batch_size = 10000
Larger batches improve scanning throughput, but increase peak memory usage: every block
in a batch is held in memory while it is downloaded and trial-decrypted. Mainnet blocks
can currently be up to 2 MiB each, so a batch of N blocks can require on the order of
N × 2 MiB of memory. The default of 1000 is conservative; operators running on
server-class hardware may wish to raise it to speed up initial sync.
The example-config command
zallet example-config generates an example configuration TOML file that can be used to
run Zallet.
The command takes one flag that is currently required: -o/--output PATH which specifies
where the generated config file should be written. The value - will write the config to
stdout.
For the Zallet alpha releases, the command also currently takes another required flag
--this-is-alpha-code-and-you-will-need-to-recreate-the-example-later.
The generated config file contains every available config option, along with their documentation:
$ zallet example-config -o -
# Default configuration for Zallet.
#
# This file is generated as an example using Zallet's current defaults. It can
# be used as a skeleton for custom configs.
#
# Fields that are required to be set are uncommented, and set to an example
# value. Every other field is commented out, and set to the current default
# value that Zallet will use for it (or `UNSET` if the field has no default).
#
# Leaving a field commented out means that Zallet will always use the latest
# default value, even if it changes in future. Uncommenting a field but keeping
# it set to the current default value means that Zallet will treat it as a
# user-configured value going forward.
#
# Settings that affect transactions created by Zallet.
#
[builder]
# Whether to spend unconfirmed transparent change when sending transactions.
#
# Does not affect unconfirmed shielded change, which cannot be spent.
#spend_zeroconf_change = true
...
The migrate-zcash-conf command
Available on crate feature
zcashd-importonly.
zallet migrate-zcash-conf migrates a zcashd configuration file (zcash.conf) to an
equivalent Zallet configuration file (zallet.toml).
The command requires at least one of the following two flag:
--path: A path to azcashdconfiguration file.--zcashd-datadir: A path to azcashddatadir. If this is provided, then--pathcan be relative (or omitted, in which case the default filenamezcash.confwill be used).
For the Zallet alpha releases, the command also currently takes another required flag
--this-is-alpha-code-and-you-will-need-to-redo-the-migration-later.
When run, Zallet will parse the zcashd config file, and migrate its various options to
equivalent Zallet config options. Non-wallet options will be ignored, and wallet options
that cannot be migrated will cause a warning to be printed to stdout.
The migrate-zcashd-wallet command
Available on crate feature
zcashd-importonly.
zallet migrate-zcashd-wallet migrates a zcashd wallet file (wallet.dat) to a Zallet
wallet (wallet.db).
zallet init-wallet-encryption must be run before this command.
Parsing a zcashd wallet file requires the db_dump utility built for Berkeley DB
version 6.2 (the version zcashd uses). When Zallet is built with the zcashd-import
feature it compiles and uses a vendored copy of this utility automatically, so you
normally do not need to provide one yourself. If that vendored utility is unavailable,
Zallet falls back to a db_dump found on the system $PATH; you can also point Zallet
at a specific zcashd installation’s db_dump with --zcashd-install-dir (see below).
The command requires at least one of the following two flag:
--path: A path to azcashdwallet file.--zcashd-datadir: A path to azcashddatadir. If this is provided, then--pathcan be relative (or omitted, in which case the default filenamewallet.datwill be used).
Additional CLI arguments:
--zcashd-install-dir: A path to a localzcashdinstallation directory, for source-based builds ofzcashd. When set, Zallet uses thedb_dumpfrom that installation’szcutil/bindirectory instead of its vendored copy. This is rarely needed, and generally not recommended: the vendoreddb_dumpis built for the Berkeley DB version (6.2) thatzcashdwallets use, so prefer it unless you have a specific reason to use yourzcashdinstallation’s utility (for example, a wallet written by a non-standard Berkeley DB build). If neither this flag nor the vendoreddb_dumpis available, Zallet falls back to adb_dumpon the system$PATH.--allow-multiple-wallet-imports: An optional flag that must be set if a user wants to import keys and transactions from multiplewallet.datfiles (not required for the firstwallet.datimport.)--buffer-wallet-transactions: If set, Zallet will eagerly fetch transaction data from the chain as part of wallet migration instead of via ordinary chain sync. This may speed up wallet recovery, but requires all wallet transactions to be buffered in-memory which may cause out-of-memory errors for large wallets.--allow-warnings: If set, Zallet will ignore errors in parsing transactions extracted from thewallet.datfile. This can enable the import of key data from wallets that have been used on consensus forks of the Zcash chain.
For the Zallet alpha releases, the command also currently takes another required flag
--this-is-alpha-code-and-you-will-need-to-redo-the-migration-later.
When run, Zallet will parse the zcashd wallet file, connect to the backing
full node (to obtain necessary chain information for setting up wallet
birthdays), create Zallet accounts corresponding to the structure of the
zcashd wallet, and store the key material in the Zallet wallet. Parsing is
performed using the db_dump command-line utility. By default Zallet uses the
copy it vendors and builds, which is the recommended choice; a zcashd-provided
db_dump from the zcutil/bin directory of a source installation (via
--zcashd-install-dir), or one on the system $PATH, are used otherwise.
The generate-encryption-identity command
zallet generate-encryption-identity generates a new age encryption identity — used to
encrypt the wallet’s key material at rest — and writes it to an identity file that zallet init-wallet-encryption can consume. It uses the same age
library that Zallet links internally, so no external rage / rage-keygen binary is
required.
$ zallet generate-encryption-identity
Public key: age1...
Output location
By default the identity is written to the configured keystore.encryption_identity path
(encryption-identity.txt in the data directory, which is ~/.zallet by default). The
data directory can be overridden with -d $DIRECTORY, and the output path and file can be
specified with -o/--output. Use -o - to write the identity to stdout instead of to a
file, which is primarily useful for scripting the setup of ephemeral test environments
(regtest, the integration test suite, testnet).
An existing identity file is never overwritten.
WARNING: If a wallet has already been initialized with this identity, deleting or replacing the identity file makes the wallet’s key material PERMANENTLY UNRECOVERABLE. Do not remove it unless you are certain that no wallet depends on it.
NOTE: Non-interactive generation is intended for disposable test environments. A mainnet wallet is not a throwaway container resource: automatically tearing down its key material means irrecoverable loss of funds. Make sure the mnemonics the wallet protects are backed up before relying on it.
Plain vs passphrase-encrypted identities
Without flags, a plain identity file is written, in rage-keygen’s format (a # created:
and # public key: comment header followed by the AGE-SECRET-KEY-1... line):
$ zallet -d /path/to/zallet/datadir generate-encryption-identity
Public key: age1...
With -p/--passphrase, the identity is passphrase-encrypted and ASCII-armored. In
interactive use you are prompted for the passphrase (with confirmation). In non-interactive
contexts (for example, automated test setup), the passphrase is read from the
ZALLET_IDENTITY_PASSPHRASE environment variable instead:
$ ZALLET_IDENTITY_PASSPHRASE=... zallet -d /path/to/zallet/datadir generate-encryption-identity -p
Public key: age1...
The environment variable, when set, is read once and is not persisted by Zallet.
Plugins
age plugin identities (e.g. YubiKey, Apple Secure Enclave, OpenPGP card) require the
corresponding age plugin binaries and are not generated by this command. See
init-wallet-encryption for using plugin identities.
The init-wallet-encryption command
zallet init-wallet-encryption prepares a Zallet wallet for storing key material
securely.
The command currently takes no arguments (beyond the top-level flags on zallet itself).
When run, Zallet will use the age encryption identity stored in a wallet’s datadir to
initialize the wallet’s encryption keys. The encryption identity file name (or path) can
be set with the keystore.encryption_identity config option.
WARNING: As of the latest Zallet alpha release (0.1.0-alpha.4),
zalletrequires the encryption identity file to already exist. You can generate a plain or passphrase-encrypted identity withzallet generate-encryption-identity.
Identity kinds
Zallet supports several kinds of age identities, and how zallet init-wallet-encryption
interacts with the user depends on what kind is used:
Plain (unencrypted) age identity file
In this case, zallet init-wallet-encryption will run successfully without any user
interaction.
The ability to spend funds in Zallet is directly tied to the capability to read the age identity file on disk. If Zallet is running, funds can be spent at any time.
Passphrase-encrypted identity file
In this case, zallet init-wallet-encryption will ask the user for the passphrase,
decrypt the identity, and then use it to initialize the wallet’s encryption keys.
Starting Zallet requires the capability to read the identity file on disk, but spending
funds additionally requires the passphrase. Zallet can be temporarily unlocked using the
JSON-RPC method walletpassphrase, and locked with walletlock.
WARNING: it is currently difficult to use
zallet rpcfor unlocking a Zallet wallet:zallet rpc walletpassphrase PASSPHRASEwill leak your passphrase into your terminal’s history.
Plugin identity file
age plugins will eventually be supported by
zallet init-wallet-encryption, but currently are tricky to set up.zallet generate-encryption-identitydoes not generate plugin identities, so setting one up requires the externalageorrageCLI (plus the relevant age plugin binary) to create the identity, followed by manual database editing.
Starting Zallet requires the capability to read the plugin identity file on disk. Then,
each time a JSON-RPC method is called that requires access to specific key material, the
plugin will be called to decrypt it, and Zallet will keep the key material in memory only
as long as required to perform the operation. This can be used to control spend authority
with an external device like a YubiKey (with age-plugin-yubikey) or a KMS.
The generate-mnemonic command
zallet generate-mnemonic generates a new BIP 39 mnemonic and stores it in a Zallet
wallet.
The command takes no arguments (beyond the top-level flags on zallet itself). When run,
Zallet will generate a mnemonic, add it to the wallet, and print out its ZIP 32 seed
fingerprint (which you will use to identify it in other Zallet commands and RPCs).
$ zallet generate-mnemonic
Seed fingerprint: zip32seedfp1qhrfsdsqlj7xuvw3ncu76u98c2pxfyq2c24zdm5jr3pr6ms6dswss6dvur
Each time you run zallet generate-mnemonic, a new mnemonic will be added to the wallet.
Be careful to only run it multiple times if you want multiple independent roots of spend
authority!
The import-mnemonic command
zallet import-mnemonic enables a BIP 39 mnemonic to be imported into a Zallet wallet.
The command takes no arguments (beyond the top-level flags on zallet itself). When run,
Zallet will ask you to enter the mnemonic. It is recommended to paste the mnemonic in from
e.g. a password manager, as what you type will not be printed to the screen and thus it is
possible to make mistakes.
$ zallet import-mnemonic
Enter mnemonic:
Once the mnemonic has been provided, press Enter. Zallet will import the mnemonic, and print out its ZIP 32 seed fingerprint (which you will use to identify it in other Zallet commands and RPCs).
$ zallet import-mnemonic
Enter mnemonic:
Seed fingerprint: zip32seedfp1qhrfsdsqlj7xuvw3ncu76u98c2pxfyq2c24zdm5jr3pr6ms6dswss6dvur
The export-mnemonic command
zallet export-mnemonic enables a BIP 39 mnemonic to be exported from a Zallet wallet.
The command takes the UUID of the account for which the mnemonic should be exported. You
can obtain this from a running Zallet wallet with zallet rpc z_listaccounts.
The mnemonic is encrypted to the same age identity that the wallet uses to internally
encrypt key material. You can then use a tool like [rage] to decrypt the resulting
file.
$ zallet export-mnemonic --armor 514ab5f4-62bd-4d8c-94b5-23fa8d8d38c2 >mnemonic.age
$ echo mnemonic.age
-----BEGIN AGE ENCRYPTED FILE-----
...
-----END AGE ENCRYPTED FILE-----
$ rage -d -i path/to/encrypted-identity.txt mnemonic.age
some seed phrase ...
The add-rpc-user command
zallet add-rpc-user produces a config entry that authorizes a user to access the
JSON-RPC interface.
The command takes the username as its only argument. When run, Zallet will ask you to enter the password. It is recommended to paste the password in from e.g. a password manager, as what you type will not be printed to the screen and thus it is possible to make mistakes.
$ zallet add-rpc-user foobar
Enter password:
Once the password has been provided, press Enter. Zallet will hash the password and print out the user entry that you need to add to your config file.
$ zallet add-rpc-user foobar
Enter password:
Add this to your zallet.toml file:
[[rpc.auth]]
user = "foobar"
pwhash = "9a7e65104358b82cdd88e39155a5c36f$5564cf1836aa589f99250d7ddc11826cbb66bf9a9ae2079d43c353b1feaec445"
The rpc command
Available on crate feature
rpc-clionly.
zallet rpc lets you communicate with a Zallet wallet’s JSON-RPC interface from a
command-line shell.
zallet rpc helpwill print a list of all JSON-RPC methods supported by Zallet.zallet rpc help <method>will print out a description of<method>.zallet rpc <method>will call that JSON-RPC method. Parameters can be provided via additional CLI arguments (zallet rpc <method> <param>).
Authentication
When Zallet starts its JSON-RPC server, it generates a random cookie credential and
writes it to {datadir}/.cookie. The zallet rpc command automatically reads this
cookie file to authenticate, so no manual password configuration is needed for local
access.
If [[rpc.auth]] users are configured in zallet.toml, zallet rpc will prefer
those credentials over the cookie file. Cookie-based auth and configured users coexist.
Comparison to zcash-cli
The zcashd full node came bundled with a zcash-cli binary, which served an equivalent
purpose to zallet rpc. There are some differences between the two, which we summarise
below:
zcash-cli functionality | zallet rpc equivalent |
|---|---|
zcash-cli -conf=<file> | zallet --config <file> rpc |
zcash-cli -datadir=<dir> | zallet --datadir <dir> rpc |
zcash-cli -stdin | Not implemented |
zcash-cli -rpcconnect=<ip> | rpc.bind setting in config file |
zcash-cli -rpcport=<port> | rpc.bind setting in config file |
zcash-cli -rpcwait | Not implemented |
zcash-cli -rpcuser=<user> | [[rpc.auth]] in config file |
zcash-cli -rpcpassword=<pw> | [[rpc.auth]] in config file |
zcash-cli -rpcclienttimeout=<n> | zallet rpc --timeout <n> |
| Hostname, domain, or IP address | Only IP address |
zcash-cli <method> [<param> ..] | zallet rpc <method> [<param> ..] |
For parameter parsing, zallet rpc is (as of the alpha releases) both more and less
flexible than zcash-cli:
-
It is more flexible because
zcash-cliimplements type-checking on method parameters, which means that it cannot be used with Zallet JSON-RPC methods where the parameters have changed.zallet rpccurrently lacks this, which means that:zallet rpcwill work against bothzcashdandzalletprocesses, which can be useful during the migration phase.- As the alpha and beta phases of Zallet progress, we can easily make changes to RPC methods as necessary.
-
It is less flexible because parameters need to be valid JSON:
- Strings need to be quoted in order to parse as JSON strings.
- Parameters that contain strings need to be externally quoted.
zcash-cli parameter | zallet rpc parameter |
|---|---|
null | null |
true | true |
42 | 42 |
string | '"string"' |
[42] | [42] |
["string"] | '["string"]' |
{"key": <value>} | '{"key": <value>}' |
Command-line repair tools
The zallet command-line tool comes bundled with a few commands that are specifically for
investigating and repairing broken wallet states:
The repair truncate-wallet command
If a Zallet wallet gets into an inconsistent state due to a reorg that it cannot handle
automatically, zallet start will shut down. If you encounter this situation, you can use
zallet repair truncate-wallet to roll back the state of the wallet to before the reorg
point, and then start the wallet again to catch back up to the current chain tip.
The command takes one argument: the maximum height that the wallet should know about after
truncation. Due to how Zallet represents its state internally, there may be heights that
the wallet cannot roll back to, in which case a lower height may be used. The actual
height used by zallet repair truncate-wallet is printed to standard output:
$ zallet repair truncate-wallet 3000000
2999500
Migrating from zcashd
TODO: Document how to migrate from zcashd to Zallet.
JSON-RPC altered semantics
Zallet implements a subset of the zcashd JSON-RPC wallet methods. While we
have endeavoured to preserve semantics where possible, for some methods it was
necessary to make changes in order for the methods to be usable with Zallet’s
wallet architecture. This page documents the semantic differences between the
zcashd and Zallet wallet methods.
Changed RPC methods
z_listaccounts
Changes to parameters:
- New
include_addressesoptional parameter.
Changes to response:
- New
account_uuidfield. - New
namefield. - New
seedfpfield, if the account has a known derivation. - New
zip32_account_indexfield, if the account has a known derivation. - The
accountfield is now only present if the account has a known derivation. - Changes to the struct within the
addressesfield:- All addresses known to the wallet within the account are now included.
- The
diversifier_indexfield is now only present if the address has known derivation information. - The
uafield is now only present for Unified Addresses. - New
saplingfield if the address is a Sapling address. - New
transparentfield if the address is a transparent address.
z_getnewaccount
Changes to parameters:
- New
account_namerequired parameter. - New
seedfpoptional parameter.- This is required if the wallet has more than one seed.
z_getaddressforaccount
Changes to parameters:
accountparameter can be a UUID.
Changes to response:
- New
account_uuidfield. accountfield in response is not present if theaccountparameter is a UUID.- The returned address is now time-based if no transparent receiver is present and no explicit index is requested.
- Returns an error if an empty list of receiver types is provided along with a previously-generated diversifier index, and the previously-generated address did not use the default set of receiver types.
listaddresses
Changes to response:
imported_watchonlyincludes addresses derived from imported Unified Viewing Keys.- Transparent addresses for which we have BIP 44 derivation information are now
listed in a new
derived_transparentfield (an array of objects) instead of thetransparentfield.
getrawtransaction
Changes to parameters:
blockhashmust benullif set; single-block lookups are not currently supported.
Changes to response:
vjoinsplit,joinSplitPubKey, andjoinSplitSigfields are always omitted.
z_viewtransaction
Changes to response:
- Some top-level fields from
gettransactionhave been added:statusconfirmationsblockhash,blockindex,blocktimeversionexpiryheight, which is now always included (instead of only when a transaction has been mined).fee, which is now included even if the transaction does not spend any value from any account in the wallet, but can also be omitted if the transparent inputs for a transaction cannot be found.generated
- New
account_uuidfield on inputs and outputs (if relevant). - New
accountstop-level field, containing a map from UUIDs of involved accounts to the effect the transaction has on them. - Information about all transparent inputs and outputs (which are always visible
to the wallet) are now included. This causes the following semantic changes:
poolfield on both inputs and outputs can be"transparent".- New fields
tInandtOutPrevon inputs. - New field
tOuton outputs. addressfield on outputs: inzcashd, this was omitted only if the output was received on an account-internal address; it is now also omitted if it is a transparent output to a script that doesn’t have an address encoding. UsewalletInternalif you need to identify change outputs.outgoingfield on outputs: inzcashd, this was always set because every decryptable shielded output is either for the wallet (outgoing = false), or in a transaction funded by the wallet (outgoing = true). Now that transparent outputs are included, this field is omitted for outputs that are not for the wallet in transactions not funded by the wallet.memofield on outputs is omitted ifpool = "transparent".memoStrfield on outputs is no longer only omitted ifmemodoes not contain valid UTF-8.
z_listunspent
Changes to response:
- For each output in the response array:
- The
amountfield has been renamed tovaluefor consistency withz_viewtransaction. Theamountfield may be reintroduced under a deprecation flag in the future if there is user demand. - A
valueZatfield has been added for consistency withz_viewtransaction - An
account_uuidfield identifying the account that received the output has been added. - The
accountfield has been removed and there is no plan to reintroduce it; use theaccount_uuidfield instead. - An
is_watch_onlyfield has been added. - The
spendablefield has been removed; useis_watch_onlyinstead. Thespendablefield may be reintroduced under a deprecation flag in the future if there is user demand. - The
changefield has been removed, as determining whether an output qualifies as change involves a bunch of annoying subtleties and the meaning of this field has varied between Sapling and Orchard. - A
walletInternalfield has been added. - Transparent outputs are now included in the response array. The
poolfield for such outputs is set to the string"transparent". - The
memofield is now omitted for transparent outputs.
- The
z_sendmany
Changes to parameters:
feemust benullif set; ZIP 317 fees are always used.- If the
minconffield is omitted, the default ZIP 315 confirmation policy (3 confirmations for trusted notes, 10 confirmations for untrusted notes) is used.
Changes to response:
- New
txidsarray field in response. txidfield is omitted iftxidshas length greater than 1.
Omitted RPC methods
The following RPC methods from zcashd have intentionally not been implemented
in Zallet, either due to being long-deprecated in zcashd, or because other RPC
methods have been updated to replace them.
| Omitted RPC method | Use this instead |
|---|---|
createrawtransaction | To-be-implemented methods for working with PCZTs |
fundrawtransaction | To-be-implemented methods for working with PCZTs |
getnewaddress | z_getnewaccount, z_getaddressforaccount |
getrawchangeaddress | |
keypoolrefill | |
importpubkey | |
importwallet | |
settxfee | |
signrawtransaction | To-be-implemented methods for working with PCZTs |
z_importwallet | |
z_getbalance | z_getbalanceforaccount, z_getbalanceforviewingkey, getbalance |
z_getmigrationstatus | |
z_getnewaddress | z_getnewaccount, z_getaddressforaccount |
z_listaddresses | listaddresses |
z_setmigration |
The z_getmigrationstatus and z_setmigration methods configured and reported
on the automatic Sprout-to-Sapling fund migration. Zallet does not support
Sprout, so there is nothing to migrate and no equivalent method is provided. If
you still hold Sprout funds, migrate them out of the Sprout pool before
transitioning your wallet to Zallet.
Supply Chain Security (SLSA)
Zallet’s release automation is designed to satisfy the latest SLSA v1.0 “Build L3” expectations: every artifact is produced on GitHub Actions with an auditable workflow identity, emits a provenance statement, and is reproducible. This page documents how the workflows operate and provides the exact commands required to validate the resulting images, binaries, attestations, and repository metadata.
Per-architecture reproducibility model. The release is multi-arch, and the two architectures are built by different reproducible toolchains — a deliberate, documented asymmetry:
linux/amd64is built with the StageX full-source-bootstrapped toolchain. StageX bootstraps the entire compiler chain from a tiny (~512-byte), hand-auditablehex0seed, so it additionally addresses the trusting-trust problem. This is the highest-assurance tier.linux/arm64is built with Nix (pinned flake:nixpkgsrev +crane+ exactrustc), producing a staticaarch64-unknown-linux-muslbinary. StageX cannot target arm64 today — itsstage0bootstrap seed is x86-only — so arm64 uses Nix instead. Nix gives rebuild-reproducibility (identical pinned inputs → byte-identical output, verifiable withdiffoscope), but its toolchain traces back to a pre-built binary bootstrap seed, so it does not by itself close trusting-trust.Both arches are therefore reproducible in the build-twice sense; only amd64 is bootstrap-grade. This page notes where the two paths differ.
Release architecture overview
Workflows triggered on a vX.Y.Z tag
.github/workflows/release.ymlorchestrates the full release. It computes metadata (set_env), builds the StageX-based amd64 image (containerjob), builds the Nix-based arm64 runtime (container_arm64job), stitches both into a single multi-arch image (manifestjob), and fans out to the binaries-and-Debian job (binaries_release) before publishing all deliverables on the tagged GitHub Release..github/workflows/build-and-push-docker-hub.yamlbuilds the amd64 OCI image deterministically with StageX, exports the runtime artifact, pushes by digest (no tags) to Docker Hub, signs the digest with Cosign (keyless OIDC), uploads the SBOM, and generates provenance viaactions/attest-build-provenance..github/workflows/build-arm64-nix.ymlbuilds the arm64 static-musl binary with Nix on a nativeubuntu-24.04-armrunner (reading thezodl-nix-cacheS3 binary cache so the musl toolchain is downloaded, not recompiled), lays it out in the sameexport-stage layout, pushes the arm64 image variant by digest, and appends the arm64 runtime to the shared artifact.manifestjob (inrelease.yml) assembles the amd64 + arm64 per-arch digests into one multi-arch OCI index per tag withdocker buildx imagetools create, then re-attests SLSA provenance on the final index digest. Pushing each arch by digest keeps tags atomic (a tag never exists as single-arch)..github/workflows/binaries-and-deb-release.ymlconsumes the exported binaries (both arches), performs smoke tests inside Debian containers, emits standalone binaries plus.debpackages, GPG-signs everything with the Zcash release key (decrypted from AWS Secrets Manager/release/gpg-signing-key), generates SPDX SBOMs, and attachesintoto.jsonlattestations. A single downstreamapt_publishjob ingests every arch’s.deband publishes ONE merged, signed APT index (-architectures=amd64,arm64) with a single S3 sync — avoiding the parallel-matrix race that would otherwise leave the publisheddists/index listing only one architecture.- Reproducible builds are invoked before/within these workflows: amd64 via StageX (
make build/utils/build.sh, Dockerfileexportstage); arm64 via theflake.nix#zalletoutput. Both emit the exact binaries consumed later, so images, standalone binaries, and Debian packages share the same reproducible artifacts per architecture.
Deliverables and metadata per release
| Artifact | Where it ships | Integrity evidence |
|---|---|---|
Multi-arch OCI image (docker.io/zodlinc/zallet) | Docker Hub | Cosign signature, Rekor entry, auto-pushed SLSA provenance, SBOM |
| Exported runtime bundle | GitHub Actions artifact (zallet-runtime-oci-*) | Detached from release, referenced for auditing |
Standalone binaries (zallet-${VERSION}-linux-{amd64,arm64}) | GitHub Release assets | GPG .asc, SPDX SBOM, intoto.jsonl provenance |
Debian packages (zallet_${VERSION}_{amd64,arm64}.deb) | GitHub Release assets + apt.z.cash | GPG .asc, SPDX SBOM, intoto.jsonl provenance |
| APT repository | Uploaded to apt.z.cash | APT Release.gpg, package .asc, cosigned source artifacts |
Targeted SLSA guarantees
- Builder identity: GitHub Actions workflows run with
permissions: id-token: write, enabling keyless Sigstore certificates bound to the workflow path (https://github.com/zcash/zallet/.github/workflows/<workflow>.yml@refs/tags/vX.Y.Z). - Provenance predicate:
actions/attest-build-provenance@v3emitshttps://slsa.dev/provenance/v1predicates for every OCI image (including the final multi-arch index), standalone binary, and.deb. Each predicate captures the git tag, commit SHA, build arguments, and resolved platform. - Reproducibility (amd64): StageX enforces a full-source-bootstrapped deterministic build. Re-running
make buildin a clean tree produces a bit-identical image whose digest matches the published amd64 digest. This is bootstrap-grade — the toolchain itself is built from a hand-auditable seed. - Reproducibility (arm64): the Nix build is rebuild-reproducible:
nix build .#zalletfrom the pinnedflake.lock(samenixpkgsrev +crane+rustc) produces a byte-identicalaarch64-unknown-linux-muslbinary, verifiable by building twice and comparing withdiffoscope. It is not bootstrap-grade — Nix’s toolchain derives from a pre-built binary bootstrap seed — so arm64 closes “did the published binary come from this source” but not the deeper trusting-trust question that StageX’s amd64 path does. Note also that Nix gives determinism by sandbox enforcement, not by proof: an impurebuild.rscan still break it (e.g.zaino-state’sbuild.rsshells out togit), which is why the arm64 result is verified by a build-twice diff rather than assumed. - GPG signing key: standalone binaries,
.debpackages, and the APTRelease.gpgare signed only with the ZODL release key (sysadmin@zodl.com, fetched from AWS Secrets Manager/release/gpg-signing-key). This is intentional: it does not dual-sign with the legacy ECC key (sysadmin@z.cash). The olderapt.z.cashpipeline dual-signed (ECC + ZODL) during the key-transition window so users with either key in their keyring could verify; the ECC key’s planned revocation is mid-2026, after which ZODL-only is the steady state. Users verify against the ZODL public key published athttps://apt.z.cash/zcash.asc.
Building Zallet yourself
The supply-chain machinery above governs the artifacts we publish — it does not constrain how you build Zallet. There are three tiers, ordered by assurance vs. convenience; pick whichever fits your needs. None of them is a prerequisite for the others.
| Tier | Command | Arch | Output | Guarantee |
|---|---|---|---|---|
| 1. Cargo (developer) | cargo build --release --bin zallet --features rpc-cli,zcashd-import | host arch | local binary | none beyond Cargo’s lockfile |
| 2. Docker (standard) | docker buildx build --platform linux/amd64,linux/arm64 -t zallet . | amd64 + arm64 | container image | rebuild-reproducible (digest-pinned bases, SOURCE_DATE_EPOCH) |
| 3a. Nix (reproducible) | nix build .#zallet | amd64 or arm64 (native) | static-musl binary | bit-for-bit reproducible |
| 3b. StageX (bootstrap-grade) | the Dockerfile.stagex build the CI runs | amd64 | static-musl image | full-source-bootstrapped + reproducible |
Tier 1 — plain Cargo
Nothing special: cargo build/cargo install work as in any Rust project. This is the right path for local development and is unaffected by any of the release tooling.
Tier 2 — the standard Dockerfile (multi-arch, “build it yourself”)
The repository’s default Dockerfile is a plain, multi-stage build on official rust + debian-slim images. It honours Docker’s $TARGETARCH, so a single command builds both architectures (including on Apple Silicon):
docker build -t zallet . # host arch
docker buildx build --platform linux/amd64,linux/arm64 . # both
This image is rebuild-reproducible: the rust and debian bases are digest-pinned, SOURCE_DATE_EPOCH (passed from the commit time) drives all build timestamps, absolute build paths are remapped out of the binary, and the apt/ldconfig caches are dropped. Two builds of the same commit with the same base digests produce the same bytes — verify by building twice and comparing, or with --output type=image,rewrite-timestamp=true. It does not bootstrap its toolchain (it pins a prebuilt rust + debian, like most reproducible-build setups), so it is not bootstrap-grade the way tier 3b (StageX, amd64) is — use tier 3 to reproduce the exact published release artifact.
Tier 3a — Nix (reproducible, both arches)
The flake.nix exposes a zallet package for both x86_64-linux and aarch64-linux, each producing a static-musl, bit-for-bit reproducible binary on a native host of that architecture:
# Install Nix (Determinate installer), then:
nix build github:zcash/wallet#zallet # builds for the host arch
./result/bin/zallet --version
# Verify reproducibility (rebuilds and compares):
nix build github:zcash/wallet#zallet --rebuild
For arm64, this is the easiest reproducible path by far — on an arm64 machine it is just “install Nix + nix build”, with no Docker, no containerd image store, and no pinned base images. (Producing an arm64 binary from an x86 host requires cross-compilation or emulation, which is no longer a two-command flow; the simple path assumes you are on the target architecture.)
Tier 3b — StageX (Dockerfile.stagex, bootstrap-grade, amd64)
Dockerfile.stagex is the full-source-bootstrapped amd64 build the release pipeline uses to publish the amd64 image. It is the highest-assurance tier (it additionally addresses trusting-trust) and requires Docker 26+ with the containerd image store enabled. See the architecture overview above for why amd64 uses StageX and arm64 uses Nix.
Verification playbook
The following sections cover every command required to validate a tagged release end-to-end (similar to Argo CD’s signed release process, but tailored to the Zallet workflows and the SLSA v1.0 predicate).
Tooling prerequisites
cosign≥ 2.1 (Sigstore verification + SBOM downloads)rekor-cli≥ 1.2 (transparency log inspection)craneorskopeo(digest lookup)oras(optional SBOM pull)ghCLI (orcurl) for release assetsjq,coreutils(sha256sum)gnupg,gpgv, and optionallydpkg-sig- Docker 25+ with containerd snapshotter (matches the CI setup) for deterministic rebuilds
Example installation on Debian/Ubuntu:
sudo apt-get update && sudo apt-get install -y jq gnupg coreutils
go install -v github.com/sigstore/rekor/cmd/rekor-cli@latest
go install github.com/sigstore/cosign/v2/cmd/cosign@latest
go install github.com/google/go-containerregistry/cmd/crane@latest
export PATH="$PATH:$HOME/go/bin"
Environment bootstrap
export VERSION=v1.2.3
export REPO=zcash/zallet
export IMAGE=docker.io/zodlinc/zallet
export IMAGE_WORKFLOW="https://github.com/${REPO}/.github/workflows/build-and-push-docker-hub.yaml@refs/tags/${VERSION}"
export BIN_WORKFLOW="https://github.com/${REPO}/.github/workflows/binaries-and-deb-release.yml@refs/tags/${VERSION}"
export OIDC_ISSUER="https://token.actions.githubusercontent.com"
export IMAGE_PLATFORMS="linux/amd64,linux/arm64" # multi-arch: amd64 via StageX, arm64 via Nix
export BINARY_SUFFIXES="linux-amd64,linux-arm64" # both suffixes ship per release
export DEB_ARCHES="amd64,arm64" # both .deb architectures ship per release
export BIN_SIGNER_WORKFLOW="github.com/${REPO}/.github/workflows/binaries-and-deb-release.yml@refs/tags/${VERSION}"
mkdir -p verify/dist
export PATH="$PATH:$HOME/go/bin"
# Tip: running the commands below inside `bash <<'EOF' … EOF` helps keep failures isolated,
# but the snippets now return with `false` so an outer shell stays alive even without it.
# Double-check that `${IMAGE}` points to the exact repository printed by the release workflow
# (e.g. `docker.io/zodlinc/zallet`). If the namespace is wrong, `cosign download`
# will look at a different repository and report "no signatures associated" even though the
# tagged digest was signed under the real namespace.
1. Validate the git tag
git fetch origin --tags
git checkout "${VERSION}"
git verify-tag "${VERSION}"
git rev-parse HEAD
Confirm that the commit printed by git rev-parse matches the subject.digest.gitCommit recorded in every provenance file (see section 6).
2. Verify the OCI image pushed to Docker Hub
export IMAGE_DIGEST=$(crane digest "${IMAGE}:${VERSION}")
cosign verify \
--certificate-identity "${IMAGE_WORKFLOW}" \
--certificate-oidc-issuer "${OIDC_ISSUER}" \
--output json \
"${IMAGE}@${IMAGE_DIGEST}" | tee verify/dist/image-cosign.json
cosign verify-attestation \
--type https://slsa.dev/provenance/v1 \
--certificate-identity "${IMAGE_WORKFLOW}" \
--certificate-oidc-issuer "${OIDC_ISSUER}" \
--output json \
"${IMAGE}@${IMAGE_DIGEST}" | tee verify/dist/image-attestation.json
jq -r '.payload' \
verify/dist/image-attestation.json | base64 -d \
> verify/dist/zallet-${VERSION}-image.slsa.intoto.jsonl
for platform in ${IMAGE_PLATFORMS//,/ }; do
platform="$(echo "${platform}" | xargs)"
[ -z "${platform}" ] && continue
platform_tag="${platform//\//-}"
cosign verify-attestation \
--type spdxjson \
--certificate-identity "${IMAGE_WORKFLOW}" \
--certificate-oidc-issuer "${OIDC_ISSUER}" \
--output json \
"${IMAGE}@${IMAGE_DIGEST}" | tee "verify/dist/image-sbom-${platform_tag}.json"
jq -r '.payload' \
"verify/dist/image-sbom-${platform_tag}.json" | base64 -d \
> "verify/dist/zallet-${VERSION}-image-${platform_tag}.sbom.spdx.json"
done
# Docker Hub does not store Sigstore transparency bundles alongside signatures,
# so the Cosign JSON output typically does NOT contain Bundle.Payload.logIndex.
# Instead, we recover the Rekor entry by searching for the image digest.
digest_no_prefix="${IMAGE_DIGEST#sha256:}"
rekor_uuid="$(
rekor-cli search \
--sha "${digest_no_prefix}" \
--format json | jq -r '.UUIDs[0]'
)"
if [[ -z "${rekor_uuid}" || "${rekor_uuid}" == "null" ]]; then
echo "Unable to locate Rekor entry for digest ${IMAGE_DIGEST} – stop verification here." >&2
false
fi
rekor-cli get --uuid "${rekor_uuid}"
Cosign v3 removed the deprecated --rekor-output flag, so the JSON emitted by
cosign verify --output json is now the canonical way to inspect the verification
result. When the registry supports Sigstore transparency bundles, Cosign can expose
the Rekor log index directly under optional.Bundle.Payload.logIndex, but Docker Hub
does not persist those bundles, so the optional section is usually empty.
Because of that, the Rekor entry is recovered by searching for the image’s content digest instead:
rekor-cli search --sha <digest>returns the list of matching UUIDs.rekor-cli get --uuid <uuid>retrieves the full transparency log entry, including the Fulcio certificate, signature and integrated timestamp.
If the Rekor search returns no UUIDs for the digest, verification must stop, because there is no transparency log entry corresponding to the signed image. In that case, inspect the “Build, Attest, Sign and publish Docker Image” workflow and confirm that the “Cosign sign image by digest (keyless OIDC)” step ran successfully for this tag and digest.
The attestation verifier now expects the canonical SLSA predicate URI
(https://slsa.dev/provenance/v1), which distinguishes the SLSA statement from the
additional https://sigstore.dev/cosign/sign/v1 bundle shipped alongside the image.
Cosign 3.0 returns the attestation envelope directly from cosign verify-attestation,
so the instructions above capture that JSON and decode the payload field instead of
calling cosign download attestation. SBOM validation reuses the same mechanism with
the spdxjson predicate and a platform annotation, so the loop above verifies and
decodes each per-platform SBOM attestation.
The SBOMs verified here are the same artifacts generated during the build
(sbom: true). You can further inspect them with tools like jq or syft to validate
dependencies and policy compliance.
3. Verify standalone binaries exported from the StageX image
gh release download "${VERSION}" --repo "${REPO}" \
--pattern "zallet-${VERSION}-linux-*" \
--dir verify/dist
curl -sSf https://apt.z.cash/zcash.asc | gpg --import -
for arch in ${BINARY_SUFFIXES//,/ }; do
arch="$(echo "${arch}" | xargs)"
[ -z "${arch}" ] && continue
artifact="verify/dist/zallet-${VERSION}-${arch}"
echo "Verifying GPG signature for ${artifact}..."
gpg --verify "${artifact}.asc" "${artifact}"
echo "Computing SHA256 for ${artifact}..."
sha256sum "${artifact}" | tee "${artifact}.sha256"
echo "Verifying GitHub SLSA provenance attestation for ${artifact}..."
gh attestation verify "${artifact}" \
--repo "${REPO}" \
--predicate-type "https://slsa.dev/provenance/v1" \
--signer-workflow "${BIN_SIGNER_WORKFLOW}"
echo
done
grep -F "PackageChecksum" "verify/dist/zallet-${VERSION}-linux-amd64.sbom.spdx"
4. Verify Debian packages before consumption or mirroring
gh release download "${VERSION}" --repo "${REPO}" \
--pattern "zallet_${VERSION}_*.deb*" \
--dir verify/dist
for arch in ${DEB_ARCHES//,/ }; do
arch="$(echo "${arch}" | xargs)"
[ -z "${arch}" ] && continue
deb="verify/dist/zallet_${VERSION}_${arch}.deb"
echo "Verifying GPG signature for ${deb}..."
gpg --verify "${deb}.asc" "${deb}"
echo "Inspecting DEB metadata for ${deb}..."
dpkg-deb --info "${deb}" | head
echo "Computing SHA256 for ${deb}..."
sha256sum "${deb}" | tee "${deb}.sha256"
echo "Verifying GitHub SLSA provenance attestation for ${deb}..."
gh attestation verify "${deb}" \
--repo "${REPO}" \
--predicate-type "https://slsa.dev/provenance/v1" \
--signer-workflow "${BIN_SIGNER_WORKFLOW}"
echo
done
The .deb SBOM files (.sbom.spdx) capture package checksums; compare them with sha256sum zallet_${VERSION}_${arch}.deb.
5. Validate apt.z.cash metadata
# 1. Get the Zcash signing key
curl -sSfO https://apt.z.cash/zcash.asc
# 2. Turn it into a keyring file in .gpg format
gpg --dearmor < zcash.asc > zcash-apt.gpg
# 3. Verify both dists using that keyring
for dist in bullseye bookworm; do
curl -sSfO "https://apt.z.cash/dists/${dist}/Release"
curl -sSfO "https://apt.z.cash/dists/${dist}/Release.gpg"
gpgv --keyring ./zcash-apt.gpg "Release.gpg" "Release"
grep -A3 zallet "Release"
done
This ensures the repository metadata match the GPG key decrypted inside the binaries-and-deb-release workflow.
6. Inspect provenance predicates (SLSA v1.0)
For any provenance file downloaded above, e.g.:
FILE=verify/dist/zallet_${VERSION}_amd64.deb
# 1) Builder ID
jq -r '.predicate.runDetails.builder.id' "${FILE}.intoto.jsonl"
# 2) Version (from the workflow ref)
jq -r '.predicate.buildDefinition.externalParameters.workflow.ref
| sub("^refs/tags/"; "")' "${FILE}.intoto.jsonl"
# 3) Git commit used for the build
jq -r '.predicate.buildDefinition.resolvedDependencies[]
| select(.uri | startswith("git+"))
| .digest.gitCommit' "${FILE}.intoto.jsonl"
# 4) Artifact digest from provenance
jq -r '.subject[].digest.sha256' "${FILE}.intoto.jsonl"
Cross-check that:
builder.idmatches the workflow that produced the artifact (${IMAGE_WORKFLOW}for OCI images,${BIN_WORKFLOW}for standalone binaries and.debpackages).subject[].digest.sha256matches the artifact’ssha256sum. (e.g image digest)materials[].digest.sha1equals thegit rev-parseresult from Step 1.
Automated validation:
gh attestation verify "${FILE}" \
--repo "${REPO}" \
--predicate-type "https://slsa.dev/provenance/v1" \
--signer-workflow "${BIN_SIGNER_WORKFLOW}"
7. Reproduce the deterministic build locally
The image is multi-arch and each architecture reproduces with its own toolchain. Extract the per-platform digest you want to check from the published manifest list:
crane manifest "${IMAGE}@${IMAGE_DIGEST}" \
| jq -r '.manifests[] | "\(.platform.architecture) \(.digest)"'
amd64 — StageX (full-source bootstrap)
git clean -fdx
git checkout "${VERSION}"
make build IMAGE_TAG="${VERSION}"
skopeo inspect docker-archive:build/oci/zallet.tar | jq -r '.Digest'
make build invokes utils/build.sh, which builds a single-platform (linux/amd64) OCI tarball at build/oci/zallet.tar. Its digest should match the amd64 per-platform digest extracted above.
arm64 — Nix (rebuild-reproducible, static musl)
Run on an aarch64 host (or any host with the arm64 Nix substituters available). Build twice and confirm the binary is byte-identical:
git checkout "${VERSION}"
nix build .#zallet # uses the pinned flake.lock
sha256sum ./result/bin/zallet
nix store delete "$(readlink -f ./result)" && nix build .#zallet --rebuild
sha256sum ./result/bin/zallet # must match the first hash
A matching hash across the two clean builds is the arm64 reproducibility guarantee. (The arm64 image variant wraps this exact binary in a scratch image, so its per-platform digest follows from the binary plus the reproducible image settings.) Because Nix enforces determinism by sandboxing rather than proving it, this build-twice check — not trust in Nix — is what establishes the result; diffoscope ./result-a/bin/zallet ./result-b/bin/zallet pinpoints any divergence if the hashes ever differ.
After importing:
make import IMAGE_TAG="${VERSION}"
docker run --rm zallet:${VERSION} zallet --version
Running this reproduction as part of downstream promotion pipelines provides additional assurance that the published image and binaries stem from the deterministic StageX build.
Supplemental provenance metadata (.provenance.json)
Every standalone binary and Debian package in a GitHub Release includes a supplemental
*.provenance.json file alongside the SLSA-standard *.intoto.jsonl attestation. For example:
zallet-v1.2.3-linux-amd64
zallet-v1.2.3-linux-amd64.asc
zallet-v1.2.3-linux-amd64.sbom.spdx
zallet-v1.2.3-linux-amd64.intoto.jsonl ← SLSA standard attestation
zallet-v1.2.3-linux-amd64.provenance.json ← supplemental metadata (non-standard)
The .provenance.json file is not a SLSA-standard predicate. It is a human-readable
JSON document that records the source Docker image reference and digest, the git commit SHA,
the GitHub Actions run ID, and the SHA-256 of the artifact — useful as a quick audit trail
but not suitable for automated SLSA policy enforcement. Use the *.intoto.jsonl attestation
(verified via gh attestation verify as shown in sections 3 and 4) for any automated
compliance checks.
Residual work
- Extend the attestation surface (e.g., SBOM attestations, vulnerability scans) if higher SLSA levels or in-toto policies are desired downstream.