OP_RETURN via an owned spend pipeline: ADR 0010 scoping, dependency trims, and the owned proposal types - #2469
OP_RETURN via an owned spend pipeline: ADR 0010 scoping, dependency trims, and the owned proposal types#2469zancas wants to merge 29 commits into
Conversation
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>
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>
01ce1b2 to
52197c7
Compare
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>
`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
left a comment
There was a problem hiding this comment.
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!
| proposal: proposal.clone(), | ||
| sending_account: account_id, | ||
| }); | ||
| // A send-all is a self-spend at heart: no vault reads its origin, |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
i notice sapling has a scope but orchard doesnt? i will revisit this with more context
| 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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>
# 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>
LOC breakdown
Diff against the
feat/ironwoodmerge-base, split at each file's#[cfg(test)]boundary (test modules trail production code in every affected file);testutils/,mocks.rs, andtests/trees count as test wholesale. Comparative migration scaffolding added and deleted within the branch nets out of these figures.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_backendfrom 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_backendnow 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→ ownedProposal), a wallet-pure build layer (proposal + keys + witnesses + provers → signed transactions), and a single apply mutation site.propose_send,propose_send_all, andquick_sendgainOption<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
FeeRulethe 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'spropose_transfer/propose_shielding, and the built transactions structurally equal tocreate_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 countsmax(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_chainin 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