Skip to content

Implement ZIP-0227 global issued-assets state in Zebra#111

Open
dmidem wants to merge 131 commits intozsa1from
zsa-integration-state
Open

Implement ZIP-0227 global issued-assets state in Zebra#111
dmidem wants to merge 131 commits intozsa1from
zsa-integration-state

Conversation

@dmidem
Copy link
Copy Markdown
Collaborator

@dmidem dmidem commented Feb 20, 2026

This PR adds global issued-asset state tracking for ZSA issuance per ZIP-0227.

Key changes:

  • Introduces AssetState / IssuedAssetChanges and integrates Orchard’s ZSA validation APIs to compute per-block issued-asset deltas.
  • During consensus checks, it uses Orchard’s validate_bundle_burn and verify_issue_bundle to validate burns and issuances while building the global issued_assets state. This relies on the Orchard refactoring in Refactor issue/burn validation for Zebra asset state integration orchard#199, which exposes/reworks these validation helpers for direct integration from Zebra.
  • Threads Orchard transaction sighashes through contextual block/tx verification so verify_issue_bundle can check issuance authorization signatures (issueAuthSig).
  • For checkpoint-verified blocks, it uses verify_trusted_issue_bundle instead of verify_issue_bundle because the current Zebra checkpoint-verification path does not provide transaction sighashes. This is intentional: checkpoint-verified blocks are treated as already verified and are only replayed through the global-state logic to (re)build issued_assets during node startup, so signature checking is not required there.
  • Persists issued-asset state in Zebra’s finalized DB, applies updates in non-finalized and finalized states with rollback support, and exposes a read path.
  • Adds an RPC method to query current asset state by AssetBase, plus tests/snapshots and workflow vectors.

Notes:

  • Issued-note rho derivation follows ZIP-0227 by passing nf_{0,0} (the first Orchard Action nullifier) into issuance verification.

arya2 and others added 30 commits November 12, 2024 01:07
…callyVerifiedBlock types, updates `IssuedAssetsChange::from_transactions()` method return type
…f BE for amount, read amount after asset base)
…ror in the function of the crate (this may not be a fully correct fix). Add a couple of FIXME comments explaining the problem.
…64 to prevent serialization errors and enable defining BurnItem in orchard, facilitating its reuse along with related functions
…tead of amount (with_asset is used to process orchard burn) - this allows avoiding the use of try_into for burn in binding_verification_key function
…action::V6 and serialization only — without changing consensus rules — they will be updated automatically after merging with the upstream version of Zebra, where those ZIP-233-related changes are already implemented)
…in Transaction::V6 and serialization only — without changing consensus rules — they will be updated automatically after merging with the upstream version of Zebra, where those ZIP-233-related changes are already implemented)"

This reverts commit 8e3076f.
…action::V6 and serialization only — without changing consensus rules — they will be updated automatically after merging with the upstream version of Zebra, where those ZIP-233-related changes are already implemented)
Copy link
Copy Markdown

@PaulLaux PaulLaux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added comments.

In addition:

  • remove (to test) "--cfg", 'feature="tx_v6"' from .cargo/config.toml and make sure it works.
  • zebra_state will not compile without --cfg feature="tx_v6" :
    • zebra-state/src/error.rs:12, 269 — import orchard_zsa that need tx_v6
    • zebra-state/src/service/non_finalized_state.rs:13 same
  • zebra-rpc has no tx_v6 feature but uses tx_v6 gated types. Compiles only because of the global rustflag.
  • zebra-consensus/Cargo.toml:38-41 : tx_v6 = ["zebra-state/tx_v6", "zebra-chain/tx_v6"] missing zebra-test/tx_v6 (again the global flag

const ENABLE_OUTPUTS = 0b00000010;
/// Enable ZSA transaction (otherwise all notes within actions must use native asset).
// FIXME: Should we use this flag explicitly anywhere in Zebra?
const ENABLE_ZSA = 0b00000100;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For tx_v5: this bit must be 0. In this implementation, we allow this bit to be 1 for v5. This is not acceptable. Probably need to define an independent Flags struct for V5 and for V6. (Version-aware Flags deserialization). Alternatively, check all flags rules for V5 after deserialization.

Copy link
Copy Markdown
Collaborator Author

@dmidem dmidem Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

For now, I chose the "check after deserialization" approach rather than splitting into two Flags structs, combined with gating ENABLE_ZSA behind #[cfg(feature = "tx_v6")].

Detail:

First, I gated ENABLE_ZSA with #[cfg(feature = "tx_v6")] in the Flags struct of shielded_data.rs:

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct Flags: u8 {
    ...
    /// Enable ZSA transaction (otherwise all notes within actions must use native asset).
    #[cfg(feature = "tx_v6")]
    const ENABLE_ZSA = 0b00000100;
}

This means when tx_v6 is disabled, bitflags::from_bits() automatically rejects bit 2 as undefined — no extra check needed in that case.

Second, when tx_v6 is enabled, ENABLE_ZSA becomes a valid defined bit so from_bits() accepts it. I therefore added an explicit consensus check in the OrchardVanilla deserializer in serialize.rs, where the transaction version context is known:

impl ZcashDeserialize for Option<orchard::ShieldedData<OrchardVanilla>> {
    fn zcash_deserialize<R: io::Read>(mut reader: R) -> Result<Self, SerializationError> {
        ...
        let flags: orchard::Flags = (&mut reader).zcash_deserialize_into()?;

        // `ENABLE_ZSA` is introduced in V6 (ZIP 230) and must be zero in V5.
        #[cfg(feature = "tx_v6")]
        if flags.contains(orchard::Flags::ENABLE_ZSA) {
            return Err(SerializationError::Parse(
                "ENABLE_ZSA is not allowed in V5 transactions",
            ));
        }
        ...
    }
}

Open questions that need separate discussion:

  1. has_enough_orchard_flags() — should ENABLE_ZSA be included there (see transaction.rs)? A V6 transaction with only ZSA actions might have neither ENABLE_SPENDS nor ENABLE_OUTPUTS set, causing this check to incorrectly reject it. Needs confirmation against ZIP 230.

  2. Native asset enforcement — the ENABLE_ZSA flag doc says (see shielded_data.rs): otherwise all notes within actions must use native asset. As discussed, this is enforced by the Halo2 proof circuit. Just to double-check: should we also add an explicit semantic check in Zebra as additional defense in depth? And should the ENABLE_ZSA doc comment mention that this constraint is enforced by the proof system?

  3. Coinbase rule for V6 — ZIP 230 states that enableZSAs must be 0 for coinbase transactions. Similar to the previous question, this is probably verified by the proof system, but just to double-check: should it also be explicitly enforced in Zebra?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We discussed these three questions separately and agreed on the following:

  1. has_enough_orchard_flags() is unrelated to ENABLE_ZSA. It only checks that Orchard actions are accompanied by at least one of ENABLE_SPENDS or ENABLE_OUTPUTS, so ENABLE_ZSA should not be included there.

  2. The native-asset restriction for transactions without ENABLE_ZSA is enforced by the proof verification mechanism, so we do not need an additional Zebra check for that.

  3. The V6 coinbase ENABLE_ZSA == 0 rule should be enforced explicitly in Zebra, so I added that consensus check in coinbase_tx_no_prevout_joinsplit_spend().

What is still missing in this PR is a unit test for the new ENABLE_ZSA == 0 coinbase rule. That is difficult to add in zsa-integration-state, because:

  • we cannot use our current Orchard ZSA workflow blocks for that, as they do not contain coinbase transactions,
  • we could construct a block with a coinbase transaction, but that would require Transaction::new_v6_coinbase(), which exists only in newer upstream Zebra. Reimplementing that function here would be unnecessary duplication and would bloat this PR.

So I added the test in the synced Zebra version instead. As a result, both the synced and unsynced zsa-integration-state branches contain the new rule, but the unit test for it is present only in the synced version, in a separate PR.

Comment on lines +180 to +186
assert_eq!(
transactions.len(),
sighashes.len(),
"Bug in caller: {} transactions but {} sighashes. Caller must provide one sighash per transaction.",
transactions.len(),
sighashes.len()
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not panic here and crash the node. The error should propagate up.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Comment on lines +205 to +220
if let Some(issue_data) = tx.orchard_issue_data() {
// ZIP-0227 defines issued-note rho as DeriveIssuedRho(nf_{0,0}, i_action, i_note),
// so we must pass the first Action nullifier (nf_{0,0}). We rely on
// `orchard_nullifiers()` preserving Action order, so `.next()` returns nf_{0,0}.
let first_nullifier =
// FIXME: For now, the only way to convert Zebra's nullifier type to Orchard's nullifier type
// is via bytes, although they both wrap pallas::Point. Consider a more direct conversion to
// avoid this round-trip, if possible.
&Nullifier::from_bytes(&<[u8; 32]>::from(
*tx.orchard_nullifiers()
.next()
// ZIP-0227 requires an issuance bundle to contain at least one OrchardZSA Action Group.
// `ShieldedData.actions` is `AtLeastOne<...>`, so nf_{0,0} must exist.
.expect("issuance must have at least one nullifier"),
))
.expect("Bytes can be converted to Nullifier");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Remove FIXME: For now, the...
  • A crafted V6 transaction with issuance data but no orchard shielded data
    reaches this code and crashes the node. Should handle this case without a crash

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enforce the "issuance requires action groups" invariant during deserialization or in semantic verification, or return AssetStateError here instead of panicking. (make sure it is handled properly at the upper layer, no crash)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need test for this case

Copy link
Copy Markdown
Collaborator Author

@dmidem dmidem Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed - now it returns an error instead of this panic: .expect("issuance must have at least one nullifier").

Regarding the test - as we discussed, this test would likely require constructing a proper mock V6 transaction with issue_data but no nullifiers, which is non-trivial and would bloat this module. Added a TODO comment to revisit this test case later instead.

Comment on lines +73 to +76
// FIXME: Consider writing a leading version byte here so we can change AssetState's
// on-disk format without silently mis-parsing old DB entries during upgrades (and fix
// from_bytes accordingly).
let mut bytes = Vec::new();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a good idea in general, but for the current development stage, it is much too speculative. It can also be handled by other means, like database schema versioning. Let's remove the fixme for now.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed - removed the comment.

ValueBalance::from_orchard_amount(orchard_value_balance)
}

/// Returns the value balances for this transaction using the provided transparent outputs.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not our responsibility, but let’s add a fixme: substruct zip233_amount if zip233 is approved.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added TODO comment:

// TODO: substruct zip233_amount if zip233 is approved.
self.transparent_value_balance_from_outputs(outputs)?
    + self.sprout_value_balance()?
    + self.sapling_value_balance()
    + self.orchard_value_balance()

None, // No sighashes - uses trusted validation without signature checks
|asset_base| zebra_db.issued_asset(asset_base),
)
.map_err(|_| BoxError::from("invalid issued assets changes"))?
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont drop the original error. Use format!("invalid issued assets changes: {e:?}") or similar

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, now it is:

.map_err(|e| BoxError::from(format!("invalid issued assets changes: {e:?}")))?

Comment on lines 40 to 46
action.notes().iter().map(|note| {
// TODO: FIXME: Make `ExtractedNoteCommitment::inner` public in `orchard` (this would
// eliminate the need for the workaround of converting `pallas::Base` from bytes
// here), or introduce a new public method in `orchard::issuance::IssueBundle` to
// retrieve note commitments directly from `orchard`.
pallas::Base::from_repr(ExtractedNoteCommitment::from(note.commitment()).to_bytes())
.unwrap()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace this long fixme+todo with:

// Replace this workaround with Orchard `ExtractedNoteCommitment` if changed to be exposed publicly.

Does we discussed it with str4d? what was the outcome?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can add add a note_commitments() method to IssueBundle if needed. (we have room to improve issuance.rs in orchard much more then to change note struct)

Copy link
Copy Markdown
Collaborator Author

@dmidem dmidem Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the TODO/FIXME comment as suggested.

Regarding eliminating the byte round-trip: yes, we can add IssueBundle::note_commitments(), and I have now implemented that in QED-it/orchard#245.

However, that change is based on the current Orchard zsa1, which is not compatible with zsa-integration-state (the source branch for this PR). So we agreed not to use it here and to keep the current Zebra-side implementation in this PR. We can switch to the Orchard method in later PRs for the synced Zebra branch.

[workspace.dependencies]
incrementalmerkletree = "0.8.2"
orchard = "0.11.0"
orchard = { version = "0.11.0", features = ["zsa-issuance", "temporary-zebra"] }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need "temporary-zebra" at this point?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new_outputs,
transaction_hashes,
// Not used in checkpoint paths.
// FIXME: Is this correct?
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks correct

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good. Removed FIXME.

fmter.field("expiry_height", &expiry_height);
}

// TODO: add zip233_amount formatting here
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add it for completeness.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added (copied from the upstream version of Zebra). Also added/copied the zip233_amount method of Transaction.

@dmidem dmidem force-pushed the zsa-integration-state branch 2 times, most recently from e3b61f2 to 8a78bda Compare March 25, 2026 13:51
…c ref for bytes OrchardWorkflowBlock instead of a vec, matching the approach used in block.rs there
@dmidem dmidem force-pushed the zsa-integration-state branch from 8a78bda to 40ba2bd Compare March 25, 2026 15:09
@dmidem
Copy link
Copy Markdown
Collaborator Author

dmidem commented Mar 29, 2026

Added comments.

In addition:

  • remove (to test) "--cfg", 'feature="tx_v6"' from .cargo/config.toml and make sure it works.
  • zebra_state will not compile without --cfg feature="tx_v6" :
    • zebra-state/src/error.rs:12, 269 — import orchard_zsa that need tx_v6
    • zebra-state/src/service/non_finalized_state.rs:13 same
  • zebra-rpc has no tx_v6 feature but uses tx_v6 gated types. Compiles only because of the global rustflag.
  • zebra-consensus/Cargo.toml:38-41 : tx_v6 = ["zebra-state/tx_v6", "zebra-chain/tx_v6"] missing zebra-test/tx_v6 (again the global flag

Thanks for the review. I addressed the review comments in this PR as much as makes sense for zsa-integration-state, and added responses to all your comments.

What is still not done here, but is already done in the synced Zebra branches (sync-zcash-v4.1.0-merge, sync-zcash-v4.1.0-merge-with-new-deps, and sync-zcash-v4.2.0-merge):

  1. zsa-integration-state still does not compile with tx_v6 / nu7 disabled. This can be fixed, but it would require repeating cfg and feature-wiring changes that are already present in the synced version, so we agreed not to duplicate that work in this older branch.

  2. The tx_v6 / nu7 cfg usage style in this branch is still not fully aligned with upstream Zebra, where these checks consistently require both flags together. The reason is the same: the cleanup is already done in the synced version.

  3. zsa-integration-state uses the older zsa1 versions of our librustzcash / orchard forks, before the latest upstream merge. The latest librustzcash updates include several breaking upstream changes, and this branch no longer compiles against them. Fixing that here would take extra work, while the synced Zebra version already works with the newer dependencies. I fixed the remaining small issues for that in sync-zcash-v4.1.0-merge-with-new-deps.

So, as we discussed, I kept this PR focused on the functional changes relevant to zsa-integration-state, and left the broader cfg / feature / dependency-sync cleanup in the synced branches, where it is already implemented.

dmidem added a commit to QED-it/orchard that referenced this pull request Mar 30, 2026
#245)

This adds `IssueBundle::note_commitments()` to expose extracted note
commitments directly as `pallas::Base` values.

This is useful in Zebra to recompute issuance-related commitments
without the current `to_bytes`/`from_bytes` workaround:


https://github.com/QED-it/zebra/blob/2634fd662a4eaca6df901c82b1bc05d2982636d7/zebra-chain/src/orchard_zsa/issuance.rs#L43

See also the related Zebra PR review comment:

QED-it/zebra#111 (comment)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants