Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions mobile-app/lib/v2/screens/send/encrypted_send_strategy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ EncryptedFee planEncryptedFee(List<WormholeUtxo> utxos, BigInt amount) {
);
} on InsufficientEncryptedFunds {
return const EncryptedFee(blocker: EncryptedSendBlocker.insufficient);
} on BatchBelowMinimumExit {
return const EncryptedFee(blocker: EncryptedSendBlocker.belowBatchMinimum);
}
}

Expand Down Expand Up @@ -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),
};
}

Expand Down
2 changes: 1 addition & 1 deletion mobile-app/lib/v2/screens/send/send_strategy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions mobile-app/test/unit/encrypted_send_strategy_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
1 change: 0 additions & 1 deletion mobile-app/test/unit/regular_send_strategy_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
107 changes: 63 additions & 44 deletions quantus_sdk/lib/src/services/wormhole_coin_selection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> inputAmounts) =>
wormholeNetScaled(inputAmounts.fold(0, (sum, amount) => sum + amount));

List<int> wormholeBatchOutputs(List<int> 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<List<T>> _chunks<T>(List<T> 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<WormholeUtxo> _sortedSpendable(List<WormholeUtxo> utxos) =>
utxos.where((utxo) => wormholeScaledFromToken(utxo.amount) > 0).toList()
..sort((a, b) => b.amount.compareTo(a.amount));

int _maxExitScaled(List<WormholeUtxo> sorted, int maxProofsPerBatch) => _chunks(
sorted,
maxProofsPerBatch,
).fold<int>(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).
Expand All @@ -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({
Expand All @@ -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<WormholeUtxo> utxos) {
final totalScaled = utxos.fold<int>(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<WormholeUtxo> 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<WormholeUtxo> utxos,
required BigInt amountToken,
Expand All @@ -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 = <WormholeLeafAssignment>[];
var remaining = targetScaled;
var consumedToken = BigInt.zero;
final selected = <WormholeUtxo>[];
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, (_) => <WormholeLeafAssignment>[]);
for (var i = 0; i < byExitDesc.length; i++) {
batches[i % numBatches].add(byExitDesc[i]);
}
for (final batch in batches) {
final totalScaled = batch.fold<int>(0, (sum, a) => sum + a.exitScaled);
if (totalScaled < wormholeMinBatchExitScaled) throw BatchBelowMinimumExit(totalScaled);
var remaining = targetScaled;
final batches = <List<WormholeLeafAssignment>>[];
for (final inputs in _chunks(selected, maxProofsPerBatch)) {
final outputAmounts = wormholeBatchOutputs(inputs.map((u) => wormholeScaledFromToken(u.amount)).toList());
final batch = <WormholeLeafAssignment>[];
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<int>(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,
Expand Down
65 changes: 38 additions & 27 deletions quantus_sdk/lib/src/services/wormhole_send_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = <List<WormholeLeafSpend>>[];
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,
Expand Down Expand Up @@ -332,7 +336,7 @@ class WormholeSendService {

final proofBytesList = List<Uint8List?>.filled(batch.length, null);
final nullifierHexes = List<String?>.filled(batch.length, null);
final futures = <Future<BigInt>>[];
final futures = <Future<({int inputScaled, BigInt recipientToken})>>[];
for (int i = 0; i < batch.length; i++) {
final spend = batch[i];
futures.add(
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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<BigInt> _generateLeafProof({
Future<({int inputScaled, BigInt recipientToken})> _generateLeafProof({
required WormholeOperation op,
required WormholeLeafSpend spend,
required String blockHash,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<WormholeLeafSpend> batch, Iterable<int> 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<int>(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
Expand Down
36 changes: 16 additions & 20 deletions quantus_sdk/test/services/wormhole_coin_selection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand All @@ -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<int>(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<int>(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<int>(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<InsufficientEncryptedFunds>().having((e) => e.maxSendableToken, 'maxSendable', tokens('1.98')),
isA<InsufficientEncryptedFunds>().having((e) => e.maxSendableToken, 'maxSendable', tokens('1.99')),
);
expect(() => selectWormholeInputs(utxos: [utxo(100), utxo(100)], amountToken: tokens('2')), e);
});
Expand All @@ -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<BatchBelowMinimumExit>()),
);
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', () {
Expand Down
Loading
Loading