Skip to content

OP_RETURN via an owned spend pipeline: ADR 0010 scoping, dependency trims, and the owned proposal types - #2469

Open
zancas wants to merge 29 commits into
devfrom
opreturn_on_proposal
Open

OP_RETURN via an owned spend pipeline: ADR 0010 scoping, dependency trims, and the owned proposal types#2469
zancas wants to merge 29 commits into
devfrom
opreturn_on_proposal

Conversation

@zancas

@zancas zancas commented Jul 16, 2026

Copy link
Copy Markdown
Member

LOC breakdown

Diff against the feat/ironwood merge-base, split at each file's #[cfg(test)] boundary (test modules trail production code in every affected file); testutils/, mocks.rs, and tests/ trees count as test wholesale. Comparative migration scaffolding added and deleted within the branch nets out of these figures.

Package Production Tests Docs & manifests
zingolib +2,622 / −1,576 +713 / −380 +3 / −5
zingo-cli +20 / −15 +0 / −1
libtonode-tests +92 / −31 +1 / −1
pepper-sync +5 / −1
workspace root +281 / −3
Total +2,647 / −1,592 +805 / −411 +285 / −10

The production additions are the owned pipeline (zingolib/src/wallet/spend/: types, plan, build, apply, fee sizing); the production deletions are the zcb trait impls (zcb_traits.rs, 1,095 lines), data/proposal.rs, and the old propose/calculate plumbing. pepper-sync's +5 is a re-export comment block, no dependency change.

What this PR does

zingolib takes ownership of its spend pipeline as an in-tree module and removes zcash_client_backend from its dependency tree (ADR 0010, in-repo), giving OP_RETURN Data — the swap memo channel for THORChain/MAYAChain deposits — a native seam instead of waiting on an upstream Proposal-API feature that never landed (zcash/librustzcash#2441 closed unmerged). cargo tree -i zcash_client_backend now resolves only through pepper-sync, whose serialization-only dependency is explicitly out of scope.

The pipeline is layered functionally, effects at the edges: a pure plan layer (&LightWallet → owned Proposal), a wallet-pure build layer (proposal + keys + witnesses + provers → signed transactions), and a single apply mutation site. propose_send, propose_send_all, and quick_send gain Option<OpReturnData> (a validated ≤80-byte newtype); the payload rides the final transaction — the exposure step of a ZIP-320 TEX pair, or the single step otherwise — and a Shield can never carry it, by construction. The ADR 0008 expiry retarget reduces to a pure field update. Refund addresses are derived at plan time and reserved only at apply, so an abandoned TEX proposal no longer burns an ephemeral index.

Fee correctness is enforced twice: the plan feeds the upstream ZIP-317 FeeRule the serialized size of every output including the null-data output, and the upstream builder's value-balance assertion refuses to build any transaction whose planned fee is off by a zatoshi. Before the old path was deleted, a comparative suite proved the planner fee-for-fee and input-for-input equal to zcb 0.24's propose_transfer/propose_shielding, and the built transactions structurally equal to create_proposed_transactions' output (version, expiry, output sets, bundle shapes), across transfer, TEX two-step, and shield. That suite exposed two real counting rules now pinned by surviving invariant tests: the Ironwood V6 bundle counts max(spends, outputs) actions while the legacy Orchard bundle is branch-dependent — cross-address pairing before NU6.3, one action per spend and output after.

All 206 zingolib tests pass. The end-to-end regtest round trip (a mined transaction carrying the payload) is op_return_data_confirms_on_chain in the libtonode suite, pending a container run.

Sequencing

Based on feat/ironwood; draft until #2419 lands. The branch carries the landed #2464 typed-error regime, which the pipeline's per-layer error enums (PlanError, BuildError, OpReturnDataError) extend.

🤖 Generated with Claude Code

zancas and others added 16 commits July 15, 2026 19:52
This is the rebase remnant of 43343158a. The fork-based offline-signing
commit it sat on is superseded by dev's retarget implementation
(c007996), and the ADR it added is superseded by dev's ADR 0008, which
records the same decision with the review outcomes folded in. What
survives is the ADR 0006 correction — the expiry of offline-calculated
transactions is no longer blocked upstream; issue #2455 is resolved by
proposal retargeting per ADR 0008 — and the .agent-plans claim file for
the commands work on this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n progress as an exact integer ratio

The interactive prompt classified command results by sniffing their
content: an 'Error:' prefix check on the sync poll and the save check,
an exact match on pepper-sync's misspelled success message, and a JSON
re-parse of the sync status string. This is the same in-band-error
pattern issue #2446 documents for the public API, repeated in-process
across the REPL's command channel.

The prompt housekeeping now runs on the command-loop thread, where the
LightClient lives, and classifies every outcome from typed values:
poll_sync's PollReport arms replace both prefix sniffs, check_save_error's
Result replaces the save sniff, and pepper_sync::sync_status is consumed
as a struct rather than re-parsed from a string. The channel request
becomes an enum (Command / PromptIndicator), so the variant, never the
response text, tells the requester how to interpret a reply. All printed
messages are preserved byte for byte except the progress figure itself.

Scan progress is now an exact integer ratio, never a float. SyncStatus
gains the integers its percentage was computed from, total_outputs_scanned
and total_outputs (both u64, the breaking addition), plus is_complete(),
which preserves the refetching-nullifiers nuance the old override of 100%
to 99% encoded. The prompt reports 'Syncing X / Y outputs', claims Synced
only from is_complete(), and falls back to a bare indicator when the total
is unknown rather than printing 0 / 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A fully synced prompt now reads '[Synced X / X outputs]' rather than a
bare '[Synced]', so the completed ratio stays visible at the prompt. The
new synced_indicator serves both paths that report completion — the idle
check and the arm that observes the sync task finishing — and falls back
to the bare form only when sync status is unavailable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The idle prompt indicator now reads '[Sync stopped at X / Y outputs]'
instead of '[Not syncing X / Y outputs]', naming the state precisely: the
wallet is partially scanned and no sync task is working on it. The bare
fallback for an unavailable status becomes '[Sync stopped]', since 'at'
without a ratio would dangle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The claim file's header still named the retired dry_commands_run_core
branch and its #2458 stack base. It now names the current branch, the
2026-07-14 rebase onto dev, and PR #2464 as the pull request this work
lands through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This finishes issue #2446's zingolib work, on the evidence of the
interface audit in issue #2465. Every public API now returns typed data
with typed errors, and no caller can learn an outcome by inspecting a
value's content.

do_delete returns std::io::Result<()>: the remove_file error passes
through typed, and the missing-file case is an ErrorKind::NotFound. The
audit found no callers outside this workspace; zingo-cli's delete
command migrates in this commit.

memo_bytes_from_string returns Result<MemoBytes, MemoError>, a thiserror
enum whose TooLong display preserves the previous message byte for byte.
This removes the last place zingolib composed error prose into a String.
zingo-cli's CommandError::InvalidMemo carries the typed error; the
testutils conversion helper is unaffected.

do_info is deleted in favor of info() -> Result<ServerInfo,
LightClientError>. ServerInfo lives in zingolib::data with a
From<&ServerInfo> for JsonValue impl that reproduces the old field
layout exactly, so presentation layers can render identical JSON — the
CLI's info command does, keeping REPL output byte-identical. Consumers
of the deleted method (zingo-mobile, zingo-pc) migrate at their next pin
bump by calling info() and serializing ServerInfo as their wire format
requires. The #2446 contract test now pins info() directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ut ratio

The review of PR #2464 found two defects in SyncStatus::is_complete. A
wallet whose birthday-to-chain-height range contains no shielded outputs
could never report complete, because the total_outputs > 0 guard
conflated "never started" with "nothing to scan". Conversely, a stale
total_outputs denominator could fake completion while ranges remained
unscanned. Completion is now the sync task's own terminal condition:
sync has started and every scan range is Scanned.

The duplicated scan-priority predicates become named helpers on
ScanPriority (is_scanned, awaits_nullifier_retrieval), collapsing four
literal copies across sync.rs and state.rs. New unit tests pin the
completion contract in pepper-sync, where it is defined.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ations

The interactive loop's send_request derives its error label from the
request variant, removing the redundant description parameter and the
clone at its call site. The three prompt indicators delegate their
shared shape to one ratio_indicator helper, and a comment explains the
deliberately doubled 'Sync error: Error:' bytes.

From<ServerInfo> for JsonValue now consumes its value, matching the
From<SyncStatus> convention and removing five clones. do_delete is
renamed delete_wallet_file: with do_info gone, the do_ prefix was a
fossil, and the interface audit (issue #2465) shows no caller outside
this workspace, so the rename rides the PR's existing breaking change.
MemoError::TooLong now carries the memo's byte length rather than its
content, so an oversized memo cannot leak into logs or error channels.

In pepper-sync, the u64 ratio fields are summed in u64 so the width
claim holds by construction, the total_outputs_scanned doc notes it may
exceed total_outputs under chain growth, and a new test pins the
vacuously complete empty-scan-ranges state. The CLI test named
in_progress_with_unknown_total is renamed to match its output-free-range
semantics, and the plan file's finished sections are re-marked Done,
flagging the superseded first description of is_complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…line plan

ADR 0010 decides that zingolib owns its spend pipeline as an in-tree
module and removes zcash_client_backend from its dependency tree. The
motivating need is a data-carrier (OP_RETURN) output on the final
transaction of a send, for which the released Proposal API has no seam;
the audit recorded in the ADR found zcb's real footprint is three
orchestration entry points fed by a trait file that is mostly stubs.
The pipeline is layered functionally: a pure plan layer, a wallet-pure
build layer, and a single apply mutation site, with the ADR 0006
proposal slot untouched.

The glossary gains the term "OP_RETURN Data" (never "memo") for the
caller-provided bytes, and .agent-plans/opreturn-zcb-removal.md holds
the ratified decisions, the phase plan, and the sequencing against
PRs #2419 and #2464.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ctly

Nothing in the workspace uses zcash_client_backend::proto: zingo-netutils
takes its gRPC types from lightwallet_protocol and the mock indexer uses
zaino_proto. The lightwalletd-tonic feature (workspace) and the
lightwalletd-tonic-transport feature (zingo-testutils) therefore only
compiled dead code; removing them drops tonic, tonic-prost, and
hyper-util from zcash_client_backend's resolved dependencies.

zcash_client_backend::zip321 is a verbatim re-export of the zip321
crate, so zingolib now depends on zip321 directly and every reference
uses the crate's own path. The crate was already in the lockfile via
zcash_client_backend; no new dependency enters the graph.

This is phase P1 of the ADR 0010 plan to remove zcash_client_backend
from zingolib entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…TURN Data

This is phase P2 of the ADR 0010 plan for zingolib to own its spend
pipeline and remove zcash_client_backend from its dependency tree. The
new wallet::spend module holds the pipeline's owned types: a Proposal
enum whose Transfer, TexTransfer, and Shield variants are the only
transaction shapes zingolib produces, a concrete generic-free Step, and
the validated OpReturnData payload (at most eighty bytes, checked once
at construction, mirroring MemoError's shape for the sibling payload).

Shape rules the old code rejected at runtime are unrepresentable or
refused at construction: a multi-step proposal is necessarily the ZIP
320 TEX flow, a Shield never carries OP_RETURN Data or spends shielded
notes, and only the flow's final transaction may carry the payload. The
with_target_height method is the pure mechanism for the ADR 0008 expiry
retarget.

The module is purely additive: the facade keeps using
zcash_client_backend until the plan, build, and apply layers land and
the P5 cutover rewires it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This is phase P3 of the ADR 0010 plan. The spend::plan module maps a
read-only wallet view and a payment request to an owned proposal with
no wallet mutation: payment routing with the unified-address
best-receiver rules and the ZIP 318 turnstile, the greedy balance loop
driving the wallet's existing selection core, single-output change
under AllowDustChange with the turnstile change-pool reroute, the ZIP
320 two-step split in which the shielding step pre-funds the exposure
step's fee through the ephemeral output, and shielding with the
threshold and uneconomic-coin pruning. OP_RETURN Data enters as a
typed parameter, rides the final step, and is fee-counted by its real
serialized size.

The spend::fee module owns only the ZIP 317 counting; every fee comes
out of the upstream FeeRule. The equivalence suite fixed a real
modeling error on the way in: the legacy Orchard V5 bundle counts
spends plus outputs (no cross-address pairing) while the Ironwood V6
bundle counts the maximum of the two, and a dedicated ironwood-spend
test now pins both rules.

Eight equivalence tests compare the planner against zcb 0.24's
propose_transfer and propose_shielding on identical synthetic wallets
- fees, selected inputs, change value and pool, InsufficientFunds
amounts, and the TEX ephemeral value all match exactly. The wallet
queries still go through the zcb trait impls; the P5 cutover inlines
them and deletes the comparative scaffolding, converting keepable
assertions into standalone invariant tests. The plan file records the
ratified P4 structural build-equivalence and P5 facade acceptance
gates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nsaction

This is phase P4 of the ADR 0010 plan. LightWallet::spend_materials is
the effectful edge: it resolves a proposal's inputs into self-contained
spend materials (notes, key scopes, merkle witnesses, per-pool anchors,
coins with signing coordinates) and derives, never reserves, the
ephemeral Refund Address of a TEX flow; derive_refund_addresses is
split out of generate_refund_addresses so an abandoned proposal leaves
no trace. build_transactions is the wallet-pure core: it drives the
upstream Builder (transaction version by consensus branch, expiry at
target plus forty), pays the plan's pools, routes post-NU6.3 Orchard
change through the dedicated change-output API, chains the TEX exposure
step onto the shielding transaction's ephemeral output, and places
OP_RETURN Data on the final transaction via the upstream null-data
primitive.

Fee pinning needs no code of ours: the builder recomputes the ZIP 317
fee from what was actually added and refuses to build unless the value
balance equals it, so every successful build proves the plan's fee and
change were exact, the null-data output's serialized size included.

Four structural equivalence tests compare the built transactions
against create_proposed_transactions' on the same proposal: version,
expiry, transparent output sets (scripts and values, so the ephemeral
address itself is compared), sapling shapes, and orchard and ironwood
action counts, across the transfer, TEX two-step, and shield shapes.
They caught a real omission: an output-only bundle still needs its
anchor, so pool involvement counts outputs, not just spends. The owned
proposal types gained the anchor height the build stage consumes and
the ephemeral-value accessor the TEX chain requires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	zingo-testutils/Cargo.toml
…ackend leaves the tree

This is phase P5 of the ADR 0010 plan, the cutover. The spend::apply
module is the pipeline's single mutation site: it records the built
transactions with Calculated status and reserves the TEX flow's Refund
Address exactly when a transaction bearing it comes into existence, so
an abandoned proposal no longer burns an index — the acceptance test
pins that divergence. The facade rewires end to end: propose_send,
propose_send_all, and quick_send carry Option<OpReturnData>;
calculate_transactions runs materials, wallet-pure build, then apply;
the ADR 0008 retarget is a pure field update on the owned proposal (the
RetargetError variant is gone); ZingoProposal and data::proposal fold
into the owned enum.

The zcb trait impls are deleted rather than re-abstracted: the wallet
queries the pipeline needs live on LightWallet as inherent methods
(target_and_anchor_heights, select_spendable_shielded_inputs with the
migration soft reservation), the build layer reads the shard trees
directly, and legacy wallet files reach read_shard through a pepper-sync
re-export, since pepper-sync owns the zcash_client_backend serialization
dependency. zcash_client_backend leaves the zingolib, zingo-cli, and
libtonode-tests manifests; only pepper-sync retains it, as ADR 0010
ratified. zcash_keys gains its unstable feature directly, for the USK
byte codecs the wallet file format depends on and zcb had been enabling
transitively.

The cutover's test sweep exposed one real fee rule the equivalence era
missed: legacy-Orchard action counting is branch-dependent — before
NU6.3 the V5 bundle pairs spends with unrelated outputs, so actions are
the larger of the two counts, while from NU6.3 the pairing is forbidden
and every spend and output occupies its own action. spend::fee now
implements both arms, the boundary-adjacent branch-id test pins them,
and the upstream builder's value-balance assertion enforces the rule on
every build. The comparative P3/P4 scaffolding died with the old path,
its ours-only assertions surviving as invariant tests. All 206 zingolib
tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A shielded send to a transparent address carries a swap-shaped
OP_RETURN payload through the in-tree pipeline; the test asserts the
transaction confirms and the payload sits in the mined transaction's
zero-value null-data output. Chain-bound, so it runs in the container
suite; this commit is compile-verified only and the run closes ADR
0010's end-to-end question.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zancas
zancas force-pushed the opreturn_on_proposal branch from 01ce1b2 to 52197c7 Compare July 16, 2026 15:08
This was referenced Jul 17, 2026
Base automatically changed from feat/ironwood to dev July 24, 2026 04:59
juanky201271 and others added 4 commits August 14, 2026 20:19
Adapts the OP_RETURN spend pipeline to dev's architecture rather than the
reverse. Where the two diverged, dev's behavior is authoritative and the
PR contributes only the mechanism OP_RETURN needs.

Reconciled by hand:

- send and shield stay specialized as dev wrote them, keeping
  preflight_transmit, TransmitReport, and the refund-address truncation
  and rotation. They take the owned proposal type and read the account
  from Proposal::account_id, so the unified calculate_and_transmit the
  PR proposed is dropped.
- dev's stored-proposal pause discipline (hold_proposal_pause and
  release_proposal_pause) survives in all four propose entry points,
  with op_return_data threaded through.
- retarget_for_offline_signing becomes infallible, which the owned
  proposal type permits, so SendError::RetargetError leaves that path.
- zcb_traits.rs is deleted with zcash_client_backend leaving zingolib.
  Its note-selection repairs are already carried by the new pipeline:
  the stale-remainder fix lives in output.rs, which plan.rs calls, and
  plan.rs walks the source pools in the caller's preference order. The
  TargetValue::AllFunds arm had no landing site, being an InputSource
  trait obligation, and send-all sizes through max_send_value.

lightclient/send.rs and zingo-cli/commands.rs took dev's rewrite with
the PR's delta reapplied on top.

360 zingolib unit tests pass, including the three regression tests that
pin the note-selection repairs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mayachain and THORChain read a swap's refund destination from the
inbound transaction's origin. A shielded spend exposes none, so a
deposit paid straight from a note leaves the protocol with nowhere to
return a failed swap's funds.

`route_via_ephemeral` sends an ordinary transparent recipient down the
exposure step that TEX addresses already take: the value moves shielded
-> wallet ephemeral t-addr -> vault, and the transaction that pays the
vault now has an origin the wallet controls. The OP_RETURN memo naming
the swap already rides that same exposure step, so both halves of what
the protocols need arrive together.

The flag stops at the plan layer. Nothing downstream distinguishes this
from a TEX flow, because structurally it is one. Send-all and the
fee-sizing probes stay single-hop: no vault reads their origin.

The closed PR #2417 carried this as a parameter to a forked
zcash_client_backend. The in-tree pipeline owns the ZIP 320 split, so
it is a routing decision here instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only wallet/error.rs conflicted, and only on its imports. dev's
"retired vocabulary" refactor dropped the nym `acquire` import, and its
`OutputRef` import serves the zcash_client_backend `ProposeSendError::
Proposal` variant this branch replaced with `Plan(PlanError)`. Neither
import has a consumer here now, so both go.

362 unit tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cargo-checkmate's doc phase denies `private_intra_doc_links`, and the
module doc has linked `super::fee` since the plan layer landed: `fee` is
crate-private, so a public doc cannot navigate there. The ephemeral-route
commit then added a second instance, linking the private `route_request`
from `plan_transfer`.

Both become prose. The reader loses nothing, since neither item was
reachable from the rendered docs to begin with.

All five checkmate phases pass: build, check, clippy, doc, format.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@juanky201271
juanky201271 marked this pull request as ready for review August 16, 2026 02:26
`generate_a_range_of_value_transfers` failed its shield with "insufficient
transparent funds to shield" against a balance the wallet has always
shielded.

The plan layer measured the threshold after the fee. Upstream measures it
before: `propose_shielding` compares `TransactionBalance::total()`, which
its own documentation defines as the sum of the proposed change and the
required fee. The two readings disagree over a wide band, because a
one-input shield costs a 15_000 ZIP 317 bundle against a 10_000
threshold, so every gross total between 15_001 and 24_999 was refused
despite clearing the threshold with room to spare.

A fee at or above the inputs now reports its own shortfall, against the
fee, which is the other refusal upstream can produce and which the net
reading was conflating with the first.

The planner had no offline test at all, which is how a shield that
refuses itself reached CI. One now pins the reading, sitting in the band
the two disagree about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@Oscar-Pepper Oscar-Pepper left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

not tested and i need to go through the logic in zingolib/src/wallet/spend/plan.rs thoroughly but on first-pass this PR looks really good. i will also look into more detail regarding some of my comments as I read through. im super excited to not be depending on another backend!

Comment thread zingolib/src/lightclient/propose.rs Outdated
proposal: proposal.clone(),
sending_account: account_id,
});
// A send-all is a self-spend at heart: no vault reads its origin,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i understand self-spend to mean a send-to-self, which a send-all is not. can we remove/correct this comment?


/// One orchard-family (Orchard or Ironwood) spend, self-contained.
pub struct OrchardSpendMaterial {
note: orchard::Note,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i notice sapling has a scope but orchard doesnt? i will revisit this with more context

Comment thread zingolib/src/wallet/spend/build.rs Outdated
if ironwood_active {
// Post-NU6.3 the Orchard bundle forbids ordinary
// outputs; change returns to a spent note's own
// address via the dedicated change-output API.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can you clarify this comment and code please? it is mentioning limitation's to orchard bundles, but my understanding is that - post-ironwood - the orchard internal key will derive addresses where fund will be directed to the ironwood internal pool. they will be in the ironwood bundle not the orchard bundle if i am correct, and therefore the limitations will not apply.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

oh i need to read add_orchard_change_output for context. i see now that the change is in the orchard scope not ironwood, even if ironwood is active

juanky201271 and others added 3 commits August 20, 2026 12:16
dev's zcash_primitives 0.30 bump is the whole of this merge. Six files
conflicted; four of them only because dev extended the zcb call sites
this branch deletes.

Reconciled by hand:

- Cargo.toml takes dev's versions and keeps this branch's two additions:
  zcash_keys' `unstable` feature, which the wallet file format needs for
  the USK byte codecs now that zcb no longer enables it transitively,
  and the direct zip321 dependency. zip321 resolves to the same
  0.9.0-rc.1 zcb 0.24.0-rc.7 already uses, so the stack keeps one copy
  of every crate and `cargo tree -i zcash_client_backend` still resolves
  only through pepper-sync.

- zcb_traits.rs is deleted as this branch always intended. dev's +267
  lines there were the OutputLockStore impl that zcb 0.24.0-rc.7 made a
  WalletWrite obligation; wallet/locks.rs goes with it, being built
  entirely out of zcb's LockOwner and OutputRef with that impl as its
  only consumer. Nothing observable is lost: dev requested no lock at
  any call site, so the lock set was empty in every flow it drove. Its
  CHANGELOG entry goes too, having become false.

- wallet/send.rs returns to this branch's shape byte for byte. dev split
  can_build_witness into a shards_are_scanned helper to answer
  InputSource::anchor_computable — a whole-tree question zcb asked and
  the in-tree planner does not, filtering note by note through
  can_build_witness, which balance.rs and output.rs still call.

- spend/build.rs adapts to BuildConfig::Standard, whose single
  orchard_pool_bundle_type fed both Orchard-family builders in 0.29 and
  splits into orchard_padding and ironwood_padding in 0.30. Both take
  BundlePadding::DEFAULT, which upstream documents as the equivalent of
  the BundleType::DEFAULT this passed before, so bundle action counts —
  and the fees the tables pin to them — are unchanged.

dev's record_price_update removal carried in without a decision.

357 zingolib unit tests pass. cargo fmt is clean, and clippy --workspace
--all-targets -- -D warnings is clean but for a large_enum_variant on
mixnet/supervisor.rs that this merge does not touch: the file is
identical to dev, whose CI passes it, because std::process::Child is
larger on Windows than on the Linux runners.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #2469 review, 2026-08-20. All three were comments that either said
something false or left the reader to go find the mechanism themselves.
No behavior changes.

- The send-all comment claimed a send-all "is a self-spend at heart",
  which it is not — a self-spend is a send to oneself — and then argued
  from "no vault reads its origin", a claim about how the wallet will be
  used rather than about what the code does. Both go. What the `false`
  says is that propose_send_all does not expose route_via_ephemeral,
  which the signature already says; whether a send-all should be able to
  carry a swap deposit is a design question, not a comment's to settle.

- SaplingSpendMaterial carries a scope and OrchardSpendMaterial does
  not, which reads as an oversight and is not one: Sapling's builder
  takes a full viewing key and the internal one is a separate
  derivation, so the scope picks between them, while one Orchard full
  viewing key spends notes of either scope. Both structs now say so.

- The Orchard change comment named the wrong constraint. Post-NU6.3 the
  legacy Orchard bundle disables cross-address transfers rather than
  forbidding ordinary outputs, and the change-output API is the only way
  to retain value in such a bundle because it pairs the output with a
  fabricated zero-valued spend at the same address inside one action.
  The comment also never said out loud that this arm is Orchard-pool
  change landing in the Orchard bundle, with the arm below carrying
  Ironwood-pool change — the exact point the reviewer had to go read
  add_orchard_change_output to recover.

cargo fmt, clippy --workspace --all-targets -D warnings and rustdoc
-D warnings are clean. The unit suite was not run: Smart App Control on
the authoring machine blocks freshly built test binaries (os error
4551). The diff contains no non-comment line, and CI runs the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
juanky201271 and others added 5 commits August 21, 2026 17:00
# Conflicts:
#	zingolib/src/testutils/lightclient.rs
ADR 0010 moved refund-address reservation from propose time to apply
time, so an abandoned proposal leaves the index free. A wallet that has
to tell a swap provider which address its deposit will come from cannot
reach that guarantee: derive_refund_addresses was pub(crate), so the only
public way to name an address was generate_refund_addresses, which
reserves.

Reserving out of band defeats the ADR twice over. The index is spent for
a transaction that may never exist, which is the gap-limit erosion the
ADR set out to retire. And the address named to the provider is then the
wrong one: the proposal derives the lowest index that is NOT reserved, so
it takes the one after. zingo-pc hit both, telling SwapKit about an
address its ZIP 320 hop would never spend through.

Both refund-scope addresses belong to the wallet, so a Mayachain or
THORChain refund read off the deposit's origin still lands somewhere
spendable. What was lost is the correspondence between the address
declared and the address observed.

Making the derivation public gives that caller the address the coming
proposal will pick, with reservation left where the ADR put it. The two
tests pin the pair of properties the caller depends on: derivation
repeats until an apply, and a reservation moves the next one on.

AI disclosure per AI_POLICY.md: written with Claude Code (Opus 5). The
diff is a visibility change, its doc, and two unit tests; the analysis of
the derive/reserve interaction was done against this branch's source and
confirmed by the second test, which fails to hold on any wallet where
reservation happens before the proposal.

Not A/B benchmarked with `makers sync-bench` as AGENTS.md requires: no
call site changed and the function body is untouched, so there is no
runtime path for a benchmark to measure. Say the word and it runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

3 participants