92: Add per-currency minimum buyback amounts to the Evelyn IOU burner

This commit is contained in:
2026-10-06 08:05:37 +02:00
parent 152137ed87
commit 49df1ac9d0
11 changed files with 353 additions and 81 deletions
@@ -13,10 +13,11 @@ import java.util.List;
* {@link MoneyAmount#currencyType() currency type}'s stable AssetAZ UUID is
* the authoritative identity, and each {@link MoneyAmount#amount() amount} is
* the minimum balance that must remain. List order is currency priority.
* Each accepted input has exactly one strictly positive maximum buyback amount
* supplied as another list of {@link MoneyAmount} values, matched by UUID
* independently of list order. Both configurations are copied defensively.
* Maxima are expressed in the input currency's own units, without guaranteeing
* Each accepted input has exactly one strictly positive minimum and maximum
* buyback amount supplied as two further lists of {@link MoneyAmount} values,
* matched by UUID independently of list order/name/symbol. The minimum must not
* exceed its maximum. All three configurations are copied defensively.
* Minima and maxima are expressed in the input currency's own units, without guaranteeing
* a particular USD value.
* Solana mint addresses and token programs are integration details resolved
* internally through the Currency Identity Service and Solana and are not
@@ -41,7 +42,10 @@ import java.util.List;
* <li>Select at most that input's configured token maximum, rounded down
* to the input currency's supported precision. Native SOL uses its native
* balance and nine-decimal precision; no future transaction fee is
* estimated when applying its reserve.</li>
* estimated when applying its reserve. Skip actual rounded candidates
* below that input's minimum and continue to later inputs in the same
* iteration; equality qualifies. Never round upward or use the reserve
* to reach a minimum. Input minima do not suppress EVE burns.</li>
* <li>Attempt no more than one exact-input Jupiter swap. Normal return is
* already {@code CONFIRMED}; it is not awaited a second time. A definitive
* failure or proven pre-submission error permits a later iteration to try
@@ -78,6 +78,13 @@ public final class EvelynIOUBurnerServiceImpl
* that input currency's own units, without an input-price conversion or
* a guarantee about its USD value.</p>
*
* <p>{@code inputCurrencyMinimumSwapAmounts} follows the same canonical
* UUID matching and immutable-copy rules. Every accepted input needs one
* strictly positive minimum no greater than its maximum. A rounded
* candidate below the minimum is skipped so later inputs remain eligible;
* equality qualifies without rounding up or spending the reserve. Three
* empty input lists permit direct-EVE-only burning.</p>
*
* @param burnerWallet dedicated wallet whose balances are inspected and
* whose complete EVE balance is burned
* @param swapService exact-input Jupiter service; normal return already
@@ -94,6 +101,10 @@ public final class EvelynIOUBurnerServiceImpl
* non-null reserve amount greater than or equal to zero; every
* non-SOL UUID must have exactly one non-blank CIS
* {@code solana-mint} external reference
* @param inputCurrencyMinimumSwapAmounts exactly one strictly positive
* token-denominated buyback minimum per accepted input UUID, no
* greater than its maximum; qualification uses the actual rounded
* input amount and does not restrict burns
* @param inputCurrencyMaximumSwapAmounts exactly one strictly positive
* token-denominated buyback maximum per accepted input UUID;
* it does not limit burns
@@ -106,14 +117,16 @@ public final class EvelynIOUBurnerServiceImpl
* @param iterationInterval strictly positive fixed delay measured from one
* completed iteration to the next
* @throws NullPointerException if an injected service, configuration
* object, list entry, reserve or maximum amount,
* object, list entry, reserve, minimum or maximum amount,
* configured currency, or UUID is null
* @throws IllegalArgumentException if a reserve or operating limit is out of
* range, a duration is non-positive, a
* UUID is unknown or duplicated, EVE is
* configured as input, or a non-SOL input
* lacks exactly one usable Solana mint,
* or maximum UUIDs do not match inputs
* or minimum/maximum UUIDs do not match inputs,
* a swap bound is non-positive, or a
* minimum exceeds its maximum
*/
public EvelynIOUBurnerServiceImpl(
@NotNull SolanaWallet burnerWallet,
@@ -123,6 +136,7 @@ public final class EvelynIOUBurnerServiceImpl
@NotNull CurrencyIdentityService currencyIdentityService,
@NotNull BoundNotificationService notificationService,
@NotNull List<MoneyAmount> inputCurrencyMinimumReserves,
@NotNull List<MoneyAmount> inputCurrencyMinimumSwapAmounts,
@NotNull List<MoneyAmount> inputCurrencyMaximumSwapAmounts,
int maximumSlippageBps,
ΩmilliSecondsΩ maximumPriceAge,
@@ -204,9 +218,19 @@ public final class EvelynIOUBurnerServiceImpl
this.inputCurrencyMinimumReserves = validateAndCopyInputs(
inputCurrencyMinimumReserves
);
this.inputCurrencyMaximumSwapAmounts = validateAndCopyMaximums(
inputCurrencyMaximumSwapAmounts
this.inputCurrencyMaximumSwapAmounts = validateAndCopySwapAmounts(
inputCurrencyMaximumSwapAmounts, "inputCurrencyMaximumSwapAmounts"
);
this.inputCurrencyMinimumSwapAmounts = validateAndCopySwapAmounts(
inputCurrencyMinimumSwapAmounts, "inputCurrencyMinimumSwapAmounts"
);
for (Map.Entry<UUID, MoneyAmount> minimum : this.inputCurrencyMinimumSwapAmounts.entrySet()) {
if (minimum.getValue().amount().compareTo(
this.inputCurrencyMaximumSwapAmounts.get(minimum.getKey()).amount()) > 0) {
throw new IllegalArgumentException(
"Minimum swap amount exceeds maximum for input currency UUID: " + minimum.getKey());
}
}
}
@Override
@@ -385,7 +409,8 @@ public final class EvelynIOUBurnerServiceImpl
ΩAmountΩ amountToSwap = availableForSwap
.min(maximumInputAmount)
.setScale(decimals, RoundingMode.DOWN);
if (amountToSwap.signum() <= 0) {
if (amountToSwap.signum() <= 0
|| amountToSwap.compareTo(inputCurrencyMinimumSwapAmounts.get(currencyId).amount()) < 0) {
continue;
}
@@ -859,70 +884,45 @@ public final class EvelynIOUBurnerServiceImpl
return List.copyOf(validatedInputs);
}
private Map<UUID, MoneyAmount> validateAndCopyMaximums(
List<MoneyAmount> configuredMaximums
private Map<UUID, MoneyAmount> validateAndCopySwapAmounts(
List<MoneyAmount> configuredAmounts,
String configurationName
) {
Objects.requireNonNull(
configuredMaximums,
"inputCurrencyMaximumSwapAmounts"
);
Objects.requireNonNull(configuredAmounts, configurationName);
Set<UUID> acceptedCurrencyIds = new HashSet<>();
for (MoneyAmount minimumReserve : inputCurrencyMinimumReserves) {
acceptedCurrencyIds.add(minimumReserve.currencyType().id());
}
Map<UUID, MoneyAmount> validatedMaximums = new HashMap<>();
Map<UUID, MoneyAmount> validatedAmounts = new HashMap<>();
for (int index = 0; index < configuredMaximums.size(); index++) {
MoneyAmount maximum = Objects.requireNonNull(
configuredMaximums.get(index),
"inputCurrencyMaximumSwapAmounts[" + index + "]"
);
Objects.requireNonNull(
maximum.amount(),
"inputCurrencyMaximumSwapAmounts[" + index + "].amount"
);
if (maximum.amount().signum() <= 0) {
throw new IllegalArgumentException(
"Input currency maximum swap amount must be greater "
+ "than zero at index " + index
);
for (int index = 0; index < configuredAmounts.size(); index++) {
String entryName = configurationName + "[" + index + "]";
MoneyAmount bound = Objects.requireNonNull(configuredAmounts.get(index), entryName);
Objects.requireNonNull(bound.amount(), entryName + ".amount");
if (bound.amount().signum() <= 0) {
throw new IllegalArgumentException(entryName + " amount must be greater than zero");
}
CurrencyType configuredCurrency = Objects.requireNonNull(
maximum.currencyType(),
"inputCurrencyMaximumSwapAmounts[" + index
+ "].currencyType"
);
UUID currencyId = Objects.requireNonNull(
configuredCurrency.id(),
"inputCurrencyMaximumSwapAmounts[" + index
+ "].currencyType.id"
);
requireCanonicalCurrency(currencyId, "configured input maximum");
bound.currencyType(), entryName + ".currencyType");
UUID currencyId = Objects.requireNonNull(configuredCurrency.id(), entryName + ".currencyType.id");
CurrencyType canonicalCurrency = requireCanonicalCurrency(currencyId, configurationName);
if (!acceptedCurrencyIds.contains(currencyId)) {
throw new IllegalArgumentException(
"Maximum swap amount configured for unaccepted input "
+ "currency UUID: " + currencyId
);
throw new IllegalArgumentException(configurationName
+ " configured for unaccepted input currency UUID: " + currencyId);
}
if (validatedMaximums.putIfAbsent(currencyId, maximum) != null) {
throw new IllegalArgumentException(
"Duplicate maximum swap amount currency UUID: "
+ currencyId
);
if (validatedAmounts.putIfAbsent(currencyId,
new MoneyAmount(bound.amount(), canonicalCurrency)) != null) {
throw new IllegalArgumentException("Duplicate " + configurationName
+ " currency UUID: " + currencyId);
}
}
for (UUID currencyId : acceptedCurrencyIds) {
if (!validatedMaximums.containsKey(currencyId)) {
throw new IllegalArgumentException(
"Missing maximum swap amount for input currency UUID: "
+ currencyId
);
if (!validatedAmounts.containsKey(currencyId)) {
throw new IllegalArgumentException("Missing " + configurationName
+ " for input currency UUID: " + currencyId);
}
}
return Map.copyOf(validatedMaximums);
return Map.copyOf(validatedAmounts);
}
private CurrencyType requireCanonicalCurrency(
@@ -1057,6 +1057,7 @@ public final class EvelynIOUBurnerServiceImpl
private final CurrencyIdentityService currencyIdentityService;
private final BoundNotificationService notificationService;
private final List<MoneyAmount> inputCurrencyMinimumReserves;
private final Map<UUID, MoneyAmount> inputCurrencyMinimumSwapAmounts;
private final Map<UUID, MoneyAmount> inputCurrencyMaximumSwapAmounts;
private final int maximumSlippageBps;
private final ΩmilliSecondsΩ maximumPriceAge;
@@ -307,6 +307,10 @@ public final class NenjimHubImpl implements NenjimHub {
new MoneyAmount(new ΩAmountΩ("0.1"), cis.resolve(CurrencyTypeIds.USDT_ID)),
new MoneyAmount(new ΩAmountΩ("0.095"), cis.resolve(CurrencyTypeIds.SYRUPUSDC_ID))
);
List<MoneyAmount> inputCurrencyMinimumSwapAmounts = List.of(
new MoneyAmount(new ΩAmountΩ("0.05"), cis.resolve(CurrencyTypeIds.USDT_ID)),
new MoneyAmount(new ΩAmountΩ("0.045"), cis.resolve(CurrencyTypeIds.SYRUPUSDC_ID))
);
List<MoneyAmount> inputCurrencyMaximumSwapAmounts = List.of(
new MoneyAmount(new ΩAmountΩ("0.25"), cis.resolve(CurrencyTypeIds.USDT_ID)),
new MoneyAmount(new ΩAmountΩ("0.2"), cis.resolve(CurrencyTypeIds.SYRUPUSDC_ID))
@@ -323,6 +327,7 @@ public final class NenjimHubImpl implements NenjimHub {
cis,
discordAssetAZTokenNotificationService,
inputCurrencyMinimumReserves,
inputCurrencyMinimumSwapAmounts,
inputCurrencyMaximumSwapAmounts,
200,
2*1000*60L,