Add saved payout destination UX and service contracts (#54)

This commit is contained in:
Stephan D committed 2026-10-16 12:01:33 +02:00
1 parent a5689876ff
commit 24af794112
49 files changed
+4687 -62

No files matched your search

@@ -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.
@@ -0,0 +1,49 @@
/// Local, explicit-label paste parsing. Raw pasted text is never retained.
class BankPaste {
BankPaste._(this.values, this.conflicts);
final Map<String, String> values;
final Set<String> conflicts;
static BankPaste parse(String text) {
final values = <String, String>{};
final conflicts = <String>{};
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));
}
}
@@ -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 = <String, Map<String, SavedDestination>>{};
final attempts = <String, Map<String, (String, CreationResult)>>{};
final audit = <DestinationAudit>[];
final recoveries = <String, Map<String, (String, String)>>{};
final fingerprints = <String, Map<String, String?>>{};
}
/// 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 = <String, PayoutEligibility>{};
int requirementsRevision = 1;
bool loseNextSaveResponse = false;
bool reusableCards = true, cardRecovery = true;
DestinationIssue? nextSaveError;
bool directoryUnavailable = false;
List<Bank> 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<String, SavedDestination> get _records =>
memory.records.putIfAbsent(customer, () => {});
Map<String, (String, CreationResult)> get _attempts =>
memory.attempts.putIfAbsent(customer, () => {});
@override
bool get isSimulated => true;
@override
Future<DestinationCapabilities> capabilities() async =>
DestinationCapabilities(
types: DestinationType.values,
assets: assets,
reusableCards: true,
cardRecovery: true,
simulated: true,
);
@override
Future<List<SavedDestination>> 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<SavedDestination> 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<DestinationValidation> 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] ?? <String, String?>{})[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<PayoutEligibility> assess(
DestinationInput input,
PayoutContext context,
) async => _assess(input, context);
@override
Future<CreationResult> 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<CreationResult?> creation(String idempotencyKey) async =>
_attempts[idempotencyKey]?.$2;
@override
Future<SavedDestination> 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<void> 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<List<Bank>> banks(String query, {Future<void>? 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<DestinationLease> 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<SavedDestination> 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;
}
@@ -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<void> 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<void> 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<void> 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();
}
}
@@ -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();
@@ -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<void> rename(String nickname) => _run(() async {
destination = await service.rename(
destination.id,
nickname,
destination.metadataRevision,
);
});
Future<void> remove() => _run(() async {
await service.remove(
destination.id,
destination.metadataRevision,
destination.executionRevision,
);
removed = true;
});
Future<void> refresh() => _run(() async {
destination = await service.get(destination.id);
removed = destination.removed;
});
Future<void> _run(Future<void> 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();
}
}
@@ -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<ProviderBinding> 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<ProviderBinding> 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<DestinationType> types = const [],
List<CryptoAsset> assets = const [],
this.reusableCards = false,
this.cardRecovery = false,
this.simulated = false,
}) : types = List.unmodifiable(types),
assets = List.unmodifiable(assets);
final List<DestinationType> types;
final List<CryptoAsset> 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;
}
@@ -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<SavedDestination> methods = [];
bool loading = false, failed = false, _disposed = false;
int _revision = 0;
Future<void> 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<SavedDestination> 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();
}
}
@@ -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<void>? _abort;
int _generation = 0;
bool _disposed = false, loading = false;
String query = '';
List<Bank> 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<void> _lookup(String value, int generation) async {
loading = true;
notifyListeners();
try {
final abort = Completer<void>();
_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();
}
}
@@ -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 = <String, PayoutEligibility>{};
bool busy = false, _disposed = false;
DestinationIssue? error;
Future<void> assess(List<SavedDestination> 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<SavedReference?> 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();
}
}
@@ -0,0 +1,39 @@
import 'models.dart';
abstract interface class DestinationService {
bool get isSimulated;
Future<DestinationCapabilities> capabilities();
Future<List<SavedDestination>> list();
Future<SavedDestination> get(String id);
Future<DestinationValidation> validate(DestinationDetails details);
Future<PayoutEligibility> assess(
DestinationInput input,
PayoutContext context,
);
Future<CreationResult> create(
DestinationDetails details,
String nickname,
String idempotencyKey, {
String? replaces,
});
Future<CreationResult?> creation(String idempotencyKey);
Future<SavedDestination> rename(
String id,
String nickname,
int metadataRevision,
);
Future<void> remove(String id, int metadataRevision, int executionRevision);
Future<List<Bank>> banks(String query, {Future<void>? abortTrigger});
Future<DestinationLease> prepare(
DestinationInput input,
PayoutContext context,
);
Future<SavedDestination> restoreCard(
String id,
int executionRevision,
CardDestination capture,
String idempotencyKey,
);
void clearTransient();
}
@@ -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<String, Object?> 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<String, Object?> 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<String, dynamic> 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<String, dynamic>.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),
),
};
}
@@ -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<DestinationCapabilities> capabilities() async =>
DestinationCapabilities();
@override
Future<List<SavedDestination>> list() async => [];
Never _unsupported() =>
throw const DestinationException(DestinationIssue.unsupported);
@override
Future<SavedDestination> get(String id) async => _unsupported();
@override
Future<DestinationValidation> validate(DestinationDetails details) async =>
_unsupported();
@override
Future<PayoutEligibility> assess(
DestinationInput input,
PayoutContext context,
) async => const PayoutEligibility(
EligibilityState.unsupported,
reason: DestinationIssue.unsupported,
canSave: false,
);
@override
Future<CreationResult> create(
DestinationDetails details,
String nickname,
String idempotencyKey, {
String? replaces,
}) async => _unsupported();
@override
Future<CreationResult?> creation(String idempotencyKey) async =>
_unsupported();
@override
Future<SavedDestination> rename(
String id,
String nickname,
int metadataRevision,
) async => _unsupported();
@override
Future<void> remove(
String id,
int metadataRevision,
int executionRevision,
) async => _unsupported();
@override
Future<List<Bank>> banks(String query, {Future<void>? abortTrigger}) async =>
_unsupported();
@override
Future<DestinationLease> prepare(
DestinationInput input,
PayoutContext context,
) async => _unsupported();
@override
Future<SavedDestination> restoreCard(
String id,
int executionRevision,
CardDestination capture,
String idempotencyKey,
) async => _unsupported();
@override
void clearTransient() {}
}
@@ -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;
}
@@ -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<void> 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;
@@ -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;
@@ -94,10 +94,15 @@ class BackendWalletService implements WalletService {
@override
Future<WalletSnapshot> fetch() => _mapped(send(''), BackendMapper.snapshot);
@override
Future<Quote> quote(TransferRequest request) => _mapped(
send('/quote', body: BackendMapper.request(request)),
BackendMapper.quote,
);
Future<Quote> 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<WalletOperation> submit(
String quoteId,
@@ -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 = <String, DestinationLease>{};
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);
@@ -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;
}
@@ -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';
@@ -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<WalletException>().having(
(e) => e.code,
'code',
WalletError.unsupported,
),
),
);
expect(calls, 0);
});
}
@@ -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<DestinationException>().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 = <String>{};
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 <DestinationDetails>[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<OneTimeDestination>());
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<WalletException>().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),
);
});
}
@@ -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'))!;
@@ -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(