Add optional KYC flow and service-backed account limits

This commit is contained in:
Stephan D committed 2026-09-12 09:28:24 +02:00
1 parent 61ccfc1844
commit 3397f04ab7
38 files changed
+3443 -36

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
.dart_tool/
build/
pubspec.lock
+38
View File
@@ -0,0 +1,38 @@
# sxchnge_kyc
Shared Flutter identity verification and backend-owned visible limits. Both web
and future mobile clients consume the same models, providers and service contracts.
- `IdentityProvider` coordinates draft photos, validation errors, submission,
refresh and semantic analytics. Inject `IdentityService` and `PhotoService`.
- `LimitsProvider` fetches `LimitsService` models; it does not calculate caps from
a client-selected status. Refresh after status updates. Existing values are
marked stale after an error; out-of-order responses cannot replace newer data.
- `DummyIdentityService` keeps only review metadata in memory. `DummyLimitsService`
mimics backend policy with explicitly illustrative EUR fixtures. Neither is
production authorization. `review` exists only on the dummy service.
- `FilePhotoService` opens the platform picker and validates JPEG/PNG content,
10 MiB size, minimum 600px per dimension, maximum 40 megapixels and decodability.
It performs no face matching, OCR or identity approval. Camera capture is not
implemented; users can take a photo separately and select it.
- Photo models are transient; clients must dispose providers and evict sensitive
image cache entries. No disk, browser storage, network or document telemetry is
used by these stubs. No guarantee of cryptographic memory erasure is made.
Replace both identity and limits services when connecting a backend. A submit
adapter must reuse the supplied idempotency key across retries, scope requests to
the authenticated account, map safe errors and return server-confirmed status.
Numeric values use minor currency units (currently a two-decimal currency fixture);
extend the transport with currency precision before supporting other units.
The app's `/identity` route is an explicitly public demo, not an authenticated
account endpoint. Production requires session/email guards, server authorization,
private document storage, staff review and support integration. See
[the UX/measurement contract](../../../docs/ux/kyc.md).
Validation: `flutter pub get`, `flutter analyze`, `flutter test`.
Photo selection uses Flutter's [file_selector](https://pub.dev/packages/file_selector)
and [image metadata decoding](https://pub.dev/documentation/image/latest/image/Decoder-class.html).
For native macOS consumers enable the user-selected read-only file entitlement;
iOS/Android camera permissions are not required for this file-picker-only flow.
@@ -0,0 +1,54 @@
import 'dart:typed_data';
enum IdentityStatus { unverified, pending, approved, changesRequested, denied }
enum ReviewReason {
passportUnreadable,
selfieUnreadable,
passportExpired,
unableToVerify,
}
enum PhotoKind { passport, selfie }
enum KycError {
unavailable,
internal,
permission,
format,
tooLarge,
unreadable,
tooSmall,
incomplete,
sessionExpired,
rateLimited,
conflict,
}
class KycException implements Exception {
const KycException(this.code);
final KycError code;
}
class Identity {
const Identity({
required this.status,
this.reason,
this.submissionId,
this.simulated = false,
});
final IdentityStatus status;
final ReviewReason? reason;
final String? submissionId;
final bool simulated;
bool get canSubmit =>
status == IdentityStatus.unverified ||
status == IdentityStatus.changesRequested;
}
/// Ephemeral image data only. Never serialize this model into storage or telemetry.
class Photo {
Photo(Uint8List bytes)
: bytes = Uint8List.fromList(bytes).asUnmodifiableView();
final Uint8List bytes;
}
@@ -0,0 +1,31 @@
enum AccountTier { basic, verified }
enum LimitPeriod { day, month }
class VisibleLimit {
const VisibleLimit({
required this.period,
required this.currency,
required this.basicMinor,
required this.verifiedMinor,
required this.currentMinor,
required this.remainingMinor,
});
final LimitPeriod period;
final String currency;
final int basicMinor;
final int verifiedMinor;
final int currentMinor;
final int remainingMinor;
}
class Limits {
Limits({
required this.tier,
required List<VisibleLimit> rows,
required this.simulated,
}) : rows = List.unmodifiable(rows);
final AccountTier tier;
final List<VisibleLimit> rows;
final bool simulated;
}
@@ -0,0 +1,235 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import '../models/identity.dart';
import '../services/contracts.dart';
import '../services/dummy.dart';
class IdentityProvider extends ChangeNotifier {
IdentityProvider(
this.service,
this.photos,
this.journeys, {
this.onStatusChanged,
});
final Future<void> Function()? onStatusChanged;
final IdentityService service;
final PhotoService photos;
final JourneyRecorder journeys;
Identity? record;
Photo? passport;
Photo? selfie;
KycError? error;
PhotoKind? photoError;
bool busy = false;
bool stale = false;
bool editing = false;
bool confirmed = false;
int step = 0;
bool _disposed = false;
String? _key;
bool get hasDraft => passport != null || selfie != null;
bool get canSubmit =>
!busy &&
!stale &&
confirmed &&
passport != null &&
selfie != null &&
(record?.canSubmit ?? false);
bool get isDemo => service.isSimulated;
void _notify() {
if (!_disposed) notifyListeners();
}
JourneyOperation _begin(JourneyStep step) =>
journeys.begin(JourneyKind.kyc, step, simulated: isDemo);
Future<void> refresh() async {
if (busy) return;
busy = true;
error = null;
photoError = null;
_notify();
final op = _begin(JourneyStep.kycStatus);
try {
final result = await service.fetch();
if (_disposed) return;
record = result;
stale = false;
await onStatusChanged?.call();
if (_disposed) return;
if (!result.canSubmit) {
_clear();
editing = false;
}
journeys.finish(op, simulated: isDemo, terminal: true);
} catch (e) {
if (_disposed) return;
stale = true;
error = _error(e);
journeys.finish(op, failure: JourneyFailure.unavailable, terminal: false);
}
busy = false;
_notify();
}
void start() {
if (busy || stale || !(record?.canSubmit ?? false)) return;
editing = true;
error = null;
if (!hasDraft) {
step = 0;
confirmed = false;
}
final op = _begin(JourneyStep.kycPreparation);
journeys.finish(op, simulated: isDemo);
_notify();
}
Future<void> pick(PhotoKind kind) async {
if (busy || !editing) return;
busy = true;
error = null;
photoError = null;
_notify();
final op = _begin(
kind == PhotoKind.passport
? JourneyStep.kycPassport
: JourneyStep.kycSelfie,
);
try {
final photo = await photos.pick();
if (_disposed) return;
if (photo != null) {
if (kind == PhotoKind.passport) {
passport = photo;
} else {
selfie = photo;
}
confirmed = false;
_key = null;
}
journeys.finish(op, simulated: isDemo, cancelled: photo == null);
} catch (e) {
if (_disposed) return;
error = _error(e);
photoError = kind;
journeys.finish(op, failure: JourneyFailure.document);
}
busy = false;
_notify();
}
void remove(PhotoKind kind) {
if (busy) return;
if (kind == PhotoKind.passport) {
passport = null;
} else {
selfie = null;
}
confirmed = false;
_key = null;
_notify();
}
void back() {
if (!busy && step > 0) {
step--;
error = null;
_notify();
}
}
void next() {
if (busy ||
(step == 0 && passport == null) ||
(step == 1 && selfie == null))
return;
if (step < 2) step++;
error = null;
_notify();
}
void confirm(bool value) {
if (!busy) {
confirmed = value;
_notify();
}
}
Future<void> submit() async {
if (!canSubmit) return;
busy = true;
error = null;
photoError = null;
_notify();
_key ??= List.generate(
16,
(_) => Random.secure().nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
final op = _begin(JourneyStep.kycSubmission);
try {
final result = await service.submit(
passport: passport!,
selfie: selfie!,
idempotencyKey: _key!,
);
if (_disposed) return;
if (result.canSubmit) throw const KycException(KycError.conflict);
record = result;
editing = false;
_clear();
await onStatusChanged?.call();
if (_disposed) return;
journeys.finish(op, simulated: isDemo, terminal: true);
} catch (e) {
if (_disposed) return;
error = _error(e);
if (error == KycError.conflict || error == KycError.sessionExpired)
stale = true;
journeys.finish(op, failure: JourneyFailure.unavailable);
}
busy = false;
_notify();
}
Future<void> demoReview(IdentityStatus status, {ReviewReason? reason}) async {
if (busy || service is! DummyIdentityService) return;
try {
(service as DummyIdentityService).review(status, reason: reason);
} on KycException catch (e) {
error = e.code;
_notify();
return;
}
await refresh();
}
void exit() {
if (busy) return;
journeys.abandon(JourneyExit.navigation);
_clear();
editing = false;
error = null;
_notify();
}
void _clear() {
passport = null;
selfie = null;
confirmed = false;
_key = null;
step = 0;
}
KycError _error(Object e) => e is KycException ? e.code : KycError.internal;
@override
void dispose() {
_disposed = true;
_clear();
journeys.dispose();
super.dispose();
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/foundation.dart';
import '../models/limits.dart';
import '../services/contracts.dart';
class LimitsProvider extends ChangeNotifier {
LimitsProvider(this.service);
final LimitsService service;
Limits? data;
bool loading = false;
bool failed = false;
int _revision = 0;
bool _disposed = false;
Future<void> refresh() async {
final revision = ++_revision;
loading = true;
failed = false;
notifyListeners();
try {
final result = await service.fetch();
if (_disposed || revision != _revision) return;
data = result;
} catch (_) {
if (_disposed || revision != _revision) return;
failed = true;
}
if (_disposed || revision != _revision) return;
loading = false;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,24 @@
import '../models/identity.dart';
import '../models/limits.dart';
abstract interface class IdentityService {
bool get isSimulated;
Future<Identity> fetch();
/// Backend adapters must scope the key to the authenticated account and reject
/// stale/duplicate review transitions. Retrying the same key is idempotent.
Future<Identity> submit({
required Photo passport,
required Photo selfie,
required String idempotencyKey,
});
}
abstract interface class LimitsService {
/// Fetch server-owned visible limits. No client-selected KYC status or tier.
Future<Limits> fetch();
}
abstract interface class PhotoService {
Future<Photo?> pick();
}
@@ -0,0 +1,93 @@
import '../models/identity.dart';
import '../models/limits.dart';
import '../transport/contracts.dart';
import 'contracts.dart';
/// Process-local backend stand-in. It never stores or sends document bytes.
class DummyIdentityService implements IdentityService {
DummyIdentityService({this.delay = const Duration(milliseconds: 450)});
final Duration delay;
IdentityDTO _record = const IdentityDTO('unverified');
String? _lastKey;
KycError? nextError;
@override
bool get isSimulated => true;
Future<void> _wait() async {
await Future<void>.delayed(delay);
final error = nextError;
nextError = null;
if (error != null) throw KycException(error);
}
@override
Future<Identity> fetch() async {
await _wait();
return IdentityMapper.fromDTO(_record);
}
@override
Future<Identity> submit({
required Photo passport,
required Photo selfie,
required String idempotencyKey,
}) async {
await _wait();
if (_lastKey == idempotencyKey) return IdentityMapper.fromDTO(_record);
if (!IdentityMapper.fromDTO(_record).canSubmit)
throw const KycException(KycError.conflict);
if (passport.bytes.isEmpty || selfie.bytes.isEmpty)
throw const KycException(KycError.incomplete);
_lastKey = idempotencyKey;
_record = IdentityDTO('pending', id: idempotencyKey);
return IdentityMapper.fromDTO(_record);
}
/// Explicit demo-only human decision stand-in; unavailable in real adapters.
void review(IdentityStatus status, {ReviewReason? reason}) {
if (_record.status != 'pending')
throw const KycException(KycError.conflict);
if (![
IdentityStatus.approved,
IdentityStatus.changesRequested,
IdentityStatus.denied,
].contains(status))
throw const KycException(KycError.conflict);
_record = IdentityDTO(status.name, id: _record.id, reason: reason?.name);
}
}
/// Mimics backend policy evaluation. Widgets/providers never compute active caps.
/// Values are illustrative fixtures, not the product's real limits policy.
class DummyLimitsService implements LimitsService {
DummyLimitsService(this.identity);
final IdentityService identity;
@override
Future<Limits> fetch() async {
final record = await identity.fetch();
final approved = record.status == IdentityStatus.approved;
return LimitsMapper.fromDTO(
LimitsDTO(
tier: approved ? 'verified' : 'basic',
simulated: true,
rows: [
LimitDTO(
'day',
'EUR',
10000,
250000,
approved ? 250000 : 10000,
approved ? 250000 : 10000,
),
LimitDTO(
'month',
'EUR',
50000,
1000000,
approved ? 1000000 : 50000,
approved ? 1000000 : 50000,
),
],
),
);
}
}
@@ -0,0 +1,81 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:file_selector/file_selector.dart';
import 'package:image/image.dart' as metadata;
import '../models/identity.dart';
import 'contracts.dart';
class FilePhotoService implements PhotoService {
const FilePhotoService();
static const maxBytes = 10 * 1024 * 1024;
@override
Future<Photo?> pick() async {
try {
final file = await openFile(
acceptedTypeGroups: const [
XTypeGroup(
label: 'JPEG / PNG',
extensions: ['jpg', 'jpeg', 'png'],
mimeTypes: ['image/jpeg', 'image/png'],
uniformTypeIdentifiers: ['public.jpeg', 'public.png'],
),
],
);
if (file == null) return null;
if (await file.length() > maxBytes)
throw const KycException(KycError.tooLarge);
final bytes = await file.readAsBytes();
return await validate(bytes);
} on KycException {
rethrow;
} catch (_) {
throw const KycException(KycError.permission);
}
}
static Future<Photo> validate(Uint8List bytes) async {
try {
if (bytes.length > maxBytes) throw const KycException(KycError.tooLarge);
final png =
bytes.length > 8 &&
bytes[0] == 137 &&
bytes[1] == 80 &&
bytes[2] == 78 &&
bytes[3] == 71;
final jpg =
bytes.length > 3 &&
bytes[0] == 255 &&
bytes[1] == 216 &&
bytes[2] == 255;
if (!png && !jpg) throw const KycException(KycError.format);
final metadata.Decoder decoder = png
? metadata.PngDecoder()
: metadata.JpegDecoder();
final info = decoder.startDecode(bytes);
if (info == null) throw const KycException(KycError.unreadable);
if (info.width < 600 || info.height < 600)
throw const KycException(KycError.tooSmall);
if (info.width * info.height > 40000000)
throw const KycException(KycError.tooLarge);
final codec = await ui.instantiateImageCodec(
bytes,
targetWidth: 1000,
allowUpscaling: false,
);
try {
final frame = await codec.getNextFrame();
frame.image.dispose();
} finally {
codec.dispose();
}
return Photo(bytes);
} on KycException {
rethrow;
} catch (_) {
throw const KycException(KycError.unreadable);
}
}
}
@@ -0,0 +1,64 @@
import '../models/identity.dart';
import '../models/limits.dart';
// Transport contracts for the stub. Replace with generated backend DTOs later.
class IdentityDTO {
const IdentityDTO(this.status, {this.reason, this.id, this.simulated = true});
final String status;
final String? reason;
final String? id;
final bool simulated;
}
class IdentityMapper {
static Identity fromDTO(IdentityDTO dto) => Identity(
status: IdentityStatus.values.byName(dto.status),
reason: dto.reason == null ? null : ReviewReason.values.byName(dto.reason!),
submissionId: dto.id,
simulated: dto.simulated,
);
}
class LimitsDTO {
const LimitsDTO({
required this.tier,
required this.rows,
required this.simulated,
});
final String tier;
final List<LimitDTO> rows;
final bool simulated;
}
class LimitDTO {
const LimitDTO(
this.period,
this.currency,
this.basic,
this.verified,
this.current,
this.remaining,
);
final String period;
final String currency;
final int basic, verified, current, remaining;
}
class LimitsMapper {
static Limits fromDTO(LimitsDTO dto) => Limits(
tier: AccountTier.values.byName(dto.tier),
simulated: dto.simulated,
rows: dto.rows
.map(
(r) => VisibleLimit(
period: LimitPeriod.values.byName(r.period),
currency: r.currency,
basicMinor: r.basic,
verifiedMinor: r.verified,
currentMinor: r.current,
remainingMinor: r.remaining,
),
)
.toList(),
);
}
@@ -0,0 +1,7 @@
export 'src/models/identity.dart';
export 'src/models/limits.dart';
export 'src/providers/identity.dart';
export 'src/providers/limits.dart';
export 'src/services/contracts.dart';
export 'src/services/dummy.dart';
export 'src/services/photos.dart';
@@ -0,0 +1,17 @@
name: sxchnge_kyc
description: Shared identity verification and visible limits for SXCHNGE clients.
version: 0.1.0
publish_to: none
environment:
sdk: '>=3.13.0 <4.0.0'
flutter: '>=3.47.0'
dependencies:
flutter:
sdk: flutter
image: ^4.9.0
file_selector: ^1.1.0
sxchnge_analytics:
path: ../sxchnge_analytics
dev_dependencies:
flutter_test:
sdk: flutter
@@ -0,0 +1,263 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
class Photos implements PhotoService {
Photo? result = Photo(Uint8List.fromList([1, 2, 3]));
KycError? error;
Completer<Photo?>? pending;
@override
Future<Photo?> pick() async {
if (error != null) throw KycException(error!);
return pending == null ? result : pending!.future;
}
}
class LimitStub implements LimitsService {
final requests = <Completer<Limits>>[];
@override
Future<Limits> fetch() {
final c = Completer<Limits>();
requests.add(c);
return c.future;
}
}
class BrokenAnalytics implements AnalyticsService {
@override
Future<void> capture(AnalyticsEvent event) => throw StateError('offline');
}
Future<void> draft(IdentityProvider p) async {
await p.refresh();
p.start();
await p.pick(PhotoKind.passport);
p.next();
await p.pick(PhotoKind.selfie);
p.next();
p.confirm(true);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late DummyIdentityService service;
late Photos photos;
late DummyAnalyticsService sink;
late IdentityProvider p;
setUp(() {
service = DummyIdentityService(delay: Duration.zero);
photos = Photos();
sink = DummyAnalyticsService();
p = IdentityProvider(service, photos, JourneyRecorder(sink));
});
tearDown(() => p.dispose());
test(
'unverified, pending, correction and denial retain basic service limits',
() async {
final limits = LimitsProvider(DummyLimitsService(service));
addTearDown(limits.dispose);
await limits.refresh();
expect(limits.data!.tier, AccountTier.basic);
await draft(p);
await p.submit();
expect(p.record!.status, IdentityStatus.pending);
expect(p.hasDraft, false);
await limits.refresh();
expect(limits.data!.tier, AccountTier.basic);
await p.demoReview(
IdentityStatus.changesRequested,
reason: ReviewReason.passportExpired,
);
await limits.refresh();
expect(limits.data!.tier, AccountTier.basic);
await draft(p);
await p.submit();
await p.demoReview(
IdentityStatus.denied,
reason: ReviewReason.unableToVerify,
);
p.start();
expect(p.editing, false);
await limits.refresh();
expect(limits.data!.tier, AccountTier.basic);
},
);
test('approval refreshes backend limits through callback', () async {
final limits = LimitsProvider(DummyLimitsService(service));
addTearDown(limits.dispose);
final subject = IdentityProvider(
service,
photos,
JourneyRecorder(sink),
onStatusChanged: limits.refresh,
);
addTearDown(subject.dispose);
await draft(subject);
final before = limits.data!.rows.first.currentMinor;
await subject.submit();
await subject.demoReview(IdentityStatus.approved);
expect(limits.data!.tier, AccountTier.verified);
expect(limits.data!.rows.first.currentMinor, greaterThan(before));
});
test('cancel and invalid replacement preserve photo', () async {
await draft(p);
p.back();
final original = p.selfie;
photos.result = null;
await p.pick(PhotoKind.selfie);
expect(p.selfie, same(original));
expect(p.error, null);
photos.error = KycError.tooLarge;
await p.pick(PhotoKind.selfie);
expect(p.selfie, same(original));
expect(p.error, KycError.tooLarge);
expect(p.step, 1);
await Future<void>.delayed(Duration.zero);
expect(sink.events.any((e) => e.signal == JourneySignal.cancelled), true);
});
test(
'submit retry preserves photos and counts one terminal simulated outcome',
() async {
await draft(p);
service.nextError = KycError.unavailable;
await p.submit();
expect(p.hasDraft, true);
expect(p.canSubmit, true);
await p.submit();
expect(p.record!.status, IdentityStatus.pending);
expect(p.hasDraft, false);
await Future<void>.delayed(Duration.zero);
final events = sink.events.where(
(e) => e.step == JourneyStep.kycSubmission,
);
expect(events.where((e) => e.terminal).length, 1);
expect(events.map((e) => e.journeyId).toSet().length, 1);
expect(events.every((e) => e.simulated), true);
expect(
sink.events
.expand((e) => e.properties.keys)
.any(
(k) => [
'email',
'filename',
'bytes',
'passport',
'token',
].contains(k),
),
false,
);
},
);
test('duplicate submit suppressed and service idempotency replays', () async {
await draft(p);
await Future.wait([p.submit(), p.submit()]);
await Future<void>.delayed(Duration.zero);
expect(
sink.events
.where(
(e) =>
e.step == JourneyStep.kycSubmission &&
e.signal == JourneySignal.requested,
)
.length,
1,
);
final replay = await service.submit(
passport: photos.result!,
selfie: photos.result!,
idempotencyKey: p.record!.submissionId!,
);
expect(replay.submissionId, p.record!.submissionId);
});
test('status error does not invent a tier or allow submission', () async {
service.nextError = KycError.unavailable;
await p.refresh();
expect(p.record, null);
expect(p.stale, true);
p.start();
expect(p.editing, false);
await p.refresh();
p.start();
expect(p.editing, true);
});
test(
'limits ignore late response and mark retained values stale on error',
() async {
final s = LimitStub();
final limits = LimitsProvider(s);
addTearDown(limits.dispose);
final old = limits.refresh();
final recent = limits.refresh();
s.requests[1].complete(
Limits(tier: AccountTier.verified, rows: [], simulated: true),
);
await recent;
s.requests[0].complete(
Limits(tier: AccountTier.basic, rows: [], simulated: true),
);
await old;
expect(limits.data!.tier, AccountTier.verified);
final failure = limits.refresh();
s.requests[2].completeError(StateError('offline'));
await failure;
expect(limits.failed, true);
expect(limits.data!.tier, AccountTier.verified);
},
);
test(
'exit discards images; analytics errors cannot block submission',
() async {
await draft(p);
p.exit();
expect(p.hasDraft, false);
await Future<void>.delayed(Duration.zero);
expect(sink.events.any((e) => e.signal == JourneySignal.abandoned), true);
final subject = IdentityProvider(
service,
photos,
JourneyRecorder(BrokenAnalytics()),
);
addTearDown(subject.dispose);
await draft(subject);
await subject.submit();
expect(subject.record!.status, IdentityStatus.pending);
},
);
test('late picker does not repopulate disposed state', () async {
final subject = IdentityProvider(service, photos, JourneyRecorder(sink));
await subject.refresh();
subject.start();
photos.pending = Completer<Photo?>();
final pending = subject.pick(PhotoKind.passport);
subject.dispose();
photos.pending!.complete(photos.result);
await pending;
expect(subject.hasDraft, false);
});
test('validates bytes, signature, corrupt encoding and resolution', () async {
Future<void> rejects(Uint8List bytes, KycError code) async => expectLater(
FilePhotoService.validate(bytes),
throwsA(isA<KycException>().having((e) => e.code, 'code', code)),
);
await rejects(Uint8List(FilePhotoService.maxBytes + 1), KycError.tooLarge);
await rejects(Uint8List.fromList([1, 2, 3]), KycError.format);
await rejects(Uint8List.fromList([255, 216, 255, 0]), KycError.unreadable);
final r = ui.PictureRecorder();
ui.Canvas(r);
final pic = r.endRecording();
final image = await pic.toImage(20, 20);
final bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!
.buffer
.asUint8List();
image.dispose();
pic.dispose();
await rejects(bytes, KycError.tooSmall);
});
}