Skip to content

feat(swift-example-app): send from Core balance to a shielded recipient#3885

Merged
QuantumExplorer merged 1 commit into
v3.1-devfrom
claude/mystifying-chaum-4da4c7
Jun 13, 2026
Merged

feat(swift-example-app): send from Core balance to a shielded recipient#3885
QuantumExplorer merged 1 commit into
v3.1-devfrom
claude/mystifying-chaum-4da4c7

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jun 13, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

On the SwiftExampleApp Send Dash screen, when the recipient was a shielded (Orchard) address the Send From list only offered Shielded and PlatformCore was never selectable, even with a spendable Core balance. The .orchard branch of SendViewModel.availableSources never appended .core, so the only way to fund a shielded send from Core money was a manual two-step (shield Core → self, then send shielded → recipient). That two-step was not intentional.

The underlying capability already ships: Type 18 ShieldFromAssetLock (shieldedFundFromAssetLock) takes Core L1 funds and an arbitrary recipient Orchard address. It was simply not wired into the unified Send flow.

What was done?

Wired Core → shielded into the Send flow via the existing shieldedFundFromAssetLock path. No Rust/FFI changes — Swift example-app only.

SendViewModel.swift:

  • Added a coreToShielded SendFlow (display "Shield from Core", lock.shield icon, fee metadata).
  • availableSources now offers .core for an Orchard recipient, appended last so the private shielded → shielded path stays the auto-selected default.
  • updateFlow maps (.orchard, .core).coreToShielded.
  • executeSend dispatches the new flow to shieldedFundFromAssetLock: parses the recipient's 43-byte Orchard address and funds from the standard BIP44 Core account (typeTag/standardTag == 0) with the largest confirmed balance (asset locks draw UTXOs from a single account).
  • canSend gates the flow on a minimum lock size (minShieldFromCoreDuffs, mirroring ShieldedFundFromAssetLockView.minDuffs) so a sub-fee amount can't kick off a doomed lock-build + ~30s Halo 2 proof.
  • estimateFee maps the flow to the .transfer fee kind (Type 18 carves the same compute_minimum_shielded_fee base).

SendTransactionView.swift:

  • Added flowColor for the new flow.
  • Added feeUnit(for:) to decouple the fee-display unit from the source unit — this flow spends Core duffs but its pool fee is denominated in credits, which would otherwise render 1000× too large. Behaviour-preserving for every existing flow.

Design notes for reviewers

  • Amount semantics: the typed amount is the L1 lock size in duffs; the recipient receives lock_value − pool_fee. This mirrors ShieldedFundFromAssetLockView's "lock size, not net amount" convention rather than grossing up the fee — Type 18's Orchard value_balance is baked into the Halo 2 proof at build time and can't be re-derived afterward.
  • Inline await, not staged progress: matches the other six flows in executeSend. Tradeoff: the "Sending…" overlay can sit for a minute+ while the asset-lock pipeline runs (build lock → IS-lock/ChainLock wait → proof → submit). The dedicated ShieldedFundFromAssetLockView remains for the rich staged-progress experience. This path bypasses the shield coordinator, but the Rust shield_guard mutex still serializes shield-class ops per wallet, so concurrent attempts block rather than corrupt.
  • Single-account funding: the screen's "Core" balance sums across accounts, but an asset lock draws from one account — so an amount under the displayed total can still fail if the balance is split. Documented inline.

How Has This Been Tested?

  • Strict simulator build passes. ./build_ios.sh --target sim (which builds the example app with -warnings-as-errors) completes with ** BUILD SUCCEEDED **; both SendViewModel.swift and SendTransactionView.swift compile as part of it, with zero Swift warnings/errors. (A fresh FFI build was required because the prebuilt xcframeworks on disk all predate the platform_wallet_mnemonic_* FFI symbols this branch's SwiftDashSDK sources need.)
  • Self-reviewed all SendFlow switch sites for exhaustiveness (displayName/iconName/estimatedFee/canSend/estimateFee/executeSend/flowColor/feeUnit).
  • The new code only uses already-shipping APIs in patterns identical to existing call sites (shieldedFundFromAssetLock / ShieldedFundFromAssetLockRecipient as used by ShieldedFundFromAssetLockView; accountBalances(for:); DashAddress.parse .orchard extraction as used by the shielded → shielded flow).
  • No automated UI tests were added (example-app UI wiring). End-to-end on-device exercise of the new send path (asset-lock → proof → recipient receives the note after sync) was not run as part of this change.

Breaking Changes

None. Swift example-app changes only; no consensus, protocol, FFI, or SDK public-API changes.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

The Send Dash screen only offered Shielded and Platform as funding
sources for a shielded (Orchard) recipient — Core was never selectable
even with a spendable Core balance, because the `.orchard` branch of
`availableSources` never appended `.core`. The only way to fund a
shielded send from Core money was the two-step shield-then-send, which
was not intentional.

Wire Core -> shielded through the existing Type 18 ShieldFromAssetLock
path (`shieldedFundFromAssetLock`):

- Add a `coreToShielded` SendFlow and offer `.core` as a source for an
  Orchard recipient, appended last so the private shielded->shielded
  path stays the auto-selected default.
- Map `(.orchard, .core)` -> `.coreToShielded` and dispatch
  `executeSend` to `shieldedFundFromAssetLock`, parsing the recipient's
  43-byte Orchard address and funding from the standard BIP44 Core
  account with the largest confirmed balance (asset locks draw UTXOs
  from a single account).
- Gate the flow on a minimum lock size so a sub-fee amount can't kick
  off a doomed lock-build + ~30s proof.
- Decouple the fee-display unit from the source unit (`feeUnit(for:)`):
  this flow spends Core duffs but its pool fee is denominated in
  credits, which would otherwise render 1000x too large.

The typed amount is the L1 lock size (duffs); the recipient receives
lock_value - pool_fee, mirroring ShieldedFundFromAssetLockView's
"lock size, not net amount" convention (Type 18's Orchard value_balance
is baked into the Halo 2 proof at build time and can't be re-derived).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a new coreToShielded send flow that enables Core L1 funds to be locked and minted as shielded Orchard notes. The flow is wired through SendViewModel (enum variant, validation, source selection, flow resolution, fee estimation, and execution) and SendTransactionView (fee unit display and flow color).

Changes

Core-to-Shielded Send Flow Implementation

Layer / File(s) Summary
SendFlow enum extension
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift
SendFlow gains a coreToShielded case with display name, icon name, and fallback estimated fee mapped in computed properties.
SendViewModel configuration and enablement
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift
Introduces minShieldFromCoreDuffs static constant and updates canSend validation to check that the duffs amount meets the minimum threshold for this flow.
Source selection and flow resolution
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift
availableSources now includes .core for Orchard destinations, and updateFlow resolves the (Orchard recipient, Core source) pair to coreToShielded.
Fee estimation for shielded flows
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift
estimateFee(for:) groups coreToShielded with other shielded flows to apply the correct fee kind and FFI fallback handling.
Send execution for coreToShielded
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift
executeSend implements a new coreToShielded case that validates the Orchard recipient, selects a spendable Core account by largest confirmed balance, calls walletManager.shieldedFundFromAssetLock, and sets the success message.
View layer fee unit and color
packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift
Estimated fee row now uses a SendFlow-derived unit via feeUnit(for:) (where coreToShielded treats fees as credits), and flowColor(for:) explicitly maps coreToShielded to purple.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related PRs

  • dashpay/platform#3845: Adds Rust FFI and Swift wrapper for shielded fee estimation, directly supporting the fee kind/unit selection wired in this PR's estimateFee(for:) and view layer updates.

Suggested reviewers

  • shumkov
  • llbartekll
  • ZocoLini

Poem

🐰 A Core-to-Orchard path now shines so bright,
From L1 locked funds to shielded light,
Account selection, fee estimation true,
The coreToShielded flow sees SendViewModel through! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: enabling sends from Core balance to shielded recipients, which is the primary objective of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/mystifying-chaum-4da4c7

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@thepastaclaw

thepastaclaw commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit aebecb7)

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Scoped, well-commented change wiring a new Core-to-shielded source through the existing Type 18 ShieldFromAssetLock API. Two real issues: the fee estimate for the new flow omits the asset_lock_base_cost component (UI under-reports the fee), and canSend/availableSources operate on the cross-account Core total while execution funds from a single BIP44 standard account — an amount within the displayed total but exceeding the chosen account's balance kicks off the ~30s lock+proof pipeline only to fail late.

🟡 2 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift`:
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift:262-267: Core-to-shielded fee estimate omits asset_lock_base_cost
  Mapping `.coreToShielded` to `ShieldedFeeKind.transfer` dispatches the FFI to `compute_minimum_shielded_fee` only. The actual Type 18 pool fee that Rust carves (rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs:478) and drive-abci enforces is `compute_minimum_shielded_fee(num_actions) + asset_lock_base_cost`. The inline comment claiming Type 18 'carves the same compute_minimum_shielded_fee base' is misleading — it carves base + asset_lock_base_cost. The Send screen therefore displays a fee lower than the network will deduct from the lock value, and the implied recipient net (`lock_value − pool_fee`) is off by the same amount. The fix likely requires a new `ShieldedFeeKind` variant on the FFI side (e.g. `.shieldFromAssetLock`) since the existing three kinds don't include the asset-lock base cost; until that exists, at minimum drop the misleading comment and flag the displayed fee as a base-only estimate so users aren't led to expect the recipient to receive `amount − base_fee`.
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift:171-175: canSend / availableSources don't gate on the single funding account that executeSend will use
  Two related mismatches against execution reality:

  1. `canSend` for `.coreToShielded` only checks `amountDuffs >= minShieldFromCoreDuffs`. But `executeSend` (lines 567–573) funds from a single account — the largest confirmed balance among BIP44 standard accounts (`typeTag == 0 && standardTag == 0`). A user whose Core funds are split across multiple BIP44 accounts can pass `canSend`, kick off the ~30s lock-build + Halo 2 proof pipeline, and only fail at the asset-lock submission step with 'No spendable Core account to fund the shield' or a backend error — exactly the late-failure scenario the `minShieldFromCoreDuffs` floor exists to prevent. The dedicated `ShieldedFundFromAssetLockView` already gates submit on `selectedCoreAccountBalanceDuffs >= amount` for this reason.

  2. `availableSources` (and `coreBalanceSnapshot` in SendTransactionView, which sums every entry from `accountBalances`) makes `.core` selectable for Orchard recipients whenever any account has balance, including non-BIP44 / non-standard accounts that the execute-time filter excludes. A wallet whose Core funds live entirely in other `typeTag`/`standardTag` combinations would see the Core source available and then fail with 'No spendable Core account to fund the shield' after the user commits.

  Fix: expose the chosen funding account's confirmed balance to the view model and gate `canSend` on `amountDuffs <= funding.confirmed`; mirror the same BIP44-standard predicate in `coreBalanceSnapshot`/`availableSources` so what the UI offers matches what execute time can actually spend, or at minimum do a synchronous pre-check at the top of the `.coreToShielded` case that fails fast before the async asset-lock pipeline starts.

Comment on lines +262 to 267
case .shieldedToShielded, .platformToShielded, .coreToShielded:
// Type 18 ShieldFromAssetLock carves the same
// `compute_minimum_shielded_fee` base as the Shield/transfer
// path; its fee is denominated in credits (deducted from the
// locked value), so the view renders it with the credits unit.
kind = .transfer

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Core-to-shielded fee estimate omits asset_lock_base_cost

Mapping .coreToShielded to ShieldedFeeKind.transfer dispatches the FFI to compute_minimum_shielded_fee only. The actual Type 18 pool fee that Rust carves (rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs:478) and drive-abci enforces is compute_minimum_shielded_fee(num_actions) + asset_lock_base_cost. The inline comment claiming Type 18 'carves the same compute_minimum_shielded_fee base' is misleading — it carves base + asset_lock_base_cost. The Send screen therefore displays a fee lower than the network will deduct from the lock value, and the implied recipient net (lock_value − pool_fee) is off by the same amount. The fix likely requires a new ShieldedFeeKind variant on the FFI side (e.g. .shieldFromAssetLock) since the existing three kinds don't include the asset-lock base cost; until that exists, at minimum drop the misleading comment and flag the displayed fee as a base-only estimate so users aren't led to expect the recipient to receive amount − base_fee.

source: ['codex']

Comment on lines +171 to +175
case .coreToShielded:
// Funded by an L1 asset lock denominated in duffs; gate on
// the lock floor so a doomed (sub-fee) amount can't kick off
// the lock-build + proof pipeline.
return (amountDuffs ?? 0) >= Self.minShieldFromCoreDuffs

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: canSend / availableSources don't gate on the single funding account that executeSend will use

Two related mismatches against execution reality:

  1. canSend for .coreToShielded only checks amountDuffs >= minShieldFromCoreDuffs. But executeSend (lines 567–573) funds from a single account — the largest confirmed balance among BIP44 standard accounts (typeTag == 0 && standardTag == 0). A user whose Core funds are split across multiple BIP44 accounts can pass canSend, kick off the ~30s lock-build + Halo 2 proof pipeline, and only fail at the asset-lock submission step with 'No spendable Core account to fund the shield' or a backend error — exactly the late-failure scenario the minShieldFromCoreDuffs floor exists to prevent. The dedicated ShieldedFundFromAssetLockView already gates submit on selectedCoreAccountBalanceDuffs >= amount for this reason.

  2. availableSources (and coreBalanceSnapshot in SendTransactionView, which sums every entry from accountBalances) makes .core selectable for Orchard recipients whenever any account has balance, including non-BIP44 / non-standard accounts that the execute-time filter excludes. A wallet whose Core funds live entirely in other typeTag/standardTag combinations would see the Core source available and then fail with 'No spendable Core account to fund the shield' after the user commits.

Fix: expose the chosen funding account's confirmed balance to the view model and gate canSend on amountDuffs <= funding.confirmed; mirror the same BIP44-standard predicate in coreBalanceSnapshot/availableSources so what the UI offers matches what execute time can actually spend, or at minimum do a synchronous pre-check at the top of the .coreToShielded case that fails fast before the async asset-lock pipeline starts.

source: ['claude', 'codex']

@QuantumExplorer
QuantumExplorer merged commit 64bd057 into v3.1-dev Jun 13, 2026
19 of 20 checks passed
@QuantumExplorer
QuantumExplorer deleted the claude/mystifying-chaum-4da4c7 branch June 13, 2026 20:57
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.

2 participants