Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
691d27c
Add the zakura-dynamic-ivk crate
czarcas7ic Oct 8, 2026
68efa4e
Drop later-PR integration prose from the dynamic-ivk README
czarcas7ic Oct 10, 2026
908e174
Treat a missing or unknown swap type as an unknown receipt
czarcas7ic Oct 10, 2026
129fd30
Zeroize the derived rivk before copying it into the FVK
czarcas7ic Oct 10, 2026
e9edece
Stop exposing the unbound memo from a recovered note
czarcas7ic Oct 10, 2026
6144c65
Drop the SQLite claim from the dynamic-ivk changelog
czarcas7ic Oct 10, 2026
b7b3f47
Reject publishable packages that depend on unpublishable ones
czarcas7ic Oct 10, 2026
f42480f
Make zakura-dynamic-ivk publishable
czarcas7ic Oct 10, 2026
c4fa00a
Keep exact-input refunds unknown without a refunded amount
czarcas7ic Oct 10, 2026
7cba471
Merge main into the dynamic-ivk crate
czarcas7ic Oct 11, 2026
d67a472
Normalize failed swaps by refund direction
czarcas7ic Oct 11, 2026
2808186
Expect no refund after an exact-output success reporting zero
czarcas7ic Oct 11, 2026
cfeafcc
Note that an incomplete deposit settles through REFUNDED
czarcas7ic Oct 11, 2026
c36ab7c
Avoid asserting an incomplete deposit always becomes REFUNDED
czarcas7ic Oct 11, 2026
f623926
Root the graph check at the hand-written members too
czarcas7ic Oct 11, 2026
7ffd40e
Note that the rivk PRF state is not wiped
czarcas7ic Oct 11, 2026
6b259b0
Say dynamic-key notes carry no memo the wallet reads
czarcas7ic Oct 11, 2026
4d1f81e
Ignore refund memos that do not decode as v1 records
czarcas7ic Oct 11, 2026
95c1559
Rename dynamic IVKs to one-time IVKs
czarcas7ic Oct 12, 2026
032a42c
Move NEAR status normalization out of the one-time IVK crate
czarcas7ic Oct 12, 2026
f0f1b14
Move swap conventions into a `swap` module
czarcas7ic Oct 12, 2026
ceb231a
Add the `zakura-near-intents` provider adapter crate
czarcas7ic Oct 12, 2026
7b2a048
Trim the one-time IVK crate
czarcas7ic Oct 12, 2026
f4f1eba
Move note recovery out of the one-time IVK crate
czarcas7ic Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ members = [
"zakura/pir-status",
"zakura/transaction-status",
"zakura/pir-transparent",
"zakura/dynamic-ivk",
]

resolver = "2"
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ or `zakura/` and are listed in `layout.extra_members` in
`manifests/sources.toml`. Checked-in protobuf bindings have an explicit
regeneration command, separate from ordinary builds.

The unpublished [dynamic IVK](zakura/dynamic-ivk/README.md) crate provides shared
derivation of receiving keys that keep an account's spending authority with their
own incoming viewing keys, and refund memo helpers, with an Ironwood proof test for
mixed ordinary, refund, and incoming inputs.

## How the rewiring works

Nothing is patched at the source level. `manifests/sources.toml` holds a
Expand Down
2 changes: 1 addition & 1 deletion manifests/sources.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ vendored_directory = "librustzcash"
vendor_branch = "vendor/librustzcash"
upstream_manifest = "librustzcash/upstream-workspace.toml"
# `zakura/` members are added here as they are written.
extra_members = ["wallet-lib", "zakura/pir-enhance", "zakura/pir-enhance-types", "zakura/pir-native", "zakura/pir-status", "zakura/transaction-status", "zakura/pir-transparent"]
extra_members = ["wallet-lib", "zakura/pir-enhance", "zakura/pir-enhance-types", "zakura/pir-native", "zakura/pir-status", "zakura/transaction-status", "zakura/pir-transparent", "zakura/dynamic-ivk"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Include extra members in graph verification

Adding this crate only to layout.extra_members leaves it outside the graph verifier's reachability roots: verify-zakura-graph.sh seeds its queue solely from manifest["crate"], and a repo-wide dependency search confirms no vendored crate reaches zakura-dynamic-ivk. Consequently, forbidden crates or duplicate Zakura versions introduced through this new dependency-family boundary would still pass the repository's advertised graph check; include applicable extra members as verification roots while continuing to exclude the dual-family facade.

AGENTS.md reference: AGENTS.md:L43-L46

Useful? React with 👍 / 👎.

# The backend selector. Named separately because the verification scripts have
# to build it one backend at a time, unlike every other member.
facade = "wallet-lib"
Expand Down
16 changes: 16 additions & 0 deletions zakura/dynamic-ivk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Changelog

## [Unreleased]

- Add unpublished dynamic IVK derivation (`KeyId::derive`): each key keeps the
account's `ak` and `nk` with its own `rivk`, identified by purpose and index.
Add the `RefundMemo` funding record, which carries only the refund index, and
its `REFUND_MEMO_MAGIC` prefix, with independent KDF vectors and an Ironwood
receive/reconstruct/spend proof test.
- Add the shared completion limits, `RESTORE_WATCH_SECS` (24 hours for restored
keys) and `COMPLETION_LIMIT_SECS` (30 days), and direction-aware NEAR
status normalization, `lifecycle::near_observation`, including refunded amounts
and exact-output leftovers. Durable scheduling and receipt accounting live in
SQLite.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove the remaining nonexistent SQLite integration claim

Although the earlier README assertion was removed, this new changelog entry still tells consumers that durable scheduling and receipt accounting live in SQLite. A repo-wide search of this squashed commit finds no dynamic_ivk module, dependency, schema, or tests under librustzcash/zcash_client_sqlite, so the release note still advertises integration that cannot be used; either land that integration or describe it as future work.

AGENTS.md reference: AGENTS.md:L9-L12

Useful? React with 👍 / 👎.

- Add transport-independent incoming-note authentication and commitment-path
validation for privately discovered payments.
29 changes: 29 additions & 0 deletions zakura/dynamic-ivk/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[package]
name = "zakura-dynamic-ivk"
version = "0.0.0"
publish = false
description = "Experimental dynamic IVKs: derived Ironwood receiving keys and refund memos"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"

[dependencies]
blake2b_simd.workspace = true
ff.workspace = true
orchard.workspace = true
pasta_curves.workspace = true
zcash_protocol.workspace = true
zcash_note_encryption.workspace = true
zeroize.workspace = true

[dev-dependencies]
hex.workspace = true
incrementalmerkletree.workspace = true
orchard = { workspace = true, features = ["circuit"] }
rand = { workspace = true, features = ["thread_rng"] }
shardtree.workspace = true

[lints]
workspace = true
100 changes: 100 additions & 0 deletions zakura/dynamic-ivk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Dynamic IVKs

Unpublished implementation of the draft v1 dynamic IVK and refund-memo
conventions. A dynamic key keeps the account's spending authority (`ak` and `nk`)
with its own `rivk`, so each has its own incoming viewing key and address, and a
wallet trial-decrypts with a changing set of them. Wallets use them for swap refund
and incoming addresses. The derivation still requires cryptographic review before
issuing live addresses.

## Wallet integration

1. Reserve a purpose-specific index durably before exposing an address.
2. Call `KeyId::new(purpose, index).derive(account_external_fvk)` and select
`address_at(0u32, Scope::External)` for the receiver.
3. Register that FVK for scanning and retain its purpose/index with received notes.
4. For a refund, encode `RefundMemo` in an ordinary internal note in the funding
transaction, whose only transparent output pays the P2PKH or P2SH deposit.
Decode only after note authentication and verification that the transaction
was the wallet's own send, including zero-value and spent notes.
5. Reconstruct inputs with their derived FVKs. Use the account's spending key to
sign, and its ordinary internal key for change.

`has_same_spending_authority` compares the `ak` and `nk` of valid FVKs. It does not
replace recipient, nullifier, signature, value, or change validation. The wallet
owns reservation, persistence, coverage, lifecycle, and note selection. There is
no alternate balance store in this crate.

The SQLite backend implements this contract with its `orchard` feature: durable
key registration, scanning, completion, and the restore sweeps that
`zakura-pir-receiver` runs. Its rustdoc, starting at
`zcash_client_sqlite::wallet::dynamic_ivk`, documents the calls a wallet makes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove the nonexistent SQLite integration claim

At this commit, a repository-wide search finds no dynamic_ivk module, tests, or dependency under zakura-client-sqlite, and zakura-pir-receiver is also absent. Integrators therefore cannot resolve the documented rustdoc path, and the advertised durable registration, scanning, and restore flow does not exist; either include that integration or describe it as future work.

Useful? React with 👍 / 👎.


Software PCZT signing of dynamic-key notes works through the same builder.
Hardware signers need firmware qualification before dynamic keys are enabled for
hardware accounts.

## Derivation

A dynamic key keeps the account's `ak` and `nk` and replaces its external `rivk`:

```text
rivk' = ToScalar(PRF^expand_rivk([0x85] || ak || nk || [purpose] || I2LEOSP_64(index)))
```

`PRF^expand` is BLAKE2b-512 personalized with `Zcash_ExpandSeed` over
`rivk || input`, and `ToScalar` reduces its little-endian output modulo the Pallas
scalar order. `purpose` is 0 for refund and 1 for incoming. This is ZIP 32's
internal-key derivation (first byte `0x83`, no suffix) with a different first byte
and a 9-byte suffix. The input fits one BLAKE2b block, as the internal key's does,
so a ZIP 2005 recovery circuit could accept dynamic keys as one more `rivk` case
with purpose and index as private inputs. Network and pool identify stored keys
but are not derivation inputs.

`0x85` was unused by the protocol specification and ZIPs when chosen. Reserve it
with the ZIP editors before issuing live addresses. Parsing the result validates
both external and internal incoming viewing keys; with negligible probability an
index has no valid key, and derivation returns an error.

## Refund memo

| Offset | Bytes | Field |
|---|---:|---|
| 0 | 5 | `FF 5A 53 57 50` (`0xFF`, `ZSWP`) |
| 5 | 1 | Version `1` |
| 6 | 1 | Refund purpose `0` |
| 7 | 8 | Index, little-endian |
| 15 | 497 | Reserved: written as zero, ignored on decode |

The memo does not store the deposit address. A restored refund key needs none: it
is swept once and, if the provider was given its address in the last day, scans for
another 24 hours, with no provider lookups. Incoming indices are
recovered through lookahead, not this memo. `RefundMemo::decode` returns `None`
for any memo that is not a v1 record. A wallet that selects records by their
`REFUND_MEMO_MAGIC` prefix keeps one it cannot decode pending, without failing,
and refund issuance waits for it, since it may hold a refund index.

## Validation

From the workspace root:

```sh
cargo test -p zakura-dynamic-ivk --locked
cargo test -p zakura-client-sqlite --features orchard,test-dependencies --locked dynamic_ivk

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Replace the empty SQLite test filter

At this commit, zakura-client-sqlite contains no test whose name matches dynamic_ivk, so this Cargo command selects zero tests and still exits successfully. It therefore gives a false green for the claimed SQLite integration; point it at an existing test or add the missing test and invoke it through the repository runner, which explicitly treats an empty filtered selection as failure.

AGENTS.md reference: AGENTS.md:L34-L36

Useful? React with 👍 / 👎.

```

Regenerate the KDF vectors from this directory with
`python3 tests/vectors/generate.py > tests/vectors/rivk.csv`.

## Protocol baseline

Zcash Protocol Specification **v2026.7.0-202-gafa086, NU6.3 proposal**, commit
`afa086bd976e316612a5c06fb139429958d07d84`:

- [§4.2.3, Key Components, pp.40–41](https://github.com/zcash/zips/blob/afa086bd976e316612a5c06fb139429958d07d84/protocol/protocol.tex#L5753-L5909),
PDF anchor `orchardkeycomponents`.
- [§5.6.4.4, Raw Full Viewing Keys, p.123](https://github.com/zcash/zips/blob/afa086bd976e316612a5c06fb139429958d07d84/protocol/protocol.tex#L13077-L13104),
PDF anchor `orchardfullviewingkeyencoding`.

The dynamic IVK KDF and memo format are proposed wallet conventions, separate from
these protocol requirements. Passing these tests is not a cryptographic review.
176 changes: 176 additions & 0 deletions zakura/dynamic-ivk/src/keys.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
use ff::{FromUniformBytes, PrimeField};
use orchard::keys::FullViewingKey;
use pasta_curves::pallas;
use zeroize::Zeroizing;

/// First byte of the `PRF^expand` input that derives a dynamic key's `rivk`.
///
/// Unused by the protocol specification and ZIPs when chosen; it must be reserved
/// with the ZIP editors before any address goes live.
const RIVK_DOMAIN: u8 = 0x85;

/// Independent v1 receiving sequences under one account's spending authority.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Purpose {
/// Return of funds from a Zcash-funded swap, recorded in its funding memo.
Refund,
/// Payment into Zcash, recovered through receiver lookahead.
Receive,
}

/// A v1 key within one account's Ironwood receiving namespace.
///
/// Account and network are supplied by the owning wallet. This is not an
/// operation ID: multiple swaps may be associated with the same receiving key.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct KeyId {
purpose: Purpose,
index: u64,
}

impl KeyId {
/// Identifies the v1 key at `index` in the selected purpose's sequence.
pub fn new(purpose: Purpose, index: u64) -> Self {
Self { purpose, index }
}

/// The independent sequence containing this key.
pub fn purpose(self) -> Purpose {
self.purpose
}

/// The index in that sequence.
pub fn index(self) -> u64 {
self.index
}

/// Derives this key's v1 FVK from the account's **external** FVK.
///
/// The result keeps the account's `ak` and `nk` and replaces `rivk` with
/// `ToScalar(PRF^expand_rivk([RIVK_DOMAIN] || ak || nk || [purpose] || index))`,
/// where `purpose` is 0 for refund and 1 for incoming and `index` is little-endian.
/// This is ZIP 32's internal-key derivation (first byte `0x83`, no suffix) with
/// another domain byte and a suffix, so a ZIP 2005 recovery circuit can check
/// it the same way.
///
/// Use external diversifier index zero for the key's receiver. The account FVK
/// is sufficient; this never needs a spending key. Network and pool belong to
/// the caller's key identity, but are not inputs to the v1 KDF. The caller must
/// reserve the index durably before exposing its address. With negligible
/// probability the result is not a valid FVK, and that index has no key.
pub fn derive(self, account: &FullViewingKey) -> Result<FullViewingKey, DerivationError> {
let mut bytes = Zeroizing::new(account.to_bytes());
let mut suffix = [0; 9];
suffix[0] = self.purpose.code();
suffix[1..].copy_from_slice(&self.index.to_le_bytes());
let rivk = expand_rivk(&bytes, RIVK_DOMAIN, &suffix);
bytes[64..].copy_from_slice(&rivk);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Zeroize the derived rivk copy

expand_rivk returns the derived viewing-key material into this ordinary stack array, which is not wiped after it is copied into the zeroizing FVK byte buffer. Thus each derivation leaves an avoidable extra rivk copy available to later stack reuse or process-memory inspection, despite the surrounding code explicitly zeroizing the account bytes and wide PRF output; wrap this result in Zeroizing or write it directly into zeroizing storage.

Useful? React with 👍 / 👎.

// Parsing checks both external and derived internal IVKs.
FullViewingKey::from_bytes(&bytes).ok_or(DerivationError)
}
}

impl Purpose {
/// The purpose byte in the v1 derivation input.
fn code(self) -> u8 {
match self {
Self::Refund => 0,
Self::Receive => 1,
}
}
}

/// The derived `rivk` does not give a valid viewing key, so the index has no key.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DerivationError;

impl core::fmt::Display for DerivationError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("dynamic IVK index has no valid viewing key")
}
}

impl std::error::Error for DerivationError {}

/// `ToScalar^Orchard(PRF^expand_rivk([domain] || ak || nk || suffix))` over the raw
/// FVK encoding `ak || nk || rivk` (protocol §5.6.4.4), as ZIP 32 derives an
/// internal `rivk`.
fn expand_rivk(fvk: &[u8; 96], domain: u8, suffix: &[u8]) -> [u8; 32] {
let wide = Zeroizing::new(
*blake2b_simd::Params::new()
.hash_length(64)
.personal(b"Zcash_ExpandSeed")
.to_state()
.update(&fvk[64..])
.update(&[domain])
.update(&fvk[..64])
.update(suffix)
.finalize()
.as_array(),
);
// FromUniformBytes reduces a little-endian integer modulo the Pallas scalar
// order, which is ToScalar^Orchard. pallas::Base would define a different KDF.
pallas::Scalar::from_uniform_bytes(&wide).to_repr()
}

/// Checks whether two valid FVKs share `ak` and `nk`, permitting different `rivk`.
///
/// This checks the account relationship only. Signing still requires the usual
/// recipient, nullifier, randomized signing-key, value, and output checks.
pub fn has_same_spending_authority(account: &FullViewingKey, candidate: &FullViewingKey) -> bool {
// Raw FVK encoding is ak || nk || rivk, each 32 bytes (protocol §5.6.4.4).
let account = Zeroizing::new(account.to_bytes());
let candidate = Zeroizing::new(candidate.to_bytes());
account[..64] == candidate[..64]
}

#[cfg(test)]
mod tests {
use super::*;
use orchard::keys::{Scope, SpendingKey};

/// The first Orchard key-component test vector's spending key, published in
/// zakura-orchard's `src/test_vectors/keys.rs`. No wallet secrets are used.
fn vector_account() -> FullViewingKey {
let sk = hex::decode("5d7a8f739a2d9e945b0ce152a8049e294c4d6e66b164939daffa2ef6ee692148")
.unwrap()
.try_into()
.unwrap();
FullViewingKey::from(&SpendingKey::from_bytes(sk).unwrap())
}

#[test]
fn matches_python_prf_expand_vectors() {
let account = vector_account();
for row in include_str!("../tests/vectors/rivk.csv").lines().skip(1) {
let fields: Vec<_> = row.split(',').collect();
let purpose = match fields[0] {
"refund" => Purpose::Refund,
"receive" => Purpose::Receive,
_ => panic!("unknown vector purpose"),
};
let key = KeyId::new(purpose, fields[1].parse().unwrap())
.derive(&account)
.unwrap();
assert_eq!(hex::encode(&key.to_bytes()[64..]), fields[2], "{row}");
}
}

#[test]
fn zip32_internal_byte_reproduces_the_orchard_internal_key() {
let account = vector_account();
let internal = expand_rivk(&account.to_bytes(), 0x83, &[]);
assert_eq!(
hex::encode(internal),
"901a30b99ae1570cb80bb616aeef3bb916c640c4cc620f9b4b4499c74332eb2a"
);
let mut bytes = account.to_bytes();
bytes[64..].copy_from_slice(&internal);
assert_eq!(
FullViewingKey::from_bytes(&bytes)
.unwrap()
.address_at(0u32, Scope::External),
account.address_at(0u32, Scope::Internal)
);
}
}
21 changes: 21 additions & 0 deletions zakura/dynamic-ivk/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//! Experimental dynamic IVKs: Ironwood receiving keys that share an account's
//! spending authority, each with its own incoming viewing key, and the refund memo
//! that lets a restore find refund keys.
//!
//! This implements a draft wallet convention, not a consensus key derivation.
//! It is unpublished and must not be used to issue live addresses before review.
//! Wallets own durable index reservation, authenticated funding-note provenance,
//! scanning coverage, and ordinary note accounting. This crate owns derivation,
//! byte formats, and completion policy so wallets can share those rules.

#![forbid(unsafe_code)]
#![deny(missing_docs)]

pub mod lifecycle;
pub mod recovery;

mod keys;
mod memo;

pub use keys::{DerivationError, KeyId, Purpose, has_same_spending_authority};
pub use memo::{REFUND_MEMO_MAGIC, RefundMemo};
Loading
Loading