Repository navigation
Add the zakura-one-time-ivk and zakura-near-intents crates #120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
691d27c
68efa4e
908e174
129fd30
e9edece
6144c65
b7b3f47
f42480f
c4fa00a
7cba471
d67a472
2808186
cfeafcc
c36ab7c
f623926
7ffd40e
6b259b0
4d1f81e
95c1559
032a42c
f0f1b14
ceb231a
7b2a048
f4f1eba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 AGENTS.md reference: AGENTS.md:L9-L12 Useful? React with 👍 / 👎. |
||
| - Add transport-independent incoming-note authentication and commitment-path | ||
| validation for privately discovered payments. | ||
| 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 |
| 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
At this commit, a repository-wide search finds no 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
At this commit, 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. | ||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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) | ||
| ); | ||
| } | ||
| } | ||
| 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}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding this crate only to
layout.extra_membersleaves it outside the graph verifier's reachability roots:verify-zakura-graph.shseeds its queue solely frommanifest["crate"], and a repo-wide dependency search confirms no vendored crate reacheszakura-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 👍 / 👎.