diff --git a/mobile-app/lib/v2/screens/send/encrypted_send_strategy.dart b/mobile-app/lib/v2/screens/send/encrypted_send_strategy.dart index 272b025b..ff8ec681 100644 --- a/mobile-app/lib/v2/screens/send/encrypted_send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/encrypted_send_strategy.dart @@ -31,8 +31,6 @@ EncryptedFee planEncryptedFee(List utxos, BigInt amount) { ); } on InsufficientEncryptedFunds { return const EncryptedFee(blocker: EncryptedSendBlocker.insufficient); - } on BatchBelowMinimumExit { - return const EncryptedFee(blocker: EncryptedSendBlocker.belowBatchMinimum); } } @@ -97,7 +95,6 @@ class EncryptedSendStrategy extends SendStrategy { null => null, EncryptedSendBlocker.notQuantized => l10n.encryptedSendAmountStep(AppConstants.tokenSymbol), EncryptedSendBlocker.insufficient => l10n.sendLogicInsufficientBalance, - EncryptedSendBlocker.belowBatchMinimum => l10n.encryptedSendMinimum(AppConstants.tokenSymbol), }; } diff --git a/mobile-app/lib/v2/screens/send/send_strategy.dart b/mobile-app/lib/v2/screens/send/send_strategy.dart index ca5364d4..5f3a54cb 100644 --- a/mobile-app/lib/v2/screens/send/send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/send_strategy.dart @@ -57,7 +57,7 @@ class ProposeFee extends SendFee { } /// Why an encrypted send can't be built for the entered amount. -enum EncryptedSendBlocker { notQuantized, insufficient, belowBatchMinimum } +enum EncryptedSendBlocker { notQuantized, insufficient } /// Fee for an encrypted (wormhole) send: the in-circuit volume fee plus /// quantization dust, carried with the coin-selection [plan] that produced it. diff --git a/mobile-app/test/unit/encrypted_send_strategy_test.dart b/mobile-app/test/unit/encrypted_send_strategy_test.dart index b90522bf..f37c9f68 100644 --- a/mobile-app/test/unit/encrypted_send_strategy_test.dart +++ b/mobile-app/test/unit/encrypted_send_strategy_test.dart @@ -55,10 +55,10 @@ void main() { expect(sub.read().isLoading, isTrue); await container.read(encryptedStateProvider(account.walletIndex).future); - expect((sub.read().requireValue as EncryptedFee).plan?.feeToken, wormholeTokenFromScaled(3)); + expect((sub.read().requireValue as EncryptedFee).plan?.feeToken, wormholeTokenFromScaled(1)); container.invalidate(encryptedStateProvider(account.walletIndex)); - expect(sub.read().value?.displayFee, wormholeTokenFromScaled(3)); + expect(sub.read().value?.displayFee, wormholeTokenFromScaled(1)); expect(loads, 2); }); diff --git a/mobile-app/test/unit/regular_send_strategy_test.dart b/mobile-app/test/unit/regular_send_strategy_test.dart index fd466121..0116ca1e 100644 --- a/mobile-app/test/unit/regular_send_strategy_test.dart +++ b/mobile-app/test/unit/regular_send_strategy_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/quantus_sdk/lib/src/services/wormhole_coin_selection.dart b/quantus_sdk/lib/src/services/wormhole_coin_selection.dart index 2857abce..125107c1 100644 --- a/quantus_sdk/lib/src/services/wormhole_coin_selection.dart +++ b/quantus_sdk/lib/src/services/wormhole_coin_selection.dart @@ -23,18 +23,44 @@ String wormholeVolumeFeePercentText() { /// never a hand-edited value. final BigInt wormholeScaleFactor = vesting_pallet.Constants().payoutQuantum; -/// Chain's `MinimumTransferAmount` (0.1 token) in scaled units, enforced per -/// aggregated batch on the total exit amount. -const int wormholeMinBatchExitScaled = 10; - int wormholeScaledFromToken(BigInt token) => (token ~/ wormholeScaleFactor).toInt(); BigInt wormholeTokenFromScaled(int scaled) => BigInt.from(scaled) * wormholeScaleFactor; -/// Max total output the circuit allows for a consumed input: +/// Max total output the circuit allows for a private batch: /// `(out1 + out2) * 10000 <= input * (10000 - feeBps)`. int wormholeNetScaled(int inputScaled) => inputScaled * (10000 - wormholeVolumeFeeBps) ~/ 10000; +int wormholeBatchNetScaled(Iterable inputAmounts) => + wormholeNetScaled(inputAmounts.fold(0, (sum, amount) => sum + amount)); + +List wormholeBatchOutputs(List inputAmounts) { + if (inputAmounts.any((amount) => amount < 0)) throw ArgumentError('Wormhole batch inputs must be non-negative'); + final outputs = [...inputAmounts]; + var fee = outputs.fold(0, (sum, amount) => sum + amount) - wormholeBatchNetScaled(outputs); + for (var i = outputs.length - 1; i >= 0 && fee > 0; i--) { + final deduction = outputs[i] < fee ? outputs[i] : fee; + outputs[i] -= deduction; + fee -= deduction; + } + if (fee != 0) throw StateError('Unable to allocate wormhole batch fee'); + return outputs; +} + +List> _chunks(List values, int size) { + if (size <= 0) throw ArgumentError.value(size, 'size', 'must be positive'); + return [for (var i = 0; i < values.length; i += size) values.sublist(i, (i + size).clamp(0, values.length))]; +} + +List _sortedSpendable(List utxos) => + utxos.where((utxo) => wormholeScaledFromToken(utxo.amount) > 0).toList() + ..sort((a, b) => b.amount.compareTo(a.amount)); + +int _maxExitScaled(List sorted, int maxProofsPerBatch) => _chunks( + sorted, + maxProofsPerBatch, +).fold(0, (sum, batch) => sum + wormholeBatchNetScaled(batch.map((utxo) => wormholeScaledFromToken(utxo.amount)))); + /// One leaf proof's spend: consumes [utxo] entirely, pays [recipientScaled] to /// the recipient (exit slot 1) and [changeScaled] back to the sender's fresh /// change address (exit slot 2, zero when unused). @@ -54,8 +80,7 @@ class WormholeSpendPlan { final BigInt amountToken; final BigInt changeToken; - /// Everything consumed that neither the recipient nor the change receives: - /// the 4 bps volume fee plus sub-0.01-tokens quantization dust. + /// Everything consumed that neither the recipient nor the change receives. final BigInt feeToken; const WormholeSpendPlan({ @@ -81,25 +106,14 @@ class InsufficientEncryptedFunds extends WormholeSelectionException { : super('Insufficient encrypted funds: max sendable is $maxSendableToken token units'); } -/// An aggregation batch's total exit would fall below the chain's minimum -/// (0.1 token); the amounts are too fragmented to send this way. -class BatchBelowMinimumExit extends WormholeSelectionException { - BatchBelowMinimumExit(int totalScaled) - : super('Batch exit total $totalScaled is below the chain minimum of $wormholeMinBatchExitScaled (0.1 token)'); -} - -/// Maximum amount spendable from [utxos] (sum of per-input nets after the -/// volume fee), in token units. -BigInt wormholeMaxSendable(List utxos) { - final totalScaled = utxos.fold(0, (sum, u) => sum + wormholeNetScaled(wormholeScaledFromToken(u.amount))); - return wormholeTokenFromScaled(totalScaled); +/// Maximum amount spendable from [utxos] after one fee per private batch. +BigInt wormholeMaxSendable(List utxos, {int maxProofsPerBatch = 7}) { + return wormholeTokenFromScaled(_maxExitScaled(_sortedSpendable(utxos), maxProofsPerBatch)); } /// Selects inputs to send exactly [amountToken] (a multiple of 0.01 tokens) to -/// the recipient, largest-first. Every leaf pays its full net to the recipient -/// except the last, which splits between the recipient remainder and change. -/// Leaves are distributed round-robin (largest exits first) across the minimum -/// number of 7-proof batches so each batch clears the chain's minimum exit. +/// the recipient, largest-first. The volume fee is deducted once per private +/// batch; any remaining output returns to the sender as change. WormholeSpendPlan selectWormholeInputs({ required List utxos, required BigInt amountToken, @@ -113,38 +127,43 @@ WormholeSpendPlan selectWormholeInputs({ } final targetScaled = wormholeScaledFromToken(amountToken); - final candidates = utxos.where((u) => wormholeNetScaled(wormholeScaledFromToken(u.amount)) > 0).toList() - ..sort((a, b) => b.amount.compareTo(a.amount)); - final maxSendable = wormholeMaxSendable(candidates); + final candidates = _sortedSpendable(utxos); + final maxSendable = wormholeTokenFromScaled(_maxExitScaled(candidates, maxProofsPerBatch)); if (wormholeTokenFromScaled(targetScaled) > maxSendable) { throw InsufficientEncryptedFunds(maxSendable); } - final assignments = []; - var remaining = targetScaled; - var consumedToken = BigInt.zero; + final selected = []; + var completedBatchNet = 0; + var currentBatchInput = 0; for (final utxo in candidates) { - final net = wormholeNetScaled(wormholeScaledFromToken(utxo.amount)); - final pay = net < remaining ? net : remaining; - assignments.add(WormholeLeafAssignment(utxo: utxo, recipientScaled: pay, changeScaled: net - pay)); - consumedToken += utxo.amount; - remaining -= pay; - if (remaining == 0) break; + if (selected.isNotEmpty && selected.length % maxProofsPerBatch == 0) { + completedBatchNet += wormholeNetScaled(currentBatchInput); + currentBatchInput = 0; + } + selected.add(utxo); + currentBatchInput += wormholeScaledFromToken(utxo.amount); + if (completedBatchNet + wormholeNetScaled(currentBatchInput) >= targetScaled) break; } - final numBatches = (assignments.length + maxProofsPerBatch - 1) ~/ maxProofsPerBatch; - final byExitDesc = [...assignments]..sort((a, b) => b.exitScaled.compareTo(a.exitScaled)); - final batches = List.generate(numBatches, (_) => []); - for (var i = 0; i < byExitDesc.length; i++) { - batches[i % numBatches].add(byExitDesc[i]); - } - for (final batch in batches) { - final totalScaled = batch.fold(0, (sum, a) => sum + a.exitScaled); - if (totalScaled < wormholeMinBatchExitScaled) throw BatchBelowMinimumExit(totalScaled); + var remaining = targetScaled; + final batches = >[]; + for (final inputs in _chunks(selected, maxProofsPerBatch)) { + final outputAmounts = wormholeBatchOutputs(inputs.map((u) => wormholeScaledFromToken(u.amount)).toList()); + final batch = []; + for (var i = 0; i < inputs.length; i++) { + final pay = outputAmounts[i] < remaining ? outputAmounts[i] : remaining; + batch.add(WormholeLeafAssignment(utxo: inputs[i], recipientScaled: pay, changeScaled: outputAmounts[i] - pay)); + remaining -= pay; + } + batches.add(batch); } + if (remaining != 0) throw StateError('Selected wormhole inputs are short by $remaining scaled units'); + final assignments = batches.expand((batch) => batch); final changeScaled = assignments.fold(0, (sum, a) => sum + a.changeScaled); final changeToken = wormholeTokenFromScaled(changeScaled); + final consumedToken = selected.fold(BigInt.zero, (sum, utxo) => sum + utxo.amount); return WormholeSpendPlan( batches: batches, amountToken: amountToken, diff --git a/quantus_sdk/lib/src/services/wormhole_send_service.dart b/quantus_sdk/lib/src/services/wormhole_send_service.dart index 21182e0f..83d6ad35 100644 --- a/quantus_sdk/lib/src/services/wormhole_send_service.dart +++ b/quantus_sdk/lib/src/services/wormhole_send_service.dart @@ -182,6 +182,7 @@ class WormholeSendService { if (batch.isEmpty || batch.length > maxProofsPerBatch) { throw StateError('Batch of ${batch.length} spends violates aggregation arity $maxProofsPerBatch'); } + _validateBatchOutputs(batch, batch.map((spend) => wormholeScaledFromToken(spend.transfer.amount))); } return proveAndSubmitBatches( op: op, @@ -266,25 +267,28 @@ class WormholeSendService { _log('Found ${unspent.length} unspent transfers'); op.checkCancelled(); - // A claim pays each leaf's full net (post-fee) amount to the destination. + // A claim pays each private batch's full net amount to the destination. // The secret lives only in this buffer and is zeroized as soon as the // proofs are done (M11). final secretBytes = Uint8List.fromList(hex.decode(secretHex.replaceFirst('0x', ''))); try { final destinationBytes = Uint8List.fromList(getAccountId32(destinationAddress)); - final spends = [ - for (final transfer in unspent) - WormholeLeafSpend( - transfer: transfer, - secret: secretBytes, - exitAccount1: destinationBytes, - outputAmount1: wormholeNetScaled(wormholeScaledFromToken(transfer.amount)), - ), - ]; - final batches = [ - for (var i = 0; i < spends.length; i += maxProofsPerBatch) - spends.sublist(i, (i + maxProofsPerBatch).clamp(0, spends.length)), - ]; + final batches = >[]; + for (var i = 0; i < unspent.length; i += maxProofsPerBatch) { + final transfers = unspent.sublist(i, (i + maxProofsPerBatch).clamp(0, unspent.length)); + final outputAmounts = wormholeBatchOutputs( + transfers.map((transfer) => wormholeScaledFromToken(transfer.amount)).toList(), + ); + batches.add([ + for (var j = 0; j < transfers.length; j++) + WormholeLeafSpend( + transfer: transfers[j], + secret: secretBytes, + exitAccount1: destinationBytes, + outputAmount1: outputAmounts[j], + ), + ]); + } return await proveAndSubmitBatches( op: op, @@ -332,7 +336,7 @@ class WormholeSendService { final proofBytesList = List.filled(batch.length, null); final nullifierHexes = List.filled(batch.length, null); - final futures = >[]; + final futures = >[]; for (int i = 0; i < batch.length; i++) { final spend = batch[i]; futures.add( @@ -363,8 +367,9 @@ class WormholeSendService { } final outputs = await Future.wait(futures, eagerError: true); - for (final out in outputs) { - recipientTotal += out; + _validateBatchOutputs(batch, outputs.map((output) => output.inputScaled)); + for (final output in outputs) { + recipientTotal += output.recipientToken; } op.checkCancelled(); @@ -426,10 +431,10 @@ class WormholeSendService { } /// Generates a single leaf proof and writes it (and its nullifier hex) to - /// the output buffers. Returns the token amount paid to exit slot 1. + /// the output buffers. Returns its decoded input and exit-slot-1 amount. /// [onComplete] fires once the proof is written so callers can update /// progress per-leaf. - Future _generateLeafProof({ + Future<({int inputScaled, BigInt recipientToken})> _generateLeafProof({ required WormholeOperation op, required WormholeLeafSpend spend, required String blockHash, @@ -463,13 +468,6 @@ class WormholeSendService { ); final inputAmount = wormhole_ffi.decodeLeafAmount(leafData: leafData); - final maxOutput = wormholeNetScaled(inputAmount); - if (spend.outputAmount1 + spend.outputAmount2 > maxOutput) { - throw StateError( - 'Leaf ${transfer.leafIndex}: assigned outputs ${spend.outputAmount1}+${spend.outputAmount2} ' - 'exceed net input $maxOutput (input $inputAmount)', - ); - } final wormholeAddressBytes = wormhole_ffi.decodeLeafToAccount(leafData: leafData); // The FFI proof itself cannot be interrupted, so check one last time @@ -505,7 +503,20 @@ class WormholeSendService { onComplete?.call(); // On-chain dispatch transfers `outputAmount * scaleFactor` token units to // each exit account; slot 1 is the recipient's exact contribution. - return wormholeTokenFromScaled(spend.outputAmount1); + return (inputScaled: inputAmount, recipientToken: wormholeTokenFromScaled(spend.outputAmount1)); + } + + void _validateBatchOutputs(List batch, Iterable inputAmounts) { + final inputs = inputAmounts.toList(); + if (inputs.length != batch.length) throw StateError('Wormhole batch input count changed during proving'); + if (batch.any((spend) => spend.outputAmount1 < 0 || spend.outputAmount2 < 0)) { + throw StateError('Wormhole batch outputs must be non-negative'); + } + final outputTotal = batch.fold(0, (sum, spend) => sum + spend.outputAmount1 + spend.outputAmount2); + final maxOutput = wormholeBatchNetScaled(inputs); + if (outputTotal > maxOutput) { + throw StateError('Batch outputs $outputTotal exceed net input $maxOutput'); + } } /// Submits an unsigned extrinsic via `author_submitExtrinsic` and returns the diff --git a/quantus_sdk/test/services/wormhole_coin_selection_test.dart b/quantus_sdk/test/services/wormhole_coin_selection_test.dart index f73c7069..68a383ef 100644 --- a/quantus_sdk/test/services/wormhole_coin_selection_test.dart +++ b/quantus_sdk/test/services/wormhole_coin_selection_test.dart @@ -24,6 +24,7 @@ void main() { expect(wormholeVolumeFeeBps, 4); expect(wormholeVolumeFeePercentText(), '0.04'); expect(wormholeNetScaled(2500), 2499); + expect(wormholeBatchOutputs(List.filled(7, 50)), [50, 50, 50, 50, 50, 50, 49]); }); group('selectWormholeInputs', () { @@ -33,36 +34,31 @@ void main() { expect(plan.inputCount, 3); expect(plan.batches.length, 1); expect(plan.amountToken, tokens('10')); - expect(plan.changeToken, tokens('0.87')); - expect(plan.feeToken, tokens('0.03')); + expect(plan.changeToken, tokens('0.89')); + expect(plan.feeToken, tokens('0.01')); final recipientTotal = plan.batches[0].fold(0, (s, a) => s + a.recipientScaled); expect(wormholeTokenFromScaled(recipientTotal), tokens('10')); expect(plan.batches[0].where((a) => a.changeScaled > 0).length, 1); - for (final a in plan.batches[0]) { - final net = wormholeNetScaled(wormholeScaledFromToken(a.utxo.amount)); - expect(a.recipientScaled + a.changeScaled, net); - } + final inputs = plan.batches[0].map((a) => wormholeScaledFromToken(a.utxo.amount)); + expect(plan.batches[0].fold(0, (sum, a) => sum + a.exitScaled), wormholeBatchNetScaled(inputs)); }); test('splits across batches beyond 7 inputs, change appears once', () { final plan = selectWormholeInputs(utxos: List.generate(9, (_) => utxo(200)), amountToken: tokens('16')); - // 200 nets 199; 9 inputs net 17.91 total, 8 inputs net 15.92 < 16. + // Seven inputs net 13.99; eight net 15.98, so the ninth is required. expect(plan.inputCount, 9); expect(plan.batches.length, 2); expect(plan.batches.every((b) => b.length <= 7), isTrue); expect(plan.batches.expand((b) => b).where((a) => a.changeScaled > 0).length, 1); - for (final batch in plan.batches) { - final exit = batch.fold(0, (s, a) => s + a.exitScaled); - expect(exit, greaterThanOrEqualTo(wormholeMinBatchExitScaled)); - } - expect(plan.changeToken, tokens('1.91')); + expect(plan.changeToken, tokens('1.98')); + expect(plan.feeToken, tokens('0.02')); }); test('insufficient funds reports exact max sendable', () { final e = throwsA( - isA().having((e) => e.maxSendableToken, 'maxSendable', tokens('1.98')), + isA().having((e) => e.maxSendableToken, 'maxSendable', tokens('1.99')), ); expect(() => selectWormholeInputs(utxos: [utxo(100), utxo(100)], amountToken: tokens('2')), e); }); @@ -74,15 +70,15 @@ void main() { ); }); - test('rejects a batch below the chain minimum exit', () { - expect( - () => selectWormholeInputs(utxos: [utxo(9)], amountToken: wormholeTokenFromScaled(8)), - throwsA(isA()), - ); + test('allows sub-0.1 exits because the chain has no separate minimum', () { + final plan = selectWormholeInputs(utxos: [utxo(9)], amountToken: wormholeTokenFromScaled(8)); + expect(plan.amountToken, wormholeTokenFromScaled(8)); + expect(plan.feeToken, wormholeTokenFromScaled(1)); }); - test('wormholeMaxSendable sums per-input nets', () { - expect(wormholeMaxSendable([utxo(110), utxo(580), utxo(400)]), tokens('10.87')); + test('wormholeMaxSendable deducts one fee per batch', () { + expect(wormholeMaxSendable([utxo(110), utxo(580), utxo(400)]), tokens('10.89')); + expect(wormholeMaxSendable(List.generate(7, (_) => utxo(50))), tokens('3.49')); }); test('exactly 7 inputs fit in a single batch', () { diff --git a/quantus_sdk/test/services/wormhole_send_service_test.dart b/quantus_sdk/test/services/wormhole_send_service_test.dart index 8a418b19..311d2ae4 100644 --- a/quantus_sdk/test/services/wormhole_send_service_test.dart +++ b/quantus_sdk/test/services/wormhole_send_service_test.dart @@ -141,8 +141,8 @@ void main() { transferCount: BigInt.one, ); - Future<_StubProvingSendService> runClaim({Object? proveError}) async { - final service = _StubProvingSendService(utxoService: _FakeUtxoService()..unspent = [transfer]) + Future<_StubProvingSendService> runClaim({Object? proveError, List? transfers}) async { + final service = _StubProvingSendService(utxoService: _FakeUtxoService()..unspent = transfers ?? [transfer]) ..proveError = proveError; final claim = service.claimRewards( wormholeAddress: 'wormhole_addr', @@ -173,5 +173,27 @@ void main() { final liveSecret = service.capturedBatches![0][0].secret; expect(liveSecret.every((b) => b == 0), isTrue); }); + + test('deducts the volume fee once per private batch', () async { + final rewards = [ + for (var i = 0; i < 7; i++) + WormholeTransfer( + id: 't$i', + blockHeight: i, + fromId: 'from', + toId: 'wormhole_addr', + amount: wormholeTokenFromScaled(50), + toHash: '0x00', + leafIndex: BigInt.from(i + 1), + transferCount: BigInt.one, + ), + ]; + + final service = await runClaim(transfers: rewards); + final outputs = service.capturedBatches!.single.map((spend) => spend.outputAmount1).toList(); + expect(outputs.where((amount) => amount == 50).length, 6); + expect(outputs.where((amount) => amount == 49).length, 1); + expect(outputs.fold(0, (sum, amount) => sum + amount), 349); + }); }); }