90: Support SyrupUSDC in the Evelyn IOU burner with per-currency swap limits

This commit is contained in:
2026-10-05 20:35:56 +02:00
parent b9369a870f
commit adb5f0235a
11 changed files with 430 additions and 83 deletions
@@ -13,6 +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
* 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
* part of Evelyn's input configuration.</p>
@@ -26,10 +31,10 @@ import java.util.List;
* submitted in an iteration that begins with a pending burn.</li>
* <li>Unless swaps are suspended, inspect accepted currencies in priority
* order. Canonicalize each AssetAZ UUID, resolve its native-SOL or SPL
* representation internally, apply its own minimum reserve, and require a
* fresh input/USDC price. Missing or unusable candidate data does not
* representation internally and apply its own minimum reserve without an
* input-price lookup. Missing or unusable candidate data does not
* prevent later currencies from being considered.</li>
* <li>Select at most the configured USDC-equivalent maximum, rounded down
* <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>
@@ -40,7 +45,7 @@ import java.util.List;
* lifecycle.</li>
* <li>Reload the complete visible Evelyn IOU balance, including tokens
* deposited or acquired before this iteration. Absence or failure of a
* swap does not suppress this step, and the USDC buyback limit never caps
* swap does not suppress this step, and the input buyback limit never caps
* the amount eligible for burning.</li>
* <li>Require a positive, sufficiently fresh canonical EVE/USDT price and
* calculate the complete balance's dollar value before submitting a burn.
@@ -30,8 +30,10 @@ import java.math.RoundingMode;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
@@ -41,7 +43,6 @@ import java.util.concurrent.TimeUnit;
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.EVE_ID;
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.SOLANA_ID;
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.USDC_ID;
import static com.r35157.assetaz.services.cis.CurrencyTypeIds.USDT_ID;
import static com.r35157.assetaz.services.cis.ExternalCurrencyReference.SOLANA_MINT_NAMESPACE;
@@ -65,13 +66,20 @@ public final class EvelynIOUBurnerServiceImpl
* metadata. Solana mint addresses and token programs are resolved
* internally and are not constructor configuration.</p>
*
* <p>{@code inputCurrencyMaximumSwapAmounts} must contain exactly one
* strictly positive maximum for every accepted input and no other currency.
* Maxima are copied defensively and matched by canonical currency UUID,
* independently of list order or supplied metadata. Each maximum is in
* that input currency's own units, without an input-price conversion or
* a guarantee about its USD value.</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
* means the swap reached {@code CONFIRMED}
* @param solanaBlockChain Solana access used for mint ownership, token
* precision, and burn finalization
* @param tickerService latest-price source for input/USDC and EVE/USDT
* @param tickerService latest-price source for EVE/USDT before burning
* @param currencyIdentityService authoritative resolver for AssetAZ UUIDs
* and Solana mint references
* @param notificationService fully bound synchronous destination for the
@@ -81,24 +89,26 @@ 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 maximumSwapAmountInUSDC strictly positive USDC-equivalent ceiling
* for one buyback; it does not limit burns
* @param inputCurrencyMaximumSwapAmounts exactly one strictly positive
* token-denominated buyback maximum per accepted input UUID;
* it does not limit burns
* @param maximumSlippageBps Jupiter slippage limit in basis points from 0
* through 10000 inclusive
* @param maximumPriceAge strictly positive maximum observation age in
* milliseconds for both input and EVE prices
* milliseconds for the EVE/USDT price
* @param burnFinalizationTimeout strictly positive timeout passed unchanged
* to each {@code FINALIZED} burn await
* @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 amount,
* object, list entry, reserve or maximum amount,
* configured currency, or UUID is null
* @throws IllegalArgumentException if a reserve or scalar limit is out of
* @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
* lacks exactly one usable Solana mint,
* or maximum UUIDs do not match inputs
*/
public EvelynIOUBurnerServiceImpl(
@NotNull SolanaWallet burnerWallet,
@@ -108,7 +118,7 @@ public final class EvelynIOUBurnerServiceImpl
@NotNull CurrencyIdentityService currencyIdentityService,
@NotNull BoundNotificationService notificationService,
@NotNull List<MoneyAmount> inputCurrencyMinimumReserves,
@NotNull ΩUSDCAmountΩ maximumSwapAmountInUSDC,
@NotNull List<MoneyAmount> inputCurrencyMaximumSwapAmounts,
int maximumSlippageBps,
ΩmilliSecondsΩ maximumPriceAge,
@NotNull Duration burnFinalizationTimeout,
@@ -139,15 +149,6 @@ public final class EvelynIOUBurnerServiceImpl
"notificationService"
);
Objects.requireNonNull(
maximumSwapAmountInUSDC,
"maximumSwapAmountInUSDC"
);
if (maximumSwapAmountInUSDC.signum() <= 0) {
throw new IllegalArgumentException(
"maximumSwapAmountInUSDC must be greater than zero"
);
}
if (maximumSlippageBps < 0
|| maximumSlippageBps > MAXIMUM_SLIPPAGE_BPS) {
throw new IllegalArgumentException(
@@ -179,7 +180,6 @@ public final class EvelynIOUBurnerServiceImpl
);
}
this.maximumSwapAmountInUSDC = maximumSwapAmountInUSDC;
this.maximumSlippageBps = maximumSlippageBps;
this.maximumPriceAge = maximumPriceAge;
this.maximumPriceAgeDuration = Duration.ofMillis(maximumPriceAge);
@@ -188,7 +188,6 @@ public final class EvelynIOUBurnerServiceImpl
EVE_ID,
"Evelyn IOU"
);
this.canonicalUsdc = requireCanonicalCurrency(USDC_ID, "USDC");
CurrencyType canonicalUsdt = requireCanonicalCurrency(
USDT_ID,
"USDT"
@@ -200,6 +199,9 @@ public final class EvelynIOUBurnerServiceImpl
this.inputCurrencyMinimumReserves = validateAndCopyInputs(
inputCurrencyMinimumReserves
);
this.inputCurrencyMaximumSwapAmounts = validateAndCopyMaximums(
inputCurrencyMaximumSwapAmounts
);
}
@Override
@@ -353,18 +355,6 @@ public final class EvelynIOUBurnerServiceImpl
continue;
}
ΩPriceΩ inputPriceInUsdc;
if (USDC_ID.equals(currencyId) || USDT_ID.equals(currencyId)) {
inputPriceInUsdc = BigDecimal.ONE;
} else {
inputPriceInUsdc = findFreshPrice(
new TradingPair(canonicalCurrency, canonicalUsdc)
);
if (inputPriceInUsdc == null) {
continue;
}
}
if (!nativeSolana) {
try {
decimals = resolveMintDecimals(
@@ -381,11 +371,9 @@ public final class EvelynIOUBurnerServiceImpl
}
}
ΩAmountΩ maximumInputAmount = maximumSwapAmountInUSDC.divide(
inputPriceInUsdc,
decimals,
RoundingMode.DOWN
);
ΩAmountΩ maximumInputAmount = inputCurrencyMaximumSwapAmounts
.get(currencyId)
.amount();
ΩAmountΩ amountToSwap = availableForSwap
.min(maximumInputAmount)
.setScale(decimals, RoundingMode.DOWN);
@@ -830,6 +818,72 @@ public final class EvelynIOUBurnerServiceImpl
return List.copyOf(validatedInputs);
}
private Map<UUID, MoneyAmount> validateAndCopyMaximums(
List<MoneyAmount> configuredMaximums
) {
Objects.requireNonNull(
configuredMaximums,
"inputCurrencyMaximumSwapAmounts"
);
Set<UUID> acceptedCurrencyIds = new HashSet<>();
for (MoneyAmount minimumReserve : inputCurrencyMinimumReserves) {
acceptedCurrencyIds.add(minimumReserve.currencyType().id());
}
Map<UUID, MoneyAmount> validatedMaximums = 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
);
}
CurrencyType configuredCurrency = Objects.requireNonNull(
maximum.currencyType(),
"inputCurrencyMaximumSwapAmounts[" + index
+ "].currencyType"
);
UUID currencyId = Objects.requireNonNull(
configuredCurrency.id(),
"inputCurrencyMaximumSwapAmounts[" + index
+ "].currencyType.id"
);
requireCanonicalCurrency(currencyId, "configured input maximum");
if (!acceptedCurrencyIds.contains(currencyId)) {
throw new IllegalArgumentException(
"Maximum swap amount configured for unaccepted input "
+ "currency UUID: " + currencyId
);
}
if (validatedMaximums.putIfAbsent(currencyId, maximum) != null) {
throw new IllegalArgumentException(
"Duplicate maximum swap amount currency UUID: "
+ currencyId
);
}
}
for (UUID currencyId : acceptedCurrencyIds) {
if (!validatedMaximums.containsKey(currencyId)) {
throw new IllegalArgumentException(
"Missing maximum swap amount for input currency UUID: "
+ currencyId
);
}
}
return Map.copyOf(validatedMaximums);
}
private CurrencyType requireCanonicalCurrency(
UUID currencyId,
String purpose
@@ -961,13 +1015,12 @@ public final class EvelynIOUBurnerServiceImpl
private final CurrencyIdentityService currencyIdentityService;
private final BoundNotificationService notificationService;
private final List<MoneyAmount> inputCurrencyMinimumReserves;
private final ΩUSDCAmountΩ maximumSwapAmountInUSDC;
private final Map<UUID, MoneyAmount> inputCurrencyMaximumSwapAmounts;
private final int maximumSlippageBps;
private final ΩmilliSecondsΩ maximumPriceAge;
private final Duration maximumPriceAgeDuration;
private final Duration burnFinalizationTimeout;
private final long iterationIntervalNanos;
private final CurrencyType canonicalUsdc;
private final TradingPair eveUsdtTradingPair;
private ScheduledExecutorService worker;
@@ -303,9 +303,14 @@ public final class NenjimHubImpl implements NenjimHub {
}
private NenjimProcess constructEvelynIOUBurnerProdProcess() {
List<MoneyAmount> burnTokenCandidates = List.of(new MoneyAmount(new ΩAmountΩ("0.1"), cis.resolve(CurrencyTypeIds.USDT_ID)));
ΩUSDCAmountΩ maximumSwapAmountPerIterationInUSD = new ΩUSDCAmountΩ("0.25");
List<MoneyAmount> inputCurrencyMinimumReserves = List.of(
new MoneyAmount(new ΩAmountΩ("0.1"), cis.resolve(CurrencyTypeIds.USDT_ID)),
new MoneyAmount(new ΩAmountΩ("0.095"), 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))
);
EvelynIOUBurnerServiceImpl process = new EvelynIOUBurnerServiceImpl(
solanaWalletEvelynBurnerProd,
@@ -317,8 +322,8 @@ public final class NenjimHubImpl implements NenjimHub {
TICKER_SERVICE_ID),
cis,
discordAssetAZTokenNotificationService,
burnTokenCandidates,
maximumSwapAmountPerIterationInUSD,
inputCurrencyMinimumReserves,
inputCurrencyMaximumSwapAmounts,
200,
2*1000*60L,
Duration.ofSeconds(10),