feat: encrypted account send via ZK proofs#561
Conversation
Make encrypted (wormhole) accounts spendable: one HD sequence of wormhole addresses acts as a single UTXO pool with coin selection, per-send fresh change addresses, rotating receive addresses and gap-limit recovery. Generalizes WormholeClaimService into WormholeSendService (claim stays as a wrapper) and packages the flow as an EncryptedSendStrategy.
Fix many user interface items Fix logout bugs Fix stale cache bugs
n13
left a comment
There was a problem hiding this comment.
Verdict: Request changes
I found two blocking correctness issues in the encrypted-send lifecycle:
-
Preserve pending-spend state across refreshes (
mobile-app/lib/shared/utils/polling_refresh_scope.dart:75,quantus_sdk/lib/src/services/encrypted_account_service.dart:92-100). After pool acceptance,onBatchSubmittedpersists nullifiers/change specifically to bridge the period before the indexer reports them. But pull-to-refresh and app-resume callrefreshActiveAccountBalance, which callsdiscardCachedState()and deletes that same state file. If the indexer is still behind, the nextload()exposes the just-spent inputs as spendable again and can also roll back/reusenextIndex. Please clear only the chain-derived transfer/nullifier caches here and retain pending spends plus the monotonic address index until normal reconciliation/expiry. -
Keep cancellation scoped to the operation that was cancelled (
quantus_sdk/lib/src/services/wormhole_send_service.dart:163-168).Future.anyreturnsClaimCancelledwhile the proof flow keeps running, and its later_checkCancelled()calls read the mutable service-level_cancelCompleter. If the user closes the cancelled screen and starts another send before the orphaned FFI proof finishes, the second call replaces_cancelCompleter; the first flow now sees not-cancelled and can aggregate/submit after the UI already reported cancellation. Please capture/pass an operation-local cancellation token, or serialize operations, so an abandoned flow remains cancelled permanently.
Validation at head c650e7b88c1c967bebf61833277bbc72153265d2:
- SDK
flutter analyze --no-pub: clean - Mobile
flutter analyze: two non-blockingprefer_const_constructorsinfos - Focused wormhole coin-selection tests: 6/6 passed
- Rust library tests: 13/13 passed
- Full SDK suite: 97 tests passed; 3 setup failures because the local Flutter runner could not load
rust_lib_quantus_wallet.framework - Focused mobile tests could not build because the isolated worktree lacks the ignored
.envasset
Code Review — feat: encrypted account send via ZK proofsVerdict: Request Changes This is a strong PR with clean architecture — the Blocking Issues1. Cancellation token is service-level mutable state — race on overlapping operations
Future<ClaimResult> _withCancellation(Future<ClaimResult> Function() flow) async {
final cancelCompleter = Completer<void>();
_cancelCompleter = cancelCompleter; // ← service-level
...
return await Future.any([flow(), cancelGuard]);
}
Fix: Pass an operation-scoped cancellation token (e.g. a per-call 2.
|
n13
left a comment
There was a problem hiding this comment.
Verdict: Request changes
The pending-spend refresh issue from the previous review is fixed: refresh now preserves the persisted pending spends and monotonic nextIndex while clearing address-specific chain caches.
One blocking correctness issue remains:
- Cancellation still is not scoped to the cancelled operation (
quantus_sdk/lib/src/services/wormhole_send_service.dart:106-113,quantus_sdk/lib/src/services/wormhole_send_service.dart:163-182,quantus_sdk/lib/src/services/wormhole_send_service.dart:193-195).cancel()now awaits_operationDone, but_operationDoneis completed by_withCancellation'sfinally. Because_withCancellationawaitsFuture.any, thatfinallyruns immediately when the cancellation guard wins; it does not wait for the orphanedflow()/ FFI proof work to finish. The UI can therefore return fromcancel(), allow a new send, and replace_cancelCompleter. The old flow's later_checkCancelled()and UTXOisCancelled: () => _cancelledcalls then read the new operation's incomplete shared completer and can continue to aggregate or submit._opIdcurrently only guards cleanup and is never consulted by_checkCancelled, so it does not prevent this. Please give each flow an immutable operation-local token and pass/check that token throughout the flow (including the UTXO callback), or truly serialize operations by retaining/awaiting the underlying flow future before allowing another operation. Add a regression test that cancels during an unresolved proof, starts a second operation, completes the first proof, and verifies the first operation never reaches aggregation/submission.
Validation at head 46659e348be1767ee287876f7e30fbac6dbb3027:
- SDK
flutter analyze --no-pub: clean - Focused wormhole coin-selection tests: 6/6 passed
- GitHub reports no checks for this branch
n13
left a comment
There was a problem hiding this comment.
Review by Grok 4.5
Summary
This PR wires encrypted-account (wormhole) sends end-to-end: coin selection with volume fee / change, dual-exit ZK proofs via FRB, batch submit, Riverpod balance state, and a shared proving progress UI. The core spend math and FFI surface look coherent and are covered by solid coin-selection unit tests. Dominant risks are lifecycle races (logout / dispose while proving), UX promises that are not implemented (notifications, full private activity), and stale plans / nextIndex allocation that are not revalidated at submit time.
Verdict
REQUEST_CHANGES (posted as COMMENT because GitHub disallows requesting changes on your own PR)
Detail
REQUEST_CHANGES
Block on the logout/dispose race that can rewrite encrypted-account disk state after session wipe, and on the progress footer promising notifications that are never scheduled. Other items can follow as follow-ups but should not ship silently.
Issue counts by severity
- bugs: 4
- suggestions: 4
- nits: 2
| _startSend(); | ||
| } | ||
|
|
||
| Future<void> _startSend() async { |
There was a problem hiding this comment.
[bug] _startSend captures EncryptedAccountService and runs proving/submit with no dispose cancellation. Logout clears wormhole caches + encrypted_account_w*.json (SubstrateService.logout / EncryptedAccountService.clearAllPersistedState) and only invalidates Riverpod providers (logout_service.dart), without calling cancel(). The in-flight send keeps the old service alive; onBatchSubmitted can recreate encrypted_account_w{N}.json after wipe, leaking pending nullifiers/change into the next session at the same wallet index. Secrets/mnemonic-derived material already held in memory also continue to be used post-logout.
Suggestion: On logout (and preferably in EncryptedSendProgressScreen.dispose), await encryptedAccountService.cancel() for every live wallet (or a process-wide send cancel registry) before clearing disk; make onBatchSubmitted / _mutateState no-op if a session generation token has changed; optionally refuse to persist after logout.
There was a problem hiding this comment.
users are supposed to either wait for the send to go through or cancel the send
Action items:
- Make sure users are kept on the send page until complete or canceled
- if canceled, make sure we await the successful cancellation
| ), | ||
| const SizedBox(height: 32), | ||
| Text( | ||
| _canceling ? l10n.commonCanceling : l10n.encryptedSendProgressFooter, |
There was a problem hiding this comment.
[bug] Footer copy (encryptedSendProgressFooter) tells the user they can leave the app and will be notified when the private send completes. This flow never creates a PendingTransactionEvent, never starts pending-tx polling, and never posts a local/remote notification on success/failure. Proving also runs only while the isolate lives—OS kill mid-prove drops unsubmitted work with no recovery UX beyond a later balance refresh. PopScope(canPop: !_running) further prevents leaving the route while running (only backgrounding the app).
Suggestion: Either implement pending-state + notification on pool acceptance / finality (mirroring regular sends), or change the copy to match reality (must keep app open; no notification). Prefer recording submitted batch hashes and notifying when remaining work finishes or fails.
There was a problem hiding this comment.
change the footer text, they cannot leave the app
| active.account.accountId, | ||
| colors, | ||
| l10n, | ||
| isPrivate: isEncryptedAccount(active.account), |
There was a problem hiding this comment.
[bug] Private activity labels (isPrivate: isEncryptedAccount(...)) are applied to whatever GraphQL history is already loaded for active.account.accountId. Encrypted account identity is wormhole index 0 only (AccountsService.createEncryptedAccount), while receive/change rotate across HD indices. Deposits to the receive address and most private spends will not appear under index 0’s transfer history, so “Private Sent/Received” UI is largely cosmetic and activity remains empty or wrong for real encrypted usage.
Suggestion: Build encrypted activity from wormhole transfers / pending spends across discovered HD addresses (or a dedicated indexer view), not transparent accountId history alone. Until then, hide the activity list or show an explicit empty state explaining private activity is not listed.
There was a problem hiding this comment.
We can show total deposits and total spends, or else just show a private activity label.
Solution: When we have established total balance, in that process we should have all data to show total received and total sent, so lets only show two items, total received, and total sent
Total received is across all wormhole addresses, total spent is the total of all nullifers, we already gather this information when calculating balance, so should be easy to put there, no additional calls needed.
| /// so the next [load] re-queries from chain. Preserves pending-spend records | ||
| /// and nextIndex — those are only pruned by [load]'s reconciliation or by | ||
| /// the 1-hour expiry, never by a refresh. | ||
| Future<void> discardCachedState() async { |
There was a problem hiding this comment.
[suggestion] discardCachedState only clears caches for addresses already present in _keyPairs. After provider recreation (logout/login, process restart, first encryptedAccountServiceProvider read), _keyPairs is empty, so pull-to-refresh / refreshActiveAccountBalance becomes a no-op discard and relies entirely on existing on-disk transfer/nullifier caches. That is usually OK, but it cannot force a full cache drop when the in-memory key cache is cold.
Suggestion: Persist known address indices (or scan nextIndex + pending change addresses from _FileState) and clear caches for those even when _keyPairs is empty; or clear all wormhole caches for the wallet on force reload.
There was a problem hiding this comment.
force reload should clear all caches, that's the point
There was a problem hiding this comment.
make sure force reload clears all caches
| bool get showPrivateSendNotice => true; | ||
|
|
||
| @override | ||
| String? sourceAccountId(WidgetRef ref) => account.accountId; |
There was a problem hiding this comment.
[suggestion] Self-send guard uses account.accountId (wormhole index 0 only). Users can still target their current receive address (nextIndex) or other HD wormhole addresses. That is wasteful (volume fee) and reduces privacy by linking addresses; depending on chain exit semantics it may also be surprising.
Suggestion: Treat all derived wormhole addresses for the wallet (at least 0..nextIndex plus pending change addresses) as self-send destinations, or clearly allow “move to own address” as an explicit consolidation action.
There was a problem hiding this comment.
This is true. Treat all derived wormhole addresses for the wallet as self address.
| BigInt quan(String v) => wormholePlanckFromScaled((double.parse(v) * 100).round()); | ||
|
|
||
| void main() { | ||
| group('selectWormholeInputs', () { |
There was a problem hiding this comment.
[suggestion] Test coverage stops at pure coin selection. There are no tests for pending-spend reconciliation, onBatchSubmitted partial-batch persistence, dual-exit WormholeLeafSpend construction, logout/clear races, or encrypted fee blockers in the UI strategy.
Suggestion: Add unit tests for EncryptedAccountService state machine (pending prune/expiry, change index bump) and for WormholeSendService.sendSpends batch callback ordering; widget/strategy tests for quantization / insufficient / below-minimum blockers.
There was a problem hiding this comment.
ok will add missing tests
| WormholeProgressSteps( | ||
| steps: [ | ||
| (1, l10n.encryptedSendStepPreparing), | ||
| (2, l10n.encryptedSendStepGathering), |
There was a problem hiding this comment.
[nit] Progress UI always lists steps 2–3 (“Gathering funds”, “Securing transaction”), but sendSpends only runs step 1 then jumps to 4–6. Those steps auto-complete when step 4 starts (WormholeProgressSteps via _maxStartedStep), which is slightly misleading during encrypted send (fine for redeem/claim).
Suggestion: Drive the step list from the operation type (claim vs send) or omit discovery steps for sendSpends.
There was a problem hiding this comment.
make sure our progress UX actually shows what we're doing!
n13
left a comment
There was a problem hiding this comment.
Review at head 2827618 — updated
Previous blocking items (pending-spend refresh, footer text, progress steps) are fixed. One remaining correctness issue found and patched in this review.
Fix applied: cancellation state transition race
_startSend() was catching ClaimCancelled and setting _running = false immediately when Future.any picked up the cancel guard — but the underlying FFI flow was still running at that point. Since PopScope(canPop: !_running), this allowed the user to navigate away before the operation had truly stopped.
Fix: _startSend() no longer transitions UI state on ClaimCancelled. Instead, _cancel() owns the transition — it awaits cancel() (which blocks until _operationDone resolves, i.e. the FFI work actually finishes), then sets _running = false / _cancelled = true. The screen stays in the "Canceling…" state with canPop: false until the operation has truly stopped.
This eliminates the theoretical race where a second operation could start while the first was still winding down.
Non-blocking observations (no changes made)
-
Self-send guard only checks index 0 —
EncryptedSendStrategy.sourceAccountIdreturnsaccount.accountId(wormhole index 0 only). The user can enter their current receive address (nextIndex) without triggering the self-send warning. Per PR discussion, all derived wormhole addresses for the wallet should be treated as self. -
_readState()outside_stateLock—send()readsnextIndexoutside the lock to allocate the change address. A concurrentonBatchSubmittedcall (from the same send) could bumpnextIndexbetween the read and usage. Low practical risk in single-isolate Dart but would be race-free if routed through_mutateState. -
Indonesian localization strings — Several
encryptedSendStep*entries inapp_localizations_id.dartare still English. Not blocking.
Validation
- SDK
flutter analyze --no-pub: clean - Focused wormhole coin-selection tests: 6/6 passed
- Encrypted account service tests: 3/3 passed
n13
left a comment
There was a problem hiding this comment.
Verdict: Request changes
GitHub will record this as a comment because the authenticated reviewer account owns the PR.
At head 5f61718, the earlier pending-spend refresh, progress-footer, derived-address self-send, encrypted activity summary, and Indonesian localization findings are fixed. The coin-selection and dual-exit wiring look coherent, mobile analysis is clean, the focused SDK suite passes 22/22, and the GitHub Analyze check is green.
I still see two blocking lifecycle issues:
-
Logout does not quiesce encrypted-account work before wiping the session (
mobile-app/lib/services/logout_service.dart:39-58,quantus_sdk/lib/src/services/substrate_service.dart:391-398,quantus_sdk/lib/src/services/encrypted_account_service.dart:137-191, 238-258, 305-313). Invalidating Riverpod providers does not cancel an already-runningload()orsend(). Both retain the old mnemonic-derived material; a delayedload()can write_FileStateafter logout, and a delayed submitted batch can callonBatchSubmittedand recreateencrypted_account_w{N}.jsonafterclearAllPersistedState(). That lets old-wallet state leak into the next session at the same wallet index, and a send already past authentication can continue after logout. Please cancel and await every live encrypted operation before clearing settings/caches, and gate every post-await persistence callback with a session-generation token. Add a delayed-load/delayed-send versus logout regression test. -
Cancellation is still service-global rather than operation-local (
quantus_sdk/lib/src/services/wormhole_send_service.dart:97-112, 169-205, 232-239)._withCancellationcreates a localcancelCompleter, but_checkCancelled()and the UTXO callback read_cancelled, which dereferences the current service-level_cancelCompleter._opIdonly protects cleanup. If a new operation starts on the same service before the old FFI flow reaches a checkpoint, it replaces the completer and the cancelled flow can resume aggregation/submission. Awaitingcancel()in the encrypted progress screen mitigates one caller, but it does not make the shared service safe and the miner/redeem callers do not await cancellation. Thread an immutable per-operation context/token through the flow and havecancel()await the flow it actually cancelled. Add the unresolved-proof / cancel / start-second-op regression test requested in the previous review.
Before merge, I would also address these repository-policy and user-safety items:
EncryptedSendProgressScreenowns the send state machine and launches it frominitState(mobile-app/lib/v2/screens/send/encrypted_send_progress_screen.dart:44-98). Quantus guidance requires state in providers and widgets to be renderers; this lifecycle ownership is also what makes disposal/logout hard to control. Move the operation/controller state into an auto-disposed provider and cancel/await it on disposal/session teardown.- The copy
Activity not visible on chain(mobile-app/lib/l10n/app_en.arb:2810) overstates the privacy guarantee. Wormhole hides source linkage, but exits still create observable recipient/amount state changes. Describe the actual guarantee instead of claiming the activity is absent from chain. - The new progress screen hardcodes colors and text styles and uses
withValues(alpha:)(mobile-app/lib/v2/screens/send/encrypted_send_progress_screen.dart:194-216, 224-239, 285-299). The design-system rules requirecontext.colors.*,context.themeText.*, and.useOpacity().
Validation at 5f617181a34b2da46933c4c461eb82d7e956c827:
- GitHub Analyze: pass
mobile-app:flutter analyze— no issuesquantus_sdk: focused coin-selection + encrypted-account-service tests — 22/22 passed
| return _FileState.fromJson(jsonDecode(await file.readAsString()) as Map<String, dynamic>); | ||
| } | ||
|
|
||
| Future<_FileState> _mutateState(_FileState Function(_FileState) fn) { |
There was a problem hiding this comment.
[bug] Logout still races with in-flight encrypted-account mutations. SubstrateService.logout() calls EncryptedAccountService.clearAllPersistedState() (and wipes mnemonic/settings), but any concurrent _mutateState from load() / send() onBatchSubmitted keeps running on the old EncryptedAccountService instance. That path only does file I/O (no mnemonic check) and can recreate encrypted_account_w{N}.json after the wipe with the previous session’s nextIndex / pendingSpends. A subsequent wallet at the same walletIndex then inherits phantom pending change or a wrong next index. Riverpod invalidation after logout does not stop already-scheduled async work.
Suggestion: Add a generation/session token or disposed flag set by clearAllPersistedState / logout; every _mutateState must re-check it after acquiring _stateLock and before writeAsString. Prefer failing the write over resurrecting cleared state. Optionally cancel() in-flight sends and refuse new load/send after dispose.
| try { | ||
| final cancelGuard = cancelCompleter.future.then<ClaimResult>((_) => throw const ClaimCancelled()); | ||
| return await Future.any([flow, cancelGuard]); | ||
| return await Future.any([flowFuture, cancelGuard]); |
There was a problem hiding this comment.
[bug] Cancellation still fails open around submit. _withCancellation uses Future.any([flowFuture, cancelGuard]), so the caller receives ClaimCancelled as soon as cancel() completes the completer, while flowFuture continues until the next _checkCancelled(). There is no cancel check inside Future.wait proof generation or _submitExtrinsic. A cancel after the pre-submit _checkCancelled() (line 344) still submits the batch and may run onBatchSubmitted. EncryptedSendProgressScreen treats ClaimCancelled as non-success (only invalidates state; _cancel marks the UI cancelled), so the user can see “Send Cancelled” after funds actually left. Late cancel on the final batch is the worst case: full send succeeds, UI says cancelled.
Suggestion: Do not surface cancel to the UI via a racing Future.any alone. Either (a) only check cancel between batches and have cancel() await the true flow result, mapping “cancelled after N batches submitted” to a partial/success outcome, or (b) capture an op-local cancel flag and, when cancel wins the race, still await flowFuture and if txHashes is non-empty report partial/complete success instead of pure cancel. At minimum, re-check cancel immediately before _submitExtrinsic and document that in-flight submit cannot be aborted.
| required ClaimProgressCallback onProgress, | ||
| String? rpcUrl, | ||
| }) async { | ||
| final changeIndex = (await _readState()).nextIndex; |
There was a problem hiding this comment.
[suggestion] Change-address allocation still does final changeIndex = (await _readState()).nextIndex outside _stateLock. Concurrent _mutateState writers (send batch persistence vs load() reconciliation after pull-to-refresh / provider invalidation) can interleave with this unlocked read. Writes are not atomic (direct writeAsString), so unlocked _readState can also observe a torn JSON file and fail the send. Index allocation is not reserved until a change-bearing batch’s onBatchSubmitted runs, so two overlapping sends could also pick the same change index.
Suggestion: Allocate and reserve changeIndex inside _mutateState at the start of send (bump or mark reserved under the lock). Route all state reads that inform writes through the lock; consider write-temp-then-rename for the state file.
| bool get showPrivateSendNotice => true; | ||
|
|
||
| @override | ||
| String? sourceAccountId(WidgetRef ref) => account.accountId; |
There was a problem hiding this comment.
[suggestion] Self-send protection still only compares the recipient to account.accountId, which for encrypted accounts is the wormhole address at HD index 0 (AccountsService.createEncryptedAccount). Deposits/change use later indices shown on Receive (receiveKeyPair() → nextIndex). Pasting your current receive address or an older change address is allowed, burning the privacy fee for a self-transfer with no warning.
Suggestion: For encrypted strategy, treat any derived wormhole address in 0..nextIndex (or known _keyPairs / discovered indices) as self-send, or resolve the recipient against the wallet’s wormhole address set before enabling Continue.
| required bool isPayMode, | ||
| }) async { | ||
| final l10n = ref.read(l10nProvider); | ||
| final plan = (fee as EncryptedFee).plan; |
There was a problem hiding this comment.
[suggestion] Submit uses the EncryptedFee.plan frozen at fee-estimate time on the amount screen with no revalidation. UTXO spendability can change before confirm (background invalidation/load, another partial send’s pending spends, long dwell on review). The progress screen then proves a stale plan; failures surface only after expensive proving, and there is no refresh of coin selection immediately before auth/prove.
Suggestion: On confirm (or at the start of EncryptedSendProgressScreen), re-load encrypted state and rebuild/verify the plan (same amount + still-unspent nullifiers). If inputs are gone, fail fast with a clear “balances changed, re-enter amount” error before circuit work.
… send state
Blocking issues:
- WormholeSendService: cancellation is now operation-local. Each claim/send
runs with an immutable WormholeOperation context (cancel token + rpcUrl)
threaded through the whole flow; a second operation on the same service can
no longer detach or un-cancel an in-flight flow. cancel() awaits the
operation it actually cancelled. The caller always receives the flow's true
outcome (no Future.any race): a cancel landing after batches were submitted
returns a partial ClaimResult(cancelled: true) with the real totals instead
of ClaimCancelled, so the UI can never claim "cancelled" while funds moved.
Extra cancel checkpoints before proof generation and immediately before
submission; in-flight submission is documented as non-abortable.
- EncryptedAccountService: logout now quiesces encrypted work before wiping
state. Instances register in a live set; dispose() refuses new work,
cancels/awaits the in-flight send and drains queued mutations; disposeAll()
runs at the start of SubstrateService.logout (before settings/caches are
cleared) and inside clearAllPersistedState(). Every _mutateState re-checks
the disposed gate under the state lock before writing, so a delayed load()/
onBatchSubmitted can never recreate encrypted_account_w{N}.json after
logout. Provider invalidation also disposes via ref.onDispose.
- Change-index allocation now happens under the state lock with an in-memory
reservation set (two overlapping sends get distinct change addresses), all
reads that inform writes go through the lock, and the state file is written
temp-then-rename so readers can never observe a torn file.
Repository-policy / user-safety items:
- The encrypted send state machine moved out of the widget into an
auto-disposed Notifier provider (encryptedSendControllerProvider) that owns
revalidation, proving, submission, cancellation and phase transitions;
EncryptedSendProgressScreen is now a renderer. Disposal cancels the
underlying operation.
- The spend plan is revalidated at the start of the progress flow: encrypted
state is re-loaded and every planned input checked against the fresh
unspent set, failing fast with a dedicated "balance changed" error before
any circuit work.
- privateSendSubtitle no longer overstates the privacy guarantee (source
linkage is hidden; recipient/amount state changes remain observable).
- Progress screen uses context.colors.* / context.themeText.* / useOpacity()
instead of hardcoded colors, inline text styles and withValues(alpha:).
- Redeem screen and miner claim dialog surface partial-cancel results as
cancelled-with-partial-totals instead of full success.
Regression tests: delayed-send/delayed-load vs logout, refuse-after-dispose,
overlapping-send change-index allocation, and unresolved-flow / cancel /
second-operation cancellation semantics. SDK suite 133/133, mobile 218/218,
analyzers clean on quantus_sdk, mobile-app, miner-app.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed the review findings in 29647d5: 1. Logout vs in-flight encrypted work (blocking) — 2. Service-global cancellation (blocking) — cancellation is now operation-local: an immutable 3. Change-index allocation under lock — allocation happens inside the state lock with an in-memory reservation set (overlapping sends get distinct change addresses, covered by a test); all reads that inform writes are routed through the lock; the state file is written temp-then-rename. 4. Widget-owned state machine — the send operation moved into an auto-disposed 5. Stale-plan submission — the plan is revalidated at the start of the progress flow: encrypted state is re-loaded and every planned input checked against the fresh unspent set, failing fast with a dedicated "balance changed, re-enter amount" error before any circuit work. 6. Copy — 7. Design system — the progress screen uses Validation at 29647d5: 🤖 Generated with Claude Code |
|
Ok now ready for review by others |
Summary
EncryptedSendStrategyintegrating with the shared send UI viaSendStrategyabstractionwormhole_claim_service→wormhole_send_serviceto support both claims and sendsTest plan
wormhole_coin_selection_test.dart)