Skip to content

feat(swift-example-app): multi-recipient Core L1 send (CORE-10)#3904

Merged
QuantumExplorer merged 2 commits into
v3.1-devfrom
claude/stupefied-joliot-b5d4a5
Jun 15, 2026
Merged

feat(swift-example-app): multi-recipient Core L1 send (CORE-10)#3904
QuantumExplorer merged 2 commits into
v3.1-devfrom
claude/stupefied-joliot-b5d4a5

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Jun 15, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Test-plan item CORE-10 (Multi-recipient Core send) — tracked in #3897 — was marked 🔌 "SDK-only, no UI". The Core→Core send flow only let you pay a single recipient, even though the FFI core_wallet_send_to_addresses (via the ManagedCoreWallet.sendToAddresses(recipients:) wrapper) already takes parallel address/amount arrays, and coin selection + multi-output tx construction already happen entirely on the Rust side. This surfaces that capability in the UI.

What was done?

A UI + marshalling-only change — no new FFI, no logic moved into Swift (per packages/swift-sdk/CLAUDE.md). Swift just collects N (address, amount) pairs and passes them through the existing wrapper.

SendViewModel

  • Added a CoreRecipient row value type + @Published additionalCoreRecipients, with addCoreRecipient / removeCoreRecipient.
  • coreRecipients builds the ordered [(address, amountDuffs)] list (primary row + extras) and returns nil unless every row is a valid on-network Core address with amount > 0 — so the batch is validated atomically. coreSendTotalDuffs sums it for the summary.
  • canSend (.coreToCore) and the .coreToCore branch of executeSend now build from coreRecipients and pass the array straight to sendToAddresses(recipients:). The success message reflects the output count ("Payment sent to N recipients"). Every other flow (shielded / platform / coreToShielded …) is byte-for-byte unchanged.

SendTransactionView

  • An "Additional Recipients" section with an "Add recipient" button, shown only for the coreToCore flow; each extra row has its own address + amount fields, a remove control, and a per-row Core-address badge / inline "Not a Core address on this network" hint.
  • An "Outputs" summary (each output + Total + Fee) rendered when there is more than one recipient. The single-recipient case is unchanged and remains the simple default.

TEST_PLAN.md

  • CORE-10 flipped 🔌 → ✅ with the entry point; removed from the Appendix B "SDK-only, no UI" list.

How Has This Been Tested?

Built with packages/swift-sdk/build_ios.sh --target sim + xcodebuild (BUILD SUCCEEDED), then driven end-to-end on a booted iPhone 17 / testnet simulator (simulator-control skill / idb):

  • Sent 0.001 + 0.002 DASH from TestnetWallet to two external addresses (a second on-device wallet) in one transaction. Success alert read "Payment sent to 2 recipients".
  • On-chain (insight.testnet): txid 30010050…17f840fc, block 1,495,895, txlock: true, 3 vouts0.001 → recipient A, 0.002 → recipient B, 0.811… → change. One input → one tx.
  • Sender confirmed balance dropped 1.81473134 → 1.81172874 DASH = sum (300,000) + fee (260 duffs).
  • Both recipients credited after sync (recipient wallet: 2 UTXOs = 300,000 duffs).
  • Incidental: per-row validation correctly flagged a malformed address and disabled Send; Remove recipient works.

Full write-up posted to #3897.

Breaking Changes

None. UI-only addition; the FFI/wrapper signature was already array-based and is unchanged.

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

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Enabled multi-recipient “Core to Core” sends in a single transaction.
    • Added an “Additional Recipients” section to add/remove extra recipients.
    • Added an “Outputs” summary showing each address/amount, plus total and estimated fee when available.
  • Bug Fixes

    • Improved send eligibility for multi-recipient batches to require all entries be valid.
  • Tests

    • Added offline test coverage for multi-recipient validation, ordering, overflow handling, and removal behavior.
  • Documentation

    • Updated the Core/Wallet catalog and action availability notes to reflect the new multi-recipient send.

The Core->Core send flow only let you pay a single recipient, even
though `core_wallet_send_to_addresses` (via the `sendToAddresses`
wrapper) already takes parallel address/amount arrays and the Rust
side already does coin selection + multi-output tx construction. This
surfaces that capability in the UI — a UI + marshalling change only,
no new FFI.

SendViewModel:
- Add a `CoreRecipient` row type + `additionalCoreRecipients`, with
  `add/removeCoreRecipient`.
- `coreRecipients` builds the ordered `[(address, amountDuffs)]` list
  (primary row + extras) and returns nil unless EVERY row is a valid
  on-network Core address with amount > 0, so the batch is gated
  atomically. `coreSendTotalDuffs` sums it for the summary.
- `canSend` (.coreToCore) and the `.coreToCore` send path now build
  from `coreRecipients` and pass the array straight to the existing
  `sendToAddresses(recipients:)`. Success message reflects the output
  count. All other flows untouched.

SendTransactionView:
- "Additional Recipients" section with an "Add recipient" button,
  shown only for the coreToCore flow; per-row address/amount fields,
  a remove control, and a per-row Core-address badge / inline hint.
- "Outputs" summary (per-output + Total + Fee) when there is more than
  one recipient. Single-recipient remains the simple default.

TEST_PLAN: CORE-10 flipped from no-UI to implemented with the entry
point; removed from the SDK-only appendix.

Verified on the testnet simulator: a 0.001 + 0.002 DASH send to two
addresses produced one tx with three vouts (two recipients + change),
txlock true at block 1,495,895; sender balance dropped by sum + fee
and both recipients were credited after sync.

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

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 783f8078-a3da-4e68-b8fa-3fce6bdc7b9d

📥 Commits

Reviewing files that changed from the base of the PR and between e7cc899 and f5ac4d6.

📒 Files selected for processing (2)
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift

📝 Walkthrough

Walkthrough

Adds multi-recipient support to the coreToCore send flow in the Swift Example App. SendViewModel gains a CoreRecipient struct, an additionalCoreRecipients array, batch validation logic, and a rewritten send execution path. SendTransactionView adds corresponding UI sections and a CoreRecipientRow subview. TEST_PLAN.md marks CORE-10 as implemented, and a comprehensive test suite validates the multi-recipient behavior.

Changes

Core Multi-Recipient Send

Layer / File(s) Summary
CoreRecipient model, validation, and canSend
...Core/ViewModels/SendViewModel.swift, TEST_PLAN.md
Defines CoreRecipient struct and additionalCoreRecipients @Published array; implements addCoreRecipient/removeCoreRecipient helpers, atomic coreRecipients computed list (returns nil if any row is invalid), coreSendTotalDuffs sum, and a private Core-address validator; updates canSend for .coreToCore to require coreRecipients != nil; marks CORE-10 as implemented and removes it from SDK-only appendix.
Multi-recipient send execution
...Core/ViewModels/SendViewModel.swift
Replaces the single-recipient .coreToCore send path with one that builds the recipient list from coreRecipients, calls core.sendToAddresses(recipients:), and emits a recipient-count-aware success message.
UI form sections, outputs summary, and CoreRecipientRow
...Core/Views/SendTransactionView.swift
Conditionally renders an "Additional Recipients" section and a multi-output "Outputs" summary (with total and estimated fee); introduces private helpers additionalRecipientsSection, coreOutputsSummarySection, abbreviatedAddress; adds CoreRecipientRow with editable address/amount bindings, a remove button, and an inline Core-address validity hint.
Core multi-recipient test suite
SwiftExampleAppTests/SendViewModelCoreRecipientsTests.swift
Introduces SendViewModelCoreRecipientsTests with offline behavioral validation: fixture routing, primary-only single-recipient behavior, primary + extra recipients ordering and totals, batch invalidation on invalid address/amount/underflow/overflow, and removal returning to primary-only state with updated totals and canSend.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Suggested reviewers

  • shumkov
  • llbartekll
  • ZocoLini

Poem

🐇 Hop, hop — not one address but many today,
A list of recipients lined up in array.
Each duff is validated, each row checked with care,
The batch is assembled with code debonair.
The bunny sends coins through the chain far and wide,
Multi-output and proud with the test plan inside! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main change: implementing multi-recipient Core L1 send capability (CORE-10) in the Swift example app, directly corresponding to the primary objective of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.
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.

✏️ 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/stupefied-joliot-b5d4a5

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 15, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit f5ac4d6)

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift`:
- Around line 216-218: The coreSendTotalDuffs property uses unchecked addition
with the + operator in the reduce closure when summing amountDuffs across
recipients. Replace the unchecked addition with a safe addition approach that
detects and handles overflow. Use Swift's safe arithmetic methods like
addingReportingOverflow() in the reduce closure to safely accumulate the total,
and either log/handle overflow errors or enforce a maximum limit to prevent
silent wrapping or crashes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 100117e8-404f-4de5-854b-2228694f3f95

📥 Commits

Reviewing files that changed from the base of the PR and between e2039e5 and e7cc899.

📒 Files selected for processing (3)
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift
  • packages/swift-sdk/SwiftExampleApp/TEST_PLAN.md

@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

UI-only change that surfaces an existing Rust multi-output Core wallet API. The new Swift marshalling correctly delegates coin selection and tx construction to Rust, and gating reduces to the prior primary-only check when no extras are present. One real but edge-case UInt64 overflow in the new summary sum, plus a test-coverage gap and a minor duplicate-address UX nit.

🟡 2 suggestion(s) | 💬 1 nitpick(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:216-218: UInt64 sum in `coreSendTotalDuffs` can trap on overflow with pathologically large inputs
  `coreRecipients` validates each row independently as a UInt64 with no aggregate check, and `coreSendTotalDuffs` reduces with unchecked `+`. Two rows whose amounts are individually valid but whose sum exceeds `UInt64.max` would trap Swift in the form view as soon as the multi-output summary renders, before Rust ever sees the batch. Realistic Dash amounts can't get near that bound (max supply is ~2.1e15 duffs vs UInt64.max ~1.8e19), so this is an edge case driven only by deliberate huge input, but it is reachable from user input and easily mitigated by switching to `addingReportingOverflow` (or summing as `UInt64?` with `nil` on overflow) in `coreRecipients`/`coreSendTotalDuffs` and treating overflow as an invalid batch. Downgraded from the agent's blocking severity because the trap is not reachable for any plausible wallet amount and this is the example app, not consensus code.
- [SUGGESTION] packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift:185-218: Add unit coverage for the new `coreRecipients` marshalling and gating
  `coreRecipients` is now the single source of truth for both `canSend` gating and the ordered array handed to `sendToAddresses`, and `coreSendTotalDuffs` drives the summary total. The PR documents only manual happy-path simulator runs. A small set of view-model tests — primary-plus-extras ordering, primary-only reduction, an invalid extra address or sub-unit amount returning nil, row removal, and (if you adopt the overflow guard) overflow returning nil — would lock down the new behavior cheaply and prevent regressions reaching the FFI boundary.

Comment on lines +185 to +209
var coreRecipients: [(address: String, amountDuffs: UInt64)]? {
var outputs: [(address: String, amountDuffs: UInt64)] = []

// Primary row. The trim mirrors the existing `.coreToCore` send
// case so the marshalled address matches what the badge validated.
let primaryAddress = recipientAddress
.trimmingCharacters(in: .whitespacesAndNewlines)
guard isCoreAddress(primaryAddress),
let primaryDuffs = amountDuffs, primaryDuffs > 0
else { return nil }
outputs.append((address: primaryAddress, amountDuffs: primaryDuffs))

// Extra rows, in order.
for row in additionalCoreRecipients {
let address = row.address
.trimmingCharacters(in: .whitespacesAndNewlines)
guard isCoreAddress(address),
let rowDuffs = duffs(forRecipientAmount: row.amountString),
rowDuffs > 0
else { return nil }
outputs.append((address: address, amountDuffs: rowDuffs))
}

return outputs
}

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.

💬 Nitpick: Duplicate Core addresses across rows are accepted without warning

Pasting the same Core address into the primary row and an extra row (or into two extra rows) is accepted, and Rust will happily build a tx with two outputs paying the same address. The success message "Payment sent to \(recipients.count) recipients" then counts outputs, not distinct addresses, so a single distinct destination paid twice is reported as 2 recipients. Whether to dedupe, warn, or just rephrase the success message is a product call; flagging because the new multi-output surface makes this user-visible for the first time.

source: ['claude']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Leaving the behavior as-is: paying the same Core address across two outputs is legitimate (Rust builds two real outputs), so the count intentionally reflects outputs, not distinct addresses. Deduping would block a valid case; rewording "recipients"→"outputs" reads worse in the common single-output path. Flagging for maintainers as a product call rather than acting unilaterally.

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.

Resolved in f5ac4d6Duplicate Core addresses across rows are accepted without warning no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

@github-actions

Copy link
Copy Markdown
Contributor

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "ef3c2a743c5debededa318d575e04ee3e1d114a2878e4b118090bd0f9a11d9e8"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

…erflow + add tests

Review follow-up on CORE-10 (#3904).

- `coreSendTotalDuffs` summed recipient amounts with an unchecked `+`,
  which could trap (debug) or wrap (release) if two individually-valid
  UInt64 amounts summed past UInt64.max — reachable from raw user input
  even though real Dash amounts never approach that bound.
- Refactor the duplicated iteration into one private `coreRecipientPlan`
  that builds the ordered output list AND its running total in a single
  pass, accumulating with `addingReportingOverflow`. Aggregate overflow
  now invalidates the whole batch (returns nil) — same treatment as a
  bad address or zero amount — so Send is disabled and the summary shows
  no total rather than trapping. `coreRecipients` and `coreSendTotalDuffs`
  both derive from it, so the gated/sent list and displayed total can
  never disagree. No bare `+` remains; behavior is identical for all
  valid inputs.
- Add `SendViewModelCoreRecipientsTests` (9 tests): primary-only,
  primary+extras ordering, invalid extra address → nil, sub-unit / empty
  amount → nil, aggregate overflow → nil (asserts a single huge row is
  still valid first, proving it's the aggregate path), and row removal.

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

Copy link
Copy Markdown
Member Author

CI note — the "Swift SDK build + tests (warnings as errors)" failure is not from this change. The build succeeded and every test in the new SendViewModelCoreRecipientsTests passed (9/9). The only failing case is the pre-existing SDKMethodTests.testSimpleIdentityFetch(), which opens a live SDK(network: .testnet) and fetches a hardcoded testnet identity — a network-dependent test in a file this PR does not touch, flaky when the CI runner lacks testnet/DAPI connectivity. Re-ran the failed job to clear the transient failure.

@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

Cumulative + incremental verification at f5ac4d6 confirms both agents' conclusion: no in-scope defects. The two substantive prior findings from e7cc899 are FIXED — UInt64 aggregate overflow is guarded by addingReportingOverflow in coreRecipientPlan (SendViewModel.swift:198-234), and view-model coverage is supplied by SendViewModelCoreRecipientsTests.swift covering primary-only, ordering, invalid address, sub-unit/empty amount, aggregate overflow (using 1e11 DASH rows that overflow only on sum), and remove-and-return-to-primary. The duplicate-address nitpick is intentionally deferred (the ordering test explicitly asserts duplicate Core outputs round-trip as N distinct outputs). PR scope is Swift UI + view-model marshalling to the pre-existing sendToAddresses wrapper, so no Rust/FFI changes are required. CodeRabbit reported zero inline findings and supplied no comment IDs.

@QuantumExplorer

Copy link
Copy Markdown
Member Author

CI update — the re-run reproduced the same single failure, so this is persistent (environment/data), not a transient flake:

  • Only failing case: SDKMethodTests.testSimpleIdentityFetch() (in an untouched file), which opens a live SDK(network: .testnet) and identityGets a hardcoded testnet identity, rethrowing on any error. It fails whenever the CI runner cannot reach testnet/DAPI (or that identity is not resolvable) — independent of this PR.
  • Everything attributable to this change is green: build succeeds (warnings-as-errors passes) and all 9 SendViewModelCoreRecipientsTests pass.

Leaving the network test untouched rather than masking an unrelated, maintainer-owned test inside a CORE-10 feature PR. Flagging for a maintainer decision: admin-merge, or quarantine/skip testSimpleIdentityFetch from this build+test job separately (it really should not gate on live testnet connectivity).

@QuantumExplorer
QuantumExplorer merged commit 45fdf33 into v3.1-dev Jun 15, 2026
27 of 29 checks passed
@QuantumExplorer
QuantumExplorer deleted the claude/stupefied-joliot-b5d4a5 branch June 15, 2026 18:06
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