diff --git a/docs/implementation.md b/docs/implementation.md index 7ea7c62..d0335a4 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -968,3 +968,31 @@ remain separate capabilities. Unrelated notifications runtime changes are exclud Delivered in [PR #51](https://git.sendico.io/tech/sxchnge/pulls/51), targeting `identity-verification-48`. Merge order is #47 → #49 → #51; deploy Sendico #2897 before SXCHNGE #51. Both implementation branches are pushed and remain unmerged. + +## Saved payout destinations UX — #54 + +Branch `payout-destinations-54`, based on `local-stand-52`. Implements Settings +creation, details, rename, replacement and removal; explicit payout selection; +optional saving and one-time preparation for crypto, cards and Russian personal +accounts. Shared typed service/model/provider layers separate destination identity, +metadata/execution revisions, ownership, provider bindings, contextual eligibility, +save outcomes and financial outcomes. The customer-scoped demo replays stable +created/duplicate results, preserves immutable quote/history snapshots, rechecks at +acceptance, and demonstrates card-binding restoration without creating a second +card. Bank directory/paste handling follows the reviewed Sendico interface. + +EN/RU and 320 px/1.6× layouts are tested. Formatting (44 changed Dart files), +analysis of app/wallet/analytics, `make test-frontend` (including 89 app tests), and +`flutter build web` with Wasm dry run passed. Browser smoke inspection confirmed +bank form rendering, labeled controls and keyboard focus. Full assistive-technology +and real-provider audits were not performed. No security scans were requested/run. + +Persistence and bindings in this delivery are simulated and reset on reload. +Authenticated live mode retains explicit use of its provisioned payout destination; +new types fail closed until the backend supplies the documented capabilities and +registry/atomic-acceptance contracts. No routing, ownership-verification system, +provider integration or financial engine was added. See +[the specification and integration boundary](ux/payout-destinations.md). + +Stacked merge order: #47 → #49 → #51 → #53 → this contribution. The unrelated +notifications runtime edit is preserved outside task commits. Delivery links follow. diff --git a/docs/ux/payout-destinations.md b/docs/ux/payout-destinations.md new file mode 100644 index 0000000..28dfb5b --- /dev/null +++ b/docs/ux/payout-destinations.md @@ -0,0 +1,252 @@ +# Saved payout destinations + +Task: tech/sxchnge#54. Branch: `payout-destinations-54`. + +## Delivery and integration boundary + +The Flutter app implements Settings management and payout selection/new/one-time +flows with the injectable `DestinationService` in `sxchnge_wallet`. The included +customer-scoped **demo** service is process-local: reload discards its saved +records, attempts, directory fixtures and audit. No real card data may be entered. +The existing live wallet remains connected to provisioned Sendico destinations, +which now require explicit selection. Its existing financial adapter is unchanged. + +This is UX and supporting contract implementation, not a production destination +registry or new payment-provider integration. Live mode never falls back to a +simulated destination store. It exposes no new destination creation capabilities +until the BFF implements the contract below. `BackendWalletService` explicitly +rejects new typed inputs rather than dropping them or using a different destination. +Actual persistent storage, public Sendico capability/directory adapters, secure +card collection, binding recovery and atomic financial acceptance are integration +requirements, not claims established by client tests. The injected service boundary +lets a production implementation replace the demo without DTO/networking in widgets. + +## Screen annotations + +The four primary destinations remain Accounts, Activity, Limits and Settings. + +```text +Settings → Payout methods + Add payout method [primary action] + Search saved methods [nickname and safe display metadata] + Crypto addresses [type group; recently used first] + nickname / asset · network · shortened address + availability / required action [text, not color alone] + Bank cards + nickname / brand · masked suffix + Russian bank accounts + nickname / bank · masked suffix +``` + +```text +Add payout method + Type [explicit selection] + Type-specific destination fields [labels and inline validation] + Save for future payouts [payout only; unchecked] + Name for this method (optional) [Settings or saving enabled] + Validation / eligibility / saving [separate feedback] + Save payout method | Continue [no ordinary confirmation dialog] +``` + +```text +Pay out + Source account / available funds + Payout destination [explicit selection every time] + Saved methods | New destination + Amount [preserved during navigation/recovery] + Review + full destination, source debit, fee, receiving amount/asset + network and memo/tag for crypto; card remains masked + Pay out [amount/currency] [explicit consequential confirmation] + Processing → confirmed settlement [existing separate wallet states] +``` + +Settings: select type → details → Save. Payout: new destination → details → optional +saving → Continue. Reuse: select saved method → amount → review → confirm. No +mandatory nickname, routine ownership warning, automatic selection/substitution, +or extra verification screen is added. Removal has a consequential confirmation. +A replacement creates a new record; the original remains until explicitly removed. + +Lists group by destination type, sort recent use within groups and search safe +labels. Empty category, search no-match, unavailable data and no eligible methods +are distinct. Opening creation/management uses transient Navigator state, preserving +payout input without destination details in URLs or serialized browser history. + +## Domain and service contract + +`DestinationInput` is `SavedReference(id, executionRevision)` or +`OneTimeDestination(details)`. Validation and contextual eligibility apply equally +to both; quoting/submitting a one-time input must not create a reusable record. + +A destination has a customer-owned opaque ID independent of provider bindings. +Ownership declaration, service verification and payout eligibility are separate. +Unverified details may be saved when saving policy permits. Ownership status is +shown only when required/actionable. Format checks never establish ownership or +receiving capability, and successful persistence never establishes payout eligibility. + +`metadataRevision` protects nickname updates; `executionRevision` protects identity, +lifecycle and binding changes. Rename cannot invalidate a valid execution quote. +Removal and binding changes require execution revalidation. Immutable identities +are changed by replacement. Last-use ordering is not an execution change. + +Required service operations: capabilities; list/get; typed field validation; +contextual assessment; create; creation-attempt lookup; duplicate resolution; +rename with expected metadata revision; replacement; removal with expected +revisions; bank lookup with cancellation; immutable quote preparation. Remediation +capabilities must come from the service, never provider-specific widget logic. + +Eligibility uses current capabilities, available routes, destination requirements, +ownership/security policy, bindings and payout context including amount when relevant. +States are eligible, temporarily unavailable, requires action and unsupported. +Settings retains active unavailable methods. A contextless Settings assessment is +informational and never proof of eligibility for a particular payout. Selection, +quote preparation and acceptance revalidate independently. Losing eligibility or +removal preserves inputs and requires explicit reselection; never substitute. + +On Continue: validate fields; assess contextual eligibility; optionally persist +under saving policy; proceed only if eligible. Persistence may succeed while the +payout is ineligible. Definitive optional save failure allows retry or explicit +one-time continuation. Unknown outcomes offer status lookup; eligible one-time +continuation does not cancel/repeat a potentially successful save. Confirmation of +saving, validation, submission and settlement are separate states. + +### Canonical identities and duplicate outcomes + +- Crypto: service-defined asset ID (including token-contract identity), canonical + network ID, network-normalized address and applicable memo/tag. Identical address + strings across networks/assets are distinct. Preserve presentation case and + meaningful memo contents. Display enough metadata to distinguish same-symbol tokens. +- Russian bank: service-normalized BIK, account number and holder identity. No + frontend fuzzy matching or inference from an account prefix. +- Card: reliable service/provider fingerprint where available, never brand/last-four, + expiry or capture token. Provider-scoped fingerprints are not globally portable. + Missing reliable fingerprint permits distinct records; never guess/merge. + +Creation keys identify immutable payloads. Replay returns the same **created** or +**duplicate** result even after the referenced record is renamed/removed. Attempt +lookup distinguishes these outcomes. Concurrent creation must serialize duplicate +resolution within the authenticated customer scope. Use existing method revalidates +that record and never creates another. Existing nicknames remain unchanged. + +The demo uses a synchronous customer-scoped mutation boundary; production needs +persistent unique identity/idempotency constraints with transactions or equivalent. +No duplicate endpoint may reveal another customer's destination or attempt. + +### Bank input adapted from Sendico + +Reviewed local Sendico checkout `c5a3440fa`, especially +`docs/russian-bank-methods.md` and its form, lookup, parsing and validation tests. +The form has Account holder, Bank details and optional nickname sections. Structured +first/last names and optional patronymic preserve compound names; arbitrary full +names are shown unsplit. Business/tax selectors are excluded from personal-account UX. +Bank search is debounced 350 ms from two characters, with cancellation and stale +response rejection. Results identify name, BIK and city. Selected bank is compact, +with Change bank and expandable correspondent details. Account number accepts +whitespace and preserves leading zeros; 20-digit/control-key checking is not ownership +verification. Prefix hints remain advisory. + +Local paste import recognizes explicit EN/RU labels, previews recognized values, +flags conflicts, preserves untouched fields and never persists/transmits raw pasted +text. Directory/name/correspondent mismatches remain visible. Invalid BIK, unknown +bank, unsupported institution and service failure are distinct. On reuse, cosmetic +directory labels may refresh; execution-material changes require review/requote. +Never silently replace BIK, account or holder. Mergers/BIK changes can require a +replacement; discontinued banks remain saved but ineligible. + +### Cards and binding lifecycle + +Card saving requires reusable support or a defined restoration mechanism. An existing +card remains saved after card/binding expiry, revocation or provider outage. +Requires action means a supported recovery exists; operational outages are temporary; +without a reusable binding or supported recovery the UI must not promise reuse. +Bindings may be multiple, provider-scoped and nonportable. Re-tokenization requires +trusted same-card identification; otherwise create a replacement. Secure capture is +transient. Saved card details contain local identity/display metadata, and bindings +contain separate opaque references; PAN/CVV never enter application storage. + +## Acceptance, security and retention + +The BFF's financial acceptance boundary must atomically validate current quote, +customer authorization, execution revision, lifecycle, eligibility and requirements, +then persist the accepted immutable snapshot/intent. A preceding API assessment or +frontend check cannot guarantee financial correctness. Removal committed first rejects +acceptance; acceptance committed first survives later removal. Rejection preserves +drafts and returns reselect/review/refresh/requote actions. Expired or materially changed +quotes need explicit confirmation of recalculated terms. This is a required backend +contract; the demo's synchronous acceptance guard only demonstrates its semantics. + +Removal from the active list, binding deactivation and data erasure are separate. +Removed records cannot serve new payouts; accepted payouts retain immutable snapshots. +Production retention must assign explicit expiry/cleanup policies to abandoned quotes, +capture references, idempotency records and required financial history. Do not promise +complete erasure of retained financial records. The demo stores no browser data and +expires transient quote leases; all remaining simulation data ends on reload. + +Authorize every operation server-side and audit actor, timestamp, opaque method/attempt, +action and outcome, without destination details. Metadata edits use optimistic concurrency. +Risk-based step-up is service-policy driven; no mandatory MFA is introduced. No raw +destination, pasted text, nickname, amount or account data enters logs, analytics or replay. + +## Localization, accessibility and measurement + +EN/RU copy is maintained in the app ARB sources. Specific states cover saving, +uncertainty, duplicate reuse, changed/removed methods, directory failures, binding +authorization and quote recovery. Ordinary success says only "Payout method saved." +Forms use associated labels, field errors, a focusable live feedback area, text status +and keyboard controls. Names stack below 480 px in a 720 px maximum form. Review +320 px layouts at 1.6× text scale, long labels, full-address wrapping and 48 px targets. + +`JourneyKind.destination` / `destinationSave` measures service-confirmed saving/resolution +separately from the existing payout quote/submission/settlement signals. Retries share +recorder correlation; confirmed save closes its journey. Idle/discard attempts use the +shared 30-minute inactivity/disposal rule. Simulation events are explicitly marked. +No production persistence, cross-session funnel or financial outcome is inferred from +the demo recorder. Actual outcome events and backend audit are integration requirements. + +## Validation scenarios and recorded review + +Tests exercise one-time nonpersistence, idempotent/repeated/concurrent creation, stable +duplicate replay after rename/removal, lost-response reconciliation, customer isolation, +metadata concurrency, execution invalidation at acceptance, asset/network/token identity, +case/memo handling, fingerprint fallback, card binding recovery states, bank control keys, +directory changes, paste conflicts and DTO mappings. Widget tests exercise Settings +save/rename/remove, explicit selection, one-time payout, optional-save failure recovery, +removal during review, privacy-safe outcomes and EN/RU narrow layouts. Existing auth, +factor, wallet and workspace regressions are included in the required frontend suite. + +Research: [Wise recipient management](https://wise.com/help/articles/2491526/how-do-i-add-or-delete-recipients), +[Coinbase address books](https://help.coinbase.com/en/exchange/managing-my-account/how-does-the-address-book-work), +[Coinbase memo/tag requirements](https://help.coinbase.com/en/coinbase/trading-and-funding/sending-or-receiving-cryptocurrency/destination-tag-memo-faq), +[Stripe tokenization](https://docs.stripe.com/api/tokens), +[W3C form validation](https://www.w3.org/WAI/tutorials/forms/validation/). + +Final validation commands and PR links are recorded below at delivery. + +### Completion review — 2026-10-16 + +- Applied `review-frontend-change`, UX Design and UX Observability reviews across + app, wallet and analytics. Extracted form/search/selection/management providers; + widgets consume typed models and provider actions. Fixed narrow bank-header + overflow and bottom feedback overlapping management controls. +- Added structured immutable destination review, explicit simulated receiving + asset/amount/timing terms, independent card-binding restoration and a typed + created/duplicate analytics outcome. Nickname edits do not change execution + revisions. New typed inputs are explicitly rejected by the legacy live adapter. +- `dart format --output=none --set-exit-if-changed` over 44 changed Dart files: + passed, no changes. `git diff --check`: passed. +- `flutter analyze` in `frontend/app`, `frontend/packages/sxchnge_wallet` and + `frontend/packages/sxchnge_analytics`: passed, no issues. +- `make test-frontend`: passed all seven package/application suites, including + 89 application tests and the destination domain/recovery/concurrency scenarios. +- `flutter build web` in `frontend/app`: passed, including the Wasm dry run. +- Browser smoke check on a temporary local web preview: method selection and bank + form rendered; accessibility labels exposed; Tab moved from type to First name. + Automated EN/RU forms at 320 px and 1.6× scale passed. A full assistive-technology + audit and production/provider validation were not performed. +- An exploratory repository-root `flutter analyze` also inspected the unrelated + standalone `website/flutter/brand_palette.dart`, which lacks Flutter package + context and reported `dart:ui` errors. The required affected-package analyses + above passed; no unrelated website code or analyzer settings were changed. +- No backend code or infrastructure changes, live payments, deployment, merge or + security scanner runs were performed. Live persistence/atomic acceptance remain + the explicitly documented integration boundary. diff --git a/frontend/app/lib/destinations/bank.dart b/frontend/app/lib/destinations/bank.dart new file mode 100644 index 0000000..e005607 --- /dev/null +++ b/frontend/app/lib/destinations/bank.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'paste.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class BankFields extends StatefulWidget { + const BankFields({ + super.key, + required this.onChanged, + this.initial, + this.errorField, + }); + final ValueChanged onChanged; + final RussianBankDestination? initial; + final String? errorField; + @override + State createState() => _BankFieldsState(); +} + +class _BankFieldsState extends State { + late final first = TextEditingController(text: widget.initial?.firstName); + late final last = TextEditingController(text: widget.initial?.lastName); + late final patronymic = TextEditingController( + text: widget.initial?.patronymic, + ); + late final account = TextEditingController(text: widget.initial?.account); + final query = TextEditingController(); + late final search = BankSearchProvider( + context.read().service, + ); + late Bank? bank = widget.initial?.bank; + String? pastedHolder; + bool conflict = false; + Map pasted = {}; + bool get mismatch => + bank != null && + ((pasted['bank'] != null && pasted['bank'] != bank!.name) || + (pasted['correspondent'] != null && + pasted['correspondent'] != bank!.correspondent)); + void changed() => widget.onChanged( + bank == null + ? null + : RussianBankDestination( + firstName: first.text, + lastName: last.text, + patronymic: patronymic.text, + bank: bank!, + account: account.text, + ), + ); + Future paste() async { + final parsed = await showDialog( + context: context, + builder: (_) => const BankPasteDialog(), + ); + if (!mounted || parsed == null) return; + for (final entry in { + 'firstName': first, + 'lastName': last, + 'patronymic': patronymic, + 'account': account, + }.entries) { + if (parsed.conflicts.contains(entry.key)) { + entry.value.clear(); + } else if (parsed.values[entry.key] case final String value) { + entry.value.text = value; + } + } + setState(() { + pasted = parsed.values; + conflict = parsed.conflicts.isNotEmpty; + pastedHolder = parsed.values['holder']; + }); + if (parsed.conflicts.contains('bik')) { + setState(() => bank = null); + } + if (parsed.values['bik'] case final String bik) { + query.text = bik; + search.search(bik); + setState(() => bank = null); + } + changed(); + } + + @override + void dispose() { + for (final c in [first, last, patronymic, account, query]) { + c.dispose(); + } + search.dispose(); + super.dispose(); + } + + Widget field( + String label, + TextEditingController c, { + String? id, + bool numeric = false, + String? error, + }) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: TextField( + key: id == null ? null : ValueKey(id), + controller: c, + keyboardType: numeric ? TextInputType.number : TextInputType.name, + decoration: InputDecoration(labelText: label, errorText: error), + onChanged: (_) => changed(), + ), + ); + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l.dHolder, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + LayoutBuilder( + builder: (context, b) { + final a = field( + l.dFirst, + first, + id: 'destination-first', + error: widget.errorField == 'holder' ? l.dInvalid : null, + ); + final z = field(l.dLast, last, id: 'destination-last'); + return b.maxWidth >= 480 + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: a), + const SizedBox(width: 12), + Expanded(child: z), + ], + ) + : Column(children: [a, z]); + }, + ), + field(l.dPatronymic, patronymic), + if (pastedHolder != null) SelectableText(pastedHolder!), + if (conflict) Text(l.dPasteConflict), + Text(l.dBankDetails, style: Theme.of(context).textTheme.titleMedium), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: paste, + icon: const Icon(Icons.content_paste), + label: Text(l.dPaste), + ), + ), + ListenableBuilder( + listenable: search, + builder: (context, _) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (bank == null) ...[ + TextField( + controller: query, + decoration: InputDecoration(labelText: l.dBankSearch), + onChanged: search.search, + ), + if (search.loading) const LinearProgressIndicator(), + for (final b in search.results) + ListTile( + title: Text(b.name), + subtitle: Text('${b.bik} · ${b.city}'), + onTap: () { + setState(() => bank = b); + changed(); + }, + ), + if (search.error != null) + Text( + search.error == DestinationIssue.unavailable + ? l.dBankFailure + : l.dBankUnknown, + ), + if (search.error == DestinationIssue.unavailable) + TextButton( + onPressed: () => search.search(query.text), + child: Text(l.wRefresh), + ), + ] else ...[ + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(bank!.name), + subtitle: Text('${l.dBik} ${bank!.bik}'), + trailing: TextButton( + onPressed: () { + setState(() => bank = null); + changed(); + }, + child: Text(l.dChangeBank), + ), + ), + ExpansionTile( + title: Text(l.dBankDetails), + children: [ + SelectableText( + '${l.dCorrespondent}: ${bank!.correspondent}', + ), + ], + ), + if (!bank!.supported) Text(l.dUnsupported), + ], + ], + ), + ), + const SizedBox(height: 16), + field( + l.dAccount, + account, + id: 'destination-account', + numeric: true, + error: widget.errorField == 'account' ? l.dInvalid : null, + ), + ], + ); + } +} diff --git a/frontend/app/lib/destinations/copy.dart b/frontend/app/lib/destinations/copy.dart new file mode 100644 index 0000000..5b88ed9 --- /dev/null +++ b/frontend/app/lib/destinations/copy.dart @@ -0,0 +1,33 @@ +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import '../generated/i18n/app_localizations.dart'; + +String destinationType(AppLocalizations l, DestinationType type) => + switch (type) { + DestinationType.crypto => l.dCrypto, + DestinationType.card => l.dCard, + DestinationType.russianBank => l.dBank, + }; +String destinationIssue(AppLocalizations l, DestinationIssue issue) => + switch (issue) { + DestinationIssue.invalid => l.dInvalid, + DestinationIssue.unavailable => l.dTemporary, + DestinationIssue.uncertain => l.dUncertain, + DestinationIssue.conflict => l.dConflict, + DestinationIssue.removed => l.dRemoved, + DestinationIssue.expired => l.dCardExpired, + DestinationIssue.bankChanged => l.dBankChanged, + DestinationIssue.authorization => l.dAuthorization, + DestinationIssue.unsupported => l.dUnsupported, + DestinationIssue.duplicate => l.dDuplicate, + DestinationIssue.replacementRequired => l.dReplacementRequired, + }; +String eligibilityText(AppLocalizations l, PayoutEligibility e) => + e.reason != null + ? destinationIssue(l, e.reason!) + : switch (e.state) { + EligibilityState.eligible => l.dEligible, + EligibilityState.temporarilyUnavailable => l.dTemporary, + EligibilityState.requiresAction => l.dAction, + EligibilityState.unsupported => l.dUnsupported, + }; diff --git a/frontend/app/lib/destinations/crypto.dart b/frontend/app/lib/destinations/crypto.dart new file mode 100644 index 0000000..c5858d4 --- /dev/null +++ b/frontend/app/lib/destinations/crypto.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class CryptoFields extends StatefulWidget { + const CryptoFields({ + super.key, + required this.capabilities, + required this.onChanged, + this.initial, + this.errorField, + }); + final DestinationCapabilities capabilities; + final ValueChanged onChanged; + final CryptoDestination? initial; + final String? errorField; + @override + State createState() => _CryptoFieldsState(); +} + +class _CryptoFieldsState extends State { + late final address = TextEditingController(text: widget.initial?.address); + late final memo = TextEditingController(text: widget.initial?.memo); + late String? assetId = widget.initial?.assetId; + late String? networkId = widget.initial?.networkId; + void changed() { + final a = widget.capabilities.assets + .where((a) => a.id == assetId && a.networkId == networkId) + .firstOrNull; + widget.onChanged( + a == null + ? null + : CryptoDestination( + assetId: a.id, + networkId: a.networkId, + address: address.text, + memo: memo.text, + assetLabel: a.label, + networkLabel: a.networkLabel, + ), + ); + } + + @override + void dispose() { + address.dispose(); + memo.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final assets = widget.capabilities.assets; + final selected = assets + .where((a) => a.id == assetId && a.networkId == networkId) + .firstOrNull; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + key: ValueKey('asset-$assetId'), + initialValue: assets.any((a) => a.id == assetId) ? assetId : null, + isExpanded: true, + decoration: InputDecoration(labelText: l.dAsset), + items: [ + for (final a in assets) + DropdownMenuItem(value: a.id, child: Text(a.label)), + ], + onChanged: (v) { + setState(() { + assetId = v; + networkId = null; + memo.clear(); + }); + changed(); + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + key: ValueKey('network-$assetId-$networkId'), + initialValue: + assets.any((a) => a.id == assetId && a.networkId == networkId) + ? networkId + : null, + isExpanded: true, + decoration: InputDecoration(labelText: l.dNetwork), + items: [ + for (final a in assets.where((a) => a.id == assetId)) + DropdownMenuItem(value: a.networkId, child: Text(a.networkLabel)), + ], + onChanged: (v) { + setState(() => networkId = v); + changed(); + }, + ), + const SizedBox(height: 16), + TextField( + key: const ValueKey('destination-address'), + controller: address, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: l.dAddress, + errorText: widget.errorField == 'address' ? l.dInvalid : null, + ), + onChanged: (_) => changed(), + ), + if (selected?.memoSupported == true) ...[ + const SizedBox(height: 16), + TextField( + key: const ValueKey('destination-memo'), + controller: memo, + decoration: InputDecoration( + labelText: l.dMemo, + errorText: widget.errorField == 'memo' ? l.dInvalid : null, + ), + onChanged: (_) => changed(), + ), + ], + const SizedBox(height: 12), + Text(l.dCryptoHelp), + ], + ); + } +} diff --git a/frontend/app/lib/destinations/detail.dart b/frontend/app/lib/destinations/detail.dart new file mode 100644 index 0000000..53a5ff8 --- /dev/null +++ b/frontend/app/lib/destinations/detail.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'copy.dart'; +import 'details.dart'; +import 'form.dart'; +import 'nickname.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class DestinationDetailPage extends StatefulWidget { + const DestinationDetailPage({super.key, required this.destination}); + final SavedDestination destination; + @override + State createState() => _DestinationDetailPageState(); +} + +class _DestinationDetailPageState extends State { + late final p = DestinationManagementProvider( + context.read().service, + widget.destination, + ); + late final nickname = TextEditingController( + text: widget.destination.nickname, + ); + Future remove() async { + final l = AppLocalizations.of(context)!; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l.dRemoveTitle), + content: Text(l.dRemoveHelp), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(l.wStay), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l.dRemove), + ), + ], + ), + ); + if (!mounted || confirmed != true) return; + await p.remove(); + if (mounted && p.removed) Navigator.pop(context); + } + + @override + void dispose() { + p.dispose(); + nickname.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + return ListenableBuilder( + listenable: p, + builder: (context, _) => Scaffold( + appBar: AppBar(title: Text(l.dDetails)), + body: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 120), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DestinationDetailsView(details: p.destination.details), + const SizedBox(height: 24), + DestinationNickname( + controller: nickname, + enabled: !p.busy, + onChanged: (_) {}, + ), + if (p.busy) const LinearProgressIndicator(), + if (p.error != null) ...[ + Semantics( + liveRegion: true, + child: Text(destinationIssue(l, p.error!)), + ), + TextButton( + onPressed: p.busy ? null : p.refresh, + child: Text(l.dReload), + ), + ], + const SizedBox(height: 16), + FilledButton( + onPressed: p.busy + ? null + : () async { + await p.rename(nickname.text); + if (context.mounted && p.error == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.dSaved))); + } + }, + child: Text(l.dRename), + ), + if (p.destination.details is CardDestination && + context + .read() + .capabilities + ?.cardRecovery == + true) + OutlinedButton( + onPressed: p.busy + ? null + : () async { + final restored = + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => DestinationFormPage( + initial: p.destination.details, + restoring: p.destination.input, + ), + ), + ); + if (mounted && restored != null) { + await p.refresh(); + } + }, + child: Text(l.dUpdateAuthorization), + ), + TextButton( + onPressed: p.busy + ? null + : () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => DestinationFormPage( + initial: p.destination.details, + replacing: p.destination.id, + ), + ), + ); + }, + child: Text(l.dReplace), + ), + TextButton( + onPressed: p.busy ? null : remove, + child: Text(l.dRemove), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/destinations/details.dart b/frontend/app/lib/destinations/details.dart new file mode 100644 index 0000000..e74f604 --- /dev/null +++ b/frontend/app/lib/destinations/details.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import '../generated/i18n/app_localizations.dart'; + +/// Structured review renders the immutable service snapshot, never a nickname. +class DestinationDetailsView extends StatelessWidget { + const DestinationDetailsView({super.key, required this.details}); + final DestinationDetails details; + Widget field(String label, String value) => Padding( + padding: const EdgeInsets.only(top: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [Text(label), SelectableText(value)], + ), + ); + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: switch (details) { + CryptoDestination d => [ + field(l.dAsset, d.assetLabel), + field(l.dNetwork, d.networkLabel), + field(l.dAddress, d.address), + field(l.dMemo, d.memo.isEmpty ? l.dNoMemo : d.memo), + ], + CardDestination d => [field(l.dCard, d.display)], + RussianBankDestination d => [ + field(l.dHolder, d.holder), + field(l.dBankSearch, d.bank.name), + field(l.dBik, d.bank.bik), + field(l.dAccount, d.account), + field(l.dCorrespondent, d.bank.correspondent), + ], + }, + ); + } +} diff --git a/frontend/app/lib/destinations/form.dart b/frontend/app/lib/destinations/form.dart new file mode 100644 index 0000000..b541f43 --- /dev/null +++ b/frontend/app/lib/destinations/form.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'bank.dart'; +import 'copy.dart'; +import 'crypto.dart'; +import 'nickname.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class DestinationFormPage extends StatefulWidget { + const DestinationFormPage({ + super.key, + this.payoutContext, + this.initial, + this.replacing, + this.restoring, + }); + final PayoutContext? payoutContext; + final DestinationDetails? initial; + final String? replacing; + final SavedReference? restoring; + @override + State createState() => _DestinationFormPageState(); +} + +class _DestinationFormPageState extends State { + late final p = DestinationFormProvider( + context.read().service, + context.read(), + context: widget.payoutContext, + initial: widget.initial, + replacing: widget.replacing, + restoring: widget.restoring, + ); + late DestinationType? type = widget.initial?.type; + final nickname = TextEditingController(); + final focus = FocusNode(); + Future proceed({ + bool withoutSaving = false, + bool existing = false, + }) async { + if (existing) { + await p.useExisting(); + } else { + await p.proceed(withoutSaving: withoutSaving); + } + if (!mounted) return; + if (p.result != null) { + final l = AppLocalizations.of(context)!; + if (p.saved?.outcome == SaveOutcome.created || widget.restoring != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + widget.restoring == null ? l.dSaved : l.dAuthorizationUpdated, + ), + ), + ); + } + Navigator.pop(context, p.result); + return; + } + focus.requestFocus(); + } + + @override + void dispose() { + p.dispose(); + nickname.dispose(); + focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final caps = context.watch().capabilities; + return ListenableBuilder( + listenable: p, + builder: (context, _) => PopScope( + canPop: !p.busy, + child: Scaffold( + appBar: AppBar(title: Text(l.dAdd)), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (caps?.simulated == true) ...[ + Text(l.dDemo), + const SizedBox(height: 16), + ], + Text(l.dOwn), + const SizedBox(height: 20), + if (caps == null || caps.types.isEmpty) + Text(l.dNoCapabilities) + else ...[ + DropdownButtonFormField( + initialValue: caps.types.contains(type) ? type : null, + isExpanded: true, + decoration: InputDecoration(labelText: l.dType), + items: [ + for (final t in caps.types) + DropdownMenuItem( + value: t, + child: Text( + destinationType(l, t), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: + p.busy || p.uncertain || widget.restoring != null + ? null + : (v) { + setState(() => type = v); + p.change(null); + }, + ), + const SizedBox(height: 20), + AbsorbPointer( + absorbing: p.busy || p.uncertain, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (type == DestinationType.crypto) + CryptoFields( + capabilities: caps, + initial: widget.initial is CryptoDestination + ? widget.initial as CryptoDestination + : null, + errorField: p.errorField, + onChanged: p.change, + ), + if (type == DestinationType.russianBank) + BankFields( + initial: + widget.initial is RussianBankDestination + ? widget.initial as RussianBankDestination + : null, + errorField: p.errorField, + onChanged: p.change, + ), + if (type == DestinationType.card && + caps.simulated) ...[ + Text(l.dCardDemo), + const SizedBox(height: 16), + RadioGroup( + groupValue: p.details is CardDestination + ? (p.details as CardDestination).capture + : null, + onChanged: (value) { + if (value != null) { + p.change( + CardDestination( + capture: value, + brand: 'Demo card', + last4: '4242', + expiryMonth: 12, + expiryYear: 2030, + ), + ); + } + }, + child: Column( + children: [ + RadioListTile( + value: 'demo-card-ready', + title: Text(l.dCardReady), + ), + RadioListTile( + value: 'demo-card-recover', + title: Text(l.dCardRecover), + ), + ], + ), + ), + ], + if (type == DestinationType.card && !caps.simulated) + Text(l.dNoCapabilities), + if (widget.payoutContext != null) ...[ + const SizedBox(height: 16), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: p.save, + onChanged: (v) => p.preferences(save: v), + title: Text(l.dSaveFuture), + ), + ], + if (widget.restoring == null && + (widget.payoutContext == null || p.save)) ...[ + const SizedBox(height: 16), + DestinationNickname( + controller: nickname, + enabled: !p.busy && !p.uncertain, + onChanged: (v) => p.preferences(nickname: v), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + Focus( + focusNode: focus, + skipTraversal: true, + child: Semantics( + liveRegion: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (p.error != null) + Text( + p.uncertain + ? l.dUncertain + : p.errorField != null + ? l.dInvalid + : '${p.savingFailed ? '${l.dSaveFailed}\n' : ''}${destinationIssue(l, p.error!)}', + key: const ValueKey('destination-error'), + ), + if (p.eligibility != null && + !p.eligibility!.eligible) + Text( + '${p.saved != null ? '${l.dSavedIneligible}\n' : ''}${eligibilityText(l, p.eligibility!)}', + ), + if (p.saved?.outcome == + SaveOutcome.duplicate) ...[ + Text(l.dDuplicate), + TextButton( + onPressed: p.busy + ? null + : () => proceed(existing: true), + child: Text(l.dUseExisting), + ), + ], + if (p.saved?.outcome == SaveOutcome.created && + p.result == null) + Text(l.dSaved), + ], + ), + ), + ), + if (p.busy) const LinearProgressIndicator(), + if (p.uncertain) + TextButton( + onPressed: p.busy ? null : () => proceed(), + child: Text(l.dCheckSave), + ), + if (p.savingFailed && widget.payoutContext != null) + TextButton( + onPressed: p.busy + ? null + : () => proceed(withoutSaving: true), + child: Text(l.dWithoutSave), + ), + if (p.save && widget.payoutContext != null) + Text(l.dSaveHelp), + const SizedBox(height: 12), + FilledButton( + key: const ValueKey('destination-continue'), + onPressed: p.busy || p.details == null + ? null + : () => proceed(), + child: Text( + widget.restoring != null + ? l.dUpdateAuthorization + : widget.payoutContext == null + ? l.dSave + : p.error != null && !p.uncertain + ? l.dRetrySave + : l.dContinue, + ), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/app/lib/destinations/list.dart b/frontend/app/lib/destinations/list.dart new file mode 100644 index 0000000..0a24562 --- /dev/null +++ b/frontend/app/lib/destinations/list.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'copy.dart'; +import 'detail.dart'; +import 'form.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class DestinationsPage extends StatefulWidget { + const DestinationsPage({super.key, this.payoutContext}); + final PayoutContext? payoutContext; + @override + State createState() => _DestinationsPageState(); +} + +class _DestinationsPageState extends State { + String query = ''; + late final selection = DestinationSelectionProvider( + context.read().service, + widget.payoutContext ?? const PayoutContext(''), + ); + Future refresh() async { + final p = context.read(); + await p.refresh(); + if (!mounted) return; + await selection.assess(p.methods); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) refresh(); + }); + } + + Future add() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + DestinationFormPage(payoutContext: widget.payoutContext), + ), + ); + if (!mounted) return; + if (result != null && widget.payoutContext != null) { + Navigator.pop(context, result); + } else { + await refresh(); + } + } + + Future choose(SavedDestination d) async { + if (widget.payoutContext == null) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => DestinationDetailPage(destination: d), + ), + ); + if (mounted) await refresh(); + } else { + final ref = await selection.select(d); + if (mounted && ref != null) Navigator.pop(context, ref); + } + } + + @override + void dispose() { + selection.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final p = context.watch(); + return Scaffold( + appBar: AppBar( + title: Text(widget.payoutContext == null ? l.dTitle : l.dChoose), + actions: [ + IconButton( + tooltip: l.dReload, + onPressed: p.loading ? null : refresh, + icon: const Icon(Icons.refresh), + ), + ], + ), + body: ListenableBuilder( + listenable: selection, + builder: (context, _) => ListView( + padding: const EdgeInsets.all(24), + children: [ + if (p.capabilities?.simulated == true) ...[ + Text(l.dDemo), + const SizedBox(height: 16), + ], + FilledButton.icon( + onPressed: p.loading || (p.capabilities?.types.isEmpty ?? true) + ? null + : add, + icon: const Icon(Icons.add), + label: Text(widget.payoutContext == null ? l.dAdd : l.dNew), + ), + if (p.capabilities?.types.isEmpty == true) + Text(l.dManagementUnavailable), + const SizedBox(height: 20), + TextField( + decoration: InputDecoration( + labelText: l.dSearch, + prefixIcon: const Icon(Icons.search), + ), + onChanged: (v) => setState(() => query = v), + ), + if (p.loading || selection.busy) const LinearProgressIndicator(), + if (p.failed) Text(l.dUnavailable), + if (selection.error != null) + Semantics( + liveRegion: true, + child: Text(destinationIssue(l, selection.error!)), + ), + if (widget.payoutContext != null && + !p.loading && + p.methods.isNotEmpty && + !selection.assessments.values.any((a) => a.eligible)) + Text(l.dNoEligible), + for (final type + in p.capabilities?.types ?? []) ...[ + const SizedBox(height: 24), + Text( + destinationType(l, type), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + if (p.search(query, type).isEmpty && !p.loading && !p.failed) + Text(query.isEmpty ? l.dEmpty : l.dNoMatches), + for (final d in p.search(query, type)) + Card( + child: ListTile( + contentPadding: const EdgeInsets.all(16), + title: Text(d.label), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (d.nickname.isNotEmpty) Text(d.details.display), + if (selection.assessments[d.id] + case final PayoutEligibility e) + Text(eligibilityText(l, e)), + ], + ), + trailing: const Icon(Icons.chevron_right), + onTap: selection.busy ? null : () => choose(d), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/frontend/app/lib/destinations/nickname.dart b/frontend/app/lib/destinations/nickname.dart new file mode 100644 index 0000000..9fa1bf5 --- /dev/null +++ b/frontend/app/lib/destinations/nickname.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class DestinationNickname extends StatelessWidget { + const DestinationNickname({ + super.key, + required this.controller, + required this.onChanged, + this.enabled = true, + }); + final TextEditingController controller; + final ValueChanged onChanged; + final bool enabled; + @override + Widget build(BuildContext context) => TextField( + key: const ValueKey('destination-nickname'), + controller: controller, + enabled: enabled, + maxLength: 80, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.dNickname, + ), + onChanged: onChanged, + ); +} diff --git a/frontend/app/lib/destinations/paste.dart b/frontend/app/lib/destinations/paste.dart new file mode 100644 index 0000000..64a4a2f --- /dev/null +++ b/frontend/app/lib/destinations/paste.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import '../generated/i18n/app_localizations.dart'; + +class BankPasteDialog extends StatefulWidget { + const BankPasteDialog({super.key}); + @override + State createState() => _BankPasteDialogState(); +} + +class _BankPasteDialogState extends State { + final text = TextEditingController(); + BankPaste? result; + @override + void dispose() { + text.clear(); + text.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final labels = { + 'firstName': l.dFirst, + 'lastName': l.dLast, + 'patronymic': l.dPatronymic, + 'account': l.dAccount, + 'bik': 'BIK', + 'bank': l.dBankSearch, + 'holder': l.dHolder, + 'correspondent': l.dCorrespondent, + }; + return AlertDialog( + title: Text(l.dPaste), + content: SizedBox( + width: 480, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text(l.dPasteHelp), + const SizedBox(height: 12), + TextField( + controller: text, + maxLines: 6, + decoration: InputDecoration(labelText: l.dBankDetails), + onChanged: (_) => setState(() => result = null), + ), + if (result != null) ...[ + const SizedBox(height: 12), + if (result!.conflicts.isNotEmpty) Text(l.dPasteConflict), + for (final e in result!.values.entries) + Text('${labels[e.key]}: ${e.value}'), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l.wClose), + ), + FilledButton( + onPressed: result == null + ? () => setState(() => result = BankPaste.parse(text.text)) + : result!.values.isEmpty + ? null + : () => Navigator.pop(context, result), + child: Text(result == null ? l.dPasteReview : l.dPasteApply), + ), + ], + ); + } +} diff --git a/frontend/app/lib/l10n/en.arb b/frontend/app/lib/l10n/en.arb index f2497a1..3a93507 100644 --- a/frontend/app/lib/l10n/en.arb +++ b/frontend/app/lib/l10n/en.arb @@ -289,5 +289,95 @@ "type": "int" } } - } + }, + "dTitle": "Payout methods", + "dAdd": "Add payout method", + "dChoose": "Payout destination", + "dNew": "Use a new destination", + "dSave": "Save payout method", + "dContinue": "Continue", + "dSaveFuture": "Save for future payouts", + "dSaveHelp": "Continue will also save this destination.", + "dNickname": "Name for this method (optional)", + "dSaved": "Payout method saved.", + "dSaveFailed": "Couldn’t save these details. Your input is still here.", + "dRetrySave": "Retry saving", + "dWithoutSave": "Continue without saving", + "dUncertain": "We couldn’t confirm saving. This method may already be saved.", + "dCheckSave": "Check save status", + "dDuplicate": "This destination is already saved. Use the existing method.", + "dUseExisting": "Use existing method", + "dCrypto": "Crypto address", + "dCard": "Bank card", + "dBank": "Russian bank account", + "dType": "Destination type", + "dSearch": "Search saved methods", + "dEmpty": "No saved methods in this category.", + "dNoMatches": "No matching methods.", + "dEligible": "Available", + "dTemporary": "Temporarily unavailable for this payout.", + "dAction": "Action required", + "dUnsupported": "This method isn’t supported for this payout.", + "dUnavailable": "Couldn’t load methods. Try again.", + "dNoCapabilities": "No destination types are currently available.", + "dDemo": "Demo destinations — saving lasts until reload. No real payout or card collection is connected.", + "dAsset": "Receiving asset", + "dNetwork": "Network", + "dAddress": "Wallet address", + "dMemo": "Memo / destination tag", + "dNoMemo": "No memo / tag", + "dCryptoHelp": "Use the network supported by your receiving wallet. A valid address format does not guarantee delivery.", + "dHolder": "Account holder", + "dFirst": "First name", + "dLast": "Last name", + "dPatronymic": "Patronymic (optional)", + "dBankDetails": "Bank details", + "dBankSearch": "Bank name or BIK", + "dAccount": "20-digit account number", + "dCorrespondent": "Correspondent account", + "dChangeBank": "Change bank", + "dBankUnknown": "Bank not found. Check the name or BIK.", + "dBankFailure": "Bank directory is unavailable. Your input is preserved.", + "dBankChanged": "Bank details changed. Review them and add a replacement destination.", + "dPaste": "Paste bank details", + "dPasteHelp": "Paste labelled bank details. Review recognized values before applying them.", + "dPasteReview": "Review recognized details", + "dPasteApply": "Use these details", + "dPasteConflict": "Conflicting values were found. Correct the affected fields in the form.", + "dInvalid": "Check the entered details and correct the highlighted field.", + "dCardDemo": "Choose a simulated secure card capture. Do not enter a real card number.", + "dCardReady": "Demo card •••• 4242", + "dCardRecover": "Demo card requiring renewed authorization", + "dCardExpired": "This card has expired. Add a replacement card.", + "dAuthorization": "Authorization needs updating. Follow the service-supported recovery or add a replacement.", + "dRemoved": "This method was removed. Your draft is preserved. Choose a destination to continue.", + "dConflict": "This method changed in another session. Refresh and review the latest version.", + "dRename": "Rename", + "dRemove": "Remove", + "dReplace": "Add replacement", + "dRemoveTitle": "Remove payout method?", + "dRemoveHelp": "Removed methods cannot be used for new payouts. Submitted payouts and required financial records are retained.", + "dDetails": "Destination details", + "dReload": "Refresh methods", + "dSelect": "Select destination", + "dQuoteChanged": "Payout terms changed. Your draft is preserved. Select a destination and review a new quote.", + "dOwn": "Use a wallet, card, or account that belongs to you.", + "dSavedIneligible": "Saved. This method is unavailable for the current payout.", + "dSaveFailedLabel": "Saving failed", + "dNoEligible": "No methods are available for this payout. You can add a destination or refresh.", + "dDemoTerms": "Demo receiving amounts use a fixed 1:1 ratio. No real payout is made.", + "dTiming": "Expected processing time: {seconds} seconds", + "@dTiming": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "dBik": "BIK", + "dBankMismatch": "Pasted bank details differ from the selected bank. Check the details before continuing.", + "dManagementUnavailable": "Saving payout methods isn’t available yet. You can still use your configured payout destination.", + "dUpdateAuthorization": "Update authorization", + "dAuthorizationUpdated": "Authorization updated.", + "dReplacementRequired": "Couldn’t confirm this is the same card. Add a replacement destination." } diff --git a/frontend/app/lib/l10n/ru.arb b/frontend/app/lib/l10n/ru.arb index fa172e2..49a5117 100644 --- a/frontend/app/lib/l10n/ru.arb +++ b/frontend/app/lib/l10n/ru.arb @@ -289,5 +289,95 @@ "type": "int" } } - } + }, + "dTitle": "Способы вывода", + "dAdd": "Добавить способ вывода", + "dChoose": "Реквизиты для вывода", + "dNew": "Использовать новые реквизиты", + "dSave": "Сохранить способ вывода", + "dContinue": "Продолжить", + "dSaveFuture": "Сохранить для будущих выводов", + "dSaveHelp": "При продолжении реквизиты также будут сохранены.", + "dNickname": "Название способа вывода (необязательно)", + "dSaved": "Способ вывода сохранён.", + "dSaveFailed": "Не удалось сохранить реквизиты. Введённые данные остались в форме.", + "dRetrySave": "Повторить сохранение", + "dWithoutSave": "Продолжить без сохранения", + "dUncertain": "Не удалось подтвердить сохранение. Возможно, способ уже сохранён.", + "dCheckSave": "Проверить статус сохранения", + "dDuplicate": "Эти реквизиты уже сохранены. Используйте существующий способ.", + "dUseExisting": "Использовать существующий способ", + "dCrypto": "Криптоадрес", + "dCard": "Банковская карта", + "dBank": "Российский банковский счёт", + "dType": "Тип реквизитов", + "dSearch": "Поиск сохранённых способов", + "dEmpty": "В этой категории нет сохранённых способов.", + "dNoMatches": "Подходящие способы не найдены.", + "dEligible": "Доступно", + "dTemporary": "Временно недоступно для этого вывода.", + "dAction": "Требуется действие", + "dUnsupported": "Этот способ не поддерживается для данного вывода.", + "dUnavailable": "Не удалось загрузить способы. Повторите попытку.", + "dNoCapabilities": "Сейчас нет доступных типов реквизитов.", + "dDemo": "Демонстрационные реквизиты — сохранение действует до перезагрузки. Реальный вывод и ввод карт не подключены.", + "dAsset": "Получаемый актив", + "dNetwork": "Сеть", + "dAddress": "Адрес кошелька", + "dMemo": "Memo / тег назначения", + "dNoMemo": "Без memo / тега", + "dCryptoHelp": "Выберите сеть, поддерживаемую кошельком получателя. Верный формат адреса не гарантирует зачисление.", + "dHolder": "Владелец счёта", + "dFirst": "Имя", + "dLast": "Фамилия", + "dPatronymic": "Отчество (необязательно)", + "dBankDetails": "Банковские реквизиты", + "dBankSearch": "Название банка или БИК", + "dAccount": "20-значный номер счёта", + "dCorrespondent": "Корреспондентский счёт", + "dChangeBank": "Изменить банк", + "dBankUnknown": "Банк не найден. Проверьте название или БИК.", + "dBankFailure": "Справочник банков недоступен. Введённые данные сохранены.", + "dBankChanged": "Банковские реквизиты изменились. Проверьте их и добавьте новые реквизиты.", + "dPaste": "Вставить банковские реквизиты", + "dPasteHelp": "Вставьте реквизиты с названиями полей. Проверьте распознанные значения перед применением.", + "dPasteReview": "Проверить распознанные реквизиты", + "dPasteApply": "Использовать эти реквизиты", + "dPasteConflict": "Найдены противоречивые значения. Исправьте соответствующие поля в форме.", + "dInvalid": "Проверьте введённые реквизиты и исправьте выделенное поле.", + "dCardDemo": "Выберите демонстрационный результат безопасного ввода карты. Не вводите настоящий номер карты.", + "dCardReady": "Демо-карта •••• 4242", + "dCardRecover": "Демо-карта с истёкшей авторизацией", + "dCardExpired": "Срок действия карты истёк. Добавьте новую карту.", + "dAuthorization": "Необходимо обновить авторизацию. Используйте доступное восстановление или добавьте новые реквизиты.", + "dRemoved": "Этот способ удалён. Черновик сохранён. Выберите реквизиты для продолжения.", + "dConflict": "Этот способ изменён в другой сессии. Обновите и проверьте актуальную версию.", + "dRename": "Переименовать", + "dRemove": "Удалить", + "dReplace": "Добавить новые реквизиты", + "dRemoveTitle": "Удалить способ вывода?", + "dRemoveHelp": "Удалённые способы недоступны для новых выводов. Отправленные заявки и обязательные финансовые записи сохраняются.", + "dDetails": "Реквизиты", + "dReload": "Обновить способы", + "dSelect": "Выбрать реквизиты", + "dQuoteChanged": "Условия вывода изменились. Черновик сохранён. Выберите реквизиты и проверьте новую котировку.", + "dOwn": "Используйте принадлежащий вам кошелёк, карту или счёт.", + "dSavedIneligible": "Сохранено. Этот способ недоступен для текущего вывода.", + "dSaveFailedLabel": "Ошибка сохранения", + "dNoEligible": "Для этого вывода нет доступных способов. Добавьте реквизиты или обновите список.", + "dDemoTerms": "Демонстрационные суммы зачисления рассчитаны по фиксированному курсу 1:1. Реальный вывод не выполняется.", + "dTiming": "Ожидаемое время обработки: {seconds} сек.", + "@dTiming": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, + "dBik": "БИК", + "dBankMismatch": "Вставленные реквизиты отличаются от выбранного банка. Проверьте их перед продолжением.", + "dManagementUnavailable": "Сохранение способов вывода пока недоступно. Вы можете использовать ранее настроенные реквизиты для вывода.", + "dUpdateAuthorization": "Обновить авторизацию", + "dAuthorizationUpdated": "Авторизация обновлена.", + "dReplacementRequired": "Не удалось подтвердить, что это та же карта. Добавьте новые реквизиты." } diff --git a/frontend/app/lib/main.dart b/frontend/app/lib/main.dart index fe2f172..0be4eec 100644 --- a/frontend/app/lib/main.dart +++ b/frontend/app/lib/main.dart @@ -37,6 +37,7 @@ class SxchngeApp extends StatefulWidget { this.identityService, this.limitsService, this.walletService, + this.destinationService, this.twoFactorService, this.photoService = const FilePhotoService(), this.loginService = const DummyLoginService(), @@ -49,6 +50,7 @@ class SxchngeApp extends StatefulWidget { final IdentityService? identityService; final LimitsService? limitsService; final WalletService? walletService; + final DestinationService? destinationService; final TwoFactorService? twoFactorService; final PhotoService photoService; final LoginService loginService; @@ -89,10 +91,17 @@ class _SxchngeAppState extends State { initialLocale: _locale.locale.languageCode, factors: _authFactors, )); + late final _destinationService = + widget.destinationService ?? + (_walletService.isSimulated && _walletService is DummyWalletService + ? _walletService.destinations + : const UnavailableDestinationService()); + late final _destinations = DestinationsProvider(_destinationService); late final _wallet = WalletProvider( _walletService, _analytics, factors: _factors, + destinations: _destinationService, ); late final _limits = widget.limitsService ?? @@ -131,6 +140,7 @@ class _SxchngeAppState extends State { if (customer != _walletCustomer) { _walletCustomer = customer; _wallet.reset(); + _destinations.reset(); } final path = _router.routeInformationProvider.value.uri.path; if (path == '/' && _previousPath != '/' && _previousPath != '/two-factor') { @@ -145,6 +155,7 @@ class _SxchngeAppState extends State { _router.dispose(); _journeys.dispose(); _wallet.dispose(); + _destinations.dispose(); if (_walletService is BackendWalletService) { _walletService.close(); } @@ -161,6 +172,7 @@ class _SxchngeAppState extends State { Widget build(BuildContext context) => MultiProvider( providers: [ ChangeNotifierProvider.value(value: _wallet), + ChangeNotifierProvider.value(value: _destinations), ChangeNotifierProvider.value(value: _security), Provider.value(value: _factors), Provider.value(value: _journeys), diff --git a/frontend/app/lib/routing/router.dart b/frontend/app/lib/routing/router.dart index fda5cce..cd728c9 100644 --- a/frontend/app/lib/routing/router.dart +++ b/frontend/app/lib/routing/router.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import '../auth/page.dart'; +import '../destinations/list.dart'; import '../kyc/page.dart'; import '../security/challenge.dart'; import '../verification/page.dart'; @@ -82,6 +83,12 @@ GoRouter createAppRouter({bool Function()? authenticated}) { GoRoute( path: '/settings', builder: (context, state) => const SettingsPage(), + routes: [ + GoRoute( + path: 'payout-methods', + builder: (context, state) => const DestinationsPage(), + ), + ], ), GoRoute( path: '/top-up', diff --git a/frontend/app/lib/workspace/copy.dart b/frontend/app/lib/workspace/copy.dart index 061e490..a54fa7a 100644 --- a/frontend/app/lib/workspace/copy.dart +++ b/frontend/app/lib/workspace/copy.dart @@ -48,6 +48,7 @@ String fundsText( _ => l.wFailedOutgoing, }; String walletError(AppLocalizations l, WalletError e) => switch (e) { + WalletError.destinationChanged => l.dQuoteChanged, WalletError.invalidAmount => l.wInvalidAmount, WalletError.insufficientFunds => l.wInsufficient, WalletError.limitExceeded => l.wLimitError, diff --git a/frontend/app/lib/workspace/settings.dart b/frontend/app/lib/workspace/settings.dart index 136bfa0..c108eea 100644 --- a/frontend/app/lib/workspace/settings.dart +++ b/frontend/app/lib/workspace/settings.dart @@ -106,6 +106,15 @@ class SettingsPage extends StatelessWidget { ), ), const SizedBox(height: 16), + Card( + child: ListTile( + leading: const Icon(Icons.account_balance_outlined), + title: Text(l.dTitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/settings/payout-methods'), + ), + ), + const SizedBox(height: 16), const SecuritySettings(), const SizedBox(height: 20), TextButton( diff --git a/frontend/app/lib/workspace/summary.dart b/frontend/app/lib/workspace/summary.dart index e36323f..a6c4a77 100644 --- a/frontend/app/lib/workspace/summary.dart +++ b/frontend/app/lib/workspace/summary.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -6,6 +8,8 @@ import 'package:provider/provider.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart'; +import '../destinations/details.dart'; + import 'copy.dart'; import '../generated/i18n/app_localizations.dart'; @@ -49,13 +53,23 @@ class TransferSummary extends StatelessWidget { q.request.kind == TransferKind.payout ? l.wRecipientGets : l.wCredit, - money(context, b, q.credit), + q.payoutTerms == null + ? money(context, b, q.credit) + : '${NumberFormat.decimalPatternDigits(locale: Localizations.localeOf(context).toLanguageTag(), decimalDigits: q.payoutTerms!.precision).format(q.payoutTerms!.amount / pow(10, q.payoutTerms!.precision))} ${q.payoutTerms!.assetLabel}', large: true, ), + if (q.payoutTerms != null) ...[ + const SizedBox(height: 12), + Text(l.dTiming(q.payoutTerms!.expectedSeconds)), + if (p.service.isSimulated) Text(l.dDemoTerms), + ], if (q.request.kind == TransferKind.payout) ...[ const Divider(height: 32), Text(l.wRecipient), - SelectableText(q.request.recipient), + if (q.payoutSnapshot != null) + DestinationDetailsView(details: q.payoutSnapshot!) + else + SelectableText(q.request.recipient), ], ], ), diff --git a/frontend/app/lib/workspace/transfer.dart b/frontend/app/lib/workspace/transfer.dart index 6136eb6..fd0ecaa 100644 --- a/frontend/app/lib/workspace/transfer.dart +++ b/frontend/app/lib/workspace/transfer.dart @@ -11,6 +11,7 @@ import 'package:sxchnge_auth/sxchnge_auth.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import '../security/challenge.dart'; +import '../destinations/list.dart'; import 'copy.dart'; import 'summary.dart'; @@ -32,7 +33,7 @@ class TransferPageState extends State { source: widget.source, ); final _amount = TextEditingController(); - final _recipient = TextEditingController(); + final _focus = FocusNode(); final _content = GlobalKey(); Timer? _timer; @@ -41,7 +42,13 @@ class TransferPageState extends State { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _provider.addListener(_changed); + if (mounted) { + _provider.addListener(_changed); + final destinations = context.read(); + if (destinations.capabilities == null && !destinations.loading) { + unawaited(destinations.refresh()); + } + } }); _timer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted && _provider.quote != null) setState(() {}); @@ -118,7 +125,7 @@ class TransferPageState extends State { _provider.removeListener(_changed); _provider.dispose(); _amount.dispose(); - _recipient.dispose(); + _focus.dispose(); super.dispose(); } @@ -134,14 +141,16 @@ class TransferPageState extends State { final accounts = wallet.data?.accounts ?? []; final source = wallet.account(p.source); final live = !wallet.service.isSimulated; + final destinations = context.watch(); + final legacyPayout = + live && + !(destinations.capabilities?.types.isNotEmpty ?? false) && + destinations.methods.isEmpty; final expiresIn = p.quote?.expiresAt.difference(DateTime.now()).inSeconds ?? 0; final unavailableMethod = live && - ((widget.kind == TransferKind.payout && - (source?.payoutMethod.isEmpty ?? true)) || - (widget.kind == TransferKind.topUp && - !(source?.canFund ?? false))); + ((widget.kind == TransferKind.topUp && !(source?.canFund ?? false))); return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Center( @@ -235,7 +244,11 @@ class TransferPageState extends State { FilledButton( key: const ValueKey('transfer-confirm'), onPressed: p.busy ? null : _confirm, - child: Text(l.wConfirm), + child: Text( + widget.kind == TransferKind.payout + ? '${l.wPayout} ${money(context, source!, p.quote!.debit)}' + : l.wConfirm, + ), ), TextButton( onPressed: p.busy ? null : p.edit, @@ -316,23 +329,66 @@ class TransferPageState extends State { ), if (widget.kind == TransferKind.payout) ...[ const SizedBox(height: 20), - if (live && source?.payoutMethod.isNotEmpty == true) - InputDecorator( - decoration: InputDecoration( - labelText: l.wRecipient, + if (legacyPayout) ...[ + if (source?.payoutMethod.isNotEmpty == true) + OutlinedButton.icon( + key: const ValueKey( + 'choose-configured-destination', + ), + icon: const Icon( + Icons.account_balance_outlined, + ), + label: Text(source!.payoutLabel), + onPressed: p.busy + ? null + : () => p.change( + recipient: source.payoutMethod, + payoutLabel: source.payoutLabel, + ), ), - child: Text(source!.payoutLabel), - ), - if (!live) - TextField( - key: const ValueKey('transfer-recipient'), - controller: _recipient, - enabled: !p.busy, - decoration: InputDecoration( - labelText: l.wRecipient, - hintText: l.wRecipientHint, + if (source?.payoutMethod.isEmpty ?? true) + Text(l.wNoPayoutMethod), + ] else + OutlinedButton.icon( + key: const ValueKey( + 'choose-payout-destination', ), - onChanged: (v) => p.change(recipient: v), + icon: const Icon( + Icons.account_balance_wallet_outlined, + ), + label: Text( + p.payoutDestination == null + ? l.dSelect + : p.payoutLabel, + ), + onPressed: p.busy + ? null + : () async { + final selected = + await Navigator.push< + DestinationInput + >( + context, + MaterialPageRoute( + builder: (_) => + DestinationsPage( + payoutContext: + PayoutContext( + p.source, + amount: source + ?.parse( + p.amount, + ), + ), + ), + ), + ); + if (!context.mounted || + selected == null) { + return; + } + await p.selectPayout(selected); + }, ), ], if (unavailableMethod) ...[ @@ -346,7 +402,13 @@ class TransferPageState extends State { const SizedBox(height: 28), FilledButton( key: const ValueKey('transfer-review'), - onPressed: p.busy || unavailableMethod + onPressed: + p.busy || + unavailableMethod || + (widget.kind == TransferKind.payout && + (legacyPayout + ? p.recipient.isEmpty + : p.payoutDestination == null)) ? null : p.review, child: Text(l.wReview), diff --git a/frontend/app/test/destination_helpers.dart b/frontend/app/test/destination_helpers.dart new file mode 100644 index 0000000..5698357 --- /dev/null +++ b/frontend/app/test/destination_helpers.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +const testDestination = CryptoDestination( + assetId: 'demo:ethereum:native', + networkId: 'eip155:1', + address: '0x1234567890123456789012345678901234567890', + assetLabel: 'ETH', + networkLabel: 'Ethereum', +); +Future seedDestination(DummyDestinationService service) async { + await service.create( + testDestination, + 'Test wallet', + 'test-destination-creation', + ); +} + +Future selectPayout(WidgetTester t) async { + final button = find.byKey(const ValueKey('choose-payout-destination')); + await t.ensureVisible(button); + await t.tap(button); + await t.pumpAndSettle(); + final row = find.text('Test wallet'); + await t.ensureVisible(row); + await t.tap(row); + await t.pumpAndSettle(); +} diff --git a/frontend/app/test/destinations_test.dart b/frontend/app/test/destinations_test.dart new file mode 100644 index 0000000..96300d5 --- /dev/null +++ b/frontend/app/test/destinations_test.dart @@ -0,0 +1,245 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'package:sxchnge_app/main.dart'; +import 'package:sxchnge_app/providers/locale.dart'; + +import 'destination_helpers.dart'; + +Future click(WidgetTester t, Finder finder) async { + await t.ensureVisible(finder); + await t.pumpAndSettle(); + await t.tap(finder); + await t.pumpAndSettle(); +} + +Future open( + WidgetTester t, { + String path = '/settings/payout-methods', + String locale = 'en', + DummyDestinationService? destinations, + DummyAnalyticsService? analytics, +}) async { + t.platformDispatcher.defaultRouteNameTestValue = path; + addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue); + final wallet = DummyWalletService( + DummyIdentityService(delay: Duration.zero), + delay: Duration.zero, + destinations: destinations, + initialLocale: locale, + settlement: Duration.zero, + ); + await t.pumpWidget( + SxchngeApp( + walletService: wallet, + analyticsService: analytics, + localeProvider: LocaleProvider(initialLocale: Locale(locale)), + ), + ); + await t.pumpAndSettle(); + addTearDown(() => t.pumpWidget(const SizedBox.shrink())); + return wallet; +} + +Future cryptoForm(WidgetTester t, {bool payout = false}) async { + await click( + t, + find.text(payout ? 'Use a new destination' : 'Add payout method').first, + ); + await click(t, find.byType(DropdownButtonFormField)); + await click(t, find.text('Crypto address').last); + await click(t, find.byKey(const ValueKey('asset-null'))); + await click(t, find.text('ETH').last); + await click(t, find.byType(DropdownButtonFormField).last); + await click(t, find.text('Ethereum').last); + await t.enterText( + find.byKey(const ValueKey('destination-address')), + testDestination.address, + ); + await t.pump(); +} + +void main() { + testWidgets('Settings saves without nickname and exposes renamed method', ( + t, + ) async { + final wallet = await open(t); + await cryptoForm(t); + expect(find.text('Save for future payouts'), findsNothing); + await click(t, find.byKey(const ValueKey('destination-continue'))); + expect((await wallet.destinations.list()).length, 1); + expect(find.textContaining('ETH · Ethereum'), findsOneWidget); + await click(t, find.textContaining('ETH · Ethereum')); + await t.enterText( + find.byKey(const ValueKey('destination-nickname')), + 'My own wallet', + ); + await click(t, find.text('Rename')); + expect((await wallet.destinations.list()).single.nickname, 'My own wallet'); + await click(t, find.text('Remove')); + expect(find.textContaining('Submitted payouts'), findsOneWidget); + await click(t, find.widgetWithText(FilledButton, 'Remove')); + expect(await wallet.destinations.list(), isEmpty); + expect(t.takeException(), isNull); + }); + testWidgets('new one-time payout keeps saving unchecked and never persists', ( + t, + ) async { + final wallet = await open(t, path: '/payout'); + await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5'); + await click(t, find.byKey(const ValueKey('choose-payout-destination'))); + await cryptoForm(t, payout: true); + expect( + t.widget(find.byType(CheckboxListTile)).value, + isFalse, + ); + expect(find.byKey(const ValueKey('destination-nickname')), findsNothing); + await click(t, find.byKey(const ValueKey('destination-continue'))); + await click(t, find.byKey(const ValueKey('transfer-review'))); + expect(find.textContaining(testDestination.address), findsOneWidget); + await click(t, find.byKey(const ValueKey('transfer-confirm'))); + expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget); + expect(await wallet.destinations.list(), isEmpty); + }); + testWidgets( + 'optional saving failure permits explicit one-time continuation', + (t) async { + final service = DummyDestinationService(); + final wallet = await open(t, path: '/payout', destinations: service); + await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5'); + await click(t, find.byKey(const ValueKey('choose-payout-destination'))); + await cryptoForm(t, payout: true); + await click(t, find.text('Save for future payouts')); + service.nextSaveError = DestinationIssue.conflict; + await click(t, find.byKey(const ValueKey('destination-continue'))); + expect(find.textContaining('Couldn’t save'), findsOneWidget); + await click(t, find.text('Continue without saving')); + await click(t, find.byKey(const ValueKey('transfer-review'))); + await click(t, find.byKey(const ValueKey('transfer-confirm'))); + expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget); + expect(await wallet.destinations.list(), isEmpty); + }, + ); + testWidgets( + 'saved destination is never selected automatically and removal preserves amount', + (t) async { + final service = DummyDestinationService(); + await seedDestination(service); + final wallet = await open(t, path: '/payout', destinations: service); + await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5'); + expect( + t + .widget(find.byKey(const ValueKey('transfer-review'))) + .onPressed, + isNull, + ); + await selectPayout(t); + await click(t, find.byKey(const ValueKey('transfer-review'))); + final d = (await service.list()).single; + await service.remove(d.id, d.metadataRevision, d.executionRevision); + await click(t, find.byKey(const ValueKey('transfer-confirm'))); + expect(find.textContaining('Payout terms changed'), findsOneWidget); + expect(find.text('5'), findsOneWidget); + expect((await t.runAsync(wallet.fetch))!.operations, isEmpty); + }, + ); + for (final locale in ['en', 'ru']) { + testWidgets('all method forms fit $locale at 320px with large text', ( + t, + ) async { + t.view.physicalSize = const Size(320, 1100); + t.view.devicePixelRatio = 1; + t.platformDispatcher.textScaleFactorTestValue = 1.6; + addTearDown(t.view.resetPhysicalSize); + addTearDown(t.view.resetDevicePixelRatio); + addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue); + await open(t, locale: locale); + await click( + t, + find + .text( + locale == 'en' ? 'Add payout method' : 'Добавить способ вывода', + ) + .first, + ); + for (final name + in locale == 'en' + ? ['Crypto address', 'Bank card', 'Russian bank account'] + : [ + 'Криптоадрес', + 'Банковская карта', + 'Российский банковский счёт', + ]) { + await click(t, find.byType(DropdownButtonFormField)); + await click(t, find.text(name).last); + expect(t.takeException(), isNull); + } + }); + } + testWidgets('semantic save outcomes contain no destination fields', ( + t, + ) async { + final analytics = DummyAnalyticsService(); + await open(t, analytics: analytics); + await cryptoForm(t); + await click(t, find.byKey(const ValueKey('destination-continue'))); + await t.pumpAndSettle(); + final events = analytics.events + .where((e) => e.kind == JourneyKind.destination) + .toList(); + expect( + events.any( + (e) => + e.step == JourneyStep.destinationSave && + e.signal == JourneySignal.simulated, + ), + isTrue, + ); + expect( + events.map((e) => e.properties.toString()).join(), + isNot(contains(testDestination.address)), + ); + }); + + testWidgets( + 'binding recovery uses secure capture and preserves the saved card', + (t) async { + final service = DummyDestinationService(); + final created = await service.create( + const CardDestination( + capture: 'demo-card-recover', + brand: '', + last4: '', + expiryMonth: 0, + expiryYear: 0, + ), + 'Own card', + 'creation-key-000001', + ); + await open(t, destinations: service); + await click(t, find.text('Own card')); + await click(t, find.text('Update authorization')); + expect(find.byKey(const ValueKey('destination-nickname')), findsNothing); + await click(t, find.text('Demo card •••• 4242')); + await click(t, find.byKey(const ValueKey('destination-continue'))); + final saved = await service.get(created.destinationId); + expect(saved.executionRevision, 2); + expect(saved.nickname, 'Own card'); + expect((await service.list()).length, 1); + expect( + (await service.assess( + saved.input, + const PayoutContext('eur'), + )).eligible, + isTrue, + ); + }, + ); +} diff --git a/frontend/app/test/payout_backend_test.dart b/frontend/app/test/payout_backend_test.dart index d2869ff..30c7834 100644 --- a/frontend/app/test/payout_backend_test.dart +++ b/frontend/app/test/payout_backend_test.dart @@ -76,6 +76,14 @@ void main() { await t.ensureVisible(review); if (hasMethod) { expect(find.text(destinationLabel), findsOneWidget); + final choose = find.byKey( + const ValueKey('choose-configured-destination'), + ); + expect(t.widget(review).onPressed, isNull); + await t.ensureVisible(choose); + await t.tap(choose); + await t.pumpAndSettle(); + await t.ensureVisible(review); await t.tap(review); await t.pumpAndSettle(); expect(wallet.received, 'method-ref'); diff --git a/frontend/app/test/two_factor_test.dart b/frontend/app/test/two_factor_test.dart index 06d41a7..b7baa4c 100644 --- a/frontend/app/test/two_factor_test.dart +++ b/frontend/app/test/two_factor_test.dart @@ -6,9 +6,15 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_auth/sxchnge_auth.dart'; +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + import 'package:sxchnge_app/main.dart'; import 'package:sxchnge_app/providers/locale.dart'; +import 'destination_helpers.dart'; + Future tapFactor(WidgetTester t, String key) async { final finder = find.byKey(ValueKey(key)); await t.ensureVisible(finder); @@ -31,8 +37,16 @@ Future openFactor( delay: Duration.zero, method: method, ); + final wallet = DummyWalletService( + DummyIdentityService(delay: Duration.zero), + factors: service, + initialLocale: locale, + delay: Duration.zero, + ); + await seedDestination(wallet.destinations); await t.pumpWidget( SxchngeApp( + walletService: wallet, twoFactorService: service, analyticsService: analytics, localeProvider: LocaleProvider(initialLocale: Locale(locale)), @@ -174,10 +188,7 @@ void main() { await openFactor(t, path: '/$kind', enabled: true); await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10'); if (kind == 'payout') { - await t.enterText( - find.byKey(const ValueKey('transfer-recipient')), - 'Sample recipient', - ); + await selectPayout(t); } await tapFactor(t, 'transfer-review'); await tapFactor(t, 'transfer-confirm'); diff --git a/frontend/app/test/workspace_test.dart b/frontend/app/test/workspace_test.dart index bbb9be2..33028bc 100644 --- a/frontend/app/test/workspace_test.dart +++ b/frontend/app/test/workspace_test.dart @@ -11,6 +11,8 @@ import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import 'package:sxchnge_app/main.dart'; import 'package:sxchnge_app/providers/locale.dart'; +import 'destination_helpers.dart'; + Future tap(WidgetTester t, Finder f) async { await t.ensureVisible(f); await t.tap(f); @@ -32,6 +34,7 @@ Future open( settlement: settlement, initialLocale: locale, ); + await seedDestination(service.destinations); await t.pumpWidget( SxchngeApp( identityService: identity, @@ -78,10 +81,7 @@ void main() { expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget); await tap(t, find.text('Pay out').last); await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5'); - await t.enterText( - find.byKey(const ValueKey('transfer-recipient')), - 'Sample recipient', - ); + await selectPayout(t); await tap(t, find.byKey(const ValueKey('transfer-review'))); expect(find.text('Recipient receives'), findsOneWidget); await tap(t, find.byKey(const ValueKey('transfer-confirm'))); @@ -101,10 +101,7 @@ void main() { ); s.failNextOperation = true; await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10'); - await t.enterText( - find.byKey(const ValueKey('transfer-recipient')), - 'Sample recipient', - ); + await selectPayout(t); await tap(t, find.byKey(const ValueKey('transfer-review'))); await tap(t, find.byKey(const ValueKey('transfer-confirm'))); // Settlement is service-owned; refresh after the simulated processing period. diff --git a/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart b/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart index 043bbf7..22e481e 100644 --- a/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart +++ b/frontend/packages/sxchnge_analytics/lib/sxchnge_analytics.dart @@ -11,8 +11,11 @@ enum JourneyKind { exchange, payout, security, + destination, } +enum DestinationResolution { created, duplicate } + enum JourneyStep { login, signup, @@ -23,6 +26,10 @@ enum JourneyStep { kycSelfie, kycSubmission, kycStatus, + destinationDraft, + destinationSave, + destinationReconciliation, + destinationBinding, transferDraft, transferQuote, transferSubmission, @@ -78,6 +85,7 @@ class AnalyticsEvent { required this.sequence, required this.elapsedMs, this.step, + this.resolution, this.operation, this.failure, this.exit, @@ -90,6 +98,7 @@ class AnalyticsEvent { final JourneyKind kind; final JourneySignal signal; final JourneyStep? step; + final DestinationResolution? resolution; final JourneyFailure? failure; final JourneyExit? exit; final int sequence; @@ -105,6 +114,7 @@ class AnalyticsEvent { JourneyKind.exchange => 'exchange', JourneyKind.payout => 'payout', JourneyKind.security => 'security', + JourneyKind.destination => 'destination', _ => 'auth', }; var stage = step == null ? 'journey' : _snake(step!.name); @@ -123,6 +133,7 @@ class AnalyticsEvent { 'terminal': terminal, 'simulated': simulated, if (step != null) 'step': _snake(step!.name), + if (resolution != null) 'destination_resolution': resolution!.name, if (operation != null) 'operation': operation!, if (failure != null) 'failure': _snake(failure!.name), if (exit != null) 'exit': _snake(exit!.name), @@ -210,6 +221,7 @@ class JourneyRecorder { JourneyFailure? failure, bool terminal = false, bool cancelled = false, + DestinationResolution? resolution, }) { final journey = operation._journey; if (_disposed || operation._finished || journey.closed) return; @@ -227,6 +239,7 @@ class JourneyRecorder { ? JourneySignal.simulated : JourneySignal.succeeded, operation: operation, + resolution: resolution, failure: failure, terminal: complete, simulated: simulated || (complete && journey.simulated), @@ -258,6 +271,7 @@ class JourneyRecorder { _Journey journey, JourneySignal signal, { JourneyOperation? operation, + DestinationResolution? resolution, JourneyFailure? failure, JourneyExit? exit, bool terminal = false, @@ -271,6 +285,7 @@ class JourneyRecorder { sequence: ++journey.sequence, elapsedMs: journey.time.elapsedMilliseconds, step: operation?.step, + resolution: resolution, operation: operation?.number, failure: failure, exit: exit, diff --git a/frontend/packages/sxchnge_wallet/README.md b/frontend/packages/sxchnge_wallet/README.md index ddaa252..794f475 100644 --- a/frontend/packages/sxchnge_wallet/README.md +++ b/frontend/packages/sxchnge_wallet/README.md @@ -47,3 +47,17 @@ once on success/failure. Closing the app while pending does not count as draft abandonment. No amounts, account IDs or recipient data enter events. All demo signals are simulated. Cross-session outcomes require backend event delivery; client-only analytics are not production accounting or conversion evidence. + +## Saved destinations — #54 + +The injectable `DestinationService` adds canonical crypto, secure-card capture and +Russian bank models, saved/one-time input, independent metadata/execution revisions, +stable created/duplicate outcomes, contextual eligibility and management providers. +`DummyDestinationService` is customer-scoped and process-local. It has isolated +fixtures for directory/capture/recovery states, not live banking or card collection. +The demo wallet binds immutable destination snapshots to quotes and rechecks at its +synchronous acceptance boundary. Production must enforce those checks atomically +in the backend. Live mode currently advertises no new destination capabilities and +preserves explicit selection of the already provisioned wallet method. It never +uses a demo store. See [the UX/service contract](../../../docs/ux/payout-destinations.md) +for integration, security, retention, localization and acceptance requirements. diff --git a/frontend/packages/sxchnge_wallet/lib/src/destinations/bank_paste.dart b/frontend/packages/sxchnge_wallet/lib/src/destinations/bank_paste.dart new file mode 100644 index 0000000..2b3665c --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/destinations/bank_paste.dart @@ -0,0 +1,49 @@ +/// Local, explicit-label paste parsing. Raw pasted text is never retained. +class BankPaste { + BankPaste._(this.values, this.conflicts); + final Map values; + final Set conflicts; + static BankPaste parse(String text) { + final values = {}; + final conflicts = {}; + const labels = { + 'бик': 'bik', + 'bik': 'bik', + 'счет': 'account', + 'счёт': 'account', + 'номер счета': 'account', + 'номер счёта': 'account', + 'account': 'account', + 'account number': 'account', + 'имя': 'firstName', + 'first name': 'firstName', + 'фамилия': 'lastName', + 'last name': 'lastName', + 'отчество': 'patronymic', + 'patronymic': 'patronymic', + 'получатель': 'holder', + 'фио': 'holder', + 'account holder': 'holder', + 'банк': 'bank', + 'bank': 'bank', + 'корреспондентский счет': 'correspondent', + 'корреспондентский счёт': 'correspondent', + 'correspondent account': 'correspondent', + }; + for (final line in text.split(RegExp(r'[\r\n]+'))) { + final match = RegExp(r'^\s*([^:=]+)\s*[:=]\s*(.+?)\s*$').firstMatch(line); + if (match == null) continue; + final key = labels[match[1]!.trim().toLowerCase()]; + if (key == null) continue; + var value = match[2]!.trim(); + if (['bik', 'account', 'correspondent'].contains(key)) + value = value.replaceAll(RegExp(r'\s'), ''); + if (values.containsKey(key) && values[key] != value) conflicts.add(key); + values[key] = value; + } + for (final key in conflicts) { + values.remove(key); + } + return BankPaste._(Map.unmodifiable(values), Set.unmodifiable(conflicts)); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/destinations/dummy.dart b/frontend/packages/sxchnge_wallet/lib/src/destinations/dummy.dart new file mode 100644 index 0000000..1b73e64 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/destinations/dummy.dart @@ -0,0 +1,614 @@ +import 'dart:convert'; + +import 'key.dart'; +import 'models.dart'; +import 'service.dart'; +import 'transport.dart'; + +/// Shared only by injected demo sessions. Never production persistence. +class DestinationMemory { + final records = >{}; + final attempts = >{}; + final audit = []; + final recoveries = >{}; + final fingerprints = >{}; +} + +/// Service-authoritative simulation. No raw card capture and no live rails. +class DummyDestinationService implements DestinationService { + DummyDestinationService({ + this.customer = 'demo-customer', + DestinationMemory? memory, + DateTime Function()? now, + }) : memory = memory ?? DestinationMemory(), + now = now ?? DateTime.now; + final String customer; + final DestinationMemory memory; + final DateTime Function() now; + final availability = {}; + int requirementsRevision = 1; + bool loseNextSaveResponse = false; + bool reusableCards = true, cardRecovery = true; + DestinationIssue? nextSaveError; + bool directoryUnavailable = false; + List directory = [ + const Bank( + bik: '044525225', + name: 'Demo Bank', + city: 'Moscow', + correspondent: '30101810400000000225', + revision: 'demo-1', + ), + ]; + static const assets = [ + CryptoAsset('demo:ethereum:native', 'ETH', 'eip155:1', 'Ethereum'), + CryptoAsset( + 'demo:ethereum:usdc', + 'USDC · Ethereum · demo token A', + 'eip155:1', + 'Ethereum', + ), + CryptoAsset( + 'demo:ethereum:usdc-alt', + 'USDC · Ethereum · demo token B', + 'eip155:1', + 'Ethereum', + ), + CryptoAsset('demo:polygon:usdc', 'USDC · Polygon', 'eip155:137', 'Polygon'), + CryptoAsset( + 'demo:xrp:native', + 'XRP', + 'xrpl:mainnet', + 'XRP Ledger', + memoRequired: true, + memoSupported: true, + ), + ]; + Map get _records => + memory.records.putIfAbsent(customer, () => {}); + Map get _attempts => + memory.attempts.putIfAbsent(customer, () => {}); + @override + bool get isSimulated => true; + @override + Future capabilities() async => + DestinationCapabilities( + types: DestinationType.values, + assets: assets, + reusableCards: true, + cardRecovery: true, + simulated: true, + ); + @override + Future> list() async => + _records.values.where((d) => !d.removed).toList(); + SavedDestination _get(String id) { + final d = _records[id]; + if (d == null) throw const DestinationException(DestinationIssue.removed); + return d; + } + + @override + Future get(String id) async => _get(id); + void _audit(String reference, String action, String outcome) => memory.audit + .add(DestinationAudit(customer, reference, action, outcome, now())); + DestinationDetails _validate(DestinationDetails details) { + switch (details) { + case CryptoDestination d: + final asset = assets + .where((a) => a.id == d.assetId && a.networkId == d.networkId) + .firstOrNull; + if (asset == null) + throw const DestinationException( + DestinationIssue.invalid, + field: 'asset', + ); + final address = d.address.trim(); + final valid = asset.networkId.startsWith('eip155:') + ? RegExp(r'^0x[0-9a-fA-F]{40}$').hasMatch(address) + : RegExp(r'^r[1-9A-HJ-NP-Za-km-z]{24,34}$').hasMatch(address); + if (!valid) + throw const DestinationException( + DestinationIssue.invalid, + field: 'address', + ); + if (asset.memoRequired && !RegExp(r'^\d{1,10}$').hasMatch(d.memo)) + throw const DestinationException( + DestinationIssue.invalid, + field: 'memo', + ); + if (!asset.memoSupported && d.memo.isNotEmpty) + throw const DestinationException( + DestinationIssue.invalid, + field: 'memo', + ); + return CryptoDestination( + assetId: asset.id, + networkId: asset.networkId, + address: address, + memo: d.memo, + assetLabel: asset.label, + networkLabel: asset.networkLabel, + ); + case RussianBankDestination d: + final account = d.account.replaceAll(RegExp(r'\s'), ''); + if (d.firstName.trim().isEmpty || d.lastName.trim().isEmpty) + throw const DestinationException( + DestinationIssue.invalid, + field: 'holder', + ); + if (!RegExp(r'^\d{9}$').hasMatch(d.bank.bik) || + !validBankAccount(account, d.bank.bik)) + throw const DestinationException( + DestinationIssue.invalid, + field: 'account', + ); + if (directoryUnavailable) + throw const DestinationException( + DestinationIssue.unavailable, + field: 'bank', + ); + final bank = directory.where((b) => b.bik == d.bank.bik).firstOrNull; + if (bank == null) + throw const DestinationException( + DestinationIssue.invalid, + field: 'bank', + ); + if (bank.correspondent != d.bank.correspondent) + throw const DestinationException( + DestinationIssue.bankChanged, + field: 'bank', + ); + // Directory display values are authoritative; identity is never guessed. + return RussianBankDestination( + firstName: d.firstName.trim(), + lastName: d.lastName.trim(), + patronymic: d.patronymic.trim(), + bank: bank, + account: account, + ); + case CardDestination d: + if (![ + 'demo-card-ready', + 'demo-card-recover', + 'demo-card-unknown', + ].contains(d.capture)) + throw const DestinationException( + DestinationIssue.invalid, + field: 'card', + ); + // These are secure-collection fixtures, not user-entered card metadata. + return CardDestination( + capture: d.capture, + brand: 'Demo card', + last4: d.capture == 'demo-card-unknown' ? '0027' : '4242', + expiryMonth: 12, + expiryYear: now().year + 2, + ); + } + } + + @override + Future validate(DestinationDetails details) async => + DestinationValidation(DestinationMapper.clone(_validate(details))); + + String? _identity(DestinationDetails details) => switch (details) { + CryptoDestination d => jsonEncode([ + d.type.name, + d.assetId, + d.networkId, + d.networkId.startsWith('eip155:') ? d.address.toLowerCase() : d.address, + d.memo, + ]), + RussianBankDestination d => jsonEncode([ + d.type.name, + d.bank.bik, + d.account, + d.firstName, + d.lastName, + d.patronymic, + ]), + CardDestination d => + d.capture.isEmpty + ? (memory.fingerprints[customer] ?? {})[d.identity] + : d.capture == 'demo-card-unknown' + ? null + : 'demo-card-fingerprint:4242', + }; + DestinationDetails _resolve(DestinationInput input) { + if (input is OneTimeDestination) return _validate(input.details); + final ref = input as SavedReference; + final d = _get(ref.id); + if (d.removed) throw const DestinationException(DestinationIssue.removed); + if (d.executionRevision != ref.executionRevision) + throw const DestinationException(DestinationIssue.conflict); + return d.details; + } + + PayoutEligibility _assess(DestinationInput input, PayoutContext context) { + final d = _resolve(input); + if (input is SavedReference) { + final saved = _get(input.id); + if (saved.ownership.actionRequired) + return const PayoutEligibility( + EligibilityState.requiresAction, + reason: DestinationIssue.authorization, + ); + final override = availability[input.id]; + if (override != null) return override; + if (d is CardDestination) { + if (d.expiryYear < now().year || + d.expiryYear == now().year && d.expiryMonth < now().month) + return const PayoutEligibility( + EligibilityState.requiresAction, + reason: DestinationIssue.expired, + ); + final ready = saved.bindings.any( + (b) => + b.reusable && + b.state == BindingState.ready && + (b.expiresAt == null || now().isBefore(b.expiresAt!)), + ); + if (!ready) { + final recoverable = + cardRecovery && saved.bindings.any((b) => b.recoverable); + final temporary = saved.bindings.any( + (b) => b.state == BindingState.unavailable, + ); + return PayoutEligibility( + recoverable + ? EligibilityState.requiresAction + : temporary + ? EligibilityState.temporarilyUnavailable + : EligibilityState.unsupported, + reason: recoverable + ? DestinationIssue.authorization + : temporary + ? DestinationIssue.unavailable + : DestinationIssue.unsupported, + ); + } + } + } + if (context.source.isNotEmpty && + !['eur', 'usd', 'gbp'].contains(context.source)) + return const PayoutEligibility( + EligibilityState.unsupported, + reason: DestinationIssue.unsupported, + ); + if (d is RussianBankDestination) { + if (directoryUnavailable) + return const PayoutEligibility( + EligibilityState.temporarilyUnavailable, + reason: DestinationIssue.unavailable, + ); + final current = directory.where((b) => b.bik == d.bank.bik).firstOrNull; + if (current == null || !current.supported) + return const PayoutEligibility( + EligibilityState.unsupported, + reason: DestinationIssue.unsupported, + ); + if (current.correspondent != d.bank.correspondent) + return const PayoutEligibility( + EligibilityState.requiresAction, + reason: DestinationIssue.bankChanged, + ); + } + if (d is CardDestination && d.capture == 'demo-card-recover') + return const PayoutEligibility( + EligibilityState.requiresAction, + reason: DestinationIssue.authorization, + ); + return PayoutEligibility( + EligibilityState.eligible, + canSave: d is! CardDestination || reusableCards || cardRecovery, + ); + } + + @override + Future assess( + DestinationInput input, + PayoutContext context, + ) async => _assess(input, context); + + @override + Future create( + DestinationDetails details, + String nickname, + String idempotencyKey, { + String? replaces, + }) async { + if (idempotencyKey.length < 16 || nickname.length > 80) + throw const DestinationException(DestinationIssue.invalid); + final payload = jsonEncode([ + DestinationMapper.details(details).value, + nickname, + replaces, + ]); + final previous = _attempts[idempotencyKey]; + if (previous != null) { + if (previous.$1 != payload) + throw const DestinationException(DestinationIssue.conflict); + return previous.$2; + } + final error = nextSaveError; + nextSaveError = null; + if (error != null) { + _audit(idempotencyKey, 'create', 'failed'); + throw DestinationException(error); + } + if (replaces != null && _get(replaces).removed) + throw const DestinationException(DestinationIssue.removed); + final normalized = _validate(details); + if (normalized is CardDestination && !reusableCards && !cardRecovery) + throw const DestinationException(DestinationIssue.unsupported); + final identity = _identity(normalized); + final existing = identity == null + ? null + : _records.values + .where((r) => !r.removed && _identity(r.details) == identity) + .firstOrNull; + final result = CreationResult( + existing == null ? SaveOutcome.created : SaveOutcome.duplicate, + existing?.id ?? destinationKey(), + ); + // Synchronous mutation is the demo's linearization boundary; no await gap. + if (existing == null) { + _records[result.destinationId] = SavedDestination( + id: result.destinationId, + details: normalized is CardDestination + ? CardDestination( + identity: result.destinationId, + brand: normalized.brand, + last4: normalized.last4, + expiryMonth: normalized.expiryMonth, + expiryYear: normalized.expiryYear, + ) + : DestinationMapper.clone(normalized), + nickname: nickname.trim(), + bindings: normalized is CardDestination + ? [ + ProviderBinding( + reference: destinationKey(), + state: + !reusableCards || + normalized.capture == 'demo-card-recover' + ? BindingState.expired + : BindingState.ready, + reusable: reusableCards, + recoverable: cardRecovery, + ), + ] + : [], + ); + } + if (existing == null && normalized is CardDestination) + memory.fingerprints.putIfAbsent( + customer, + () => {}, + )[result.destinationId] = identity; + _attempts[idempotencyKey] = (payload, result); + _audit( + result.destinationId, + replaces == null ? 'create' : 'replacement', + result.outcome.name, + ); + if (loseNextSaveResponse) { + loseNextSaveResponse = false; + throw const DestinationException(DestinationIssue.uncertain); + } + return result; + } + + @override + Future creation(String idempotencyKey) async => + _attempts[idempotencyKey]?.$2; + @override + Future rename( + String id, + String nickname, + int metadataRevision, + ) async { + final d = _get(id); + if (d.removed) throw const DestinationException(DestinationIssue.removed); + if (d.metadataRevision != metadataRevision) { + _audit(id, 'rename', 'conflict'); + throw const DestinationException(DestinationIssue.conflict); + } + if (nickname.length > 80) + throw const DestinationException( + DestinationIssue.invalid, + field: 'nickname', + ); + final updated = SavedDestination( + id: d.id, + details: d.details, + nickname: nickname.trim(), + metadataRevision: d.metadataRevision + 1, + executionRevision: d.executionRevision, + bindings: d.bindings, + lastUsed: d.lastUsed, + ownership: d.ownership, + ); + _records[id] = updated; + _audit(id, 'rename', 'succeeded'); + return updated; + } + + @override + Future remove( + String id, + int metadataRevision, + int executionRevision, + ) async { + final d = _get(id); + if (d.removed) return; + if (d.metadataRevision != metadataRevision || + d.executionRevision != executionRevision) { + _audit(id, 'remove', 'conflict'); + throw const DestinationException(DestinationIssue.conflict); + } + _records[id] = SavedDestination( + id: d.id, + details: d.details, + nickname: d.nickname, + metadataRevision: d.metadataRevision + 1, + executionRevision: d.executionRevision + 1, + removed: true, + bindings: d.bindings, + lastUsed: d.lastUsed, + ownership: d.ownership, + ); + _audit(id, 'remove', 'succeeded'); + } + + @override + Future> banks(String query, {Future? abortTrigger}) async { + if (directoryUnavailable) + throw const DestinationException(DestinationIssue.unavailable); + final value = query.trim().toLowerCase(); + if (RegExp(r'^\d+$').hasMatch(value) && value.length > 9) + throw const DestinationException(DestinationIssue.invalid, field: 'bik'); + if (value.length < 2) return []; + return directory + .where( + (b) => value.length == 9 && RegExp(r'^\d+$').hasMatch(value) + ? b.bik == value + : b.name.toLowerCase().contains(value) || b.bik.contains(value), + ) + .toList(); + } + + DestinationDetails _snapshot(DestinationDetails details) => + details is CardDestination + ? CardDestination( + identity: details.identity, + brand: details.brand, + last4: details.last4, + expiryMonth: details.expiryMonth, + expiryYear: details.expiryYear, + ) + : DestinationMapper.clone(details); + + @override + Future prepare( + DestinationInput input, + PayoutContext context, + ) async { + final eligibility = _assess(input, context); + if (!eligibility.eligible) + throw DestinationException( + eligibility.reason ?? DestinationIssue.unsupported, + ); + return DestinationLease( + input: input, + snapshot: _snapshot(_resolve(input)), + context: context, + requirementsRevision: requirementsRevision, + expiresAt: now().add(const Duration(seconds: 60)), + ); + } + + /// Synchronous demo acceptance boundary; production enforcement belongs in BFF. + void accept(DestinationLease lease) { + if (!now().isBefore(lease.expiresAt)) + throw const DestinationException(DestinationIssue.expired); + if (requirementsRevision != lease.requirementsRevision) + throw const DestinationException(DestinationIssue.conflict); + final result = _assess(lease.input, lease.context); + if (!result.eligible) + throw DestinationException(result.reason ?? DestinationIssue.unsupported); + if (lease.input case SavedReference ref) { + final d = _get(ref.id); + _records[d.id] = SavedDestination( + id: d.id, + details: d.details, + nickname: d.nickname, + metadataRevision: d.metadataRevision, + executionRevision: d.executionRevision, + bindings: d.bindings, + lastUsed: now(), + ownership: d.ownership, + ); + } + } + + @override + Future restoreCard( + String id, + int executionRevision, + CardDestination capture, + String idempotencyKey, + ) async { + if (idempotencyKey.length < 16) + throw const DestinationException(DestinationIssue.invalid); + final payload = jsonEncode([ + id, + executionRevision, + DestinationMapper.details(capture).value, + ]); + final attempts = memory.recoveries.putIfAbsent(customer, () => {}); + final previous = attempts[idempotencyKey]; + if (previous != null) { + if (previous.$1 != payload) + throw const DestinationException(DestinationIssue.conflict); + return _get(previous.$2); + } + final d = _get(id); + if (d.removed) throw const DestinationException(DestinationIssue.removed); + if (d.executionRevision != executionRevision) + throw const DestinationException(DestinationIssue.conflict); + if (!cardRecovery || d.details is! CardDestination) + throw const DestinationException(DestinationIssue.unsupported); + final normalized = _validate(capture) as CardDestination; + final fingerprint = _identity(normalized); + if (fingerprint == null || fingerprint != _identity(d.details)) + throw const DestinationException(DestinationIssue.replacementRequired); + if (capture.capture != 'demo-card-ready') + throw const DestinationException(DestinationIssue.authorization); + final updated = SavedDestination( + id: d.id, + details: CardDestination( + identity: (d.details as CardDestination).identity, + brand: normalized.brand, + last4: normalized.last4, + expiryMonth: normalized.expiryMonth, + expiryYear: normalized.expiryYear, + ), + nickname: d.nickname, + metadataRevision: d.metadataRevision, + executionRevision: d.executionRevision + 1, + lastUsed: d.lastUsed, + ownership: d.ownership, + bindings: [ + ProviderBinding( + reference: destinationKey(), + state: BindingState.ready, + reusable: true, + recoverable: true, + ), + ], + ); + _records[id] = updated; + attempts[idempotencyKey] = (payload, id); + _audit(id, 'bindingRestored', 'succeeded'); + return updated; + } + + @override + void clearTransient() { + /* No transient server records survive method calls. */ + } +} + +bool validBankAccount(String account, String bik) { + if (!RegExp(r'^\d{20}$').hasMatch(account) || + !RegExp(r'^\d{9}$').hasMatch(bik)) + return false; + final digits = bik.substring(6) + account; + var sum = 0; + for (var i = 0; i < digits.length; i++) { + sum += int.parse(digits[i]) * [7, 1, 3][i % 3]; + } + return sum % 10 == 0; +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/destinations/form.dart b/frontend/packages/sxchnge_wallet/lib/src/destinations/form.dart new file mode 100644 index 0000000..25e01c8 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/destinations/form.dart @@ -0,0 +1,276 @@ +import 'package:flutter/foundation.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import 'key.dart'; +import 'models.dart'; +import 'service.dart'; + +class DestinationFormProvider extends ChangeNotifier { + DestinationFormProvider( + this.service, + this.analytics, { + this.context, + this.initial, + this.replacing, + this.restoring, + }) : recorder = JourneyRecorder(analytics), + details = initial { + if (context == null) _startJourney(); + } + final DestinationService service; + final AnalyticsService analytics; + final JourneyRecorder recorder; + final PayoutContext? context; + final DestinationDetails? initial; + final String? replacing; + final SavedReference? restoring; + DestinationDetails? details; + String nickname = ''; + bool save = false, busy = false, _disposed = false, uncertain = false; + DestinationIssue? error; + String? errorField; + CreationResult? saved; + PayoutEligibility? eligibility; + DestinationInput? result; + String? _key; + bool _creationStarted = false; + bool _journeyStarted = false; + void _startJourney() { + if (_journeyStarted) return; + _journeyStarted = true; + final op = recorder.begin( + JourneyKind.destination, + JourneyStep.destinationDraft, + simulated: service.isSimulated, + ); + recorder.finish(op, simulated: service.isSimulated); + } + + bool savingFailed = false; + bool get hasDraft => details != null || nickname.isNotEmpty; + void change(DestinationDetails? value) { + if (busy || uncertain) return; + details = value; + error = null; + errorField = null; + eligibility = null; + result = null; + saved = null; + _key = null; + _creationStarted = false; + notifyListeners(); + } + + void preferences({bool? save, String? nickname}) { + if (busy || uncertain) return; + if (save == true) _startJourney(); + if (save == false && this.save) { + recorder.abandon(JourneyExit.modeChange); + _journeyStarted = false; + } + this.save = save ?? this.save; + this.nickname = nickname ?? this.nickname; + _key = null; + saved = null; + error = null; + notifyListeners(); + } + + Future proceed({bool withoutSaving = false}) async { + if (busy || details == null) return; + if (withoutSaving && !uncertain) recorder.abandon(JourneyExit.modeChange); + if (uncertain && !withoutSaving) { + await reconcile(); + return; + } + busy = true; + error = null; + errorField = null; + savingFailed = false; + if (!uncertain) _creationStarted = false; + notifyListeners(); + try { + // Validation precedes both persistence and payout assessment. + final normalized = (await service.validate(details!)).details; + if (_disposed) return; + if (restoring != null) { + _key ??= destinationKey(); + final bindingOperation = recorder.begin( + JourneyKind.destination, + JourneyStep.destinationBinding, + simulated: service.isSimulated, + ); + final restored = await service.restoreCard( + restoring!.id, + restoring!.executionRevision, + normalized as CardDestination, + _key!, + ); + recorder.finish( + bindingOperation, + terminal: true, + simulated: service.isSimulated, + ); + if (!_disposed) result = restored.input; + } else if (context != null) { + eligibility = await service.assess( + OneTimeDestination(normalized), + context!, + ); + if (_disposed) return; + } + if (restoring == null && + (context == null || save) && + !withoutSaving && + saved == null) { + if (eligibility?.canSave == false) + throw const DestinationException(DestinationIssue.unsupported); + _key ??= destinationKey(); + final op = recorder.begin( + JourneyKind.destination, + JourneyStep.destinationSave, + simulated: service.isSimulated, + ); + _creationStarted = true; + try { + saved = await service.create( + normalized, + nickname, + _key!, + replaces: replacing, + ); + _creationStarted = false; + if (_disposed) return; + recorder.finish( + op, + simulated: service.isSimulated, + terminal: true, + resolution: DestinationResolution.values.byName( + saved!.outcome.name, + ), + ); + } catch (e) { + savingFailed = true; + recorder.finish( + op, + failure: JourneyFailure.unavailable, + simulated: service.isSimulated, + ); + rethrow; + } + } + if (restoring != null) { + // Restored bindings never create or rename a saved destination. + } else if (context == null) { + if (saved?.outcome == SaveOutcome.created) + result = SavedReference( + saved!.destinationId, + (await service.get(saved!.destinationId)).executionRevision, + ); + } else if (eligibility!.eligible) { + if (withoutSaving || saved == null) { + result = OneTimeDestination(normalized); + } else if (saved!.outcome == SaveOutcome.created) { + final d = await service.get(saved!.destinationId); + result = d.input; + } + // Duplicates need explicit Use existing method, never another create. + } + } on DestinationException catch (e) { + error = e.issue; + errorField = e.field; + uncertain = + _creationStarted && + [ + DestinationIssue.uncertain, + DestinationIssue.unavailable, + ].contains(e.issue); + } catch (_) { + error = DestinationIssue.unavailable; + uncertain = _creationStarted; + } + if (_disposed) return; + busy = false; + notifyListeners(); + } + + Future useExisting() async { + if (busy || saved == null) return; + error = null; + busy = true; + notifyListeners(); + try { + final d = await service.get(saved!.destinationId); + if (d.removed) throw const DestinationException(DestinationIssue.removed); + if (context != null) + eligibility = await service.assess(d.input, context!); + if (context == null || eligibility!.eligible) result = d.input; + } on DestinationException catch (e) { + error = e.issue; + } catch (_) { + error = DestinationIssue.unavailable; + } + if (_disposed) return; + busy = false; + notifyListeners(); + } + + Future reconcile() async { + if (busy || _key == null) return; + busy = true; + notifyListeners(); + final op = recorder.begin( + JourneyKind.destination, + JourneyStep.destinationReconciliation, + simulated: service.isSimulated, + ); + try { + final outcome = await service.creation(_key!); + if (_disposed) return; + saved = outcome; + recorder.finish( + op, + simulated: service.isSimulated, + terminal: outcome != null, + resolution: outcome == null + ? null + : DestinationResolution.values.byName(outcome.outcome.name), + ); + uncertain = false; + _creationStarted = false; + error = null; + // Absence is an authoritative lookup result, not a timeout inference. + if (outcome?.outcome == SaveOutcome.created && context == null) { + final d = await service.get(outcome!.destinationId); + if (d.removed) + throw const DestinationException(DestinationIssue.removed); + result = d.input; + } + } on DestinationException catch (e) { + recorder.finish( + op, + simulated: service.isSimulated, + failure: JourneyFailure.unavailable, + ); + error = e.issue; + } catch (_) { + recorder.finish( + op, + simulated: service.isSimulated, + failure: JourneyFailure.unavailable, + ); + error = DestinationIssue.unavailable; + } + if (_disposed) return; + busy = false; + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + recorder.dispose(abandonActive: !uncertain); + super.dispose(); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/destinations/key.dart b/frontend/packages/sxchnge_wallet/lib/src/destinations/key.dart new file mode 100644 index 0000000..17cc43c --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/destinations/key.dart @@ -0,0 +1,7 @@ +import 'dart:math'; + +/// An opaque attempt/record reference, never derived from destination details. +String destinationKey() => List.generate( + 16, + (_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'), +).join(); diff --git a/frontend/packages/sxchnge_wallet/lib/src/destinations/management.dart b/frontend/packages/sxchnge_wallet/lib/src/destinations/management.dart new file mode 100644 index 0000000..59c65b3 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/destinations/management.dart @@ -0,0 +1,53 @@ +import 'package:flutter/foundation.dart'; + +import 'models.dart'; +import 'service.dart'; + +class DestinationManagementProvider extends ChangeNotifier { + DestinationManagementProvider(this.service, this.destination); + final DestinationService service; + SavedDestination destination; + DestinationIssue? error; + bool busy = false, removed = false, _disposed = false; + Future rename(String nickname) => _run(() async { + destination = await service.rename( + destination.id, + nickname, + destination.metadataRevision, + ); + }); + Future remove() => _run(() async { + await service.remove( + destination.id, + destination.metadataRevision, + destination.executionRevision, + ); + removed = true; + }); + Future refresh() => _run(() async { + destination = await service.get(destination.id); + removed = destination.removed; + }); + Future _run(Future Function() action) async { + if (busy) return; + busy = true; + error = null; + notifyListeners(); + try { + await action(); + } on DestinationException catch (e) { + error = e.issue; + } catch (_) { + error = DestinationIssue.unavailable; + } + if (_disposed) return; + busy = false; + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/destinations/models.dart b/frontend/packages/sxchnge_wallet/lib/src/destinations/models.dart new file mode 100644 index 0000000..685bad8 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/destinations/models.dart @@ -0,0 +1,255 @@ +enum DestinationType { crypto, card, russianBank } + +enum EligibilityState { + eligible, + temporarilyUnavailable, + requiresAction, + unsupported, +} + +enum DestinationIssue { + invalid, + unavailable, + uncertain, + conflict, + removed, + expired, + bankChanged, + authorization, + unsupported, + duplicate, + replacementRequired, +} + +enum SaveOutcome { created, duplicate } + +enum BindingState { ready, expired, revoked, unavailable } + +class DestinationException implements Exception { + const DestinationException(this.issue, {this.field}); + final DestinationIssue issue; + final String? field; +} + +sealed class DestinationDetails { + const DestinationDetails(); + DestinationType get type; + String get display; + String get review; +} + +class CryptoDestination extends DestinationDetails { + const CryptoDestination({ + required this.assetId, + required this.networkId, + required this.address, + this.memo = '', + required this.assetLabel, + required this.networkLabel, + }); + final String assetId, networkId, address, memo, assetLabel, networkLabel; + @override + DestinationType get type => DestinationType.crypto; + @override + String get display => + '$assetLabel · $networkLabel · ${address.length > 14 ? '${address.substring(0, 6)}…${address.substring(address.length - 6)}' : address}'; + @override + String get review => + '$assetLabel · $networkLabel\n$address${memo.isEmpty ? '' : '\n$memo'}'; +} + +/// Secure capture references are short lived, not PAN/CVV or destination IDs. +class CardDestination extends DestinationDetails { + const CardDestination({ + this.capture = '', + this.identity = '', + required this.brand, + required this.last4, + required this.expiryMonth, + required this.expiryYear, + }); + final String capture, identity, brand, last4; + final int expiryMonth, expiryYear; + @override + DestinationType get type => DestinationType.card; + @override + String get display => '$brand · •••• $last4'; + @override + String get review => display; +} + +class RussianBankDestination extends DestinationDetails { + const RussianBankDestination({ + required this.firstName, + required this.lastName, + this.patronymic = '', + required this.bank, + required this.account, + }); + final String firstName, lastName, patronymic, account; + final Bank bank; + String get holder => + [firstName, lastName, patronymic].where((s) => s.isNotEmpty).join(' '); + @override + DestinationType get type => DestinationType.russianBank; + @override + String get display => + '${bank.name} · •••• ${account.length < 4 ? account : account.substring(account.length - 4)}'; + @override + String get review => + '$holder\n${bank.name}\n${bank.bik}\n$account\n${bank.correspondent}'; +} + +class Bank { + const Bank({ + required this.bik, + required this.name, + required this.city, + required this.correspondent, + required this.revision, + this.supported = true, + }); + final String bik, name, city, correspondent, revision; + final bool supported; +} + +class CryptoAsset { + const CryptoAsset( + this.id, + this.label, + this.networkId, + this.networkLabel, { + this.memoRequired = false, + this.memoSupported = false, + }); + final String id, label, networkId, networkLabel; + final bool memoRequired, memoSupported; +} + +class ProviderBinding { + const ProviderBinding({ + required this.reference, + required this.state, + required this.reusable, + this.recoverable = false, + this.expiresAt, + }); + final String reference; + final BindingState state; + final bool reusable, recoverable; + final DateTime? expiresAt; +} + +class OwnershipAssessment { + const OwnershipAssessment({ + this.declaredOwn = true, + this.verified = false, + this.actionRequired = false, + }); + final bool declaredOwn, verified, actionRequired; +} + +class SavedDestination { + SavedDestination({ + required this.id, + required this.details, + this.nickname = '', + this.metadataRevision = 1, + this.executionRevision = 1, + this.removed = false, + this.lastUsed, + this.ownership = const OwnershipAssessment(), + List bindings = const [], + }) : bindings = List.unmodifiable(bindings); + final String id, nickname; + final DestinationDetails details; + final int metadataRevision, executionRevision; + final bool removed; + final DateTime? lastUsed; + final OwnershipAssessment ownership; + final List bindings; + String get label => nickname.isEmpty ? details.display : nickname; + SavedReference get input => SavedReference(id, executionRevision); +} + +sealed class DestinationInput { + const DestinationInput(); +} + +class SavedReference extends DestinationInput { + const SavedReference(this.id, this.executionRevision); + final String id; + final int executionRevision; +} + +class OneTimeDestination extends DestinationInput { + const OneTimeDestination(this.details); + final DestinationDetails details; +} + +class PayoutContext { + const PayoutContext(this.source, {this.amount}); + final String source; + final int? amount; +} + +class PayoutEligibility { + const PayoutEligibility(this.state, {this.reason, this.canSave = true}); + final EligibilityState state; + final DestinationIssue? reason; + final bool canSave; + bool get eligible => state == EligibilityState.eligible; +} + +class DestinationValidation { + const DestinationValidation(this.details); + final DestinationDetails details; +} + +class DestinationCapabilities { + DestinationCapabilities({ + List types = const [], + List assets = const [], + this.reusableCards = false, + this.cardRecovery = false, + this.simulated = false, + }) : types = List.unmodifiable(types), + assets = List.unmodifiable(assets); + final List types; + final List assets; + final bool reusableCards, cardRecovery, simulated; +} + +/// Outcome identity never changes when a record is later renamed or removed. +class CreationResult { + const CreationResult(this.outcome, this.destinationId); + final SaveOutcome outcome; + final String destinationId; +} + +class DestinationLease { + const DestinationLease({ + required this.input, + required this.snapshot, + required this.context, + required this.requirementsRevision, + required this.expiresAt, + }); + final DestinationInput input; + final DestinationDetails snapshot; + final PayoutContext context; + final int requirementsRevision; + final DateTime expiresAt; +} + +class DestinationAudit { + const DestinationAudit( + this.actor, + this.reference, + this.action, + this.outcome, + this.at, + ); + final String actor, reference, action, outcome; + final DateTime at; +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/destinations/provider.dart b/frontend/packages/sxchnge_wallet/lib/src/destinations/provider.dart new file mode 100644 index 0000000..734d182 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/destinations/provider.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; + +import 'models.dart'; +import 'service.dart'; + +class DestinationsProvider extends ChangeNotifier { + DestinationsProvider(this.service); + final DestinationService service; + DestinationCapabilities? capabilities; + List methods = []; + bool loading = false, failed = false, _disposed = false; + int _revision = 0; + Future refresh() async { + final revision = ++_revision; + loading = true; + failed = false; + notifyListeners(); + try { + final caps = await service.capabilities(); + final rows = await service.list(); + if (_disposed || revision != _revision) return; + capabilities = caps; + methods = rows; + } catch (_) { + if (_disposed || revision != _revision) return; + failed = true; + } + if (_disposed) return; + loading = false; + notifyListeners(); + } + + List search(String query, DestinationType type) { + final q = query.trim().toLowerCase(); + final rows = methods + .where( + (d) => + d.details.type == type && + '${d.label} ${d.details.display}'.toLowerCase().contains(q), + ) + .toList(); + rows.sort( + (a, b) => (b.lastUsed ?? DateTime(1970)).compareTo( + a.lastUsed ?? DateTime(1970), + ), + ); + return rows; + } + + void reset() { + _revision++; + methods = []; + capabilities = null; + loading = failed = false; + service.clearTransient(); + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _revision++; + super.dispose(); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/destinations/search.dart b/frontend/packages/sxchnge_wallet/lib/src/destinations/search.dart new file mode 100644 index 0000000..a8bc66c --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/destinations/search.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'models.dart'; +import 'service.dart'; + +class BankSearchProvider extends ChangeNotifier { + BankSearchProvider(this.service); + final DestinationService service; + Timer? _timer; + Completer? _abort; + int _generation = 0; + bool _disposed = false, loading = false; + String query = ''; + List results = []; + DestinationIssue? error; + void search(String value) { + _timer?.cancel(); + if (_abort != null && !_abort!.isCompleted) _abort!.complete(); + _abort = null; + final generation = ++_generation; + query = value; + results = []; + error = null; + loading = false; + if (value.trim().length >= 2) { + _timer = Timer( + const Duration(milliseconds: 350), + () => _lookup(value, generation), + ); + } + notifyListeners(); + } + + Future _lookup(String value, int generation) async { + loading = true; + notifyListeners(); + try { + final abort = Completer(); + _abort = abort; + final rows = await service.banks(value, abortTrigger: abort.future); + if (_disposed || generation != _generation) return; + results = rows; + if (rows.isEmpty) error = DestinationIssue.invalid; + } on DestinationException catch (e) { + if (!_disposed && generation == _generation) error = e.issue; + } catch (_) { + if (!_disposed && generation == _generation) + error = DestinationIssue.unavailable; + } + if (_disposed || generation != _generation) return; + loading = false; + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _generation++; + _timer?.cancel(); + if (_abort != null && !_abort!.isCompleted) _abort!.complete(); + super.dispose(); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/destinations/selection.dart b/frontend/packages/sxchnge_wallet/lib/src/destinations/selection.dart new file mode 100644 index 0000000..9751f50 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/destinations/selection.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; + +import 'models.dart'; +import 'service.dart'; + +class DestinationSelectionProvider extends ChangeNotifier { + DestinationSelectionProvider(this.service, this.context); + final DestinationService service; + final PayoutContext context; + final assessments = {}; + bool busy = false, _disposed = false; + DestinationIssue? error; + Future assess(List methods) async { + for (final d in methods) { + try { + final assessment = await service.assess(d.input, context); + if (_disposed) return; + assessments[d.id] = assessment; + } on DestinationException catch (e) { + if (_disposed) return; + assessments[d.id] = PayoutEligibility( + EligibilityState.requiresAction, + reason: e.issue, + ); + } catch (_) { + if (_disposed) return; + assessments[d.id] = const PayoutEligibility( + EligibilityState.temporarilyUnavailable, + reason: DestinationIssue.unavailable, + ); + } + } + if (!_disposed) notifyListeners(); + } + + Future select(SavedDestination d) async { + if (busy) return null; + busy = true; + error = null; + notifyListeners(); + SavedReference? result; + try { + final current = await service.get(d.id); + final a = await service.assess(current.input, context); + if (_disposed) return null; + assessments[d.id] = a; + if (a.eligible) result = current.input; + } on DestinationException catch (e) { + error = e.issue; + } catch (_) { + error = DestinationIssue.unavailable; + } + if (_disposed) return null; + busy = false; + notifyListeners(); + return result; + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/destinations/service.dart b/frontend/packages/sxchnge_wallet/lib/src/destinations/service.dart new file mode 100644 index 0000000..e859fda --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/destinations/service.dart @@ -0,0 +1,39 @@ +import 'models.dart'; + +abstract interface class DestinationService { + bool get isSimulated; + Future capabilities(); + Future> list(); + Future get(String id); + Future validate(DestinationDetails details); + Future assess( + DestinationInput input, + PayoutContext context, + ); + Future create( + DestinationDetails details, + String nickname, + String idempotencyKey, { + String? replaces, + }); + Future creation(String idempotencyKey); + Future rename( + String id, + String nickname, + int metadataRevision, + ); + Future remove(String id, int metadataRevision, int executionRevision); + Future> banks(String query, {Future? abortTrigger}); + Future prepare( + DestinationInput input, + PayoutContext context, + ); + + Future restoreCard( + String id, + int executionRevision, + CardDestination capture, + String idempotencyKey, + ); + void clearTransient(); +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/destinations/transport.dart b/frontend/packages/sxchnge_wallet/lib/src/destinations/transport.dart new file mode 100644 index 0000000..93745eb --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/destinations/transport.dart @@ -0,0 +1,116 @@ +import 'models.dart'; + +/// Local DTO boundary: transport fields never escape services into providers/UI. +class DestinationDTO { + const DestinationDTO(this.value); + final Map value; +} + +abstract final class DestinationMapper { + static DestinationDTO details(DestinationDetails value) => + DestinationDTO(switch (value) { + CryptoDestination d => { + 'type': 'crypto', + 'assetId': d.assetId, + 'networkId': d.networkId, + 'address': d.address, + 'memo': d.memo, + 'assetLabel': d.assetLabel, + 'networkLabel': d.networkLabel, + }, + CardDestination d => { + 'type': 'card', + 'capture': d.capture, + 'identity': d.identity, + 'brand': d.brand, + 'last4': d.last4, + 'expiryMonth': d.expiryMonth, + 'expiryYear': d.expiryYear, + }, + RussianBankDestination d => { + 'type': 'russianBank', + 'firstName': d.firstName, + 'lastName': d.lastName, + 'patronymic': d.patronymic, + 'account': d.account, + 'bank': bank(d.bank), + }, + }); + static Map bank(Bank b) => { + 'bik': b.bik, + 'name': b.name, + 'city': b.city, + 'correspondent': b.correspondent, + 'revision': b.revision, + 'supported': b.supported, + }; + static Bank fromBank(Map v) => Bank( + bik: v['bik'] as String, + name: v['name'] as String, + city: v['city'] as String, + correspondent: v['correspondent'] as String, + revision: v['revision'] as String, + supported: v['supported'] as bool, + ); + static DestinationDetails fromDTO(DestinationDTO dto) { + final d = dto.value; + return switch (d['type']) { + 'crypto' => CryptoDestination( + assetId: d['assetId'] as String, + networkId: d['networkId'] as String, + address: d['address'] as String, + memo: d['memo'] as String, + assetLabel: d['assetLabel'] as String, + networkLabel: d['networkLabel'] as String, + ), + 'card' => CardDestination( + capture: d['capture'] as String, + identity: d['identity'] as String, + brand: d['brand'] as String, + last4: d['last4'] as String, + expiryMonth: d['expiryMonth'] as int, + expiryYear: d['expiryYear'] as int, + ), + 'russianBank' => RussianBankDestination( + firstName: d['firstName'] as String, + lastName: d['lastName'] as String, + patronymic: d['patronymic'] as String, + account: d['account'] as String, + bank: fromBank(Map.from(d['bank'] as Map)), + ), + _ => throw const DestinationException(DestinationIssue.unsupported), + }; + } + + static DestinationDetails clone(DestinationDetails d) => fromDTO(details(d)); +} + +sealed class DestinationInputDTO { + const DestinationInputDTO(); +} + +class SavedInputDTO extends DestinationInputDTO { + const SavedInputDTO(this.id, this.executionRevision); + final String id; + final int executionRevision; +} + +class OneTimeInputDTO extends DestinationInputDTO { + const OneTimeInputDTO(this.details); + final DestinationDTO details; +} + +abstract final class DestinationInputMapper { + static DestinationInputDTO toDTO(DestinationInput input) => switch (input) { + SavedReference r => SavedInputDTO(r.id, r.executionRevision), + OneTimeDestination d => OneTimeInputDTO( + DestinationMapper.details(d.details), + ), + }; + static DestinationInput fromDTO(DestinationInputDTO dto) => switch (dto) { + SavedInputDTO d => SavedReference(d.id, d.executionRevision), + OneTimeInputDTO d => OneTimeDestination( + DestinationMapper.fromDTO(d.details), + ), + }; +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/destinations/unavailable.dart b/frontend/packages/sxchnge_wallet/lib/src/destinations/unavailable.dart new file mode 100644 index 0000000..856ac08 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/lib/src/destinations/unavailable.dart @@ -0,0 +1,70 @@ +import 'models.dart'; +import 'service.dart'; + +/// Fail closed when the live backend has not supplied the destination contract. +/// Never substitutes a demo store for an authenticated live wallet. +class UnavailableDestinationService implements DestinationService { + const UnavailableDestinationService(); + @override + bool get isSimulated => false; + @override + Future capabilities() async => + DestinationCapabilities(); + @override + Future> list() async => []; + Never _unsupported() => + throw const DestinationException(DestinationIssue.unsupported); + @override + Future get(String id) async => _unsupported(); + @override + Future validate(DestinationDetails details) async => + _unsupported(); + @override + Future assess( + DestinationInput input, + PayoutContext context, + ) async => const PayoutEligibility( + EligibilityState.unsupported, + reason: DestinationIssue.unsupported, + canSave: false, + ); + @override + Future create( + DestinationDetails details, + String nickname, + String idempotencyKey, { + String? replaces, + }) async => _unsupported(); + @override + Future creation(String idempotencyKey) async => + _unsupported(); + @override + Future rename( + String id, + String nickname, + int metadataRevision, + ) async => _unsupported(); + @override + Future remove( + String id, + int metadataRevision, + int executionRevision, + ) async => _unsupported(); + @override + Future> banks(String query, {Future? abortTrigger}) async => + _unsupported(); + @override + Future prepare( + DestinationInput input, + PayoutContext context, + ) async => _unsupported(); + @override + Future restoreCard( + String id, + int executionRevision, + CardDestination capture, + String idempotencyKey, + ) async => _unsupported(); + @override + void clearTransient() {} +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart b/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart index e968aad..d2d5202 100644 --- a/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart +++ b/frontend/packages/sxchnge_wallet/lib/src/models/wallet.dart @@ -1,3 +1,5 @@ +import '../destinations/models.dart'; + enum TransferKind { topUp, exchange, payout } enum OperationStatus { pending, completed, failed } @@ -12,6 +14,7 @@ enum WalletError { unsupported, notFound, uncertain, + destinationChanged, } class WalletException implements Exception { @@ -64,12 +67,14 @@ class TransferRequest { required this.amount, this.destination, this.recipient = '', + this.payoutDestination, }); final TransferKind kind; final String source; final int amount; final String? destination; final String recipient; + final DestinationInput? payoutDestination; } class Quote { @@ -82,11 +87,15 @@ class Quote { required this.expiresAt, required this.rateNumerator, required this.rateDenominator, + this.payoutSnapshot, + this.payoutTerms, }); final String id; final TransferRequest request; final int fee, debit, credit, rateNumerator, rateDenominator; final DateTime expiresAt; + final DestinationDetails? payoutSnapshot; + final PayoutTerms? payoutTerms; } class WalletOperation { @@ -116,3 +125,14 @@ class AccountProfile { const AccountProfile(this.locale); final String locale; } + +class PayoutTerms { + const PayoutTerms( + this.amount, + this.precision, + this.assetLabel, + this.expectedSeconds, + ); + final int amount, precision, expectedSeconds; + final String assetLabel; +} diff --git a/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart b/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart index dcd5481..2660e88 100644 --- a/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart +++ b/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart @@ -6,6 +6,7 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_auth/sxchnge_auth.dart'; +import '../destinations/models.dart'; import '../models/wallet.dart'; import 'wallet.dart'; @@ -40,16 +41,18 @@ class TransferProvider extends ChangeNotifier { if (!accounts.any((a) => a.id == destination) || destination == source) { destination = accounts.where((a) => a.id != source).firstOrNull?.id; } - if (!wallet.service.isSimulated && kind == TransferKind.payout) { - recipient = wallet.account(source)?.payoutMethod ?? ''; - } } void _walletChanged() { if (_disposed) return; final previous = source; _syncAccounts(); - if (source != previous) notifyListeners(); + if (source != previous) { + payoutDestination = null; + payoutLabel = ''; + recipient = ''; + notifyListeners(); + } } final WalletProvider wallet; @@ -58,6 +61,8 @@ class TransferProvider extends ChangeNotifier { String source; String? destination; String amount = '', recipient = ''; + DestinationInput? payoutDestination; + String payoutLabel = ''; Quote? quote; WalletOperation? result; WalletError? error; @@ -72,7 +77,7 @@ class TransferProvider extends ChangeNotifier { TransferKind.payout => JourneyKind.payout, }; bool get hasDraft => - amount.isNotEmpty || (wallet.service.isSimulated && recipient.isNotEmpty); + amount.isNotEmpty || payoutDestination != null || recipient.isNotEmpty; bool get expired => quote != null && !DateTime.now().isBefore(quote!.expiresAt); void change({ @@ -80,8 +85,17 @@ class TransferProvider extends ChangeNotifier { String? destination, String? amount, String? recipient, + DestinationInput? payoutDestination, + String? payoutLabel, }) { if (busy || uncertain) return; + if (source != null && source != this.source) { + this.payoutDestination = null; + this.payoutLabel = ''; + recipient = ''; + } + this.payoutDestination = payoutDestination ?? this.payoutDestination; + this.payoutLabel = payoutLabel ?? this.payoutLabel; this.source = source ?? this.source; this.destination = destination ?? this.destination; this.amount = amount ?? this.amount; @@ -96,6 +110,36 @@ class TransferProvider extends ChangeNotifier { notifyListeners(); } + Future selectPayout(DestinationInput input) async { + if (busy || uncertain || wallet.destinations == null) return; + busy = true; + error = null; + notifyListeners(); + try { + final service = wallet.destinations!; + final assessment = await service.assess( + input, + PayoutContext(source, amount: wallet.account(source)?.parse(amount)), + ); + if (!assessment.eligible) + throw const DestinationException(DestinationIssue.unsupported); + final details = input is OneTimeDestination + ? input.details + : (await service.get((input as SavedReference).id)).details; + if (_disposed) return; + busy = false; + change(payoutDestination: input, payoutLabel: details.display); + } catch (_) { + if (_disposed) return; + payoutDestination = null; + payoutLabel = ''; + error = WalletError.destinationChanged; + } + if (_disposed) return; + busy = false; + notifyListeners(); + } + void edit() { if (!busy && !uncertain) { quote = null; @@ -133,6 +177,7 @@ class TransferProvider extends ChangeNotifier { amount: money, destination: kind == TransferKind.exchange ? destination : null, recipient: recipient.trim(), + payoutDestination: payoutDestination, ), ); if (_disposed || revision != _revision) return; @@ -142,6 +187,10 @@ class TransferProvider extends ChangeNotifier { } catch (e) { if (_disposed) return; error = _code(e); + if (error == WalletError.destinationChanged) { + payoutDestination = null; + payoutLabel = ''; + } recorder.finish(op, failure: _failure(error!)); } if (_disposed) return; @@ -225,6 +274,11 @@ class TransferProvider extends ChangeNotifier { uncertain = false; } else { error = _code(e); + if (error == WalletError.destinationChanged) { + quote = null; + payoutDestination = null; + payoutLabel = ''; + } } // All transport/unexpected failures are ambiguous after execution started. uncertain = [ @@ -247,6 +301,7 @@ class TransferProvider extends ChangeNotifier { error = null; factorError = null; recipient = ''; + payoutDestination = null; recorder.finish(op, simulated: wallet.service.isSimulated); wallet.track(value, recorder, journey); _handedOff = true; diff --git a/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart b/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart index 6fa8d89..20691b4 100644 --- a/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart +++ b/frontend/packages/sxchnge_wallet/lib/src/providers/wallet.dart @@ -6,12 +6,19 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_auth/sxchnge_auth.dart'; +import '../destinations/service.dart'; import '../models/wallet.dart'; import '../services/contracts.dart'; class WalletProvider extends ChangeNotifier { - WalletProvider(this.service, this.analytics, {this.factors}); + WalletProvider( + this.service, + this.analytics, { + this.factors, + this.destinations, + }); final WalletService service; + final DestinationService? destinations; final TwoFactorService? factors; final AnalyticsService analytics; WalletSnapshot? data; diff --git a/frontend/packages/sxchnge_wallet/lib/src/services/backend.dart b/frontend/packages/sxchnge_wallet/lib/src/services/backend.dart index 415412b..161e967 100644 --- a/frontend/packages/sxchnge_wallet/lib/src/services/backend.dart +++ b/frontend/packages/sxchnge_wallet/lib/src/services/backend.dart @@ -94,10 +94,15 @@ class BackendWalletService implements WalletService { @override Future fetch() => _mapped(send(''), BackendMapper.snapshot); @override - Future quote(TransferRequest request) => _mapped( - send('/quote', body: BackendMapper.request(request)), - BackendMapper.quote, - ); + Future quote(TransferRequest request) async { + if (request.payoutDestination != null) + throw const WalletException(WalletError.unsupported); + return _mapped( + send('/quote', body: BackendMapper.request(request)), + BackendMapper.quote, + ); + } + @override Future submit( String quoteId, diff --git a/frontend/packages/sxchnge_wallet/lib/src/services/dummy.dart b/frontend/packages/sxchnge_wallet/lib/src/services/dummy.dart index e6ecd47..213daaa 100644 --- a/frontend/packages/sxchnge_wallet/lib/src/services/dummy.dart +++ b/frontend/packages/sxchnge_wallet/lib/src/services/dummy.dart @@ -5,6 +5,9 @@ import 'package:sxchnge_auth/sxchnge_auth.dart'; import 'package:sxchnge_kyc/limits_transport.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; +import '../destinations/dummy.dart'; +import '../destinations/models.dart'; +import '../destinations/transport.dart'; import '../models/wallet.dart'; import '../transport/wallet.dart'; import 'contracts.dart'; @@ -14,13 +17,17 @@ class DummyWalletService implements WalletService { DummyWalletService( this.identity, { this.factors, + DummyDestinationService? destinations, this.delay = const Duration(milliseconds: 350), this.settlement = const Duration(seconds: 2), DateTime Function()? now, String initialLocale = 'en', - }) : now = now ?? DateTime.now, + }) : destinations = destinations ?? DummyDestinationService(now: now), + now = now ?? DateTime.now, _locale = initialLocale; final IdentityService identity; + final DummyDestinationService destinations; + final _destinationLeases = {}; final TwoFactorService? factors; final Duration delay, settlement; final DateTime Function() now; @@ -59,6 +66,10 @@ class DummyWalletService implements WalletService { }; int _eur(int minor, String id) => (minor * _weight(id) + 1099) ~/ 1100; void _settle() { + _quotes.removeWhere((_, quote) => !now().isBefore(quote.expires)); + _destinationLeases.removeWhere( + (_, lease) => !now().isBefore(lease.expiresAt), + ); for (final o in _submissions.values) { if (o.status != 'pending' || now().isBefore(o.ready)) continue; o.status = o.fail ? 'failed' : 'completed'; @@ -133,9 +144,19 @@ class DummyWalletService implements WalletService { if (r.kind == TransferKind.exchange && (r.source == r.destination || r.destination == null)) throw const WalletException(WalletError.unsupported); - if (r.kind == TransferKind.payout && - (r.recipient.trim().length < 6 || r.recipient.length > 140)) - throw const WalletException(WalletError.invalidRecipient); + DestinationLease? lease; + if (r.kind == TransferKind.payout) { + if (r.payoutDestination == null) + throw const WalletException(WalletError.invalidRecipient); + try { + lease = await destinations.prepare( + r.payoutDestination!, + PayoutContext(r.source, amount: r.amount), + ); + } on DestinationException { + throw const WalletException(WalletError.destinationChanged); + } + } if (r.kind == TransferKind.exchange) _account(r.destination!); final fee = switch (r.kind) { TransferKind.topUp => 0, @@ -146,19 +167,30 @@ class DummyWalletService implements WalletService { final d = r.kind == TransferKind.exchange ? _weight(r.destination!) : 1; final q = QuoteDTO( 'quote-${++_serial}', - WalletMapper.request(r), + WalletMapper.request(r, includePayoutInput: false), fee, r.kind == TransferKind.topUp ? r.amount : r.amount + fee, r.amount * n ~/ d, now().add(const Duration(seconds: 60)), n, d, + payoutTerms: lease == null + ? null + : PayoutTermsDTO(r.amount, 2, switch (lease.snapshot) { + CryptoDestination d => d.assetLabel, + RussianBankDestination() => 'RUB', + CardDestination() => _account(r.source).currency, + }, settlement.inSeconds), + payoutSnapshot: lease == null + ? null + : DestinationMapper.details(lease.snapshot), ); if (q.credit <= 0) throw const WalletException(WalletError.invalidAmount); final verified = await _verified(); _settle(); _check(q, verified); _quotes[q.id] = q; + if (lease != null) _destinationLeases[q.id] = lease; return WalletMapper.quote(q); } @@ -193,6 +225,17 @@ class DummyWalletService implements WalletService { if (_usedQuotes.contains(q.id)) throw const WalletException(WalletError.expiredQuote); _check(q, verified); + final lease = _destinationLeases[q.id]; + if (q.request.kind == 'payout' && lease == null) + throw const WalletException(WalletError.expiredQuote); + if (lease != null) { + try { + destinations.accept(lease); + } on DestinationException { + throw const WalletException(WalletError.destinationChanged); + } + } + // No asynchronous gap between destination acceptance and financial mutation. _usedQuotes.add(q.id); if (q.request.kind != 'topUp') _account(q.request.source).available -= q.debit; @@ -205,6 +248,7 @@ class DummyWalletService implements WalletService { ); failNextOperation = false; _submissions[idempotencyKey] = o; + _destinationLeases.remove(q.id); if (loseNextResponse) { loseNextResponse = false; throw const WalletException(WalletError.uncertain); diff --git a/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart b/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart index e49f6f5..92af0c3 100644 --- a/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart +++ b/frontend/packages/sxchnge_wallet/lib/src/transport/wallet.dart @@ -1,3 +1,4 @@ +import '../destinations/transport.dart'; import '../models/wallet.dart'; class AccountDTO { @@ -23,11 +24,13 @@ class RequestDTO { this.source, this.destination, this.amount, - this.recipient, - ); + this.recipient, { + this.payoutInput, + }); final String kind, source, recipient; final String? destination; final int amount; + final DestinationInputDTO? payoutInput; } class QuoteDTO { @@ -39,12 +42,16 @@ class QuoteDTO { this.credit, this.expires, this.numerator, - this.denominator, - ); + this.denominator, { + this.payoutSnapshot, + this.payoutTerms, + }); final String id; final RequestDTO request; final int fee, debit, credit, numerator, denominator; final DateTime expires; + final DestinationDTO? payoutSnapshot; + final PayoutTermsDTO? payoutTerms; } class OperationDTO { @@ -78,14 +85,28 @@ abstract final class WalletMapper { payoutLabel: d.payoutLabel, canFund: d.canFund, ); - static RequestDTO request(TransferRequest r) => - RequestDTO(r.kind.name, r.source, r.destination, r.amount, r.recipient); + static RequestDTO request( + TransferRequest r, { + bool includePayoutInput = true, + }) => RequestDTO( + r.kind.name, + r.source, + r.destination, + r.amount, + r.recipient, + payoutInput: !includePayoutInput || r.payoutDestination == null + ? null + : DestinationInputMapper.toDTO(r.payoutDestination!), + ); static TransferRequest fromRequest(RequestDTO d) => TransferRequest( kind: TransferKind.values.byName(d.kind), source: d.source, destination: d.destination, amount: d.amount, recipient: d.recipient, + payoutDestination: d.payoutInput == null + ? null + : DestinationInputMapper.fromDTO(d.payoutInput!), ); static Quote quote(QuoteDTO d) => Quote( id: d.id, @@ -96,6 +117,17 @@ abstract final class WalletMapper { expiresAt: d.expires, rateNumerator: d.numerator, rateDenominator: d.denominator, + payoutTerms: d.payoutTerms == null + ? null + : PayoutTerms( + d.payoutTerms!.amount, + d.payoutTerms!.precision, + d.payoutTerms!.assetLabel, + d.payoutTerms!.expectedSeconds, + ), + payoutSnapshot: d.payoutSnapshot == null + ? null + : DestinationMapper.fromDTO(d.payoutSnapshot!), ); static WalletOperation operation(OperationDTO d) => WalletOperation( id: d.id, @@ -105,3 +137,14 @@ abstract final class WalletMapper { ); static AccountProfile profile(ProfileDTO dto) => AccountProfile(dto.locale); } + +class PayoutTermsDTO { + const PayoutTermsDTO( + this.amount, + this.precision, + this.assetLabel, + this.expectedSeconds, + ); + final int amount, precision, expectedSeconds; + final String assetLabel; +} diff --git a/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart b/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart index eddac5d..5354f04 100644 --- a/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart +++ b/frontend/packages/sxchnge_wallet/lib/sxchnge_wallet.dart @@ -6,3 +6,15 @@ export 'src/services/dummy.dart'; export 'src/providers/limits.dart'; export 'src/services/backend.dart'; export 'src/services/factors.dart'; + +export 'src/destinations/models.dart'; +export 'src/destinations/service.dart'; +export 'src/destinations/dummy.dart'; +export 'src/destinations/provider.dart'; +export 'src/destinations/bank_paste.dart'; +export 'src/destinations/unavailable.dart'; +export 'src/destinations/form.dart'; +export 'src/destinations/search.dart'; +export 'src/destinations/selection.dart'; +export 'src/destinations/management.dart'; +export 'src/destinations/key.dart'; diff --git a/frontend/packages/sxchnge_wallet/test/backend_test.dart b/frontend/packages/sxchnge_wallet/test/backend_test.dart index cc92808..c8816b2 100644 --- a/frontend/packages/sxchnge_wallet/test/backend_test.dart +++ b/frontend/packages/sxchnge_wallet/test/backend_test.dart @@ -235,11 +235,52 @@ void main() { addTearDown(draft.dispose); await wallet.refresh(); expect(draft.source, 'ledger'); - expect(draft.recipient, 'method'); + expect(draft.recipient, isEmpty); expect(draft.hasDraft, false); + draft.change(recipient: 'method'); + expect(draft.hasDraft, true); draft.change(amount: '20.01'); await draft.review(); await draft.submit(approval: const FactorGrant('approved-intent-key')); expect(draft.result, isNotNull); }); + + test('new typed destinations cannot be silently dropped into the legacy live API', () async { + var calls = 0; + final service = BackendWalletService( + baseUri: Uri.parse('https://example.test'), + accessToken: () => 'test-token', + client: MockClient((request) async { + calls++; + return http.Response('{}', 500); + }), + ); + addTearDown(service.close); + await expectLater( + service.quote( + const TransferRequest( + kind: TransferKind.payout, + source: 'ledger', + amount: 100, + payoutDestination: OneTimeDestination( + CryptoDestination( + assetId: 'asset', + networkId: 'network', + address: 'address', + assetLabel: 'Asset', + networkLabel: 'Network', + ), + ), + ), + ), + throwsA( + isA().having( + (e) => e.code, + 'code', + WalletError.unsupported, + ), + ), + ); + expect(calls, 0); + }); } diff --git a/frontend/packages/sxchnge_wallet/test/destinations_test.dart b/frontend/packages/sxchnge_wallet/test/destinations_test.dart new file mode 100644 index 0000000..16374d1 --- /dev/null +++ b/frontend/packages/sxchnge_wallet/test/destinations_test.dart @@ -0,0 +1,617 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:sxchnge_analytics/sxchnge_analytics.dart'; + +import 'package:sxchnge_kyc/sxchnge_kyc.dart'; + +import 'package:sxchnge_wallet/sxchnge_wallet.dart'; + +import 'package:sxchnge_wallet/src/destinations/transport.dart'; + +const crypto = CryptoDestination( + assetId: 'demo:ethereum:native', + networkId: 'eip155:1', + address: '0x1234567890123456789012345678901234567890', + assetLabel: 'Untrusted label', + networkLabel: 'Untrusted network', +); +const input = OneTimeDestination(crypto); +const card = CardDestination( + capture: 'demo-card-ready', + brand: 'Untrusted brand', + last4: '9999', + expiryMonth: 1, + expiryYear: 1900, +); +const bank = Bank( + bik: '044525225', + name: 'Demo Bank', + city: 'Moscow', + correspondent: '30101810400000000225', + revision: 'demo-1', +); +const russian = RussianBankDestination( + firstName: 'Anna Maria', + lastName: 'de la Cruz', + bank: bank, + account: '40817810899910004312', +); +Matcher issue(DestinationIssue value) => + throwsA(isA().having((e) => e.issue, 'issue', value)); +void main() { + late DummyDestinationService service; + setUp(() { + service = DummyDestinationService(); + }); + test('one-time validation, quotation and acceptance never save', () async { + final lease = await service.prepare( + input, + const PayoutContext('eur', amount: 100), + ); + expect(lease.snapshot.display, contains('ETH')); + service.accept(lease); + expect(await service.list(), isEmpty); + }); + test('idempotency replays creation and rejects changed payload', () async { + final first = await service.create( + crypto, + 'Private name', + 'creation-key-000001', + ); + expect(first.outcome, SaveOutcome.created); + expect( + (await service.create( + crypto, + 'Private name', + 'creation-key-000001', + )).destinationId, + first.destinationId, + ); + await expectLater( + service.create(crypto, 'Changed', 'creation-key-000001'), + issue(DestinationIssue.conflict), + ); + expect((await service.list()).length, 1); + }); + test( + 'duplicate outcome is stable after rename, removal and concurrent create', + () async { + final results = await Future.wait( + List.generate( + 8, + (i) => service.create(crypto, 'Name $i', 'creation-key-00000$i'), + ), + ); + expect(results.where((r) => r.outcome == SaveOutcome.created).length, 1); + expect(results.map((r) => r.destinationId).toSet().length, 1); + final d = await service.get(results.first.destinationId); + final renamed = await service.rename( + d.id, + 'Original stays', + d.metadataRevision, + ); + await service.remove( + d.id, + renamed.metadataRevision, + renamed.executionRevision, + ); + final replay = await service.create( + crypto, + 'Name 1', + 'creation-key-000001', + ); + expect(replay.outcome, SaveOutcome.duplicate); + expect(replay.destinationId, d.id); + expect( + (await service.creation('creation-key-000001'))!.outcome, + SaveOutcome.duplicate, + ); + }, + ); + test( + 'save timeout reconciles a created record separately from duplicate', + () async { + service.loseNextSaveResponse = true; + await expectLater( + service.create(crypto, '', 'creation-key-000001'), + issue(DestinationIssue.uncertain), + ); + expect( + (await service.creation('creation-key-000001'))!.outcome, + SaveOutcome.created, + ); + service.loseNextSaveResponse = true; + await expectLater( + service.create(crypto, '', 'creation-key-000002'), + issue(DestinationIssue.uncertain), + ); + expect( + (await service.creation('creation-key-000002'))!.outcome, + SaveOutcome.duplicate, + ); + }, + ); + test('customer isolation includes duplicates, attempt lookup, reads and mutations', () async { + final memory = DestinationMemory(); + final a = DummyDestinationService(customer: 'a', memory: memory); + final b = DummyDestinationService(customer: 'b', memory: memory); + final saved = await a.create(crypto, 'A', 'creation-key-000001'); + expect(await b.creation('creation-key-000001'), isNull); + await expectLater( + b.get(saved.destinationId), + issue(DestinationIssue.removed), + ); + await expectLater( + b.rename(saved.destinationId, 'B', 1), + issue(DestinationIssue.removed), + ); + await expectLater( + b.remove(saved.destinationId, 1, 1), + issue(DestinationIssue.removed), + ); + expect( + (await b.create(crypto, 'B', 'creation-key-000001')).outcome, + SaveOutcome.created, + ); + }); + test('metadata concurrency never changes execution revision or invalidates lease', () async { + final result = await service.create(crypto, '', 'creation-key-000001'); + final d = await service.get(result.destinationId); + final lease = await service.prepare(d.input, const PayoutContext('eur')); + final changed = await service.rename(d.id, 'New label', d.metadataRevision); + expect(changed.executionRevision, d.executionRevision); + expect(changed.metadataRevision, d.metadataRevision + 1); + service.accept(lease); + await expectLater( + service.rename(d.id, 'Stale', d.metadataRevision), + issue(DestinationIssue.conflict), + ); + }); + test( + 'removal invalidates draft lease and never mutates previous snapshots', + () async { + final result = await service.create(crypto, '', 'creation-key-000001'); + final d = await service.get(result.destinationId); + final lease = await service.prepare(d.input, const PayoutContext('eur')); + await service.remove(d.id, d.metadataRevision, d.executionRevision); + expect(() => service.accept(lease), issue(DestinationIssue.removed)); + expect(lease.snapshot.review, contains(crypto.address)); + expect(await service.list(), isEmpty); + expect(service.memory.audit.map((a) => a.action), contains('remove')); + }, + ); + test( + 'requirements and contextual availability are rechecked at acceptance', + () async { + final result = await service.create(crypto, '', 'creation-key-000001'); + final d = await service.get(result.destinationId); + final lease = await service.prepare(d.input, const PayoutContext('eur')); + service.availability[d.id] = const PayoutEligibility( + EligibilityState.temporarilyUnavailable, + reason: DestinationIssue.unavailable, + ); + expect(() => service.accept(lease), issue(DestinationIssue.unavailable)); + service.availability.clear(); + service.requirementsRevision++; + expect(() => service.accept(lease), issue(DestinationIssue.conflict)); + }, + ); + test( + 'network and canonical token identity distinguish duplicate addresses', + () async { + final ids = {}; + for (final a in DummyDestinationService.assets.where( + (a) => a.networkId.startsWith('eip155:'), + )) { + final result = await service.create( + CryptoDestination( + assetId: a.id, + networkId: a.networkId, + address: crypto.address, + memo: '', + assetLabel: a.label, + networkLabel: a.networkLabel, + ), + '', + destinationKey(), + ); + ids.add(result.destinationId); + } + expect(ids.length, 4); + }, + ); + test( + 'address presentation preserves case; normalization is service-owned', + () async { + const upper = CryptoDestination( + assetId: 'demo:ethereum:native', + networkId: 'eip155:1', + address: '0xABCDEFabcdefABCDEFabcdefABCDEFabcdefABCD', + assetLabel: 'ETH', + networkLabel: 'Ethereum', + ); + final first = await service.create(upper, '', 'creation-key-000001'); + final lower = CryptoDestination( + assetId: upper.assetId, + networkId: upper.networkId, + address: upper.address.toLowerCase(), + assetLabel: 'ETH', + networkLabel: 'Ethereum', + ); + expect( + (await service.create(lower, '', 'creation-key-000002')).destinationId, + first.destinationId, + ); + expect( + (await service.get(first.destinationId)).details.review, + contains(upper.address), + ); + }, + ); + test('memo requirements are validated before saving', () async { + const d = CryptoDestination( + assetId: 'demo:xrp:native', + networkId: 'xrpl:mainnet', + address: 'r123456789ABCDEFGHJKLMNPQRSTU', + assetLabel: 'XRP', + networkLabel: 'XRP Ledger', + ); + await expectLater( + service.create(d, '', 'creation-key-000001'), + issue(DestinationIssue.invalid), + ); + expect(await service.list(), isEmpty); + }); + test('card captures never trust user display metadata; recovery preserves record', () async { + final r = await service.create(card, '', 'creation-key-000001'); + final saved = await service.get(r.destinationId); + expect(saved.details.display, contains('4242')); + expect(saved.details.display, isNot(contains('9999'))); + const recovery = CardDestination( + capture: 'demo-card-recover', + brand: '', + last4: '', + expiryMonth: 0, + expiryYear: 0, + ); + final other = DummyDestinationService(); + final pending = await other.create(recovery, '', 'creation-key-000001'); + final d = await other.get(pending.destinationId); + expect( + (await other.assess(d.input, const PayoutContext('eur'))).state, + EligibilityState.requiresAction, + ); + expect((await other.list()).length, 1); + }); + test('unreliable card fingerprint does not merge by last four', () async { + const d = CardDestination( + capture: 'demo-card-unknown', + brand: '', + last4: '', + expiryMonth: 0, + expiryYear: 0, + ); + final a = await service.create(d, '', 'creation-key-000001'); + final b = await service.create(d, '', 'creation-key-000002'); + expect(a.destinationId, isNot(b.destinationId)); + expect(b.outcome, SaveOutcome.created); + }); + test( + 'bank control key and directory changes are separate from ownership', + () async { + expect(validBankAccount('40817810899910004312', bank.bik), isTrue); + final r = await service.create(russian, '', 'creation-key-000001'); + final saved = await service.get(r.destinationId); + expect(saved.ownership.verified, isFalse); + service.directory = [ + const Bank( + bik: '044525225', + name: 'New cosmetic name', + city: 'Moscow', + correspondent: '30101810400000000225', + revision: 'demo-2', + ), + ]; + expect( + (await service.assess( + saved.input, + const PayoutContext('eur'), + )).eligible, + isTrue, + ); + service.directory = [ + const Bank( + bik: '044525225', + name: 'New bank', + city: 'Moscow', + correspondent: 'changed', + revision: 'demo-3', + ), + ]; + expect( + (await service.assess(saved.input, const PayoutContext('eur'))).reason, + DestinationIssue.bankChanged, + ); + expect( + (await service.get(saved.id)).details.review, + contains(bank.correspondent), + ); + }, + ); + test( + 'paste conflicts are never guessed and full holder names remain unsplit', + () { + final parsed = BankPaste.parse( + 'БИК: 044525225\nБИК: 044525999\nНомер счета: 0000 1234\nФИО: Anna Maria de la Cruz', + ); + expect(parsed.conflicts, contains('bik')); + expect(parsed.values, isNot(contains('bik'))); + expect(parsed.values['account'], '00001234'); + expect(parsed.values['holder'], 'Anna Maria de la Cruz'); + expect(parsed.values, isNot(contains('firstName'))); + }, + ); + test('all details round trip through DTO mapper', () { + for (final d in [crypto, card, russian]) { + expect(DestinationMapper.clone(d).review, d.review); + } + }); + test( + 'optional save failure can continue one-time without claiming saved', + () async { + final p = DestinationFormProvider( + service, + DummyAnalyticsService(), + context: const PayoutContext('eur'), + ); + addTearDown(p.dispose); + p.change(crypto); + p.preferences(save: true); + service.nextSaveError = DestinationIssue.conflict; + await p.proceed(); + expect(p.savingFailed, isTrue); + expect(p.saved, isNull); + await p.proceed(withoutSaving: true); + expect(p.result, isA()); + expect(await service.list(), isEmpty); + }, + ); + test('saving and contextual eligibility have independent outcomes', () async { + final p = DestinationFormProvider( + service, + DummyAnalyticsService(), + context: const PayoutContext('unsupported'), + ); + addTearDown(p.dispose); + p.change(crypto); + p.preferences(save: true); + await p.proceed(); + expect(p.saved!.outcome, SaveOutcome.created); + expect(p.eligibility!.state, EligibilityState.unsupported); + expect(p.result, isNull); + expect(p.error, isNull); + }); + test('explicit duplicate reuse preserves existing nickname', () async { + final first = await service.create( + crypto, + 'Original', + 'creation-key-000001', + ); + final p = DestinationFormProvider( + service, + DummyAnalyticsService(), + context: const PayoutContext('eur'), + ); + addTearDown(p.dispose); + p.change(crypto); + p.preferences(save: true, nickname: 'New'); + await p.proceed(); + expect(p.saved!.outcome, SaveOutcome.duplicate); + expect(p.result, isNull); + await p.useExisting(); + expect((p.result as SavedReference).id, first.destinationId); + expect((await service.get(first.destinationId)).nickname, 'Original'); + }); + test( + 'wallet acceptance rejects removed destination without debiting', + () async { + final wallet = DummyWalletService( + DummyIdentityService(delay: Duration.zero), + delay: Duration.zero, + destinations: service, + ); + final created = await service.create(crypto, '', 'creation-key-000001'); + final d = await service.get(created.destinationId); + final q = await wallet.quote( + TransferRequest( + kind: TransferKind.payout, + source: 'eur', + amount: 1000, + payoutDestination: d.input, + ), + ); + await service.remove(d.id, d.metadataRevision, d.executionRevision); + await expectLater( + wallet.submit(q.id, 'request'), + throwsA( + isA().having( + (e) => e.code, + 'code', + WalletError.destinationChanged, + ), + ), + ); + expect((await wallet.fetch()).accounts.first.available, 7500); + expect((await wallet.fetch()).operations, isEmpty); + }, + ); + + test('large collections search safe metadata and sort recent use without choosing', () async { + for (var i = 0; i < 240; i++) { + await service.create( + CryptoDestination( + assetId: crypto.assetId, + networkId: crypto.networkId, + address: '0x' + i.toRadixString(16).padLeft(40, '0'), + assetLabel: 'ETH', + networkLabel: 'Ethereum', + ), + 'Wallet ' + i.toString(), + destinationKey(), + ); + } + final rows = await service.list(); + service.accept( + await service.prepare(rows.last.input, const PayoutContext('eur')), + ); + final p = DestinationsProvider(service); + addTearDown(p.dispose); + await p.refresh(); + expect(p.search('', DestinationType.crypto).first.id, rows.last.id); + expect( + p.search('Wallet 239', DestinationType.crypto).single.id, + rows.last.id, + ); + expect(p.search('not found', DestinationType.crypto), isEmpty); + }); + test('cards without supported reuse or recovery cannot be saved', () async { + service.reusableCards = false; + service.cardRecovery = false; + expect( + (await service.assess( + const OneTimeDestination(card), + const PayoutContext('eur'), + )).canSave, + isFalse, + ); + await expectLater( + service.create(card, '', 'creation-key-000001'), + issue(DestinationIssue.unsupported), + ); + expect(await service.list(), isEmpty); + }); + test('existing card survives loss of its only reusable binding', () async { + final result = await service.create(card, '', 'creation-key-000001'); + final d = await service.get(result.destinationId); + expect((d.details as CardDestination).capture, isEmpty); + final old = await service.prepare(d.input, const PayoutContext('eur')); + service.memory.records[service.customer]![d.id] = SavedDestination( + id: d.id, + details: d.details, + metadataRevision: d.metadataRevision, + executionRevision: d.executionRevision + 1, + bindings: [ + const ProviderBinding( + reference: 'binding', + state: BindingState.revoked, + reusable: true, + recoverable: true, + ), + ], + ); + final changed = await service.get(d.id); + expect( + (await service.assess(changed.input, const PayoutContext('eur'))).state, + EligibilityState.requiresAction, + ); + expect(() => service.accept(old), issue(DestinationIssue.conflict)); + expect((await service.list()).length, 1); + service.cardRecovery = false; + expect( + (await service.assess(changed.input, const PayoutContext('eur'))).state, + EligibilityState.unsupported, + ); + }); + test( + 'accepted payout snapshot survives nickname changes and removal', + () async { + final wallet = DummyWalletService( + DummyIdentityService(delay: Duration.zero), + delay: Duration.zero, + destinations: service, + ); + final result = await service.create(crypto, '', 'creation-key-000001'); + final d = await service.get(result.destinationId); + final q = await wallet.quote( + TransferRequest( + kind: TransferKind.payout, + source: 'eur', + amount: 1000, + payoutDestination: d.input, + ), + ); + final renamed = await service.rename( + d.id, + 'After quote', + d.metadataRevision, + ); + final accepted = await wallet.submit(q.id, 'payout-key'); + await service.remove( + d.id, + renamed.metadataRevision, + renamed.executionRevision, + ); + expect((await wallet.fetch()).operations.single.id, accepted.id); + expect(accepted.quote.payoutSnapshot!.review, contains(crypto.address)); + }, + ); + + test('card recovery preserves identity, invalidates execution and replays idempotently', () async { + const capture = CardDestination( + capture: 'demo-card-recover', + brand: '', + last4: '', + expiryMonth: 0, + expiryYear: 0, + ); + final created = await service.create( + capture, + 'Keep label', + 'creation-key-000001', + ); + final d = await service.get(created.destinationId); + final restored = await service.restoreCard( + d.id, + d.executionRevision, + card, + 'card-recovery-key-0001', + ); + expect(restored.id, d.id); + expect(restored.nickname, d.nickname); + expect(restored.metadataRevision, d.metadataRevision); + expect(restored.executionRevision, d.executionRevision + 1); + expect( + (await service.assess( + restored.input, + const PayoutContext('eur'), + )).eligible, + isTrue, + ); + expect( + (await service.restoreCard( + d.id, + d.executionRevision, + card, + 'card-recovery-key-0001', + )).executionRevision, + restored.executionRevision, + ); + expect((await service.list()).length, 1); + const unidentified = CardDestination( + capture: 'demo-card-unknown', + brand: '', + last4: '', + expiryMonth: 0, + expiryYear: 0, + ); + await expectLater( + service.restoreCard( + d.id, + restored.executionRevision, + unidentified, + 'card-recovery-key-0002', + ), + issue(DestinationIssue.replacementRequired), + ); + }); +} diff --git a/frontend/packages/sxchnge_wallet/test/two_factor_test.dart b/frontend/packages/sxchnge_wallet/test/two_factor_test.dart index 5ae8b12..786ba11 100644 --- a/frontend/packages/sxchnge_wallet/test/two_factor_test.dart +++ b/frontend/packages/sxchnge_wallet/test/two_factor_test.dart @@ -27,6 +27,15 @@ void main() { amount: 1000, destination: kind == TransferKind.exchange ? 'usd' : null, recipient: 'Sample recipient', + payoutDestination: OneTimeDestination( + CryptoDestination( + assetId: 'demo:ethereum:native', + networkId: 'eip155:1', + address: '0x1234567890123456789012345678901234567890', + assetLabel: 'ETH', + networkLabel: 'Ethereum', + ), + ), ), ); await expectLater( @@ -59,6 +68,15 @@ void main() { source: 'eur', amount: 1000, recipient: 'Sample recipient', + payoutDestination: OneTimeDestination( + CryptoDestination( + assetId: 'demo:ethereum:native', + networkId: 'eip155:1', + address: '0x1234567890123456789012345678901234567890', + assetLabel: 'ETH', + networkLabel: 'Ethereum', + ), + ), ), ); final c = (await factor.begin(FactorPurpose.payout, q.id))!; @@ -85,6 +103,15 @@ void main() { source: 'eur', amount: 1000, recipient: 'Sample recipient', + payoutDestination: OneTimeDestination( + CryptoDestination( + assetId: 'demo:ethereum:native', + networkId: 'eip155:1', + address: '0x1234567890123456789012345678901234567890', + assetLabel: 'ETH', + networkLabel: 'Ethereum', + ), + ), ), ); final c = (await factor.begin(FactorPurpose.payout, 'different-quote'))!; diff --git a/frontend/packages/sxchnge_wallet/test/wallet_test.dart b/frontend/packages/sxchnge_wallet/test/wallet_test.dart index efc2435..14c0a60 100644 --- a/frontend/packages/sxchnge_wallet/test/wallet_test.dart +++ b/frontend/packages/sxchnge_wallet/test/wallet_test.dart @@ -39,6 +39,15 @@ void main() { destination: to, amount: amount, recipient: recipient, + payoutDestination: OneTimeDestination( + CryptoDestination( + assetId: 'demo:ethereum:native', + networkId: 'eip155:1', + address: '0x1234567890123456789012345678901234567890', + assetLabel: 'ETH', + networkLabel: 'Ethereum', + ), + ), ), ); return s.submit(q.id, key); @@ -107,6 +116,15 @@ void main() { source: 'eur', amount: 7000, recipient: 'Sample recipient', + payoutDestination: OneTimeDestination( + CryptoDestination( + assetId: 'demo:ethereum:native', + networkId: 'eip155:1', + address: '0x1234567890123456789012345678901234567890', + assetLabel: 'ETH', + networkLabel: 'Ethereum', + ), + ), ), ); now = now.add(const Duration(seconds: 61)); @@ -127,6 +145,15 @@ void main() { source: 'eur', amount: 6000, recipient: 'Sample recipient', + payoutDestination: OneTimeDestination( + CryptoDestination( + assetId: 'demo:ethereum:native', + networkId: 'eip155:1', + address: '0x1234567890123456789012345678901234567890', + assetLabel: 'ETH', + networkLabel: 'Ethereum', + ), + ), ), ); final b = await s.quote( @@ -135,6 +162,15 @@ void main() { source: 'eur', amount: 6000, recipient: 'Sample recipient', + payoutDestination: OneTimeDestination( + CryptoDestination( + assetId: 'demo:ethereum:native', + networkId: 'eip155:1', + address: '0x1234567890123456789012345678901234567890', + assetLabel: 'ETH', + networkLabel: 'Ethereum', + ), + ), ), ); await s.submit(a.id, 'first'); @@ -160,6 +196,15 @@ void main() { source: 'eur', amount: 11000, recipient: 'Sample recipient', + payoutDestination: OneTimeDestination( + CryptoDestination( + assetId: 'demo:ethereum:native', + networkId: 'eip155:1', + address: '0x1234567890123456789012345678901234567890', + assetLabel: 'ETH', + networkLabel: 'Ethereum', + ), + ), ), ), throwsA(