Files
sxchnge/frontend/packages/sxchnge_wallet/lib/src/providers/transfer.dart
T

359 lines
9.8 KiB
Dart

import 'dart:math';
import 'package:flutter/foundation.dart';
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';
class TransferProvider extends ChangeNotifier {
TransferProvider(this.wallet, this.kind, {String? source})
: source = source ?? wallet.data?.accounts.firstOrNull?.id ?? 'eur',
recorder = JourneyRecorder(wallet.analytics) {
destination =
wallet.data?.accounts
.where((a) => a.id != this.source)
.firstOrNull
?.id ??
'usd';
wallet.addListener(_walletChanged);
_syncAccounts();
final op = recorder.begin(
journey,
JourneyStep.transferDraft,
simulated: wallet.service.isSimulated,
);
recorder.finish(op, simulated: wallet.service.isSimulated);
}
void _syncAccounts() {
final accounts = wallet.data?.accounts;
if (accounts == null ||
accounts.isEmpty ||
busy ||
uncertain ||
quote != null)
return;
if (!accounts.any((a) => a.id == source)) source = accounts.first.id;
if (!accounts.any((a) => a.id == destination) || destination == source) {
destination = accounts.where((a) => a.id != source).firstOrNull?.id;
}
}
void _walletChanged() {
if (_disposed) return;
final previous = source;
_syncAccounts();
if (source != previous) {
payoutDestination = null;
payoutLabel = '';
recipient = '';
notifyListeners();
}
}
final WalletProvider wallet;
final TransferKind kind;
final JourneyRecorder recorder;
String source;
String? destination;
String amount = '', recipient = '';
DestinationInput? payoutDestination;
String payoutLabel = '';
Quote? quote;
WalletOperation? result;
WalletError? error;
FactorError? factorError;
bool noSubmission = false;
bool busy = false, uncertain = false, _disposed = false, _handedOff = false;
int _revision = 0;
String? _key;
JourneyKind get journey => switch (kind) {
TransferKind.topUp => JourneyKind.topUp,
TransferKind.exchange => JourneyKind.exchange,
TransferKind.payout => JourneyKind.payout,
};
bool get hasDraft =>
amount.isNotEmpty || payoutDestination != null || recipient.isNotEmpty;
bool get expired =>
quote != null && !DateTime.now().isBefore(quote!.expiresAt);
void change({
String? source,
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;
this.recipient = recipient ?? this.recipient;
if (source != null) _syncAccounts();
quote = null;
noSubmission = false;
error = null;
factorError = null;
_key = null;
_revision++;
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;
error = null;
factorError = null;
_key = null;
notifyListeners();
}
}
Future<void> review() async {
if (busy || uncertain) return;
final money = wallet.account(source)?.parse(amount);
if (money == null || money <= 0) {
error = WalletError.invalidAmount;
notifyListeners();
return;
}
busy = true;
noSubmission = false;
error = null;
factorError = null;
notifyListeners();
final revision = ++_revision;
final op = recorder.begin(
journey,
JourneyStep.transferQuote,
simulated: wallet.service.isSimulated,
);
try {
final q = await wallet.service.quote(
TransferRequest(
kind: kind,
source: source,
amount: money,
destination: kind == TransferKind.exchange ? destination : null,
recipient: recipient.trim(),
payoutDestination: payoutDestination,
),
);
if (_disposed || revision != _revision) return;
quote = q;
_key = null;
recorder.finish(op, simulated: wallet.service.isSimulated);
} catch (e) {
if (_disposed) return;
error = _code(e);
if (error == WalletError.destinationChanged) {
payoutDestination = null;
payoutLabel = '';
}
recorder.finish(op, failure: _failure(error!));
}
if (_disposed) return;
busy = false;
notifyListeners();
}
Future<FactorChallenge?> challenge() async {
if (busy || uncertain || quote == null) return null;
if (expired) {
error = WalletError.expiredQuote;
notifyListeners();
return null;
}
busy = true;
error = null;
factorError = null;
notifyListeners();
FactorChallenge? result;
final op = recorder.begin(
journey,
JourneyStep.factorChallenge,
simulated: wallet.service.isSimulated,
);
try {
result = await wallet.factors?.begin(
FactorPurpose.values.byName(kind.name),
quote!.id,
);
} catch (e) {
if (!_disposed) {
factorError = e is FactorException ? e.code : FactorError.unavailable;
}
}
if (_disposed) return null;
busy = false;
notifyListeners();
recorder.finish(
op,
simulated: wallet.service.isSimulated,
failure: factorError == null ? null : JourneyFailure.unavailable,
);
return result;
}
Future<void> submit({FactorGrant? approval}) async {
if (busy || quote == null || uncertain) return;
if (expired) {
error = WalletError.expiredQuote;
notifyListeners();
return;
}
busy = true;
noSubmission = false;
error = null;
factorError = null;
notifyListeners();
_key ??= approval?.token;
_key ??= List.generate(
16,
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
final op = recorder.begin(
journey,
JourneyStep.transferSubmission,
simulated: wallet.service.isSimulated,
);
try {
final r = await wallet.service.submit(
quote!.id,
_key!,
approval: approval?.token,
);
if (_disposed) return;
_accepted(r, op);
} catch (e) {
if (_disposed) return;
if (e is FactorException) {
error = null;
factorError = e.code;
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 = [
WalletError.unavailable,
WalletError.uncertain,
].contains(error);
recorder.finish(
op,
failure: error == null ? JourneyFailure.invalid : _failure(error!),
);
}
if (_disposed) return;
busy = false;
notifyListeners();
}
void _accepted(WalletOperation value, JourneyOperation op) {
result = value;
uncertain = false;
error = null;
factorError = null;
recipient = '';
payoutDestination = null;
recorder.finish(op, simulated: wallet.service.isSimulated);
wallet.track(value, recorder, journey);
_handedOff = true;
}
Future<void> reconcile() async {
if (busy || !uncertain || _key == null) return;
busy = true;
noSubmission = false;
error = null;
factorError = null;
notifyListeners();
final op = recorder.begin(
journey,
JourneyStep.transferSubmission,
simulated: wallet.service.isSimulated,
);
try {
final r = await wallet.service.findSubmission(_key!);
if (_disposed) return;
if (r != null) {
_accepted(r, op);
} else {
uncertain = false;
noSubmission = true;
recorder.finish(op, simulated: wallet.service.isSimulated);
}
} catch (e) {
if (_disposed) return;
error = _code(e);
recorder.finish(op, failure: JourneyFailure.unavailable);
}
if (_disposed) return;
busy = false;
notifyListeners();
}
WalletError _code(Object e) =>
e is WalletException ? e.code : WalletError.unavailable;
JourneyFailure _failure(WalletError e) => switch (e) {
WalletError.insufficientFunds => JourneyFailure.insufficientFunds,
WalletError.limitExceeded => JourneyFailure.limitExceeded,
WalletError.expiredQuote => JourneyFailure.expired,
_ => JourneyFailure.unavailable,
};
@override
void dispose() {
_disposed = true;
wallet.removeListener(_walletChanged);
recipient = '';
if (!_handedOff) recorder.dispose();
super.dispose();
}
}