Skip to content

Implement z_sendfromaccount#1

Open
hashbender wants to merge 1 commit into
mainfrom
mirror/pr-531
Open

Implement z_sendfromaccount#1
hashbender wants to merge 1 commit into
mainfrom
mirror/pr-531

Conversation

@hashbender

Copy link
Copy Markdown
Owner

Mirror-of: zcash#531
zcash#531

@tenki-reviewer

tenki-reviewer Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Complete

Files Reviewed: 29
Findings: 4

By Severity:

  • 🟡 Medium: 1
  • 🟢 Low: 3

PR introduces new RPC methods (z_proposetransaction, z_finalizetransaction, z_sendfromaccount) with 5 findings: 2 medium-severity logic bugs (non-monotonic minconf behavior, TOCTOU race in fork detection) and 3 low-severity issues (cache eviction, privacy policy bypass, stale CI comment). No critical or high-severity issues.

Files Reviewed (29 files)
.github/workflows/aggregate-audits.yml
.github/workflows/audits.yml
.github/workflows/binaries-and-deb-release.yml
.github/workflows/book.yml
.github/workflows/build-and-push-docker-hub.yaml
.github/workflows/build-arm64-nix.yml
.github/workflows/ci.yml
.github/workflows/lints.yml
.github/workflows/release.yml
.github/workflows/test-docker-container.yml
.github/workflows/zizmor.yml
Cargo.toml
supply-chain/config.toml
supply-chain/imports.lock
zallet/Cargo.toml
zallet/src/components/chain.rs
zallet/src/components/chain/zebra/reader.rs
zallet/src/components/database.rs
zallet/src/components/json_rpc.rs
zallet/src/components/json_rpc/fund_source.rs
zallet/src/components/json_rpc/methods.rs
zallet/src/components/json_rpc/methods/openrpc.rs
zallet/src/components/json_rpc/methods/z_finalize_transaction.rs
zallet/src/components/json_rpc/methods/z_propose_transaction.rs
zallet/src/components/json_rpc/methods/z_send_from_account.rs
zallet/src/components/json_rpc/methods/z_send_many.rs
zallet/src/components/json_rpc/methods/z_shieldcoinbase.rs
zallet/src/components/json_rpc/payments.rs
zallet/src/components/json_rpc/server.rs

@tenki-reviewer tenki-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Risk: 🟡 Medium (48/100) — 1 medium finding, 3 low · 3265 LOC across 29 files


Summary

This PR adds three new JSON-RPC methods (z_proposetransaction, z_finalizetransaction, z_sendfromaccount), a fork-detection Depth API chain reader, a RequiredPolicyCache for privacy policy enforcement, CI workflow updates, and supporting types. The review found 5 findings (2 medium, 3 low).

Medium Severity

  • minconf=0 treated as more restrictive than minconf=1 (z_propose_transaction.rs:94, z_send_from_account.rs:89): NonZeroU32::new(0) returns None, causing minconf=0 to enforce require_confirmed_spend=true with 1 confirmation, while minconf=1 uses require_confirmed_spend=false. Violates the zcashd convention where increasing minconf monotonically increases restrictiveness.

  • TOCTOU race in find_fork_point (reader.rs:209-241): Replaces the atomic FindForkPoint request with separate Tip + Depth calls. If the chain tip advances between calls, the computed fork point height uses a stale tip_height. The ZebraChainView already holds a snapshot-consistent tip but the reader ignores it.

Low Severity

  • RequiredPolicyCache re-insertion doesn't refresh eviction order (payments.rs:280): insert skips order.push_back for existing keys, so re-inserted keys stay at the front of the FIFO eviction queue and can be prematurely evicted.
  • Privacy policy enforcement bypassed on cache miss (z_finalize_transaction.rs:77): When the RequiredPolicyCache misses (eviction or restart), is_compatible_with enforcement is silently skipped. Acknowledged as tracking issue zcash#217.
  • Stale CI runner comment (.github/workflows/build-arm64-nix.yml:56): Runner label changed from ubuntu-24.04-arm to tenki-standard-medium-4c-8g but inline comment was not updated.

All findings relate to code introduced or modified by this PR. No findings block merging but the two medium-severity bugs should be addressed.

Comment on lines +94 to +98
let confirmations_policy = match minconf {
Some(minconf) => NonZeroU32::new(minconf).map_or(
ConfirmationsPolicy::new_symmetrical(NonZeroU32::MIN, true),
|c| ConfirmationsPolicy::new_symmetrical(c, false),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 minconf=0 treated as more restrictive than minconf=1 in new RPC methods (bug)

In z_propose_transaction.rs (lines 94-98) and z_send_from_account.rs (lines 89-93), the minconf parameter handling uses NonZeroU32::new(minconf).map_or(...). When a caller passes minconf=0 (intending to allow unconfirmed outputs per zcashd convention), NonZeroU32::new(0) returns None, falling through to ConfirmationsPolicy::new_symmetrical(NonZeroU32::MIN, true) — enforcing 1 confirmation with require_confirmed_spend=true. However, minconf=1 uses new_symmetrical(1, false) with require_confirmed_spend=false. The result: minconf=0 is more restrictive than minconf=1, which is counterintuitive and violates the zcashd API contract where higher minconf values should monotonically increase restrictiveness. Both files are new in this PR, introduced by copy-pasting the same pattern from pre-existing code in z_send_many.rs.

💡 Suggestion: Map minconf=0 to the same behavior as minconf=1 (new_symmetrical(NonZeroU32::MIN, false)) so restrictiveness is monotonic. Alternatively, explicitly reject minconf=0 with an error: minconf=0 (unconfirmed outputs) is not supported; use minconf=1 or higher.

📋 Prompt for AI Agents

In zallet/src/components/json_rpc/methods/z_propose_transaction.rs lines 94-98 and zallet/src/components/json_rpc/methods/z_send_from_account.rs lines 89-93, change the None branch of NonZeroU32::new(minconf).map_or() from ConfirmationsPolicy::new_symmetrical(NonZeroU32::MIN, true) to ConfirmationsPolicy::new_symmetrical(NonZeroU32::MIN, false). This makes minconf=0 behave identically to minconf=1 (both use 1 confirmation with require_confirmed_spend=false), maintaining monotonicity. The pre-existing instances in z_send_many.rs and other files should also be fixed but are out of scope for this PR.

Comment on lines +280 to +290
fn insert(&mut self, key: [u8; 32], policy: PrivacyPolicy) {
// Re-inserting an existing key just refreshes the policy without growing the cache.
if self.by_pczt.insert(key, policy).is_none() {
self.order.push_back(key);
while self.order.len() > self.capacity {
if let Some(evicted) = self.order.pop_front() {
self.by_pczt.remove(&evicted);
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 RequiredPolicyCache re-insertion does not refresh eviction order (bug)

In payments.rs lines 280-290, RequiredPolicyCache::insert correctly updates the policy value for an existing key via HashMap::insert, but the eviction-position refresh (order.push_back(key)) is guarded by .is_none(), meaning it only executes for new insertions. On re-insertion of an existing key, the key remains at its original position in the FIFO eviction queue. The documentation comment at line 281 says 're-inserting an existing key just refreshes the policy without growing the cache', which accurately describes the current behavior but may not match the expected semantics of a 'refresh' — typically, refreshing a cache entry should also refresh its eviction priority. A key that is repeatedly proposed-then-re-proposed could be prematurely evicted by 255 intervening proposals from other accounts.

💡 Suggestion: On re-insertion, remove the key from its current position in self.order and push it to the back, so recently-refreshed keys get eviction priority.

📋 Prompt for AI Agents

In zallet/src/components/json_rpc/payments.rs lines 280-290, modify RequiredPolicyCache::insert to refresh the eviction position of re-inserted keys. Instead of skipping order.push_back when by_pczt.insert returns Some (re-insertion), remove the key from its current position in self.order using retain and push it to the back. This ensures that re-inserted keys are moved to the most-recent position in the eviction order.

build-arm64:
name: Build aarch64-musl via Nix
runs-on: ubuntu-24.04-arm # native arm64; free for public repos
runs-on: tenki-standard-medium-4c-8g # native arm64; free for public repos

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Misleading inline comment on runner selection after runner label change (style)

In .github/workflows/build-arm64-nix.yml line 56, the runs-on field was changed from ubuntu-24.04-arm (GitHub's free native arm64 runner) to tenki-standard-medium-4c-8g (a self-hosted runner), but the inline comment # native arm64; free for public repos was not updated. The comment is now potentially inaccurate: the self-hosted runner's architecture is not indicated by its label, and self-hosted runners are not 'free for public repos' in the same way GitHub-hosted runners are. This could mislead future maintainers about the runner's capabilities.

💡 Suggestion: Update the comment to accurately describe the new runner, e.g., # self-hosted arm64 runner or remove the comment entirely since the runner label should be self-documenting.

📋 Prompt for AI Agents

In .github/workflows/build-arm64-nix.yml at line 56, change the inline comment from # native arm64; free for public repos to # self-hosted arm64 runner or simply remove the comment after the runner label.

Comment on lines +77 to +84
if let Some(required) = cached_required_policy(&pczt_policy_key(&pczt.serialize())) {
if !privacy_policy.is_compatible_with(required) {
return Err(LegacyCode::InvalidParameter.with_message(format!(
"The privacy policy {privacy_policy} does not permit this transaction, which \
requires at least {required}."
)));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Privacy policy enforcement silently bypassed on RequiredPolicyCache eviction or restart (suggestion)

In z_finalize_transaction.rs lines 72-84, the call to cached_required_policy checks the RequiredPolicyCache for the required privacy policy of a proposed PCZT. On a cache hit, the user-supplied policy is validated against the required policy via is_compatible_with. On a cache miss — caused by FIFO eviction after 256 proposals (payments.rs:280-290), process restart, or a PCZT proposed by another node — the if let Some(required) = ... block is skipped entirely, and any caller-supplied policy is accepted without validation. This means a caller who knows their transaction requires NoPrivacy can wait for cache eviction and supply FullPrivacy to bypass the acknowledgement check. The privacy policy lattice (meet, is_compatible_with) is correctly implemented, but enforcement is structurally bypassable via the cache miss path. Acknowledged by the code comment referencing zcash#217.

💡 Suggestion: On cache miss, either (1) re-derive the required policy from the PCZT if feasible, (2) reject with an error requiring the caller to re-propose, or (3) at minimum, log a warning so the bypass is auditable. Persisting the cache to disk or using a larger non-evictable cache would also mitigate the issue.

📋 Prompt for AI Agents

In zallet/src/components/json_rpc/methods/z_finalize_transaction.rs at lines 77-84, add an else branch to the if let Some(required) = cached_required_policy(...) block. In the else branch, log a warning: warn!("PCZT required-policy cache miss; accepting supplied privacy policy {} as acknowledgment without compatibility check", privacy_policy);. Optionally, if the required policy can be derived from the PCZT bytes, compute and enforce it. Also update the OpenRPC documentation to note that when the PCZT was not recently proposed by this node, the privacy_policy parameter is accepted as acknowledgment without enforcement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant