zingo-cli ↔ zebra-crosslink devnet compatibility: issue write-ups
Five issues found while getting zingo-cli to operate against a zebra-crosslink devnet
(consolidating fragmented orchard notes and shielding transparent UTXOs). Each section below is a
self-contained GitHub issue.
Issues 1–3 belong in the zingo-lib repo; issues 4–5 belong in zebra-crosslink.
Throughout, <UA> / <t-addr> are placeholders for wallet addresses and <devnet-host> for the
node endpoint.
Issue 1 — No devnet chain type: zingo-cli cannot express a chain whose upgrades activate at genesis
Repo: zingo-lib · Labels: enhancement, compatibility
Problem
zingo-cli --chain accepts only mainnet, testnet, and (feature-gated) regtest. A
zebra-crosslink devnet uses standard testnet address encoding but activates every network
upgrade at or near genesis, rather than at the public testnet's historical heights.
Neither existing option fits:
--chain testnet gives correct address prefixes but wrong consensus parameters. Every
pre-activation-height block is evaluated under the wrong network upgrade.
--chain regtest allows custom activation heights but selects NetworkType::Regtest, producing
the wrong address prefixes — addresses won't match those of the node's own wallet.
There is no way to combine "testnet address encoding" with "caller-supplied activation heights".
Proposed fix
Add a ChainType::Devnet(ConfiguredActivationHeights) variant that reuses testnet address encoding
while taking activation heights from configuration:
/// A custom testnet-encoded chain (standard testnet address prefixes) with
/// caller-supplied network-upgrade activation heights.
Devnet(zebra_chain::parameters::testnet::ConfiguredActivationHeights),
fn network_type(&self) -> NetworkType {
match self {
ChainType::Devnet(_) => NetworkType::Test, // testnet address encoding
// ...
}
}
fn activation_height(&self, nu: NetworkUpgrade) -> Option<BlockHeight> {
match self {
// identical handling to Regtest: heights come from config
Regtest(activation_heights) | Devnet(activation_heights) => { /* ... */ }
// ...
}
}
with chain_from_str("devnet") supplying the crosslink activation schedule
(before_overwinter: 0, everything through nu6: 1, nu6_1/nu7: None), plus the
corresponding Display, donation-address, and data-directory arms, and a --chain help-text
update.
The activation heights are currently hardcoded in chain_from_str. Making them configurable
(config file or CLI flag) would generalise this to any devnet, and is probably the better long-term
shape.
Verification
With --chain devnet, zingo-cli's derived addresses match the node's own wallet addresses
exactly (see Issue 2, which must also be fixed for this to hold).
Issue 2 — Seed derivation mismatch: zebra-crosslink truncates the BIP-39 seed to 32 bytes
Repo: zingo-lib (interop) · Labels: bug, compatibility
Problem
Importing a mnemonic exported from a zebra-crosslink wallet into zingo-cli derives a completely
different set of addresses, so the wallet appears empty despite holding funds.
Root cause: zebra-crosslink derives its unified spending key from only the first 32 bytes of the
standard 64-byte BIP-39 seed:
// zebra-crosslink: wallet/src/lib.rs, stuff_from_seed_phrase()
let seed = mnemonic.to_seed(""); // 64 bytes
UnifiedSpendingKey::from_seed(¶ms, &seed[..32], account_index)
zingo-lib correctly passes the full 64 bytes, per ZIP-32. The two therefore derive different keys
from the same phrase.
This is a zebra-crosslink bug — ZIP-32 specifies the full seed, so crosslink is the
non-conforming side. But its devnets already have live wallets and funds derived this way, so
zingo-lib needs to interoperate with the existing behaviour.
Proposed fix
Replicate the truncation, scoped strictly to the devnet chain type so no standard-network
derivation path is affected:
// zebra-crosslink wallets derive keys from only the first 32 bytes of the
// standard 64-byte BIP-39 seed. Match that to stay compatible with mnemonics
// exported from a zebra-crosslink wallet. Devnet only.
if matches!(network, ChainType::Devnet(_)) {
let seed64 = mnemonic.to_seed("");
let usk = UnifiedSpendingKey::from_seed(network, &seed64[..32], account_index)
.map_err(KeyError::KeyDerivationError)?;
return Ok(UnifiedKeyStore::Spend(Box::new(usk)));
}
This is a compatibility shim for a non-conforming counterpart, not a correctness fix, and the
comment should say so. The real fix belongs upstream in zebra-crosslink; that would be a breaking
change for existing devnet wallets and needs its own migration story.
Verification
Derived transparent and unified addresses match the node's reported wallet addresses exactly,
confirmed against two independent seeds.
Issue 3 — pepper-sync panics on a normal truncate_to_checkpoint result
Repo: zingo-lib · Labels: bug, crash
Problem
Syncing a wallet that has only ever used Orchard crashes deterministically:
thread panicked at pepper-sync/src/wallet/traits.rs:325:
max checkpoints should always be higher or equal to max verification window!
truncate_shard_trees treats a false return from truncate_to_checkpoint as fatal for both
pools:
if !self.get_shard_trees_mut()?.sapling.truncate_to_checkpoint(&truncate_height)? {
panic!("max checkpoints should always be higher or equal to max verification window!");
}
But per shardtree's documented contract, truncate_to_checkpoint returns true if truncation
succeeds or has no effect, and false only when no checkpoint exists at that id. For a pool
that has never been used — an all-Orchard wallet's empty Sapling tree — false is the normal,
expected outcome, not corruption.
The panic message is also misleading: it points at checkpoint-window sizing, which is unrelated to
the actual condition.
Impact
Beyond the crash, the panic can leave the on-disk wallet in a damaged state. In our case a wallet
that had crashed here later reported a correct balance but Insufficient balance (have 0, ...) on
every spend. A parallel fresh sync of the same seed against the same chain showed the correct
spendable balance, isolating the fault to the local wallet file rather than the chain.
Proposed fix
Treat false as the no-op it is:
// `truncate_to_checkpoint` returns `false` when no checkpoint exists at
// `truncate_height` for this pool -- the normal outcome for a pool that has
// never been used (e.g. an all-orchard wallet's empty Sapling tree) rather
// than a corruption.
let _ = self.get_shard_trees_mut()?.sapling.truncate_to_checkpoint(&truncate_height)?;
let _ = self.get_shard_trees_mut()?.orchard.truncate_to_checkpoint(&truncate_height)?;
If a genuinely unexpected false is worth detecting, it should be gated on the pool actually
having checkpointed history near the truncation point — and should not panic.
Reproduction
- Create a wallet that only ever receives Orchard funds.
- Sync it against a chain where a reorg/rollback triggers
truncate_shard_trees.
- Panic fires on the untouched Sapling tree.
Issue 4 — MAX_BLOCK_BYTES is applied as a hard per-transaction parse limit
Repo: zebra-crosslink · Labels: bug
Problem
Any transaction larger than 100,000 bytes is rejected at parse time:
bad transaction bytes: io error: failed to fill whole buffer
MAX_BLOCK_BYTES is reduced from the Zcash value of 2,000,000 to 100,000:
// zebra-chain/src/block/serialize.rs
// Note(Sam): We are making this smaller because new network does not support streaming blocks at the moment.
pub const MAX_BLOCK_BYTES: u64 = 100_000;
//pub const MAX_BLOCK_BYTES: u64 = 2_000_000;
and is then used to bound individual transaction deserialization:
// zebra-chain/src/transaction/serialize.rs:797
let mut limited_reader = reader.take(MAX_BLOCK_BYTES);
So a block-level workaround silently became a 20× tighter transaction-size limit. The failure mode
is also opaque — a truncated-read I/O error, with nothing pointing at a size limit.
Impact
This blocks exactly the operations most needed on a devnet with fragmented notes. Empirically:
| Input type |
Approx. cost |
Max per transaction |
| Orchard note (spend + proof) |
~3,300 bytes |
~28 |
| Transparent UTXO |
~170 bytes |
~500 |
Consolidating ~50 orchard notes or shielding ~2,000 transparent UTXOs must be split into many
batches, each a separate on-chain transaction with its own fee.
Suggested fix
Separate the two limits. A transaction limit should be its own constant, not an alias for the block
limit — even if the block limit stays reduced pending streaming-block support. Failing that, the
reader should surface an explicit "transaction exceeds maximum size" error rather than a truncated
read.
We worked around this by batching rather than changing the constant, since raising it has
consensus/networking implications beyond the scope of the wallet work.
Issue 5 — Node wallet only scans the external ZIP-32 scope, hiding internal-scope funds
Repo: zebra-crosslink · Labels: bug
Problem
Funds shielded via a standards-compliant wallet are invisible to the node's own wallet and cannot
be staked, even though they are fully spendable by any compliant wallet.
Per ZIP-32, shielding (transparent → orchard) sends output to the wallet's internal (change)
scope. The zebra-crosslink note scanner only watches the external scope, so:
get_wallet_sync_status reports these notes as absent from the balance entirely.
- Staking actions cannot draw on them.
zingo-cli reports the correct, full balance for the same seed — the funds are demonstrably
there.
This makes zingo-cli shield unusable as a way to prepare funds for node-side operations, which is
a natural interop workflow given Issue 4 forces batched shielding.
Reproduction
- Shield transparent funds using
zingo-cli shield against a crosslink devnet.
zingo-cli balance shows the shielded total.
- The node's
get_wallet_sync_status does not.
Workaround
Sweep internal-scope funds back to the wallet's own external address (send to self), which
re-materialises them in the external scope where the node's scanner can see them. This costs an
extra transaction and fee per sweep.
Suggested fix
Scan both scopes. The scanner already has the UFVK; deriving and matching the internal IVK
alongside the external one should be a contained change, and would also make the node's balance
agree with any standards-compliant wallet using the same seed.
zingo-cli ↔ zebra-crosslink devnet compatibility: issue write-ups
Five issues found while getting
zingo-clito operate against azebra-crosslinkdevnet(consolidating fragmented orchard notes and shielding transparent UTXOs). Each section below is a
self-contained GitHub issue.
Issues 1–3 belong in the zingo-lib repo; issues 4–5 belong in zebra-crosslink.
Throughout,
<UA>/<t-addr>are placeholders for wallet addresses and<devnet-host>for thenode endpoint.
Issue 1 — No
devnetchain type: zingo-cli cannot express a chain whose upgrades activate at genesisRepo: zingo-lib · Labels: enhancement, compatibility
Problem
zingo-cli --chainaccepts onlymainnet,testnet, and (feature-gated)regtest. Azebra-crosslink devnet uses standard testnet address encoding but activates every network
upgrade at or near genesis, rather than at the public testnet's historical heights.
Neither existing option fits:
--chain testnetgives correct address prefixes but wrong consensus parameters. Everypre-activation-height block is evaluated under the wrong network upgrade.
--chain regtestallows custom activation heights but selectsNetworkType::Regtest, producingthe wrong address prefixes — addresses won't match those of the node's own wallet.
There is no way to combine "testnet address encoding" with "caller-supplied activation heights".
Proposed fix
Add a
ChainType::Devnet(ConfiguredActivationHeights)variant that reuses testnet address encodingwhile taking activation heights from configuration:
with
chain_from_str("devnet")supplying the crosslink activation schedule(
before_overwinter: 0, everything throughnu6: 1,nu6_1/nu7:None), plus thecorresponding
Display, donation-address, and data-directory arms, and a--chainhelp-textupdate.
The activation heights are currently hardcoded in
chain_from_str. Making them configurable(config file or CLI flag) would generalise this to any devnet, and is probably the better long-term
shape.
Verification
With
--chain devnet,zingo-cli's derived addresses match the node's own wallet addressesexactly (see Issue 2, which must also be fixed for this to hold).
Issue 2 — Seed derivation mismatch: zebra-crosslink truncates the BIP-39 seed to 32 bytes
Repo: zingo-lib (interop) · Labels: bug, compatibility
Problem
Importing a mnemonic exported from a zebra-crosslink wallet into
zingo-cliderives a completelydifferent set of addresses, so the wallet appears empty despite holding funds.
Root cause: zebra-crosslink derives its unified spending key from only the first 32 bytes of the
standard 64-byte BIP-39 seed:
zingo-lib correctly passes the full 64 bytes, per ZIP-32. The two therefore derive different keys
from the same phrase.
This is a zebra-crosslink bug — ZIP-32 specifies the full seed, so crosslink is the
non-conforming side. But its devnets already have live wallets and funds derived this way, so
zingo-lib needs to interoperate with the existing behaviour.
Proposed fix
Replicate the truncation, scoped strictly to the devnet chain type so no standard-network
derivation path is affected:
This is a compatibility shim for a non-conforming counterpart, not a correctness fix, and the
comment should say so. The real fix belongs upstream in zebra-crosslink; that would be a breaking
change for existing devnet wallets and needs its own migration story.
Verification
Derived transparent and unified addresses match the node's reported wallet addresses exactly,
confirmed against two independent seeds.
Issue 3 — pepper-sync panics on a normal
truncate_to_checkpointresultRepo: zingo-lib · Labels: bug, crash
Problem
Syncing a wallet that has only ever used Orchard crashes deterministically:
truncate_shard_treestreats afalsereturn fromtruncate_to_checkpointas fatal for bothpools:
But per
shardtree's documented contract,truncate_to_checkpointreturnstrueif truncationsucceeds or has no effect, and
falseonly when no checkpoint exists at that id. For a poolthat has never been used — an all-Orchard wallet's empty Sapling tree —
falseis the normal,expected outcome, not corruption.
The panic message is also misleading: it points at checkpoint-window sizing, which is unrelated to
the actual condition.
Impact
Beyond the crash, the panic can leave the on-disk wallet in a damaged state. In our case a wallet
that had crashed here later reported a correct balance but
Insufficient balance (have 0, ...)onevery spend. A parallel fresh sync of the same seed against the same chain showed the correct
spendable balance, isolating the fault to the local wallet file rather than the chain.
Proposed fix
Treat
falseas the no-op it is:If a genuinely unexpected
falseis worth detecting, it should be gated on the pool actuallyhaving checkpointed history near the truncation point — and should not panic.
Reproduction
truncate_shard_trees.Issue 4 —
MAX_BLOCK_BYTESis applied as a hard per-transaction parse limitRepo: zebra-crosslink · Labels: bug
Problem
Any transaction larger than 100,000 bytes is rejected at parse time:
MAX_BLOCK_BYTESis reduced from the Zcash value of 2,000,000 to 100,000:and is then used to bound individual transaction deserialization:
So a block-level workaround silently became a 20× tighter transaction-size limit. The failure mode
is also opaque — a truncated-read I/O error, with nothing pointing at a size limit.
Impact
This blocks exactly the operations most needed on a devnet with fragmented notes. Empirically:
Consolidating ~50 orchard notes or shielding ~2,000 transparent UTXOs must be split into many
batches, each a separate on-chain transaction with its own fee.
Suggested fix
Separate the two limits. A transaction limit should be its own constant, not an alias for the block
limit — even if the block limit stays reduced pending streaming-block support. Failing that, the
reader should surface an explicit "transaction exceeds maximum size" error rather than a truncated
read.
We worked around this by batching rather than changing the constant, since raising it has
consensus/networking implications beyond the scope of the wallet work.
Issue 5 — Node wallet only scans the external ZIP-32 scope, hiding internal-scope funds
Repo: zebra-crosslink · Labels: bug
Problem
Funds shielded via a standards-compliant wallet are invisible to the node's own wallet and cannot
be staked, even though they are fully spendable by any compliant wallet.
Per ZIP-32, shielding (transparent → orchard) sends output to the wallet's internal (change)
scope. The zebra-crosslink note scanner only watches the external scope, so:
get_wallet_sync_statusreports these notes as absent from the balance entirely.zingo-clireports the correct, full balance for the same seed — the funds are demonstrablythere.
This makes
zingo-cli shieldunusable as a way to prepare funds for node-side operations, which isa natural interop workflow given Issue 4 forces batched shielding.
Reproduction
zingo-cli shieldagainst a crosslink devnet.zingo-cli balanceshows the shielded total.get_wallet_sync_statusdoes not.Workaround
Sweep internal-scope funds back to the wallet's own external address (
sendto self), whichre-materialises them in the external scope where the node's scanner can see them. This costs an
extra transaction and fee per sweep.
Suggested fix
Scan both scopes. The scanner already has the UFVK; deriving and matching the internal IVK
alongside the external one should be a contained change, and would also make the node's balance
agree with any standards-compliant wallet using the same seed.