Add reusable two-factor verification for login and money operations (#32)

This commit is contained in:
Stephan D committed 2026-09-12 12:44:34 +02:00
1 parent b7a58b1a87
commit fb40f860d2
41 files changed
+2439 -43

No files matched your search

@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import '../models/wallet.dart';
import 'wallet.dart';
@@ -33,6 +35,7 @@ class TransferProvider extends ChangeNotifier {
Quote? quote;
WalletOperation? result;
WalletError? error;
FactorError? factorError;
bool noSubmission = false;
bool busy = false, uncertain = false, _disposed = false, _handedOff = false;
int _revision = 0;
@@ -59,6 +62,7 @@ class TransferProvider extends ChangeNotifier {
quote = null;
noSubmission = false;
error = null;
factorError = null;
_key = null;
_revision++;
notifyListeners();
@@ -68,6 +72,7 @@ class TransferProvider extends ChangeNotifier {
if (!busy && !uncertain) {
quote = null;
error = null;
factorError = null;
_key = null;
notifyListeners();
}
@@ -84,6 +89,7 @@ class TransferProvider extends ChangeNotifier {
busy = true;
noSubmission = false;
error = null;
factorError = null;
notifyListeners();
final revision = ++_revision;
final op = recorder.begin(
@@ -115,7 +121,45 @@ class TransferProvider extends ChangeNotifier {
notifyListeners();
}
Future<void> submit() async {
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;
@@ -125,6 +169,7 @@ class TransferProvider extends ChangeNotifier {
busy = true;
noSubmission = false;
error = null;
factorError = null;
notifyListeners();
_key ??= List.generate(
16,
@@ -136,18 +181,31 @@ class TransferProvider extends ChangeNotifier {
simulated: wallet.service.isSimulated,
);
try {
final r = await wallet.service.submit(quote!.id, _key!);
final r = await wallet.service.submit(
quote!.id,
_key!,
approval: approval?.token,
);
if (_disposed) return;
_accepted(r, op);
} catch (e) {
if (_disposed) return;
error = _code(e);
if (e is FactorException) {
error = null;
factorError = e.code;
uncertain = false;
} else {
error = _code(e);
}
// All transport/unexpected failures are ambiguous after execution started.
uncertain = [
WalletError.unavailable,
WalletError.uncertain,
].contains(error);
recorder.finish(op, failure: _failure(error!));
recorder.finish(
op,
failure: error == null ? JourneyFailure.invalid : _failure(error!),
);
}
if (_disposed) return;
busy = false;
@@ -158,6 +216,7 @@ class TransferProvider extends ChangeNotifier {
result = value;
uncertain = false;
error = null;
factorError = null;
recipient = '';
recorder.finish(op, simulated: wallet.service.isSimulated);
wallet.track(value, recorder, journey);
@@ -169,6 +228,7 @@ class TransferProvider extends ChangeNotifier {
busy = true;
noSubmission = false;
error = null;
factorError = null;
notifyListeners();
final op = recorder.begin(
journey,