Add saved payout destination UX and service contracts (#54)
This commit is contained in:
1 parent
a5689876ff
commit
24af794112
49 files changed
+4687
-62
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user