Skip to content

Zingo-cli ↔ zebra-crosslink devnet compatibility #58

Description

@USCMig

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(&params, &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

  1. Create a wallet that only ever receives Orchard funds.
  2. Sync it against a chain where a reorg/rollback triggers truncate_shard_trees.
  3. 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

  1. Shield transparent funds using zingo-cli shield against a crosslink devnet.
  2. zingo-cli balance shows the shielded total.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions