Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 zcashd have 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:

EnvironmentCLI command
DebianDebian packages
UbuntuDebian 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 = true and validator_cookie_path (if using cookie auth)
  • validator_user and validator_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.
  • zebrad must be running on the same machine (Zallet reads its state files directly), built with the indexer feature, and configured with an indexer_listen_addr.
  • zebrad’s on-disk state format must match Zallet’s zebra-state version; 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 zaino backend (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

Reference

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_PASSPHRASE environment variable instead of prompting.

Reference

(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

Reference

Generate a mnemonic phrase

$ zallet -d /path/to/zallet/datadir generate-mnemonic

Reference

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

Reference

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.

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-import only.

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 a zcashd configuration file.
  • --zcashd-datadir: A path to a zcashd datadir. If this is provided, then --path can be relative (or omitted, in which case the default filename zcash.conf will 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-import only.

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 a zcashd wallet file.
  • --zcashd-datadir: A path to a zcashd datadir. If this is provided, then --path can be relative (or omitted, in which case the default filename wallet.dat will be used).

Additional CLI arguments:

  • --zcashd-install-dir: A path to a local zcashd installation directory, for source-based builds of zcashd. When set, Zallet uses the db_dump from that installation’s zcutil/bin directory instead of its vendored copy. This is rarely needed, and generally not recommended: the vendored db_dump is built for the Berkeley DB version (6.2) that zcashd wallets use, so prefer it unless you have a specific reason to use your zcashd installation’s utility (for example, a wallet written by a non-standard Berkeley DB build). If neither this flag nor the vendored db_dump is available, Zallet falls back to a db_dump on the system $PATH.
  • --allow-multiple-wallet-imports: An optional flag that must be set if a user wants to import keys and transactions from multiple wallet.dat files (not required for the first wallet.dat import.)
  • --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 the wallet.dat file. 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), zallet requires the encryption identity file to already exist. You can generate a plain or passphrase-encrypted identity with zallet 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 rpc for unlocking a Zallet wallet: zallet rpc walletpassphrase PASSPHRASE will 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-identity does not generate plugin identities, so setting one up requires the external age or rage CLI (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 ...

rage

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-cli only.

zallet rpc lets you communicate with a Zallet wallet’s JSON-RPC interface from a command-line shell.

  • zallet rpc help will 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 functionalityzallet rpc equivalent
zcash-cli -conf=<file>zallet --config <file> rpc
zcash-cli -datadir=<dir>zallet --datadir <dir> rpc
zcash-cli -stdinNot implemented
zcash-cli -rpcconnect=<ip>rpc.bind setting in config file
zcash-cli -rpcport=<port>rpc.bind setting in config file
zcash-cli -rpcwaitNot 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 addressOnly 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-cli implements type-checking on method parameters, which means that it cannot be used with Zallet JSON-RPC methods where the parameters have changed. zallet rpc currently lacks this, which means that:

    • zallet rpc will work against both zcashd and zallet processes, 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 parameterzallet rpc parameter
nullnull
truetrue
4242
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_addresses optional parameter.

Changes to response:

  • New account_uuid field.
  • New name field.
  • New seedfp field, if the account has a known derivation.
  • New zip32_account_index field, if the account has a known derivation.
  • The account field is now only present if the account has a known derivation.
  • Changes to the struct within the addresses field:
    • All addresses known to the wallet within the account are now included.
    • The diversifier_index field is now only present if the address has known derivation information.
    • The ua field is now only present for Unified Addresses.
    • New sapling field if the address is a Sapling address.
    • New transparent field if the address is a transparent address.

z_getnewaccount

Changes to parameters:

  • New account_name required parameter.
  • New seedfp optional parameter.
    • This is required if the wallet has more than one seed.

z_getaddressforaccount

Changes to parameters:

  • account parameter can be a UUID.

Changes to response:

  • New account_uuid field.
  • account field in response is not present if the account parameter 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_watchonly includes addresses derived from imported Unified Viewing Keys.
  • Transparent addresses for which we have BIP 44 derivation information are now listed in a new derived_transparent field (an array of objects) instead of the transparent field.

getrawtransaction

Changes to parameters:

  • blockhash must be null if set; single-block lookups are not currently supported.

Changes to response:

  • vjoinsplit, joinSplitPubKey, and joinSplitSig fields are always omitted.

z_viewtransaction

Changes to response:

  • Some top-level fields from gettransaction have been added:
    • status
    • confirmations
    • blockhash, blockindex, blocktime
    • version
    • expiryheight, 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_uuid field on inputs and outputs (if relevant).
  • New accounts top-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:
    • pool field on both inputs and outputs can be "transparent".
    • New fields tIn and tOutPrev on inputs.
    • New field tOut on outputs.
    • address field on outputs: in zcashd, 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. Use walletInternal if you need to identify change outputs.
    • outgoing field on outputs: in zcashd, 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.
    • memo field on outputs is omitted if pool = "transparent".
    • memoStr field on outputs is no longer only omitted if memo does not contain valid UTF-8.

z_listunspent

Changes to response:

  • For each output in the response array:
    • The amount field has been renamed to value for consistency with z_viewtransaction. The amount field may be reintroduced under a deprecation flag in the future if there is user demand.
    • A valueZat field has been added for consistency with z_viewtransaction
    • An account_uuid field identifying the account that received the output has been added.
    • The account field has been removed and there is no plan to reintroduce it; use the account_uuid field instead.
    • An is_watch_only field has been added.
    • The spendable field has been removed; use is_watch_only instead. The spendable field may be reintroduced under a deprecation flag in the future if there is user demand.
    • The change field 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 walletInternal field has been added.
    • Transparent outputs are now included in the response array. The pool field for such outputs is set to the string "transparent".
    • The memo field is now omitted for transparent outputs.

z_sendmany

Changes to parameters:

  • fee must be null if set; ZIP 317 fees are always used.
  • If the minconf field is omitted, the default ZIP 315 confirmation policy (3 confirmations for trusted notes, 10 confirmations for untrusted notes) is used.

Changes to response:

  • New txids array field in response.
  • txid field is omitted if txids has 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 methodUse this instead
createrawtransactionTo-be-implemented methods for working with PCZTs
fundrawtransactionTo-be-implemented methods for working with PCZTs
getnewaddressz_getnewaccount, z_getaddressforaccount
getrawchangeaddress
keypoolrefill
importpubkey
importwallet
settxfee
signrawtransactionTo-be-implemented methods for working with PCZTs
z_importwallet
z_getbalancez_getbalanceforaccount, z_getbalanceforviewingkey, getbalance
z_getmigrationstatus
z_getnewaddressz_getnewaccount, z_getaddressforaccount
z_listaddresseslistaddresses
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/amd64 is built with the StageX full-source-bootstrapped toolchain. StageX bootstraps the entire compiler chain from a tiny (~512-byte), hand-auditable hex0 seed, so it additionally addresses the trusting-trust problem. This is the highest-assurance tier.
  • linux/arm64 is built with Nix (pinned flake: nixpkgs rev + crane + exact rustc), producing a static aarch64-unknown-linux-musl binary. StageX cannot target arm64 today — its stage0 bootstrap seed is x86-only — so arm64 uses Nix instead. Nix gives rebuild-reproducibility (identical pinned inputs → byte-identical output, verifiable with diffoscope), 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.yml orchestrates the full release. It computes metadata (set_env), builds the StageX-based amd64 image (container job), builds the Nix-based arm64 runtime (container_arm64 job), stitches both into a single multi-arch image (manifest job), 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.yaml builds 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 via actions/attest-build-provenance.
  • .github/workflows/build-arm64-nix.yml builds the arm64 static-musl binary with Nix on a native ubuntu-24.04-arm runner (reading the zodl-nix-cache S3 binary cache so the musl toolchain is downloaded, not recompiled), lays it out in the same export-stage layout, pushes the arm64 image variant by digest, and appends the arm64 runtime to the shared artifact.
  • manifest job (in release.yml) assembles the amd64 + arm64 per-arch digests into one multi-arch OCI index per tag with docker 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.yml consumes the exported binaries (both arches), performs smoke tests inside Debian containers, emits standalone binaries plus .deb packages, GPG-signs everything with the Zcash release key (decrypted from AWS Secrets Manager /release/gpg-signing-key), generates SPDX SBOMs, and attaches intoto.jsonl attestations. A single downstream apt_publish job ingests every arch’s .deb and publishes ONE merged, signed APT index (-architectures=amd64,arm64) with a single S3 sync — avoiding the parallel-matrix race that would otherwise leave the published dists/ index listing only one architecture.
  • Reproducible builds are invoked before/within these workflows: amd64 via StageX (make build/utils/build.sh, Dockerfile export stage); arm64 via the flake.nix #zallet output. 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

ArtifactWhere it shipsIntegrity evidence
Multi-arch OCI image (docker.io/zodlinc/zallet)Docker HubCosign signature, Rekor entry, auto-pushed SLSA provenance, SBOM
Exported runtime bundleGitHub Actions artifact (zallet-runtime-oci-*)Detached from release, referenced for auditing
Standalone binaries (zallet-${VERSION}-linux-{amd64,arm64})GitHub Release assetsGPG .asc, SPDX SBOM, intoto.jsonl provenance
Debian packages (zallet_${VERSION}_{amd64,arm64}.deb)GitHub Release assets + apt.z.cashGPG .asc, SPDX SBOM, intoto.jsonl provenance
APT repositoryUploaded to apt.z.cashAPT 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@v3 emits https://slsa.dev/provenance/v1 predicates 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 build in 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 .#zallet from the pinned flake.lock (same nixpkgs rev + crane + rustc) produces a byte-identical aarch64-unknown-linux-musl binary, verifiable by building twice and comparing with diffoscope. 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 impure build.rs can still break it (e.g. zaino-state’s build.rs shells out to git), which is why the arm64 result is verified by a build-twice diff rather than assumed.
  • GPG signing key: standalone binaries, .deb packages, and the APT Release.gpg are 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 older apt.z.cash pipeline 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 at https://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.

TierCommandArchOutputGuarantee
1. Cargo (developer)cargo build --release --bin zallet --features rpc-cli,zcashd-importhost archlocal binarynone beyond Cargo’s lockfile
2. Docker (standard)docker buildx build --platform linux/amd64,linux/arm64 -t zallet .amd64 + arm64container imagerebuild-reproducible (digest-pinned bases, SOURCE_DATE_EPOCH)
3a. Nix (reproducible)nix build .#zalletamd64 or arm64 (native)static-musl binarybit-for-bit reproducible
3b. StageX (bootstrap-grade)the Dockerfile.stagex build the CI runsamd64static-musl imagefull-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)
  • crane or skopeo (digest lookup)
  • oras (optional SBOM pull)
  • gh CLI (or curl) for release assets
  • jq, coreutils (sha256sum)
  • gnupg, gpgv, and optionally dpkg-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.id matches the workflow that produced the artifact (${IMAGE_WORKFLOW} for OCI images, ${BIN_WORKFLOW} for standalone binaries and .deb packages).
  • subject[].digest.sha256 matches the artifact’s sha256sum. (e.g image digest)
  • materials[].digest.sha1 equals the git rev-parse result 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.